diff --git a/APP/__pycache__/db.cpython-314.pyc b/APP/__pycache__/db.cpython-314.pyc deleted file mode 100644 index 57ab260..0000000 Binary files a/APP/__pycache__/db.cpython-314.pyc and /dev/null differ diff --git a/APP/__pycache__/openvpn_api_v3.cpython-314.pyc b/APP/__pycache__/openvpn_api_v3.cpython-314.pyc deleted file mode 100644 index 6a1492f..0000000 Binary files a/APP/__pycache__/openvpn_api_v3.cpython-314.pyc and /dev/null differ diff --git a/APP/__pycache__/openvpn_gatherer_v3.cpython-314.pyc b/APP/__pycache__/openvpn_gatherer_v3.cpython-314.pyc deleted file mode 100644 index 255eb9b..0000000 Binary files a/APP/__pycache__/openvpn_gatherer_v3.cpython-314.pyc and /dev/null differ diff --git a/APP/config_manager.py b/APP/config_manager.py deleted file mode 100644 index a45f063..0000000 --- a/APP/config_manager.py +++ /dev/null @@ -1,155 +0,0 @@ -import os -import re -from pathlib import Path -from jinja2 import Environment, FileSystemLoader - -class ConfigManager: - def __init__(self, template_dir, output_dir): - self.template_dir = template_dir - self.output_dir = output_dir - self.env = Environment(loader=FileSystemLoader(template_dir)) - self.server_conf_path = Path(output_dir) / "server.conf" - - def read_server_config(self): - """Parse existing server config into a dictionary""" - if not self.server_conf_path.exists(): - return {} - - config = {} - try: - with open(self.server_conf_path, 'r') as f: - content = f.read() - - # Regex mappings for simple key-value pairs - mappings = { - 'port': r'^port\s+(\d+)', - 'proto': r'^proto\s+(\w+)', - 'dev': r'^dev\s+(\w+)', - 'server_network': r'^server\s+([\d\.]+)', - 'server_netmask': r'^server\s+[\d\.]+\s+([\d\.]+)', - 'topology': r'^topology\s+(\w+)', - 'cipher': r'^cipher\s+([\w\-]+)', - 'data_ciphers': r'^data-ciphers\s+([\w\-:]+)', - 'data_ciphers_fallback': r'^data-ciphers-fallback\s+([\w\-]+)', - 'status_log': r'^status\s+(.+)', - 'log_file': r'^log-append\s+(.+)', - 'ipp_path': r'^ifconfig-pool-persist\s+(.+)', - 'auth_algo': r'^auth\s+(\w+)', - 'tun_mtu': r'^tun-mtu\s+(\d+)', - 'mssfix': r'^mssfix\s+(\d+)' - } - - for key, pattern in mappings.items(): - match = re.search(pattern, content, re.MULTILINE) - if match: - config[key] = match.group(1) - - # Boolean flags - config['client_to_client'] = bool(re.search(r'^client-to-client', content, re.MULTILINE)) - # redirect-gateway is usually pushed - config['redirect_gateway'] = bool(re.search(r'push "redirect-gateway', content, re.MULTILINE)) - config['crl_verify'] = bool(re.search(r'^crl-verify', content, re.MULTILINE)) - - # DNS - # push "dhcp-option DNS 8.8.8.8" - dns_matches = re.findall(r'push "dhcp-option DNS ([\d\.]+)"', content) - if dns_matches: - config['dns_servers'] = dns_matches - - # Routes - # push "route 192.168.1.0 255.255.255.0" - route_matches = re.findall(r'push "route ([\d\.]+ [\d\.]+)"', content) - if route_matches: - config['routes'] = route_matches - - return config - except Exception as e: - print(f"Error reading config: {e}") - return {} - - def generate_server_config(self, params): - """Generate server.conf from template""" - # Defaults - defaults = { - 'port': 1194, - 'proto': 'udp', - 'server_network': '10.8.0.0', - 'server_netmask': '255.255.255.0', - 'topology': 'subnet', - 'cipher': 'AES-256-GCM', - 'auth_algo': 'SHA256', - 'data_ciphers': 'AES-256-GCM:AES-128-GCM', - 'data_ciphers_fallback': None, - 'status_log': '/var/log/openvpn/openvpn-status.log', - 'log_file': '/var/log/openvpn/openvpn.log', - 'crl_verify': True, - 'client_to_client': False, - 'redirect_gateway': True, - 'dns_servers': ['8.8.8.8', '8.8.4.4'], - 'routes': [], - 'tun_mtu': None, - 'mssfix': None - } - - # Merge params - ctx = {**defaults, **params} - - try: - template = self.env.get_template('server.conf.j2') - output = template.render(ctx) - - with open(self.server_conf_path, 'w') as f: - f.write(output) - - return True, str(self.server_conf_path) - except Exception as e: - return False, str(e) - - def generate_client_config(self, client_name, pki_path, server_config=None, extra_params=None): - """Generate client .ovpn content - server_config: dict of server security/network settings - extra_params: dict of specific overrides (remote_host, port, proto) - """ - # Checks - pki = Path(pki_path) - ca_path = pki / "ca.crt" - cert_path = pki / "issued" / f"{client_name}.crt" - key_path = pki / "private" / f"{client_name}.key" - ta_path = pki / "ta.key" - - if not (ca_path.exists() and cert_path.exists() and key_path.exists()): - return False, "Certificate files missing" - - try: - # Read contents - ca = ca_path.read_text().strip() - cert = cert_path.read_text().strip() - # Cert file often contains text before -----BEGIN CERTIFICATE----- - if "-----BEGIN CERTIFICATE-----" in cert: - cert = cert[cert.find("-----BEGIN CERTIFICATE-----"):] - - key = key_path.read_text().strip() - ta = ta_path.read_text().strip() if ta_path.exists() else None - - ctx = { - 'client_name': client_name, - 'ca': ca, - 'cert': cert, - 'key': key, - 'tls_auth': ta - } - - # Merge server config if present - if server_config: - ctx.update(server_config) - - # Merge extra params (host, port, proto) - takes precedence - if extra_params: - ctx.update(extra_params) - - template = self.env.get_template('client.ovpn.j2') - output = template.render(ctx) - return True, output - - except Exception as e: - return False, str(e) diff --git a/APP/pki_manager.py b/APP/pki_manager.py deleted file mode 100644 index ad7391e..0000000 --- a/APP/pki_manager.py +++ /dev/null @@ -1,149 +0,0 @@ -import os -import subprocess -from pathlib import Path -import shutil - -class PKIManager: - def __init__(self, easyrsa_path, pki_path): - self.easyrsa_dir = Path(easyrsa_path) - self.pki_path = Path(pki_path) - self.easyrsa_bin = self.easyrsa_dir / 'easyrsa' - - # Ensure easyrsa script is executable - if self.easyrsa_bin.exists(): - os.chmod(self.easyrsa_bin, 0o755) - - def run_easyrsa(self, args): - """Run easyrsa command""" - cmd = [str(self.easyrsa_bin)] + args - env = os.environ.copy() - # Ensure we point to the correct PKI dir if flexible - # But EasyRSA usually expects to be run inside the dir or have env var? - # Standard: run in easyrsa_dir, but PKI might be elsewhere. - # usually invoke like: easyrsa --pki-dir=/path/to/pki cmd - - # We'll use the --pki-dir arg if supported or just chdir if needed. - # EasyRSA 3 supports --pki-dir key. - - final_cmd = [str(self.easyrsa_bin), f'--pki-dir={self.pki_path}'] + args - - try: - # We run from easyrsa dir so it finds openssl-easyrsa.cnf etc if needed - result = subprocess.run( - final_cmd, - cwd=self.easyrsa_dir, - capture_output=True, - text=True, - check=True - ) - return True, result.stdout - except subprocess.CalledProcessError as e: - return False, e.stderr + "\n" + e.stdout - - def validate_pki_path(self, path_str): - """Check if a path contains a valid initialized PKI or EasyRSA structure""" - path = Path(path_str) - if not path.exists(): - return False, "Path does not exist" - - # Check for essential items: pki dir or easyrsa script inside - # Or if it IS the pki dir (contains ca.crt, issued, private) - - is_pki_root = (path / "ca.crt").exists() and (path / "private").exists() - has_pki_subdir = (path / "pki" / "ca.crt").exists() - - if is_pki_root or has_pki_subdir: - return True, "Valid PKI structure found" - return False, "No PKI structure found (missing ca.crt or private key dir)" - - def init_pki(self, force=False): - """Initialize PKI""" - if force and self.pki_path.exists(): - shutil.rmtree(self.pki_path) - - if not self.pki_path.exists(): - return self.run_easyrsa(['init-pki']) - - if (self.pki_path / "private").exists(): - return True, "PKI already initialized" - - return self.run_easyrsa(['init-pki']) - - def update_vars(self, vars_dict): - """Update vars file with provided dictionary""" - vars_path = self.easyrsa_dir / 'vars' - - # Ensure vars file is created in the EasyRSA directory that we run commands from - # Note: If we use --pki-dir, easyrsa might look for vars in the pki dir or the basedir. - # Usually it looks in the directory we invoke it from (cwd). - - # Base content - content = [ - "# Easy-RSA 3 vars file", - "set_var EASYRSA_DN \"org\"", - "set_var EASYRSA_BATCH \"1\"" - ] - - # Map of keys to allow - allowed_keys = [ - 'EASYRSA_REQ_COUNTRY', 'EASYRSA_REQ_PROVINCE', 'EASYRSA_REQ_CITY', - 'EASYRSA_REQ_ORG', 'EASYRSA_REQ_EMAIL', 'EASYRSA_REQ_OU', - 'EASYRSA_KEY_SIZE', 'EASYRSA_CA_EXPIRE', 'EASYRSA_CERT_EXPIRE', - 'EASYRSA_CRL_DAYS', 'EASYRSA_REQ_CN' - ] - - for key, val in vars_dict.items(): - if key in allowed_keys and val: - content.append(f"set_var {key} \"{val}\"") - - try: - with open(vars_path, 'w') as f: - f.write('\n'.join(content)) - return True - except Exception as e: - return False - - def build_ca(self, cn="OpenVPN-CA"): - """Build CA""" - # EasyRSA 3 uses 'build-ca nopass' and takes CN from vars or interactive. - # With batch mode, we rely on vars. But CN is special. - # We can pass --req-cn=NAME (if supported) or rely on vars having EASYRSA_REQ_CN? - # Actually in batch mode `build-ca nopass` uses the common name from vars/env. - - # If we updated vars with EASYRSA_REQ_CN, then just run it. - # But to be safe, we can try to set it via env var too. - # args: build-ca nopass - return self.run_easyrsa(['build-ca', 'nopass']) - - def build_server(self, name="server"): - """Build Server Cert""" - return self.run_easyrsa(['build-server-full', name, 'nopass']) - - def build_client(self, name): - """Build Client Cert""" - return self.run_easyrsa(['build-client-full', name, 'nopass']) - - def gen_dh(self): - """Generate Diffie-Hellman""" - return self.run_easyrsa(['gen-dh']) - - def gen_crl(self): - """Generate CRL""" - return self.run_easyrsa(['gen-crl']) - - def revoke_client(self, name): - """Revoke Client""" - # 1. Revoke - succ, out = self.run_easyrsa(['revoke', name]) - if not succ: return False, out - # 2. Update CRL - return self.gen_crl() - - def gen_ta_key(self, path): - """Generate TA Key using openvpn directly""" - try: - # openvpn --genkey --secret path - subprocess.run(['openvpn', '--genkey', '--secret', str(path)], check=True) - return True, "TA key generated" - except Exception as e: - return False, str(e) diff --git a/APP/requirements.txt b/APP/requirements.txt deleted file mode 100644 index 375d26c..0000000 --- a/APP/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Flask==3.0.0 -Flask-Cors==4.0.0 diff --git a/APP/service_manager.py b/APP/service_manager.py deleted file mode 100644 index 1871f59..0000000 --- a/APP/service_manager.py +++ /dev/null @@ -1,70 +0,0 @@ -import subprocess -import logging -import shutil - -logger = logging.getLogger(__name__) - -class ServiceManager: - def __init__(self, service_name='openvpn'): - self.service_name = service_name - self.init_system = self._detect_init_system() - - def _detect_init_system(self): - """Detect if systemd or openrc is used.""" - if shutil.which('systemctl'): - return 'systemd' - elif shutil.which('rc-service'): - return 'openrc' - else: - return 'unknown' - - def _run_cmd(self, cmd): - try: - subprocess.run(cmd, check=True, capture_output=True, text=True) - return True, "Success" - except subprocess.CalledProcessError as e: - return False, e.stderr.strip() - except Exception as e: - return False, str(e) - - def start(self): - if self.init_system == 'systemd': - return self._run_cmd(['sudo', 'systemctl', 'start', self.service_name]) - elif self.init_system == 'openrc': - return self._run_cmd(['sudo', 'rc-service', self.service_name, 'start']) - return False, "Unknown init system" - - def stop(self): - if self.init_system == 'systemd': - return self._run_cmd(['sudo', 'systemctl', 'stop', self.service_name]) - elif self.init_system == 'openrc': - return self._run_cmd(['sudo', 'rc-service', self.service_name, 'stop']) - return False, "Unknown init system" - - def restart(self): - if self.init_system == 'systemd': - return self._run_cmd(['sudo', 'systemctl', 'restart', self.service_name]) - elif self.init_system == 'openrc': - return self._run_cmd(['sudo', 'rc-service', self.service_name, 'restart']) - return False, "Unknown init system" - - def get_status(self): - """Return 'active', 'inactive', or 'error'""" - if self.init_system == 'systemd': - # systemctl is-active returns 0 if active, non-zero otherwise - try: - subprocess.run(['systemctl', 'is-active', self.service_name], check=True, capture_output=True) - return 'active' - except subprocess.CalledProcessError: - return 'inactive' - - elif self.init_system == 'openrc': - try: - res = subprocess.run(['rc-service', self.service_name, 'status'], capture_output=True, text=True) - if 'started' in res.stdout or 'running' in res.stdout: - return 'active' - return 'inactive' - except: - return 'error' - - return 'unknown' diff --git a/APP/templates/client.ovpn.j2 b/APP/templates/client.ovpn.j2 deleted file mode 100644 index 3785b76..0000000 --- a/APP/templates/client.ovpn.j2 +++ /dev/null @@ -1,40 +0,0 @@ -client -dev tun -windows-driver wintun -proto {{ proto }} -remote {{ remote_host }} {{ remote_port }} -resolv-retry infinite -nobind -persist-key -persist-tun -{% if 'tcp' in proto %} -tls-client -{% endif %} -mute-replay-warnings -remote-cert-tls server - -# Encryption Config -cipher {{ cipher | default('AES-256-GCM') }} -{% if data_ciphers %} -data-ciphers {{ data_ciphers }} -{% endif %} -{% if data_ciphers_fallback %} -data-ciphers-fallback {{ data_ciphers_fallback }} -{% endif %} -auth {{ auth_algo | default('SHA256') }} -verb 3 - -# Certificates Config - -{{ ca }} - - -{{ cert }} - - -{{ key }} - -key-direction 1 - -{{ tls_auth }} - \ No newline at end of file diff --git a/APP/templates/server.conf.j2 b/APP/templates/server.conf.j2 deleted file mode 100644 index 75bcbd3..0000000 --- a/APP/templates/server.conf.j2 +++ /dev/null @@ -1,73 +0,0 @@ -port {{ port }} -proto {{ proto }} -dev tun - -ca {{ ca_path }} -cert {{ cert_path }} -key {{ key_path }} -dh {{ dh_path }} -tls-auth {{ ta_path }} 0 - -server {{ server_network }} {{ server_netmask }} - -{% if topology %} -topology {{ topology }} -{% endif %} - -{% if ipp_path %} -ifconfig-pool-persist {{ ipp_path }} -{% endif %} - -{% if routes %} -{% for route in routes %} -push "route {{ route }}" -{% endfor %} -{% endif %} - -{% if redirect_gateway %} -push "redirect-gateway def1 bypass-dhcp" -{% endif %} - -{% if dns_servers %} -{% for dns in dns_servers %} -push "dhcp-option DNS {{ dns }}" -{% endfor %} -{% endif %} - -{% if client_to_client %} -client-to-client -{% endif %} - -keepalive 10 120 - -cipher {{ cipher }} -{% if data_ciphers %} -data-ciphers {{ data_ciphers }} -{% endif %} -{% if data_ciphers_fallback %} -data-ciphers-fallback {{ data_ciphers_fallback }} -{% endif %} - -auth {{ auth_algo }} -user nobody -group nogroup -persist-key -persist-tun - -status {{ status_log }} -log-append {{ log_file }} - -verb 3 -explicit-exit-notify 1 - -{% if crl_verify %} -crl-verify {{ crl_path }} -{% endif %} - -{% if tun_mtu %} -tun-mtu {{ tun_mtu }} -{% endif %} - -{% if mssfix %} -mssfix {{ mssfix }} -{% endif %} diff --git a/APP_CORE/README.md b/APP_CORE/README.md new file mode 100644 index 0000000..47d4d1f --- /dev/null +++ b/APP_CORE/README.md @@ -0,0 +1,38 @@ +# Core Monitoring Module (`APP_CORE`) + +The **Core Monitoring** module provides the backend logic for collecting, extracting, and serving OpenVPN usage statistics. + +## Components + +1. **Mining API (`openvpn_api_v3.py`)**: + - A Flask-based REST API running on port `5001`. + - Serves real-time data, authentication (JWT), and historical statistics. + - Connects to the SQLite database `ovpmon.db`. + +2. **Data Gatherer (`openvpn_gatherer_v3.py`)**: + - A background service/daemon. + - Parses OpenVPN server logs (`status.log`). + - Aggregates bandwidth usage into time-series tables (`usage_history`, `stats_hourly`, etc.). + +## Configuration + +Configuration is handled via `config.ini` (typically located in the project root or `/etc/openvpn/monitor/`). + +## Development + +```bash +# Setup Environment +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Run API +python3 openvpn_api_v3.py + +# Run Gatherer (in separate terminal) +python3 openvpn_gatherer_v3.py +``` + +## API Documentation + +Full API documentation is available in `DOCS/Core_Monitoring/API_Reference.md`. diff --git a/APP/config.ini b/APP_CORE/config.ini similarity index 94% rename from APP/config.ini rename to APP_CORE/config.ini index 74cd778..3e7e5aa 100644 --- a/APP/config.ini +++ b/APP_CORE/config.ini @@ -2,6 +2,7 @@ host = 0.0.0.0 port = 5000 debug = false +secret_key = ovpmon-secret-change-me [openvpn_monitor] log_path = /etc/openvpn/openvpn-status.log diff --git a/APP/db.py b/APP_CORE/db.py similarity index 70% rename from APP/db.py rename to APP_CORE/db.py index de510b1..eebd7e1 100644 --- a/APP/db.py +++ b/APP_CORE/db.py @@ -34,6 +34,15 @@ class DatabaseManager: conn = self.get_connection() cursor = conn.cursor() + def _column_exists(table, column): + cursor.execute(f"PRAGMA table_info({table})") + return any(row[1] == column for row in cursor.fetchall()) + + def _ensure_column(table, column, type_def): + if not _column_exists(table, column): + self.logger.info(f"Adding missing column {column} to table {table}") + cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {type_def}") + try: # 1. Clients Table cursor.execute(''' @@ -81,6 +90,27 @@ class DatabaseManager: ) ''') + # 2.2 Users and Auth + cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + totp_secret TEXT, + is_2fa_enabled INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 2.3 Login Attempts (Brute-force protection) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS login_attempts ( + ip_address TEXT PRIMARY KEY, + attempts INTEGER DEFAULT 0, + last_attempt TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + # 3. Aggregated Stats Tables tables = ['stats_5min', 'stats_15min', 'stats_hourly', 'stats_6h', 'stats_daily'] @@ -97,8 +127,15 @@ class DatabaseManager: ''') cursor.execute(f'CREATE INDEX IF NOT EXISTS idx_{table}_ts ON {table}(timestamp)') + # 4. Migrations (Ensure columns in existing tables) + # If users table existed but was old, ensure it has 2FA columns + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") + if cursor.fetchone(): + _ensure_column('users', 'totp_secret', 'TEXT') + _ensure_column('users', 'is_2fa_enabled', 'INTEGER DEFAULT 0') + conn.commit() - self.logger.info("Database initialized with full schema") + self.logger.info("Database initialized with full schema and migrations") except Exception as e: self.logger.error(f"Database initialization error: {e}") finally: diff --git a/APP/openvpn_api_v3.py b/APP_CORE/openvpn_api_v3.py similarity index 72% rename from APP/openvpn_api_v3.py rename to APP_CORE/openvpn_api_v3.py index df9b015..44019d2 100644 --- a/APP/openvpn_api_v3.py +++ b/APP_CORE/openvpn_api_v3.py @@ -8,12 +8,17 @@ import subprocess import os from pathlib import Path import re +import jwt +import pyotp +import bcrypt +from functools import wraps 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( level=logging.INFO, @@ -22,11 +27,13 @@ logging.basicConfig( logger = logging.getLogger(__name__) app = Flask(__name__) -CORS(app) # Enable CORS for all routes +# Enable CORS for all routes with specific headers support +CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True) class OpenVPNAPI: def __init__(self, config_file='config.ini'): self.db_manager = DatabaseManager(config_file) + self.db_manager.init_database() self.config = configparser.ConfigParser() self.config.read(config_file) @@ -42,16 +49,86 @@ class OpenVPNAPI: self.cert_extensions = self.config.get('certificates', 'certificate_extensions', fallback='crt,pem,key').split(',') self._cert_cache = {} + # Security + # Priority 1: Environment Variable + # Priority 2: Config file + self.secret_key = os.getenv('OVPMON_SECRET_KEY') or self.config.get('api', 'secret_key', fallback='ovpmon-secret-change-me') + app.config['SECRET_KEY'] = self.secret_key + + # Ensure at least one user exists + self.ensure_default_admin() + # 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""" return self.db_manager.get_connection() + def ensure_default_admin(self): + """Create a default admin user if no users exist""" + conn = self.get_db_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT COUNT(*) FROM users") + if cursor.fetchone()[0] == 0: + # Default: admin / password + password_hash = bcrypt.hashpw('password'.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + cursor.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", ('admin', password_hash)) + conn.commit() + logger.info("Default admin user created (admin/password)") + except Exception as e: + logger.error(f"Error ensuring default admin: {e}") + finally: + conn.close() + + def check_rate_limit(self, ip): + """Verify login attempts for an IP (max 5 attempts, 15m lockout)""" + conn = self.get_db_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT attempts, last_attempt FROM login_attempts WHERE ip_address = ?", (ip,)) + row = cursor.fetchone() + if row: + attempts, last_attempt = row + last_dt = datetime.strptime(last_attempt, '%Y-%m-%d %H:%M:%S') + if attempts >= 5 and datetime.utcnow() - last_dt < timedelta(minutes=15): + return False + # If lockout expired, reset + if datetime.utcnow() - last_dt >= timedelta(minutes=15): + cursor.execute("UPDATE login_attempts SET attempts = 0 WHERE ip_address = ?", (ip,)) + conn.commit() + return True + except Exception as e: + logger.error(f"Rate limit error: {e}") + return True # Allow login if rate limiting fails + finally: + conn.close() + + def record_login_attempt(self, ip, success): + """Update login attempts for an IP""" + conn = self.get_db_connection() + cursor = conn.cursor() + try: + if success: + cursor.execute("DELETE FROM login_attempts WHERE ip_address = ?", (ip,)) + else: + cursor.execute(''' + INSERT INTO login_attempts (ip_address, attempts, last_attempt) + VALUES (?, 1, datetime('now')) + ON CONFLICT(ip_address) DO UPDATE SET + attempts = attempts + 1, last_attempt = datetime('now') + ''', (ip,)) + conn.commit() + except Exception as e: + logger.error(f"Error recording login attempt: {e}") + finally: + conn.close() + # --- БЛОК РАБОТЫ С СЕРТИФИКАТАМИ (Оставлен без изменений) --- def parse_openssl_date(self, date_str): try: @@ -591,7 +668,7 @@ class OpenVPNAPI: WHERE t.timestamp >= datetime('now', '-{hours} hours') GROUP BY c.id ORDER BY total_traffic DESC - LIMIT 3 + LIMIT 10 ''' cursor.execute(query_top) top_cols = [col[0] for col in cursor.description] @@ -660,9 +737,271 @@ class OpenVPNAPI: # Initialize API instance api = OpenVPNAPI() -# --- ROUTES --- +# --- SECURITY DECORATORS --- + +def token_required(f): + @wraps(f) + def decorated(*args, **kwargs): + token = None + if 'Authorization' in request.headers: + auth_header = request.headers['Authorization'] + if auth_header.startswith('Bearer '): + token = auth_header.split(' ')[1] + + if not token: + return jsonify({'success': False, 'error': 'Token is missing'}), 401 + + try: + data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + # In a real app, you might want to verify user still exists in DB + except jwt.ExpiredSignatureError: + return jsonify({'success': False, 'error': 'Token has expired'}), 401 + except Exception: + return jsonify({'success': False, 'error': 'Token is invalid'}), 401 + + return f(*args, **kwargs) + return decorated + +# --- AUTH ROUTES --- + + +@app.route('/api/auth/login', methods=['POST']) +def login(): + data = request.get_json() + if not data or not data.get('username') or not data.get('password'): + return jsonify({'success': False, 'error': 'Missing credentials'}), 400 + + ip = request.remote_addr + if not api.check_rate_limit(ip): + return jsonify({'success': False, 'error': 'Too many login attempts. Try again in 15 minutes.'}), 429 + + conn = api.get_db_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT id, password_hash, totp_secret, is_2fa_enabled FROM users WHERE username = ?", (data['username'],)) + user = cursor.fetchone() + + if user and bcrypt.checkpw(data['password'].encode('utf-8'), user[1].encode('utf-8')): + api.record_login_attempt(ip, True) + + # If 2FA enabled, don't issue final token yet + if user[3]: # is_2fa_enabled + # Issue a short-lived temporary token for 2FA verification + temp_token = jwt.encode({ + 'user_id': user[0], + 'is_2fa_pending': True, + 'exp': datetime.utcnow() + timedelta(minutes=5) + }, app.config['SECRET_KEY'], algorithm="HS256") + return jsonify({'success': True, 'requires_2fa': True, 'temp_token': temp_token}) + + # Standard login + token = jwt.encode({ + 'user_id': user[0], + 'exp': datetime.utcnow() + timedelta(hours=8) + }, app.config['SECRET_KEY'], algorithm="HS256") + + return jsonify({'success': True, 'token': token, 'username': data['username']}) + + api.record_login_attempt(ip, False) + return jsonify({'success': False, 'error': 'Invalid username or password'}), 401 + except Exception as e: + logger.error(f"Login error: {e}") + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + finally: + conn.close() + +@app.route('/api/auth/verify-2fa', methods=['POST']) +def verify_2fa(): + data = request.get_json() + token = data.get('temp_token') + otp = data.get('otp') + + if not token or not otp: + return jsonify({'success': False, 'error': 'Missing data'}), 400 + + try: + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + if not decoded.get('is_2fa_pending'): + raise ValueError("Invalid token type") + + user_id = decoded['user_id'] + conn = api.get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT username, totp_secret FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + conn.close() + + if not user: + return jsonify({'success': False, 'error': 'User not found'}), 404 + + totp = pyotp.TOTP(user[1]) + if totp.verify(otp): + final_token = jwt.encode({ + 'user_id': user_id, + 'exp': datetime.utcnow() + timedelta(hours=8) + }, app.config['SECRET_KEY'], algorithm="HS256") + return jsonify({'success': True, 'token': final_token, 'username': user[0]}) + + return jsonify({'success': False, 'error': 'Invalid 2FA code'}), 401 + except Exception as e: + return jsonify({'success': False, 'error': 'Session expired or invalid'}), 401 + +@app.route('/api/auth/setup-2fa', methods=['POST']) +@token_required +def setup_2fa(): + # This route is called to generate a new secret + token = request.headers['Authorization'].split(' ')[1] + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + user_id = decoded['user_id'] + + secret = pyotp.random_base32() + totp = pyotp.TOTP(secret) + provisioning_uri = totp.provisioning_uri(name="admin", issuer_name="OpenVPN-Monitor") + + return jsonify({ + 'success': True, + 'secret': secret, + 'uri': provisioning_uri + }) + +@app.route('/api/auth/enable-2fa', methods=['POST']) +@token_required +def enable_2fa(): + try: + data = request.get_json() + secret = data.get('secret') + otp = data.get('otp') + + if not secret or not otp: + return jsonify({'success': False, 'error': 'Missing data'}), 400 + + logger.info(f"Attempting 2FA activation. User OTP: {otp}, Secret: {secret}") + totp = pyotp.TOTP(secret) + + # Adding valid_window=1 to allow ±30 seconds clock drift + if totp.verify(otp, valid_window=1): + token = request.headers['Authorization'].split(' ')[1] + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + user_id = decoded['user_id'] + + conn = api.get_db_connection() + cursor = conn.cursor() + cursor.execute("UPDATE users SET totp_secret = ?, is_2fa_enabled = 1 WHERE id = ?", (secret, user_id)) + conn.commit() + conn.close() + + logger.info(f"2FA enabled successfully for user ID: {user_id}") + return jsonify({'success': True, 'message': '2FA enabled successfully'}) + + # TIME DRIFT DIAGNOSTIC + current_utc = datetime.now(timezone.utc) + logger.warning(f"Invalid 2FA code provided. Server time (UTC): {current_utc.strftime('%Y-%m-%d %H:%M:%S')}") + + # Check if code matches a different hour (Common timezone issue) + found_drift = False + for h in range(-12, 13): + if h == 0: continue + if totp.verify(otp, for_time=(current_utc + timedelta(hours=h))): + logger.error(f"CRITICAL TIME MISMATCH: The provided OTP matches server time WITH A {h} HOUR OFFSET. " + f"Please ensure the phone is set to 'Automatic Date and Time' and the correct Timezone.") + found_drift = True + break + + if not found_drift: + logger.info("No simple hour-offset matches found. The code might be for a different secret or time is completely desynced.") + + return jsonify({'success': False, 'error': 'Invalid 2FA code. Check your phone clock synchronization.'}), 400 + except Exception as e: + logger.error(f"Error in enable_2fa: {e}") + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + +@app.route('/api/auth/disable-2fa', methods=['POST']) +@token_required +def disable_2fa(): + try: + token = request.headers['Authorization'].split(' ')[1] + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + user_id = decoded['user_id'] + + conn = api.get_db_connection() + cursor = conn.cursor() + cursor.execute("UPDATE users SET totp_secret = NULL, is_2fa_enabled = 0 WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + + logger.info(f"2FA disabled for user ID: {user_id}") + return jsonify({'success': True, 'message': '2FA disabled successfully'}) + except Exception as e: + logger.error(f"Error in disable_2fa: {e}") + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + +@app.route('/api/auth/change-password', methods=['POST']) +@token_required +def change_password(): + data = request.get_json() + current_password = data.get('current_password') + new_password = data.get('new_password') + + if not current_password or not new_password: + return jsonify({'success': False, 'error': 'Missing password data'}), 400 + + token = request.headers['Authorization'].split(' ')[1] + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + user_id = decoded['user_id'] + + conn = api.get_db_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT password_hash FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + + if not user or not bcrypt.checkpw(current_password.encode('utf-8'), user[0].encode('utf-8')): + return jsonify({'success': False, 'error': 'Invalid current password'}), 401 + + new_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user_id)) + conn.commit() + + logger.info(f"Password changed for user ID: {user_id}") + return jsonify({'success': True, 'message': 'Password changed successfully'}) + except Exception as e: + logger.error(f"Error changing password: {e}") + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + finally: + conn.close() + +# --- USER ROUTES --- + +@app.route('/api/v1/user/me', methods=['GET']) +@token_required +def get_me(): + try: + token = request.headers['Authorization'].split(' ')[1] + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + user_id = decoded['user_id'] + + conn = api.get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT username, is_2fa_enabled FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + conn.close() + + if not user: + return jsonify({'success': False, 'error': 'User not found'}), 404 + + return jsonify({ + 'success': True, + 'username': user[0], + 'is_2fa_enabled': bool(user[1]) + }) + except Exception as e: + logger.error(f"Error in get_me: {e}") + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + +# --- PROTECTED ROUTES --- @app.route('/api/v1/stats', methods=['GET']) +@token_required def get_stats(): """Get current statistics for all clients""" try: @@ -686,6 +1025,7 @@ def get_stats(): return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/v1/stats/system', methods=['GET']) +@token_required def get_system_stats(): """Get system-wide statistics""" try: @@ -699,6 +1039,7 @@ def get_system_stats(): return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/v1/stats/', methods=['GET']) +@token_required def get_client_stats(common_name): """ Get detailed stats for a client. @@ -769,6 +1110,7 @@ def get_client_stats(common_name): return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/v1/certificates', methods=['GET']) +@token_required def get_certificates(): try: data = api.get_certificates_info() @@ -777,6 +1119,7 @@ def get_certificates(): return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/v1/clients', methods=['GET']) +@token_required def get_clients_list(): try: data = api.get_current_stats() @@ -795,6 +1138,7 @@ def health_check(): return jsonify({'success': False, 'status': 'unhealthy', 'error': str(e)}), 500 @app.route('/api/v1/analytics', methods=['GET']) +@token_required def get_analytics(): """Get dashboard analytics data""" try: @@ -816,6 +1160,7 @@ def get_analytics(): return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/v1/sessions', methods=['GET']) +@token_required def get_sessions(): """Get all currently active sessions (real-time)""" try: @@ -830,323 +1175,9 @@ def get_sessions(): return jsonify({'success': False, 'error': str(e)}), 500 -# --- PKI MANAGEMENT ROUTES --- -@app.route('/api/v1/pki/init', methods=['POST']) -def init_pki(): - """Initialize PKI environment""" - try: - force = request.json.get('force', False) - pki_vars = request.json.get('vars', {}) - - # 0. Update Vars if provided - if pki_vars: - api.pki.update_vars(pki_vars) - - # 1. Clean/Init PKI - success, msg = api.pki.init_pki(force=force) - if not success: return jsonify({'success': False, 'error': msg}), 400 - - # 2. Build CA - # Use CN from vars if available, else default - ca_cn = pki_vars.get('EASYRSA_REQ_CN', 'OpenVPN-CA') - api.pki.build_ca(ca_cn) - - # 3. Build Server Cert - api.pki.build_server("server") - - # 4. Gen DH - api.pki.gen_dh() - - # 5. Gen TA Key - # Ensure pki dir exists - ta_path = Path(api.pki_path) / 'ta.key' - api.pki.gen_ta_key(ta_path) - - # 6. Gen CRL - api.pki.gen_crl() - return jsonify({'success': True, 'message': 'PKI initialized successfully'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 -@app.route('/api/v1/pki/validate', methods=['POST']) -def validate_pki(): - """Validate PKI path""" - try: - path = request.json.get('path') - if not path: return jsonify({'success': False, 'error': 'Path required'}), 400 - success, msg = api.pki.validate_pki_path(path) - return jsonify({'success': success, 'message': msg}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/pki/config', methods=['GET', 'POST']) -def handle_pki_config(): - """Get or Save PKI path configuration""" - try: - if request.method == 'GET': - return jsonify({ - 'success': True, - 'data': { - 'easyrsa_path': api.easyrsa_path, - 'pki_path': api.pki_path - } - }) - - # POST - path_str = request.json.get('path') - if not path_str: return jsonify({'success': False, 'error': 'Path required'}), 400 - - path = Path(path_str).resolve() - if not path.exists(): return jsonify({'success': False, 'error': 'Path invalid'}), 400 - - # Heuristic to determine easyrsa_path and pki_path - # User supplied 'path' is likely the PKI directory (containing ca.crt or being empty/prepared) - pki_path = path - easyrsa_path = path.parent # Default assumption: script is in parent - - # 1. Search for easyrsa binary (Heuristic) - potential_bins = [ - path / 'easyrsa', # Inside path - path.parent / 'easyrsa', # Parent - path.parent / 'easy-rsa' / 'easyrsa', # Sibling easy-rsa - Path('/usr/share/easy-rsa/easyrsa'), # System - Path('/etc/openvpn/easy-rsa/easyrsa') # System - ] - - found_bin = None - for bin_path in potential_bins: - if bin_path.exists(): - easyrsa_path = bin_path.parent - found_bin = bin_path - break - - # Override with explicit easyrsa_path if provided - explicit_easyrsa = request.json.get('easyrsa_path') - if explicit_easyrsa: - epath = Path(explicit_easyrsa) - if epath.is_file(): # Path to script - easyrsa_path = epath.parent - found_bin = epath - elif (epath / 'easyrsa').exists(): # Path to dir - easyrsa_path = epath - found_bin = epath / 'easyrsa' - - if not found_bin: - # Fallback: assume typical layout if not found yet - pass - - # If user pointed to root (containing pki subdir) - if (path / 'pki' / 'ca.crt').exists() or ((path / 'pki').exists() and not (path / 'ca.crt').exists()): - pki_path = path / 'pki' - # Only adjust easyrsa_path if not explicitly set/found yet - if not explicit_easyrsa and not found_bin and (path / 'easyrsa').exists(): - easyrsa_path = path - - # Update Config - if not api.config.has_section('pki'): - api.config.add_section('pki') - - api.config.set('pki', 'easyrsa_path', str(easyrsa_path)) - api.config.set('pki', 'pki_path', str(pki_path)) - - # Write config.ini - with open('config.ini', 'w') as f: - api.config.write(f) - - # Reload PKI Manager - api.easyrsa_path = str(easyrsa_path) - api.pki_path = str(pki_path) - api.pki = PKIManager(api.easyrsa_path, api.pki_path) - - return jsonify({ - 'success': True, - 'message': f'PKI Conf saved', - 'details': { - 'easyrsa_path': str(easyrsa_path), - 'pki_path': str(pki_path) - } - }) - - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/pki/client//config', methods=['GET']) -def get_client_config(name): - """Get client config (generate on fly)""" - try: - # Get defaults from active server config - server_conf = api.conf_mgr.read_server_config() - - # Determine public host - host = request.args.get('server_ip') - if not host: - host = server_conf.get('public_ip') - if not host: - try: - import socket - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - host = s.getsockname()[0] - s.close() - except: - host = '127.0.0.1' - - extra_params = { - 'remote_host': host, - 'remote_port': request.args.get('port') or server_conf.get('port', 1194), - 'proto': request.args.get('proto') or server_conf.get('proto', 'udp') - } - - succ_conf, conf_content = api.conf_mgr.generate_client_config( - name, api.pki_path, server_conf, extra_params - ) - - if not succ_conf: return jsonify({'success': False, 'error': conf_content}), 500 - - return jsonify({'success': True, 'config': conf_content, 'filename': f"{name}.ovpn"}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/pki/client', methods=['POST']) -def create_client(): - """Create new client and return config""" - try: - data = request.json - name = data.get('name') - if not name: return jsonify({'success': False, 'error': 'Name is required'}), 400 - - # 1. Build Cert - success, output = api.pki.build_client(name) - if not success: return jsonify({'success': False, 'error': output}), 500 - - # 2. Generate Config (Just to verify it works, but we don't strictly need to return it if UI doesn't download it automatically. - # However, it's good practice to return it in creation response too, in case UI changes mind) - server_ip = data.get('server_ip') or api.public_ip or '127.0.0.1' - - # Get defaults from active server config - server_conf = api.conf_mgr.read_server_config() - def_port = server_conf.get('port', 1194) - def_proto = server_conf.get('proto', 'udp') - - succ_conf, conf_content = api.conf_mgr.generate_client_config( - name, api.pki_path, server_ip, data.get('port', def_port), data.get('proto', def_proto) - ) - - if not succ_conf: return jsonify({'success': False, 'error': conf_content}), 500 - - return jsonify({'success': True, 'config': conf_content, 'filename': f"{name}.ovpn"}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/pki/client/', methods=['DELETE']) -def revoke_client(name): - """Revoke client certificate""" - try: - success, output = api.pki.revoke_client(name) - if not success: return jsonify({'success': False, 'error': output}), 500 - return jsonify({'success': True, 'message': 'Client revoked'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -# --- SERVER MANAGEMENT ROUTES --- - -@app.route('/api/v1/server/config', methods=['GET', 'POST']) -def manage_server_config(): - """Get or Save server.conf""" - try: - if request.method == 'GET': - # Check for path override (Reload from specific file) - path_arg = request.args.get('path') - - if path_arg: - # Update path preference if requested - new_path_str = str(path_arg) - if new_path_str != str(api.conf_mgr.server_conf_path): - api.server_config_path = new_path_str - api.conf_mgr.server_conf_path = Path(new_path_str) - - if not api.config.has_section('server'): api.config.add_section('server') - api.config.set('server', 'config_path', new_path_str) - with open('config.ini', 'w') as f: - api.config.write(f) - - current_conf = api.conf_mgr.read_server_config() - # Enriched with meta-config - current_conf['config_path'] = str(api.conf_mgr.server_conf_path) - current_conf['public_ip'] = api.public_ip - return jsonify({'success': True, 'data': current_conf}) - - # POST - params = request.json - # Basic validation - if not params.get('port'): return jsonify({'success': False, 'error': 'Port required'}), 400 - - # Check/Update Config Path and Public IP - new_path = params.get('config_path') - new_ip = params.get('public_ip') - - config_updated = False - if new_path and str(new_path) != str(api.conf_mgr.server_conf_path): - api.server_config_path = str(new_path) - api.conf_mgr.server_conf_path = Path(new_path) - if not api.config.has_section('server'): api.config.add_section('server') - api.config.set('server', 'config_path', str(new_path)) - config_updated = True - - if new_ip is not None and new_ip != api.public_ip: # Allow empty string - api.public_ip = new_ip - if not api.config.has_section('openvpn_monitor'): api.config.add_section('openvpn_monitor') - api.config.set('openvpn_monitor', 'public_ip', new_ip) - config_updated = True - - if config_updated: - with open('config.ini', 'w') as f: - api.config.write(f) - - # Define paths - params['ca_path'] = str(Path(api.pki_path) / 'ca.crt') - params['cert_path'] = str(Path(api.pki_path) / 'issued/server.crt') - params['key_path'] = str(Path(api.pki_path) / 'private/server.key') - params['dh_path'] = str(Path(api.pki_path) / 'dh.pem') - params['ta_path'] = str(Path(api.pki_path) / 'ta.key') - params['crl_path'] = str(Path(api.pki_path) / 'crl.pem') - - success, msg = api.conf_mgr.generate_server_config(params) - if not success: return jsonify({'success': False, 'error': msg}), 500 - - return jsonify({'success': True, 'path': msg}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/server/action', methods=['POST']) -def server_action(): - """Start/Stop/Restart OpenVPN service""" - try: - action = request.json.get('action') - if action == 'start': - success, msg = api.service.start() - elif action == 'stop': - success, msg = api.service.stop() - elif action == 'restart': - success, msg = api.service.restart() - else: - return jsonify({'success': False, 'error': 'Invalid action'}), 400 - - if not success: return jsonify({'success': False, 'error': msg}), 500 - return jsonify({'success': True, 'message': msg}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/server/status', methods=['GET']) -def server_status(): - """Get service status""" - try: - status = api.service.get_status() - return jsonify({'success': True, 'status': status}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 if __name__ == "__main__": host = api.config.get('api', 'host', fallback='0.0.0.0') diff --git a/APP/openvpn_gatherer_v3.py b/APP_CORE/openvpn_gatherer_v3.py similarity index 100% rename from APP/openvpn_gatherer_v3.py rename to APP_CORE/openvpn_gatherer_v3.py diff --git a/APP_CORE/requirements.txt b/APP_CORE/requirements.txt new file mode 100644 index 0000000..b7ab2a1 --- /dev/null +++ b/APP_CORE/requirements.txt @@ -0,0 +1,7 @@ +Flask>=3.0.0 +Flask-Cors>=4.0.0 +PyJWT>=2.10.0 +pyotp>=2.9.0 +qrcode>=7.4.2 +bcrypt>=4.2.0 + diff --git a/APP_PROFILER/README.md b/APP_PROFILER/README.md new file mode 100644 index 0000000..6558285 --- /dev/null +++ b/APP_PROFILER/README.md @@ -0,0 +1,26 @@ +# OpenVPN Profiler Module (`APP_PROFILER`) + +The **Profiler** module is a FastAPI-based service (`port 8000`) dedicated to management tasks: +- Public Key Infrastructure (PKI) management (EasyRSA wrapper). +- Client Profile (`.ovpn`) generation. +- Server Configuration management. +- Process control (Start/Stop OpenVPN service). + +## Documentation + +- **API Reference**: See `DOCS/Profiler_Management/API_Reference.md`. +- **Overview**: See `DOCS/Profiler_Management/Overview.md`. + +## Quick Start (Dev) + +```bash +# Setup +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Run +python3 main.py +# Swagger UI available at http://localhost:8000/docs +``` + diff --git a/APP_PROFILER/add_columns.py b/APP_PROFILER/add_columns.py new file mode 100644 index 0000000..32e1f51 --- /dev/null +++ b/APP_PROFILER/add_columns.py @@ -0,0 +1,51 @@ +import sqlite3 +import os + +DB_FILE = "ovpn_profiler.db" + +def migrate_db(): + if not os.path.exists(DB_FILE): + print(f"Database file {DB_FILE} not found!") + return + + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + + try: + cursor.execute("PRAGMA table_info(user_profiles)") + columns = [info[1] for info in cursor.fetchall()] + + # Add is_revoked + if "is_revoked" not in columns: + print("Adding 'is_revoked' column...") + cursor.execute("ALTER TABLE user_profiles ADD COLUMN is_revoked BOOLEAN DEFAULT 0") + else: + print("'is_revoked' column already exists.") + + # Add is_expired + if "is_expired" not in columns: + print("Adding 'is_expired' column...") + cursor.execute("ALTER TABLE user_profiles ADD COLUMN is_expired BOOLEAN DEFAULT 0") + else: + print("'is_expired' column already exists.") + + # Ensure expiration_date exists + if "expiration_date" not in columns: + print("Adding 'expiration_date' column...") + cursor.execute("ALTER TABLE user_profiles ADD COLUMN expiration_date DATETIME") + else: + print("'expiration_date' column already exists.") + + # Note: We do NOT remove 'expired_at' via ADD COLUMN script. + # SQLite does not support DROP COLUMN in older versions easily, + # and keeping it harmless is safer than complex migration logic. + print("Migration successful.") + conn.commit() + + except Exception as e: + print(f"Migration failed: {e}") + finally: + conn.close() + +if __name__ == "__main__": + migrate_db() diff --git a/APP_PROFILER/database.py b/APP_PROFILER/database.py new file mode 100644 index 0000000..41184bd --- /dev/null +++ b/APP_PROFILER/database.py @@ -0,0 +1,19 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +SQLALCHEMY_DATABASE_URL = "sqlite:///./ovpn_profiler.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/APP_PROFILER/main.py b/APP_PROFILER/main.py new file mode 100644 index 0000000..65d4e7b --- /dev/null +++ b/APP_PROFILER/main.py @@ -0,0 +1,45 @@ +import uvicorn +from fastapi import FastAPI +import sys +import os + +# Add project root to sys.path explicitly to ensure absolute imports work +import os +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from database import engine, Base +from routers import system, server, profiles, server_process +from utils.logging import setup_logging +from fastapi.middleware.cors import CORSMiddleware + +# Create Database Tables +Base.metadata.create_all(bind=engine) + +setup_logging() + +app = FastAPI( + title="OpenVPN Profiler API", + description="REST API for managing OpenVPN profiles and configuration", + version="1.0.0" +) + +# Enable CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(system.router, prefix="/api", tags=["System"]) +app.include_router(server.router, prefix="/api", tags=["Server"]) +app.include_router(profiles.router, prefix="/api", tags=["Profiles"]) +app.include_router(server_process.router, prefix="/api", tags=["Process Control"]) + +@app.get("/") +def read_root(): + return {"message": "Welcome to OpenVPN Profiler API"} + +if __name__ == "__main__": + uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True) diff --git a/APP_PROFILER/models.py b/APP_PROFILER/models.py new file mode 100644 index 0000000..9e978d4 --- /dev/null +++ b/APP_PROFILER/models.py @@ -0,0 +1,63 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON +from sqlalchemy.sql import func +from database import Base +from datetime import datetime + +class PKISetting(Base): + __tablename__ = "pki_settings" + + id = Column(Integer, primary_key=True, index=True) + fqdn_ca = Column(String, default="ovpn-ca") + fqdn_server = Column(String, default="ovpn-srv") + easyrsa_dn = Column(String, default="cn_only") + easyrsa_req_country = Column(String, default="RU") + easyrsa_req_province = Column(String, default="Moscow") + easyrsa_req_city = Column(String, default="Moscow") + easyrsa_req_org = Column(String, default="SomeORG") + easyrsa_req_email = Column(String, default="info@someorg.local") + easyrsa_req_ou = Column(String, default="IT") + easyrsa_key_size = Column(Integer, default=2048) + easyrsa_ca_expire = Column(Integer, default=3650) + easyrsa_cert_expire = Column(Integer, default=3649) + easyrsa_cert_renew = Column(Integer, default=30) + easyrsa_crl_days = Column(Integer, default=3649) + easyrsa_batch = Column(Boolean, default=True) + +class SystemSettings(Base): + __tablename__ = "system_settings" + + id = Column(Integer, primary_key=True, index=True) + protocol = Column(String, default="udp") + port = Column(Integer, default=1194) + vpn_network = Column(String, default="172.20.1.0") + vpn_netmask = Column(String, default="255.255.255.0") + tunnel_type = Column(String, default="FULL") # FULL or SPLIT + split_routes = Column(JSON, default=list) + duplicate_cn = Column(Boolean, default=False) + crl_verify = Column(Boolean, default=False) + client_to_client = Column(Boolean, default=False) + user_defined_dns = Column(Boolean, default=False) + dns_servers = Column(JSON, default=list) + user_defined_cdscripts = Column(Boolean, default=False) + connect_script = Column(String, default="") + disconnect_script = Column(String, default="") + management_interface = Column(Boolean, default=False) + management_interface_address = Column(String, default="127.0.0.1") + management_port = Column(Integer, default=7505) + public_ip = Column(String, nullable=True) + tun_mtu = Column(Integer, nullable=True) + mssfix = Column(Integer, nullable=True) + +class UserProfile(Base): + __tablename__ = "user_profiles" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, unique=True, index=True) + status = Column(String, default="active") # active, revoked + created_at = Column(DateTime, default=datetime.utcnow) + revoked_at = Column(DateTime, nullable=True) + # expired_at removed as per request + expiration_date = Column(DateTime, nullable=True) + is_revoked = Column(Boolean, default=False) + is_expired = Column(Boolean, default=False) + file_path = Column(String, nullable=True) diff --git a/APP_PROFILER/routers/__init__.py b/APP_PROFILER/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/APP_PROFILER/routers/profiles.py b/APP_PROFILER/routers/profiles.py new file mode 100644 index 0000000..3f4e5fe --- /dev/null +++ b/APP_PROFILER/routers/profiles.py @@ -0,0 +1,137 @@ +import os +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from database import get_db +from utils.auth import verify_token +from models import UserProfile +from schemas import UserProfile as UserProfileSchema, UserProfileCreate +from services import pki, generator +from datetime import datetime + +router = APIRouter(dependencies=[Depends(verify_token)]) + +@router.get("/profiles", response_model=list[UserProfileSchema]) +def list_profiles(db: Session = Depends(get_db)): + # 1. Fetch profiles from DB + profiles = db.query(UserProfile).all() + + # 2. Get PKI Data (Index mapping: CN -> Expiration Date) + pki_data = pki.get_pki_index_data(db) + + now = datetime.utcnow() + + updated_profiles = [] + + for profile in profiles: + # Sync expiration if available in PKI data + if profile.username in pki_data: + exp_date = pki_data[profile.username] + # Update DB if different + if profile.expiration_date != exp_date: + profile.expiration_date = exp_date + db.add(profile) + + # Calculate derived fields + + # 1. is_expired + is_expired = False + if profile.expiration_date: + if now > profile.expiration_date: + is_expired = True + + # 2. is_revoked + # (Assuming status='revoked' in DB is the source of truth) + is_revoked = profile.status == 'revoked' + + # 3. days_remaining (computed field) + days_remaining = None + if profile.expiration_date: + delta = profile.expiration_date - now + days_remaining = delta.days + + # Update DB fields for persistence if they differ + if profile.is_expired != is_expired: + profile.is_expired = is_expired + db.add(profile) + + if profile.is_revoked != is_revoked: + profile.is_revoked = is_revoked + db.add(profile) + + # Inject computed fields for response schema + # Since 'days_remaining' is not a DB column, we attach it to the object instance + setattr(profile, 'days_remaining', days_remaining) + + updated_profiles.append(profile) + + db.commit() # Save any updates + + return updated_profiles + +@router.post("/profiles", response_model=UserProfileSchema) +def create_profile( + profile_in: UserProfileCreate, + db: Session = Depends(get_db) +): + # Check existing + existing = db.query(UserProfile).filter(UserProfile.username == profile_in.username).first() + if existing: + raise HTTPException(status_code=400, detail="User already exists") + + # Build PKI + try: + pki.build_client(profile_in.username, db) + except Exception as e: + raise HTTPException(status_code=500, detail=f"PKI Build failed: {str(e)}") + + # Generate Config + client_conf_dir = "client-config" + os.makedirs(client_conf_dir, exist_ok=True) + file_path = os.path.join(client_conf_dir, f"{profile_in.username}.ovpn") + + try: + generator.generate_client_config(db, profile_in.username, file_path) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Config Generation failed: {str(e)}") + + # Create DB Entry + new_profile = UserProfile( + username=profile_in.username, + status="active", + created_at=datetime.utcnow(), + file_path=file_path + # expired_at would be extracted from cert in a real robust implementation + ) + db.add(new_profile) + db.commit() + db.refresh(new_profile) + return new_profile + +@router.delete("/profiles/{profile_id}") +def revoke_profile(profile_id: int, db: Session = Depends(get_db)): + profile = db.query(UserProfile).filter(UserProfile.id == profile_id).first() + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + try: + pki.revoke_client(profile.username, db) + except Exception as e: + # Log but maybe continue to update DB status? + raise HTTPException(status_code=500, detail=f"Revocation failed: {str(e)}") + + profile.status = "revoked" + profile.revoked_at = datetime.utcnow() + db.commit() + return {"message": f"Profile {profile.username} revoked"} + +@router.get("/profiles/{profile_id}/download") +def download_profile(profile_id: int, db: Session = Depends(get_db)): + profile = db.query(UserProfile).filter(UserProfile.id == profile_id).first() + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + if not profile.file_path or not os.path.exists(profile.file_path): + raise HTTPException(status_code=404, detail="Config file not found") + + return FileResponse(profile.file_path, filename=os.path.basename(profile.file_path)) diff --git a/APP_PROFILER/routers/server.py b/APP_PROFILER/routers/server.py new file mode 100644 index 0000000..4c8b26f --- /dev/null +++ b/APP_PROFILER/routers/server.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from database import get_db +from utils.auth import verify_token +from services import generator + +router = APIRouter(dependencies=[Depends(verify_token)]) + +@router.post("/server/configure") +def configure_server(db: Session = Depends(get_db)): + try: + # Generate to a temporary location or standard location + # As per plan, we behave like srvconf + output_path = "/etc/openvpn/server.conf" + # Since running locally for dev, maybe output to staging + import os + if not os.path.exists("/etc/openvpn"): + # For local dev safety, don't try to write to /etc/openvpn if not root or not existing + output_path = "staging/server.conf" + os.makedirs("staging", exist_ok=True) + + content = generator.generate_server_config(db, output_path=output_path) + return {"message": "Server configuration generated", "path": output_path} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/APP_PROFILER/routers/server_process.py b/APP_PROFILER/routers/server_process.py new file mode 100644 index 0000000..e048871 --- /dev/null +++ b/APP_PROFILER/routers/server_process.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from services import process +from utils.auth import verify_token +from typing import Optional +from fastapi import Depends + +router = APIRouter(dependencies=[Depends(verify_token)]) + +class ProcessActionResponse(BaseModel): + status: str + message: str + stdout: Optional[str] = None + stderr: Optional[str] = None + +class ProcessStats(BaseModel): + status: str + pid: Optional[int] = None + cpu_percent: float + memory_mb: float + uptime: Optional[str] = None + +@router.post("/server/process/{action}", response_model=ProcessActionResponse) +def manage_process(action: str): + """ + Control the OpenVPN server process. + Action: start, stop, restart + """ + if action not in ["start", "stop", "restart"]: + raise HTTPException(status_code=400, detail="Invalid action. Use start, stop, or restart") + + result = process.control_service(action) + + if result["status"] == "error": + raise HTTPException(status_code=500, detail=result["message"]) + + return result + +@router.get("/server/process/stats", response_model=ProcessStats) +def get_process_stats(): + """ + Get current telemetry for the OpenVPN process. + """ + return process.get_process_stats() diff --git a/APP_PROFILER/routers/system.py b/APP_PROFILER/routers/system.py new file mode 100644 index 0000000..b8e21dd --- /dev/null +++ b/APP_PROFILER/routers/system.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from database import get_db +from utils.auth import verify_token +from schemas import ( + ConfigResponse, SystemSettings, PKISetting, + SystemSettingsUpdate, PKISettingUpdate +) +from services import config, pki + +router = APIRouter(dependencies=[Depends(verify_token)]) + +@router.get("/config", response_model=ConfigResponse) +def get_config( + section: str = Query(None, enum=["server", "pki"]), + db: Session = Depends(get_db) +): + response = ConfigResponse() + if section is None or section == "server": + response.server = config.get_system_settings(db) + if section is None or section == "pki": + response.pki = config.get_pki_settings(db) + return response + +@router.put("/config/server", response_model=SystemSettings) +def update_server_config( + settings: SystemSettingsUpdate, + db: Session = Depends(get_db) +): + return config.update_system_settings(db, settings) + +@router.put("/config/pki", response_model=PKISetting) +def update_pki_config( + settings: PKISettingUpdate, + db: Session = Depends(get_db) +): + return config.update_pki_settings(db, settings) + +@router.post("/system/init") +def init_system_pki(db: Session = Depends(get_db)): + try: + msg = pki.init_pki(db) + return {"message": msg} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/system/pki") +def clear_system_pki(db: Session = Depends(get_db)): + msg = pki.clear_pki(db) + return {"message": msg} diff --git a/APP_PROFILER/schemas.py b/APP_PROFILER/schemas.py new file mode 100644 index 0000000..9c541e2 --- /dev/null +++ b/APP_PROFILER/schemas.py @@ -0,0 +1,86 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Literal +from datetime import datetime + +# --- PKI Settings Schemas --- +class PKISettingBase(BaseModel): + fqdn_ca: str = "ovpn-ca" + fqdn_server: str = "ovpn-srv" + easyrsa_dn: str = "cn_only" + easyrsa_req_country: str = "RU" + easyrsa_req_province: str = "Moscow" + easyrsa_req_city: str = "Moscow" + easyrsa_req_org: str = "SomeORG" + easyrsa_req_email: str = "info@someorg.local" + easyrsa_req_ou: str = "IT" + easyrsa_key_size: int = 2048 + easyrsa_ca_expire: int = 3650 + easyrsa_cert_expire: int = 3649 + easyrsa_cert_renew: int = 30 + easyrsa_crl_days: int = 3649 + easyrsa_batch: bool = True + +class PKISettingUpdate(PKISettingBase): + pass + +class PKISetting(PKISettingBase): + id: int + class Config: + from_attributes = True + +# --- System Settings Schemas --- +class SystemSettingsBase(BaseModel): + protocol: Literal['tcp', 'udp'] = "udp" + port: int = 1194 + vpn_network: str = "172.20.1.0" + vpn_netmask: str = "255.255.255.0" + tunnel_type: Literal['FULL', 'SPLIT'] = "FULL" + split_routes: List[str] = Field(default_factory=list) + duplicate_cn: bool = False + crl_verify: bool = False + client_to_client: bool = False + user_defined_dns: bool = False + dns_servers: List[str] = Field(default_factory=list) + user_defined_cdscripts: bool = False + connect_script: str = "" + disconnect_script: str = "" + management_interface: bool = False + management_interface_address: str = "127.0.0.1" + management_interface_address: str = "127.0.0.1" + management_port: int = 7505 + public_ip: Optional[str] = None + tun_mtu: Optional[int] = None + mssfix: Optional[int] = None + +class SystemSettingsUpdate(SystemSettingsBase): + pass + +class SystemSettings(SystemSettingsBase): + id: int + class Config: + from_attributes = True + +class ConfigResponse(BaseModel): + server: Optional[SystemSettings] = None + pki: Optional[PKISetting] = None + +# --- User Profile Schemas --- +class UserProfileBase(BaseModel): + username: str + +class UserProfileCreate(UserProfileBase): + pass + +class UserProfile(UserProfileBase): + id: int + status: str + created_at: datetime + revoked_at: Optional[datetime] = None + expiration_date: Optional[datetime] = None + days_remaining: Optional[int] = None + is_revoked: bool = False + is_expired: bool = False + file_path: Optional[str] = None + + class Config: + from_attributes = True diff --git a/APP_PROFILER/scripts/add_mtu_mss_columns.py b/APP_PROFILER/scripts/add_mtu_mss_columns.py new file mode 100644 index 0000000..092ced6 --- /dev/null +++ b/APP_PROFILER/scripts/add_mtu_mss_columns.py @@ -0,0 +1,35 @@ +import sqlite3 +import os + +DB_FILE = "ovpn_profiler.db" + +def migrate(): + if not os.path.exists(DB_FILE): + print(f"Database {DB_FILE} not found!") + return + + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + + columns = { + "tun_mtu": "INTEGER", + "mssfix": "INTEGER" + } + + print("Checking for new columns...") + for col, dtype in columns.items(): + try: + print(f"Attempting to add {col}...") + cursor.execute(f"ALTER TABLE system_settings ADD COLUMN {col} {dtype}") + print(f"Success: Column {col} added.") + except sqlite3.OperationalError as e: + if "duplicate column name" in str(e): + print(f"Column {col} already exists.") + else: + print(f"Error adding {col}: {e}") + + conn.commit() + conn.close() + +if __name__ == "__main__": + migrate() diff --git a/APP_PROFILER/scripts/add_public_ip_column.py b/APP_PROFILER/scripts/add_public_ip_column.py new file mode 100644 index 0000000..94ab169 --- /dev/null +++ b/APP_PROFILER/scripts/add_public_ip_column.py @@ -0,0 +1,28 @@ +import sqlite3 +import os + +DB_FILE = "ovpn_profiler.db" + +def migrate(): + if not os.path.exists(DB_FILE): + print(f"Database {DB_FILE} not found!") + return + + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + + try: + print("Attempting to add public_ip column...") + cursor.execute("ALTER TABLE system_settings ADD COLUMN public_ip TEXT") + conn.commit() + print("Success: Column public_ip added.") + except sqlite3.OperationalError as e: + if "duplicate column name" in str(e): + print("Column public_ip already exists.") + else: + print(f"Error: {e}") + finally: + conn.close() + +if __name__ == "__main__": + migrate() diff --git a/APP_PROFILER/scripts/migrate_from_bash.py b/APP_PROFILER/scripts/migrate_from_bash.py new file mode 100644 index 0000000..cd8e87c --- /dev/null +++ b/APP_PROFILER/scripts/migrate_from_bash.py @@ -0,0 +1,179 @@ +import sys +import os +import re +from datetime import datetime + +# Add project root to sys.path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from database import SessionLocal, engine, Base +from models import SystemSettings, PKISetting, UserProfile +from services import config as config_service + +def parse_bash_array(content, var_name): + # Dumb parser for bash arrays: VAR=( "val1" "val2" ) + # This is fragile but fits the simple format used in confvars + pattern = float = fr'{var_name}=\((.*?)\)' + match = re.search(pattern, content, re.DOTALL) + if match: + items = re.findall(r'"([^"]*)"', match.group(1)) + return items + return [] + +def parse_bash_var(content, var_name): + pattern = fr'{var_name}="?([^"\n]*)"?' + match = re.search(pattern, content) + if match: + return match.group(1) + return None + +def migrate_confvars(db): + confvars_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "confvars") + + if not os.path.exists(confvars_path): + print(f"No confvars found at {confvars_path}") + return + + with open(confvars_path, "r") as f: + content = f.read() + + print("Migrating System Settings...") + sys_settings = config_service.get_system_settings(db) + + # Map variables + protocol = parse_bash_var(content, "TPROTO") + if protocol: sys_settings.protocol = protocol + + port = parse_bash_var(content, "TPORT") + if port: sys_settings.port = int(port) + + vpn_network = parse_bash_var(content, "TSERNET") + if vpn_network: sys_settings.vpn_network = vpn_network.strip('"') + + vpn_netmask = parse_bash_var(content, "TSERMASK") + if vpn_netmask: sys_settings.vpn_netmask = vpn_netmask.strip('"') + + tunnel_type = parse_bash_var(content, "TTUNTYPE") + if tunnel_type: sys_settings.tunnel_type = tunnel_type + + duplicate_cn = parse_bash_var(content, "TDCN") + sys_settings.duplicate_cn = (duplicate_cn == "YES") + + client_to_client = parse_bash_var(content, "TC2C") + sys_settings.client_to_client = (client_to_client == "YES") + + crl_verify = parse_bash_var(content, "TREVO") + sys_settings.crl_verify = (crl_verify == "YES") + + # Arrays + split_routes = parse_bash_array(content, "TTUNNETS") + if split_routes: sys_settings.split_routes = split_routes + + dns_servers = parse_bash_array(content, "TDNS") + if dns_servers: + sys_settings.dns_servers = dns_servers + sys_settings.user_defined_dns = True + + # Scripts + conn_scripts = parse_bash_var(content, "T_CONNSCRIPTS") + sys_settings.user_defined_cdscripts = (conn_scripts == "YES") + + conn_script = parse_bash_var(content, "T_CONNSCRIPT_STRING") + if conn_script: sys_settings.connect_script = conn_script.strip('"') + + disconn_script = parse_bash_var(content, "T_DISCONNSCRIPT_STRING") + if disconn_script: sys_settings.disconnect_script = disconn_script.strip('"') + + # Mgmt + mgmt = parse_bash_var(content, "T_MGMT") + sys_settings.management_interface = (mgmt == "YES") + + mgmt_addr = parse_bash_var(content, "T_MGMT_ADDR") + if mgmt_addr: sys_settings.management_interface_address = mgmt_addr.strip('"') + + mgmt_port = parse_bash_var(content, "T_MGMT_PORT") + if mgmt_port: sys_settings.management_port = int(mgmt_port) + + db.commit() + print("System Settings Migrated.") + + print("Migrating PKI Settings...") + pki_settings = config_service.get_pki_settings(db) + + fqdn_server = parse_bash_var(content, "FQDN_SERVER") + if fqdn_server: pki_settings.fqdn_server = fqdn_server + + fqdn_ca = parse_bash_var(content, "FQDN_CA") + if fqdn_ca: pki_settings.fqdn_ca = fqdn_ca + + # EasyRSA vars + for line in content.splitlines(): + if line.startswith("export EASYRSA_"): + parts = line.split("=") + if len(parts) == 2: + key = parts[0].replace("export ", "").strip() + val = parts[1].strip().strip('"') + + # Map to model fields (lowercase) + if hasattr(pki_settings, key.lower()): + # Simple type conversion + field_type = type(getattr(pki_settings, key.lower())) + if field_type == int: + setattr(pki_settings, key.lower(), int(val)) + elif field_type == bool: + # Handle varied boolean strings + if val.lower() in ["1", "yes", "true", "on"]: + setattr(pki_settings, key.lower(), True) + else: + setattr(pki_settings, key.lower(), False) + else: + setattr(pki_settings, key.lower(), val) + + db.commit() + print("PKI Settings Migrated.") + +def migrate_users(db): + client_config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client-config") + if not os.path.exists(client_config_dir): + print("No client-config directory found.") + return + + print("Migrating Users...") + for filename in os.listdir(client_config_dir): + if filename.endswith(".ovpn"): + username = filename[:-5] # remove .ovpn + + # Check overlap + existing = db.query(UserProfile).filter(UserProfile.username == username).first() + if not existing: + # Basic import, we don't have createdAt date easily unless we stat the file + file_path = os.path.join(client_config_dir, filename) + stat = os.stat(file_path) + created_at = datetime.fromtimestamp(stat.st_ctime) + + # Try to parse ID from filename if it matches format "ID-Name" (common in this script) + # But the bash script logic was "ID-Name" -> client_name + # The UserProfile username should probably be the CommonName + + profile = UserProfile( + username=username, + status="active", + created_at=created_at, + file_path=file_path + ) + db.add(profile) + print(f"Imported user: {username}") + + db.commit() + print("Users Migrated.") + +if __name__ == "__main__": + # Ensure tables exist + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + try: + migrate_confvars(db) + migrate_users(db) + finally: + db.close() diff --git a/APP_PROFILER/services/__init__.py b/APP_PROFILER/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/APP_PROFILER/services/config.py b/APP_PROFILER/services/config.py new file mode 100644 index 0000000..cc14a25 --- /dev/null +++ b/APP_PROFILER/services/config.py @@ -0,0 +1,37 @@ +from sqlalchemy.orm import Session +from models import SystemSettings, PKISetting +from schemas import SystemSettingsUpdate, PKISettingUpdate + +def get_system_settings(db: Session): + settings = db.query(SystemSettings).first() + if not settings: + settings = SystemSettings() + db.add(settings) + db.commit() + db.refresh(settings) + return settings + +def update_system_settings(db: Session, settings_in: SystemSettingsUpdate): + settings = get_system_settings(db) + for key, value in settings_in.model_dump(exclude_unset=True).items(): + setattr(settings, key, value) + db.commit() + db.refresh(settings) + return settings + +def get_pki_settings(db: Session): + settings = db.query(PKISetting).first() + if not settings: + settings = PKISetting() + db.add(settings) + db.commit() + db.refresh(settings) + return settings + +def update_pki_settings(db: Session, settings_in: PKISettingUpdate): + settings = get_pki_settings(db) + for key, value in settings_in.model_dump(exclude_unset=True).items(): + setattr(settings, key, value) + db.commit() + db.refresh(settings) + return settings diff --git a/APP_PROFILER/services/generator.py b/APP_PROFILER/services/generator.py new file mode 100644 index 0000000..350054d --- /dev/null +++ b/APP_PROFILER/services/generator.py @@ -0,0 +1,108 @@ +import os +import logging +from jinja2 import Environment, FileSystemLoader +from sqlalchemy.orm import Session +from .config import get_system_settings, get_pki_settings +from .pki import PKI_DIR + +logger = logging.getLogger(__name__) + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TEMPLATES_DIR = os.path.join(BASE_DIR, "templates") + +env = Environment(loader=FileSystemLoader(TEMPLATES_DIR)) + +def generate_server_config(db: Session, output_path: str = "server.conf"): + settings = get_system_settings(db) + pki_settings = get_pki_settings(db) + template = env.get_template("server.conf.j2") + + # Rendering Path + file_ca_path = os.path.join(PKI_DIR, "ca.crt") + file_srv_cert_path = os.path.join(PKI_DIR, "issued", f"{pki_settings.fqdn_server}.crt") + file_srv_key_path = os.path.join(PKI_DIR, "private", f"{pki_settings.fqdn_server}.key") + file_dh_path = os.path.join(PKI_DIR, "dh.pem") + file_ta_path = os.path.join(PKI_DIR, "ta.key") + + # Render template + config_content = template.render( + protocol=settings.protocol, + port=settings.port, + ca_path=file_ca_path, + srv_cert_path=file_srv_cert_path, + srv_key_path=file_srv_key_path, + dh_path=file_dh_path, + ta_path=file_ta_path, + vpn_network=settings.vpn_network, + vpn_netmask=settings.vpn_netmask, + tunnel_type=settings.tunnel_type, + split_routes=settings.split_routes, + user_defined_dns=settings.user_defined_dns, + dns_servers=settings.dns_servers, + client_to_client=settings.client_to_client, + duplicate_cn=settings.duplicate_cn, + crl_verify=settings.crl_verify, + user_defined_cdscripts=settings.user_defined_cdscripts, + connect_script=settings.connect_script, + disconnect_script=settings.disconnect_script, + management_interface=settings.management_interface, + management_interface_address=settings.management_interface_address, + management_port=settings.management_port, + tun_mtu=settings.tun_mtu, + mssfix=settings.mssfix + ) + + # Write to file + with open(output_path, "w") as f: + f.write(config_content) + + return config_content + +def generate_client_config(db: Session, username: str, output_path: str): + settings = get_system_settings(db) + pki = get_pki_settings(db) + + # Read Certs and Keys + # Note: filenames in easy-rsa pki structure + # ca: pki/ca.crt + # cert: pki/issued/.crt + # key: pki/private/.key + # ta: pki/ta.key + + def read_file(path): + try: + with open(path, "r") as f: + return f.read().strip() + except FileNotFoundError: + logger.error(f"File not found: {path}") + return f"Error: {path} not found" + + ca_cert = read_file(os.path.join(PKI_DIR, "ca.crt")) + client_cert = read_file(os.path.join(PKI_DIR, "issued", f"{username}.crt")) + client_key = read_file(os.path.join(PKI_DIR, "private", f"{username}.key")) + tls_auth = read_file(os.path.join(PKI_DIR, "ta.key")) + + # Determine Remote IP + if settings.public_ip: + remote_ip = settings.public_ip + else: + from .utils import get_public_ip + remote_ip = get_public_ip() + + template = env.get_template("client.ovpn.j2") + + config_content = template.render( + protocol=settings.protocol, + remote_ip=remote_ip, + port=settings.port, + ca_cert=ca_cert, + client_cert=client_cert, + client_key=client_key, + tls_auth=tls_auth, + tun_mtu=settings.tun_mtu + ) + + with open(output_path, "w") as f: + f.write(config_content) + + return config_content diff --git a/APP_PROFILER/services/pki.py b/APP_PROFILER/services/pki.py new file mode 100644 index 0000000..c69e319 --- /dev/null +++ b/APP_PROFILER/services/pki.py @@ -0,0 +1,181 @@ +import os +import subprocess +import logging +from .config import get_pki_settings +from sqlalchemy.orm import Session +from datetime import datetime + +logger = logging.getLogger(__name__) + +EASY_RSA_DIR = os.path.join(os.getcwd(), "easy-rsa") +PKI_DIR = os.path.join(EASY_RSA_DIR, "pki") +INDEX_PATH = os.path.join(PKI_DIR, "index.txt") + +def get_pki_index_data(db: Session = None): + """ + Parses easy-rsa/pki/index.txt to get certificate expiration dates. + Returns a dict: { "common_name": datetime_expiration } + """ + if not os.path.exists(INDEX_PATH): + logger.warning(f"PKI index file not found at {INDEX_PATH}") + return {} + + pki_data = {} + + try: + with open(INDEX_PATH, "r") as f: + for line in f: + parts = line.strip().split('\t') + # OpenSSL index.txt format: + # 0: Flag (V=Valid, R=Revoked, E=Expired) + # 1: Expiration Date (YYMMDDHHMMSSZ) + # 2: Revocation Date (Can be empty) + # 3: Serial + # 4: Filename (unknown, often empty) + # 5: Distinguished Name (DN) -> /CN=username + + if len(parts) < 6: + continue + + flag = parts[0] + exp_date_str = parts[1] + dn = parts[5] + + if flag != 'V': # Only interested in valid certs expiration? Or all? User wants 'expired_at'. + # Even if revoked/expired, 'expired_at' (valid_until) is still a property of the cert. + pass + + # Extract CN + # dn formats: /CN=anton or /C=RU/ST=Moscow.../CN=anton + cn = None + for segment in dn.split('/'): + if segment.startswith("CN="): + cn = segment.split("=", 1)[1] + break + + if cn: + # Parse Date: YYMMDDHHMMSSZ -> 250116102805Z + # Note: OpenSSL uses 2-digit year. stored as 20YY or 19YY. + # python strptime %y handles 2-digit years (00-68 -> 2000-2068, 69-99 -> 1969-1999) + try: + exp_dt = datetime.strptime(exp_date_str, "%y%m%d%H%M%SZ") + pki_data[cn] = exp_dt + except ValueError: + logger.error(f"Failed to parse date: {exp_date_str}") + + except Exception as e: + logger.error(f"Error reading PKI index: {e}") + + return pki_data + +def _run_easyrsa(command: list, env: dict): + env_vars = os.environ.copy() + env_vars.update(env) + # Ensure EASYRSA_PKI is set + env_vars["EASYRSA_PKI"] = PKI_DIR + + cmd = [os.path.join(EASY_RSA_DIR, "easyrsa")] + command + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + env=env_vars, + cwd=EASY_RSA_DIR + ) + if result.returncode != 0: + logger.error(f"EasyRSA command failed: {cmd}") + logger.error(f"Stderr: {result.stderr}") + raise Exception(f"EasyRSA command failed: {result.stderr}") + return result.stdout + except Exception as e: + logger.exception("Error running EasyRSA") + raise e + +def _get_easyrsa_env(db: Session): + pki = get_pki_settings(db) + env = { + "EASYRSA_DN": pki.easyrsa_dn, + "EASYRSA_REQ_COUNTRY": pki.easyrsa_req_country, + "EASYRSA_REQ_PROVINCE": pki.easyrsa_req_province, + "EASYRSA_REQ_CITY": pki.easyrsa_req_city, + "EASYRSA_REQ_ORG": pki.easyrsa_req_org, + "EASYRSA_REQ_EMAIL": pki.easyrsa_req_email, + "EASYRSA_REQ_OU": pki.easyrsa_req_ou, + "EASYRSA_KEY_SIZE": str(pki.easyrsa_key_size), + "EASYRSA_CA_EXPIRE": str(pki.easyrsa_ca_expire), + "EASYRSA_CERT_EXPIRE": str(pki.easyrsa_cert_expire), + "EASYRSA_CERT_RENEW": str(pki.easyrsa_cert_renew), + "EASYRSA_CRL_DAYS": str(pki.easyrsa_crl_days), + "EASYRSA_BATCH": "1" if pki.easyrsa_batch else "0" + } + return env + +def init_pki(db: Session): + env = _get_easyrsa_env(db) + pki_settings = get_pki_settings(db) + + if os.path.exists(os.path.join(PKI_DIR, "ca.crt")): + logger.warning("PKI already initialized") + return "PKI already initialized" + + # Init PKI + _run_easyrsa(["init-pki"], env) + + # Build CA + _run_easyrsa(["--req-cn=" + pki_settings.fqdn_ca, "build-ca", "nopass"], env) + + # Build Server Cert + _run_easyrsa(["build-server-full", pki_settings.fqdn_server, "nopass"], env) + + # Gen DH + _run_easyrsa(["gen-dh"], env) + + # Gen TLS Auth Key (requires openvpn, not easyrsa) + ta_key_path = os.path.join(PKI_DIR, "ta.key") + subprocess.run(["openvpn", "--genkey", "secret", ta_key_path], check=True) + + # Gen CRL + _run_easyrsa(["gen-crl"], env) + + return "PKI Initialized" + +def clear_pki(db: Session): + # 1. Clear Database Users + from models import UserProfile + try: + db.query(UserProfile).delete() + db.commit() + except Exception as e: + logger.error(f"Failed to clear user profiles from DB: {e}") + db.rollback() + + # 2. Clear Client Configs + client_conf_dir = os.path.join(os.getcwd(), "client-config") + if os.path.exists(client_conf_dir): + import shutil + try: + shutil.rmtree(client_conf_dir) + # Recreate empty dir + os.makedirs(client_conf_dir, exist_ok=True) + except Exception as e: + logger.error(f"Failed to clear client configs: {e}") + + # 3. Clear PKI + if os.path.exists(PKI_DIR): + import shutil + shutil.rmtree(PKI_DIR) + return "System cleared: PKI environment, User DB, and Client profiles wiped." + return "PKI directory did not exist, but User DB and Client profiles were wiped." + +def build_client(username: str, db: Session): + env = _get_easyrsa_env(db) + _run_easyrsa(["build-client-full", username, "nopass"], env) + return True + +def revoke_client(username: str, db: Session): + env = _get_easyrsa_env(db) + _run_easyrsa(["revoke", username], env) + _run_easyrsa(["gen-crl"], env) + return True diff --git a/APP_PROFILER/services/process.py b/APP_PROFILER/services/process.py new file mode 100644 index 0000000..9b85af4 --- /dev/null +++ b/APP_PROFILER/services/process.py @@ -0,0 +1,140 @@ +import os +import subprocess +import logging +import time +import psutil + +logger = logging.getLogger(__name__) + +def get_os_type(): + """ + Simple check to distinguish Alpine from others. + """ + if os.path.exists("/etc/alpine-release"): + return "alpine" + return "debian" # default fallback to systemctl + +def control_service(action: str): + """ + Action: start, stop, restart + """ + if action not in ["start", "stop", "restart"]: + raise ValueError("Invalid action") + + os_type = get_os_type() + + cmd = [] + if os_type == "alpine": + cmd = ["rc-service", "openvpn", action] + else: + cmd = ["systemctl", action, "openvpn"] + + try: + # Capture output to return it or log it + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return { + "status": "success", + "message": f"Service {action} executed successfully", + "stdout": result.stdout + } + except subprocess.CalledProcessError as e: + logger.error(f"Service control failed: {e.stderr}") + return { + "status": "error", + "message": f"Failed to {action} service", + "stderr": e.stderr + } + except FileNotFoundError: + # Happens if rc-service or systemctl is missing (e.g. dev env) + return { + "status": "error", + "message": f"Command not found found for OS type {os_type}" + } + +def get_process_stats(): + """ + Returns dict with pid, cpu_percent, memory_mb, uptime. + Uses psutil for robust telemetry. + """ + pid = None + process = None + + # Find the process + try: + # Iterate over all running processes + for proc in psutil.process_iter(['pid', 'name']): + try: + if proc.info['name'] == 'openvpn': + pid = proc.info['pid'] + process = proc + break + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + pass + except Exception as e: + logger.error(f"Failed to find process via psutil: {e}") + + if not pid or not process: + return { + "status": "stopped", + "pid": None, + "cpu_percent": 0.0, + "memory_mb": 0.0, + "uptime": None + } + + # Get Stats + try: + # CPU Percent + # Increasing interval to 1.0s to ensure we capture enough ticks for accurate reading + # especially on systems with low clock resolution (e.g. 100Hz = 10ms ticks) + cpu = process.cpu_percent(interval=1.0) + + # Memory (RSS) + mem_info = process.memory_info() + rss_mb = round(mem_info.rss / 1024 / 1024, 2) + + # Uptime + create_time = process.create_time() + uptime_seconds = time.time() - create_time + uptime_str = format_seconds(uptime_seconds) + + return { + "status": "running", + "pid": pid, + "cpu_percent": cpu, + "memory_mb": rss_mb, + "uptime": uptime_str + } + + except (psutil.NoSuchProcess, psutil.AccessDenied): + # Process might have died between discovery and stats + return { + "status": "stopped", + "pid": None, + "cpu_percent": 0.0, + "memory_mb": 0.0, + "uptime": None + } + except Exception as e: + logger.error(f"Failed to get process stats: {e}") + return { + "status": "running", + "pid": pid, + "cpu_percent": 0.0, + "memory_mb": 0.0, + "uptime": None + } + +def format_seconds(seconds: float) -> str: + seconds = int(seconds) + days, seconds = divmod(seconds, 86400) + hours, seconds = divmod(seconds, 3600) + minutes, seconds = divmod(seconds, 60) + + parts = [] + if days > 0: parts.append(f"{days}d") + if hours > 0: parts.append(f"{hours}h") + if minutes > 0: parts.append(f"{minutes}m") + parts.append(f"{seconds}s") + + return " ".join(parts) diff --git a/APP_PROFILER/services/utils.py b/APP_PROFILER/services/utils.py new file mode 100644 index 0000000..21a5c2e --- /dev/null +++ b/APP_PROFILER/services/utils.py @@ -0,0 +1,29 @@ +import requests +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + +def get_public_ip() -> str: + """ + Detects public IP using external services. + Falls back to 127.0.0.1 on failure. + """ + services = [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + ] + + for service in services: + try: + response = requests.get(service, timeout=3) + if response.status_code == 200: + ip = response.text.strip() + # Basic validation could be added here + return ip + except requests.RequestException: + continue + + logger.warning("Could not detect public IP, defaulting to 127.0.0.1") + return "127.0.0.1" diff --git a/APP_PROFILER/templates/client.ovpn.j2 b/APP_PROFILER/templates/client.ovpn.j2 new file mode 100644 index 0000000..a3cf6b4 --- /dev/null +++ b/APP_PROFILER/templates/client.ovpn.j2 @@ -0,0 +1,44 @@ +client +dev tun +windows-driver wintun +proto {{ protocol }} +remote {{ remote_ip }} {{ port }} +resolv-retry infinite +nobind + +{% if tun_mtu %} +tun-mtu {{ tun_mtu }} +{% endif %} +user nobody +group nobody +persist-key +persist-tun + +{% if protocol == 'tcp' %} +tls-client +{% else %} +#tls-client +{% endif %} + +mute-replay-warnings + +remote-cert-tls server +data-ciphers CHACHA20-POLY1305:AES-256-GCM:AES-256-CBC +data-ciphers-fallback AES-256-CBC +auth SHA256 +verb 3 + +key-direction 1 + + +{{ ca_cert }} + + +{{ client_cert }} + + +{{ client_key }} + + +{{ tls_auth }} + diff --git a/APP_PROFILER/templates/server.conf.j2 b/APP_PROFILER/templates/server.conf.j2 new file mode 100644 index 0000000..1e9bc9e --- /dev/null +++ b/APP_PROFILER/templates/server.conf.j2 @@ -0,0 +1,110 @@ +dev tun +proto {{ protocol }} +{% if protocol == 'tcp' %} +tls-server +{% else %} +# explicit-exit-notify 1 +explicit-exit-notify 1 +{% endif %} +port {{ port }} + +# Keys +ca {{ ca_path }} +cert {{ srv_cert_path }} +key {{ srv_key_path }} +dh {{ dh_path }} +tls-auth {{ ta_path }} 0 + +{% if tun_mtu %} +tun-mtu {{ tun_mtu }} +{% endif %} +{% if mssfix %} +mssfix {{ mssfix }} +{% endif %} + +# Network topology +topology subnet +server {{ vpn_network }} {{ vpn_netmask }} + +ifconfig-pool-persist /etc/openvpn/ipp.txt + +log /etc/openvpn/openvpn.log +log-append /etc/openvpn/openvpn.log + +verb 3 + +# Use Extended Status Output +status /etc/openvpn/openvpn-status.log 5 +status-version 2 + +# Tunneling Mode +{% if tunnel_type == 'FULL' %} +push "redirect-gateway def1 bypass-dhcp" +# Full tunneling mode - all routes through VPN +{% else %} +# Split tunneling mode +{% for route in split_routes %} +push "route {{ route }}" +{% endfor %} +{% endif %} + +# DNS Configuration +{% if user_defined_dns %} +{% for dns in dns_servers %} +push "dhcp-option DNS {{ dns }}" +{% endfor %} +{% endif %} + +# Client-to-client communication +{% if client_to_client %} +client-to-client +{% else %} +# client-to-client disabled +{% endif %} + +user nobody +group nogroup + +# Allow same profile on multiple devices simultaneously +{% if duplicate_cn %} +duplicate-cn +{% else %} +# duplicate-cn disabled +{% endif %} + +# data protection +data-ciphers CHACHA20-POLY1305:AES-256-GCM:AES-256-CBC +data-ciphers-fallback AES-256-CBC +auth SHA256 + +keepalive 10 120 + +persist-key +persist-tun + +# check revocation list +{% if crl_verify %} +crl-verify /etc/openvpn/crl.pem +{% else %} +# crl-verify disabled +{% endif %} + +# Script Security Level +{% if user_defined_cdscripts %} +script-security 2 + +# Client Connect Script +{% if connect_script %} +client-connect "{{ connect_script }}" +{% endif %} + +# Client Disconnect Script +{% if disconnect_script %} +client-disconnect "{{ disconnect_script }}" +{% endif %} +{% endif %} + +# Enable Management Interface +{% if management_interface %} +management {{ management_interface_address }} {{ management_port }} +{% endif %} diff --git a/APP_PROFILER/test_server_process.py b/APP_PROFILER/test_server_process.py new file mode 100644 index 0000000..47a9945 --- /dev/null +++ b/APP_PROFILER/test_server_process.py @@ -0,0 +1,33 @@ +from fastapi.testclient import TestClient +from main import app +import sys +import os + +# Add project root to sys.path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +client = TestClient(app) + +def test_get_stats(): + response = client.get("/server/process/stats") + print(f"Stats response status: {response.status_code}") + print(f"Stats response body: {response.json()}") + assert response.status_code == 200 + data = response.json() + assert "pid" in data + assert "cpu_percent" in data + assert "memory_mb" in data + +def test_control_invalid(): + response = client.post("/server/process/invalid_action") + print(f"Invalid action response: {response.status_code}") + assert response.status_code == 400 + +if __name__ == "__main__": + print("Running API tests...") + try: + test_get_stats() + test_control_invalid() + print("Tests passed!") + except Exception as e: + print(f"Tests failed: {e}") diff --git a/APP_PROFILER/utils/__init__.py b/APP_PROFILER/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/APP_PROFILER/utils/auth.py b/APP_PROFILER/utils/auth.py new file mode 100644 index 0000000..e907523 --- /dev/null +++ b/APP_PROFILER/utils/auth.py @@ -0,0 +1,94 @@ +import jwt +import configparser +import os +from fastapi import Header, HTTPException, status +from pathlib import Path + +# Load config from the main APP directory +CONFIG_FILE = Path(__file__).parent.parent.parent / 'APP' / 'config.ini' + +def get_secret_key(): + # Priority 1: Environment Variable + env_secret = os.getenv('OVPMON_SECRET_KEY') + if env_secret: + print("[AUTH] Using SECRET_KEY from environment variable") + return env_secret + + # Priority 2: Config file (multiple possible locations) + # Resolve absolute path to be sure + base_path = Path(__file__).resolve().parent.parent + + config_locations = [ + base_path.parent / 'APP' / 'config.ini', # Brother directory (Local/Gitea structure) + base_path / 'APP' / 'config.ini', # Child directory + base_path / 'config.ini', # Same directory + Path('/opt/ovpmon/APP/config.ini'), # Common production path 1 + Path('/opt/ovpmon/config.ini'), # Common production path 2 + Path('/etc/ovpmon/config.ini'), # Standard linux config path + Path('/opt/ovpn_python_profiler/APP/config.ini') # Path based on traceback + ] + + config = configparser.ConfigParser() + for loc in config_locations: + if loc.exists(): + try: + config.read(loc) + if config.has_section('api') and config.has_option('api', 'secret_key'): + key = config.get('api', 'secret_key') + if key: + print(f"[AUTH] Successfully loaded SECRET_KEY from {loc}") + return key + except Exception as e: + print(f"[AUTH] Error reading config at {loc}: {e}") + continue + + print("[AUTH] WARNING: No config found, using default fallback SECRET_KEY") + return 'ovpmon-secret-change-me' + +SECRET_KEY = get_secret_key() + +async def verify_token(authorization: str = Header(None)): + if not authorization or not authorization.startswith("Bearer "): + print(f"[AUTH] Missing or invalid Authorization header: {authorization[:20] if authorization else 'None'}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is missing or invalid", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = authorization.split(" ")[1] + + try: + # Debug: Log a few chars of the key and token (safely) + # print(f"[AUTH] Decoding token with SECRET_KEY starting with: {SECRET_KEY[:3]}...") + + payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) + return payload + except Exception as e: + error_type = type(e).__name__ + error_detail = str(e) + print(f"[AUTH] JWT Decode Failed. Type: {error_type}, Detail: {error_detail}") + + # Handling exceptions dynamically to avoid AttributeError + if error_type == "ExpiredSignatureError": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + headers={"WWW-Authenticate": "Bearer"}, + ) + elif error_type in ["InvalidTokenError", "DecodeError"]: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is invalid", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + # Check if it's a TypeError (e.g. wrong arguments for decode) + if error_type == "TypeError": + print("[AUTH] Critical: jwt.decode failed with TypeError. This likely means 'jwt' package is installed instead of 'PyJWT'.") + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Authentication error: {error_type}", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/APP_PROFILER/utils/logging.py b/APP_PROFILER/utils/logging.py new file mode 100644 index 0000000..e2a9ee2 --- /dev/null +++ b/APP_PROFILER/utils/logging.py @@ -0,0 +1,12 @@ +import logging +import sys + +def setup_logging(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler("profiler.log") + ] + ) diff --git a/UI/client/.gitignore b/APP_UI/.gitignore similarity index 100% rename from UI/client/.gitignore rename to APP_UI/.gitignore diff --git a/UI/client/.vscode/extensions.json b/APP_UI/.vscode/extensions.json similarity index 100% rename from UI/client/.vscode/extensions.json rename to APP_UI/.vscode/extensions.json diff --git a/APP_UI/README.md b/APP_UI/README.md new file mode 100644 index 0000000..c67f1f4 --- /dev/null +++ b/APP_UI/README.md @@ -0,0 +1,25 @@ +# OpenVPN Dashboard UI (`APP_UI`) + +A Single Page Application (SPA) built with **Vue 3** and **Vite**. It serves as the unified dashboard for monitoring and management. + +## Project Structure + +- `src/views/`: Page components (Dashboard, Login, Profiles, etc.). +- `src/components/`: Reusable widgets (Charts, Sidebar). +- `src/stores/`: Pinia state management (Auth, Client Data). + +## Configuration + +Runtime configuration is loaded from `/config.json` (in `public/`) to allow environment-independent builds. + +## Development + +```bash +npm install +npm run dev +# Access at http://localhost:5173 +``` + +## Documentation +See `DOCS/UI/Architecture.md` for detailed architecture notes. + diff --git a/UI/client/index.html b/APP_UI/index.html similarity index 89% rename from UI/client/index.html rename to APP_UI/index.html index f16333d..67f4601 100644 --- a/UI/client/index.html +++ b/APP_UI/index.html @@ -5,7 +5,7 @@ - OpenVPN Monitor + OpenVPN Controller diff --git a/APP_UI/jsconfig.json b/APP_UI/jsconfig.json new file mode 100644 index 0000000..5d0d5c4 --- /dev/null +++ b/APP_UI/jsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": [ + "src/*" + ] + }, + "jsx": "preserve", + "module": "esnext", + "moduleResolution": "node", + "target": "esnext", + "lib": [ + "esnext", + "dom" + ], + "checkJs": true + }, + "include": [ + "src/**/*.js", + "src/**/*.vue", + "src/**/*.css", + "index.html" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/UI/client/package-lock.json b/APP_UI/package-lock.json similarity index 99% rename from UI/client/package-lock.json rename to APP_UI/package-lock.json index 980e618..b65b590 100644 --- a/UI/client/package-lock.json +++ b/APP_UI/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.13.2", "bootstrap": "^5.3.8", "chart.js": "^4.5.1", + "qrcode.vue": "^3.6.0", "sass": "^1.97.2", "sweetalert2": "^11.26.17", "vue": "^3.5.24", @@ -1936,6 +1937,15 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/qrcode.vue": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.6.0.tgz", + "integrity": "sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", diff --git a/UI/client/package.json b/APP_UI/package.json similarity index 94% rename from UI/client/package.json rename to APP_UI/package.json index 0c1a118..6d5e61c 100644 --- a/UI/client/package.json +++ b/APP_UI/package.json @@ -13,6 +13,7 @@ "axios": "^1.13.2", "bootstrap": "^5.3.8", "chart.js": "^4.5.1", + "qrcode.vue": "^3.6.0", "sass": "^1.97.2", "sweetalert2": "^11.26.17", "vue": "^3.5.24", diff --git a/UI/client/public/.htaccess b/APP_UI/public/.htaccess similarity index 100% rename from UI/client/public/.htaccess rename to APP_UI/public/.htaccess diff --git a/APP_UI/public/config.json b/APP_UI/public/config.json new file mode 100644 index 0000000..5b5a612 --- /dev/null +++ b/APP_UI/public/config.json @@ -0,0 +1,5 @@ +{ + "api_base_url": "/api/v1", + "profiles_api_base_url": "/profiles-api", + "refresh_interval": 30000 +} \ No newline at end of file diff --git a/APP_UI/public/logo.svg b/APP_UI/public/logo.svg new file mode 100644 index 0000000..fc4d6a3 --- /dev/null +++ b/APP_UI/public/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/UI/client/public/vite.svg b/APP_UI/public/vite.svg similarity index 100% rename from UI/client/public/vite.svg rename to APP_UI/public/vite.svg diff --git a/APP_UI/src/App.vue b/APP_UI/src/App.vue new file mode 100644 index 0000000..70e73ce --- /dev/null +++ b/APP_UI/src/App.vue @@ -0,0 +1,179 @@ + + + + Loading... + + + + + + + + + + + + + + + + + + + + + + + + + + {{ timezoneAbbr }} + + + + + + + + + + + + + {{ username[0]?.toUpperCase() || 'A' }} + + + {{ username }} + + + + + + + + + + + + + + + + + + diff --git a/APP_UI/src/assets/logo.svg b/APP_UI/src/assets/logo.svg new file mode 100644 index 0000000..fc4d6a3 --- /dev/null +++ b/APP_UI/src/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/APP_UI/src/assets/logo_dark.svg b/APP_UI/src/assets/logo_dark.svg new file mode 100644 index 0000000..b3600ae --- /dev/null +++ b/APP_UI/src/assets/logo_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/APP_UI/src/assets/main.css b/APP_UI/src/assets/main.css new file mode 100644 index 0000000..3c06218 --- /dev/null +++ b/APP_UI/src/assets/main.css @@ -0,0 +1,1819 @@ +/* --- THEME VARIABLES --- */ +:root { + /* Light Theme */ + --bg-body: #f6f8fa; + --bg-card: #ffffff; + --bg-element: #f6f8fa; + --bg-element-hover: #f1f3f5; + --bg-input: #ffffff; + + --text-heading: #24292f; + --text-main: #57606a; + --text-muted: #8c959f; + + --border-color: #d0d7de; + --border-subtle: #e9ecef; + + --badge-border-active: #92bea5; + --badge-border-disconnected: #d47e80; + + --accent-color: #0969da; + --success-bg: rgba(39, 174, 96, 0.15); + --success-text: #1a7f37; + --danger-bg: rgba(231, 76, 60, 0.15); + --danger-text: #cf222e; + --warning-bg: rgba(255, 193, 7, 0.15); + --warning-text: #9a6700; + + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + --toggle-off-bg: #e9ecef; + --toggle-off-border: #d0d7de; + --toggle-on-bg: #EC7C31; + --toggle-on-border: #EC7C31; + --toggle-knob: #ffffff; + + /* Control Button Branding Variables (Light) */ + --btn-start-bg: #1652B8; + --btn-start-border: #1652B8; + --btn-start-text: #ffffff; + + --btn-restart-bg: #EC7C31; + --btn-restart-border: #EC7C31; + --btn-restart-text: #ffffff; + + --btn-stop-bg: #FA7355; + --btn-stop-border: #FA7355; + --btn-stop-text: #ffffff; + + /* Sidebar Variables (Light) */ + --bg-sidebar: #24292f; + --text-sidebar: #ffffff; + --text-sidebar-muted: #8b949e; + --bg-sidebar-hover: rgba(255, 255, 255, 0.1); + --bg-sidebar-active: #EC7C31; + --text-sidebar-active: #ffffff; + --border-sidebar-active: transparent; + --border-sidebar: rgba(255, 255, 255, 0.1); + --bg-sidebar-element: rgba(255, 255, 255, 0.05); +} + +/* Dark Theme (Soft & Low Contrast) */ +[data-theme="dark"] { + --bg-body: #0d1117; + --bg-card: #161b22; + --bg-element: #21262d; + + /* Используем прозрачность для hover, чтобы текст не сливался */ + --bg-element-hover: rgba(255, 255, 255, 0.08); + + --bg-input: #0d1117; + + --text-heading: #f0f6fc; + /* Светлее для заголовков */ + --text-main: #c9d1d9; + /* Мягкий серый для текста */ + --text-muted: #8b949e; + + /* ОЧЕНЬ мягкие границы (6% прозрачности белого) */ + --border-color: rgba(240, 246, 252, 0.08); + --border-subtle: rgba(240, 246, 252, 0.04); + + --badge-border-active: #3e6f40; + --badge-border-disconnected: #793837; + + --accent-color: #58a6ff; + --success-bg: rgba(35, 134, 54, 0.15); + --success-text: #3fb950; + --danger-bg: rgba(218, 54, 51, 0.15); + --danger-text: #f85149; + --warning-bg: rgba(210, 153, 34, 0.15); + --warning-text: #d29922; + + --shadow: none; + + --toggle-off-bg: rgba(110, 118, 129, 0.1); + --toggle-off-border: rgba(240, 246, 252, 0.1); + --toggle-on-bg: rgba(236, 124, 49, 0.15); + --toggle-on-border: rgba(236, 124, 49, 0.3); + --toggle-knob: #EC7C31; + + /* Control Button Branding Variables (Dark - Soft Style) */ + --btn-start-bg: rgba(22, 82, 184, 0.15); + --btn-start-border: rgba(22, 82, 184, 0.3); + --btn-start-text: #58a6ff; + + --btn-restart-bg: rgba(236, 124, 49, 0.15); + --btn-restart-border: rgba(236, 124, 49, 0.3); + --btn-restart-text: #EC7C31; + + --btn-stop-bg: rgba(250, 115, 85, 0.15); + --btn-stop-border: rgba(250, 115, 85, 0.3); + --btn-stop-text: #FA7355; + + /* Sidebar Variables (Dark) - Same as card or slightly different */ + --bg-sidebar: #161b22; + --text-sidebar: #c9d1d9; + --text-sidebar-muted: #8b949e; + --bg-sidebar-hover: rgba(255, 255, 255, 0.08); + --bg-sidebar-active: rgba(236, 124, 49, 0.15); + --text-sidebar-active: #EC7C31; + --border-sidebar-active: rgba(236, 124, 49, 0.3); + --border-sidebar: rgba(240, 246, 252, 0.08); + --bg-sidebar-element: rgba(240, 246, 252, 0.05); + + /* Card background refinement for 2FA and others */ + --bg-card-inner: #1c2128; +} + +body { + background-color: var(--bg-body); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 0.9rem; + color: var(--text-main); + margin: 0; + padding: 0; + overflow-x: hidden; + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* --- NEW APP LAYOUT --- */ +.app-wrapper { + display: flex; + min-height: 100vh; +} + +.text-success { + color: #1652B8 !important; + /* OpenVPN Blue */ +} + +.text-primary { + color: #1652B8 !important; + /* OpenVPN Blue */ +} + +.text-warning { + color: #EC7C31 !important; + /* OpenVPN Orange */ +} + +.text-danger { + color: #FA7355 !important; + /* OpenVPN Pinkish Red */ +} + +/* Sidebar */ +.sidebar { + width: 260px; + background-color: var(--bg-sidebar); + border-right: none; + display: flex; + flex-direction: column; + position: fixed; + height: 100vh; + z-index: 1000; + transition: width 0.3s ease, background-color 0.3s ease, border-color 0.3s ease; + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.04); +} + +.sidebar-header { + height: 64px; + display: flex; + align-items: center; + padding: 0 20px; + border-bottom: 1px solid var(--border-sidebar); + font-weight: 700; + font-size: 0.95rem; + color: var(--text-sidebar); +} + +.sidebar-brand-icon { + /* Image logo styles */ + width: 28px; + height: auto; + margin-right: 10px; + min-width: 24px; + /* Ensure icon has space in compact mode */ + object-fit: contain; +} + +.sidebar-menu { + flex: 1; + padding: 10px; + overflow-y: auto; + overflow-x: hidden; +} + +.sidebar-footer { + padding: 10px; + border-top: 1px solid var(--border-sidebar); + display: flex; + align-items: center; + transition: all 0.3s ease; +} + +.sidebar.compact .sidebar-footer { + padding: 10px 5px; + justify-content: center; +} + +.nav-link { + display: flex; + align-items: center; + padding: 10px 15px; + color: var(--text-sidebar); + opacity: 0.8; + text-decoration: none; + border-radius: 6px; + margin-bottom: 5px; + transition: all 0.2s ease; + font-weight: 500; + white-space: nowrap; + /* Prevent wrapping in compact */ +} + +.nav-link:hover { + background-color: var(--bg-sidebar-hover); + color: var(--text-sidebar); + opacity: 1; +} + +.nav-link.active { + background-color: var(--bg-sidebar-active); + color: var(--text-sidebar-active); + border: 1px solid var(--border-sidebar-active); + opacity: 1; +} + +.nav-link i { + width: 24px; + text-align: center; + margin-right: 10px; + font-size: 1rem; + flex-shrink: 0; +} + +.nav-section-header { + padding: 15px 15px 5px 15px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05rem; + color: var(--text-sidebar-muted); + text-transform: uppercase; +} + +/* Compact Mode Styles */ +.sidebar.compact { + width: 64px; +} + +.sidebar.compact .sidebar-header { + padding: 0; + justify-content: center; +} + +.sidebar.compact .brand-text, +.sidebar.compact .nav-link span, +.sidebar.compact .nav-section-header, +.sidebar.compact .sidebar-header .brand-text { + display: none; +} + +.sidebar.compact .sidebar-brand-icon { + margin-right: 0; +} + +.sidebar.compact .nav-link { + justify-content: center; + padding: 10px 5px; + /* Tighter padding */ +} + +.sidebar.compact .nav-link i { + margin-right: 0; +} + +/* Sidebar collapse button */ +.toggle-btn { + padding: 0; + width: 100%; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + background-color: var(--bg-sidebar-element); + border: 1px solid var(--border-sidebar); + color: var(--text-sidebar-muted); + text-decoration: none; + transition: all 0.2s ease; +} + +.toggle-btn:hover { + background-color: var(--bg-sidebar-hover); + color: var(--text-sidebar); + border-color: var(--text-sidebar-muted); +} + +.sidebar.compact .toggle-btn { + width: 40px; + margin: 0 auto; +} + + +/* Main Content Area */ +.main-content { + flex: 1; + margin-left: 260px; + width: calc(100% - 260px); + display: flex; + flex-direction: column; + min-height: 100vh; + transition: margin-left 0.3s ease, width 0.3s ease; +} + +.app-wrapper.no-sidebar .main-content { + margin-left: 0; + width: 100%; +} + +.app-wrapper.sidebar-compact .main-content { + margin-left: 64px; + width: calc(100% - 64px); +} + +/* Top Navigation Bar (Glassmorphism) */ +.top-navbar { + height: 64px; + background-color: rgba(255, 255, 255, 0.6); + /* Semi-transparent Light */ + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 30px; + position: sticky; + top: 0; + z-index: 900; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +[data-theme="dark"] .top-navbar { + background-color: rgba(22, 27, 34, 0.6); + /* Semi-transparent Dark */ +} + + + +.page-title h2 { + margin: 0; + font-size: 1.25rem; + color: var(--text-heading); +} + +/* Content Container */ +.content-wrapper { + padding: 30px; + flex: 1; +} + +/* Adapt existing container */ +.container { + max-width: 100% !important; + margin: 0 !important; + padding: 0 !important; +} + +/* Layout Elements */ +.card { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: var(--shadow); + margin-bottom: 20px; + transition: border-color 0.3s ease; +} + +.card-header { + background: transparent; + border-bottom: 1px solid var(--border-color); + padding: 15px 20px; + font-weight: 600; + color: var(--text-heading) !important; +} + +.card-header:first-child { + height: 55px; +} + +.btn-account-action { + width: 250px; + height: 44px; + padding-left: 0 !important; + padding-right: 0 !important; + flex: none !important; +} + +/* Interior alignment for account cards */ +.card-interior-icon { + height: 70px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +.card-interior-title { + height: 40px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.5rem; +} + +.card-interior-description { + height: 60px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; + padding: 0 1rem; + text-align: center; +} + +/* Custom Progress Bars */ +.progress { + background-color: var(--bg-element) !important; + border-radius: 20px !important; + overflow: hidden; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] .progress { + background-color: rgba(255, 255, 255, 0.05) !important; + /* Global dark track visibility fix */ + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); +} + +/* Scoped Refinements for CPU Usage (Light Theme ONLY) */ +[data-theme="light"] .cpu-progress .progress { + background-color: #ffffff !important; + border: 1px solid var(--border-color) !important; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05); +} + +/* Dark Theme specific for CPU Progress (Preserving global track) */ +[data-theme="dark"] .cpu-progress .progress { + border: none !important; + background-color: rgba(255, 255, 255, 0.05) !important; +} + +.cpu-progress .progress-bar-brand { + background-color: #1652B8 !important; + /* OpenVPN Blue */ + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-size: 1rem 1rem; + transition: width 0.6s ease; + border-radius: 20px !important; +} + +[data-theme="dark"] .cpu-progress .progress-bar-brand { + background-color: #1a73e8 !important; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: var(--text-heading); + font-weight: 600; +} + +.text-muted { + color: var(--text-muted) !important; +} + +/* Navbar Actions */ +.navbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* Buttons & Controls */ +.btn-nav, +.btn-header, +.header-badge, +.header-timezone { + background: var(--bg-element); + border: 1px solid var(--border-color); + color: var(--text-heading); + transition: all 0.2s ease; + font-size: 0.85rem; + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + /* Fixed height for consistency */ + vertical-align: middle; + border-radius: 6px; + text-decoration: none; +} + +.btn-nav:hover, +.btn-header:hover { + background: var(--bg-element-hover); + border-color: var(--text-muted); + color: var(--text-heading); +} + +.btn-nav.active { + background: var(--accent-color); + color: #ffffff; + border-color: var(--accent-color); +} + +.btn-header { + padding: 0 12px; + min-width: 36px; +} + +.btn-nav { + padding: 0 12px; + margin-right: 0.5rem; +} + +.header-badge, +.header-timezone { + padding: 0 12px; +} + +.header-divider { + width: 1px; + height: 24px; + background-color: var(--border-color); + margin: 0 5px; +} + +.user-profile { + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; + border-radius: 6px; + transition: background-color 0.2s; +} + +.user-avatar-small { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; +} + +.user-meta { + line-height: 1; +} + +.user-meta .username { + font-weight: 600; + font-size: 0.85rem; + color: var(--text-heading); +} + +.btn-logout { + color: var(--danger-text) !important; + background: transparent !important; + border-color: transparent !important; +} + +.btn-logout:hover { + background-color: var(--danger-bg) !important; + color: var(--danger-text) !important; +} + + + +/* Stats Cards & KPI */ +.stats-info, +.kpi-grid { + display: flex; + gap: 15px; + flex-wrap: wrap; + margin-bottom: 15px; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 20px; +} + +.stat-item { + background: var(--bg-card); + padding: 15px; + border-radius: 6px; + border: 1px solid var(--border-color); + flex: 1; + min-width: 180px; + box-shadow: var(--shadow); +} + +.kpi-grid .stat-item { + padding: 20px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.stat-value { + font-size: 1.4rem; + font-weight: 600; + color: var(--text-heading); + line-height: 1.2; +} + +.kpi-grid .stat-content h3 { + font-size: 1.6rem; + font-weight: 700; + color: var(--text-heading); + margin: 0; + line-height: 1.2; +} + +.stat-label { + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 5px; +} + +/* Tables */ +.table { + --bs-table-bg: transparent; + --bs-table-color: var(--text-main); + --bs-table-border-color: var(--border-color); + margin-bottom: 0; +} + +.table th { + background-color: var(--bg-card); + color: var(--text-muted); + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 10; + cursor: pointer; + user-select: none; + padding: 12px 10px; +} + +.table th:hover { + color: var(--text-heading); +} + +.table th.active-sort { + color: var(--accent-color); + border-bottom-color: var(--accent-color); +} + +.table td { + padding: 12px 10px; + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; +} + +.table-hover tbody tr:hover { + background-color: var(--bg-element-hover); +} + +.table-hover tbody tr:hover td, +.table-hover tbody tr:hover .font-monospace { + color: var(--text-heading); +} + +.font-monospace { + color: var(--text-main); + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; +} + +/* Section Divider */ +.section-divider td { + background-color: var(--bg-element) !important; + font-weight: 600; + color: var(--text-heading); + padding: 8px 15px; + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + font-size: 0.8rem; +} + +/* Badges */ +.status-badge { + padding: 4px 8px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + display: inline-block; + line-height: normal; + border: 1px solid transparent; +} + +/* Using !important to ensure override of any bootstrap or generic styles */ +.status-valid, +.status-active { + background-color: rgba(22, 82, 184, 0.1) !important; + color: #1652B8 !important; + /* OpenVPN Blue */ + border-color: rgba(22, 82, 184, 0.2) !important; +} + +.status-expired, +.status-disconnected { + background-color: rgba(250, 115, 85, 0.1) !important; + color: #FA7355 !important; + /* OpenVPN Pinkish Red */ + border-color: rgba(250, 115, 85, 0.2) !important; +} + +.status-warning, +.status-expiring { + background-color: rgba(236, 124, 49, 0.1) !important; + color: #EC7C31 !important; + /* OpenVPN Orange */ + border-color: rgba(236, 124, 49, 0.2) !important; +} + +.status-server { + background-color: rgba(13, 202, 240, 0.1) !important; + color: #0c5460 !important; + border-color: rgba(13, 202, 240, 0.2) !important; +} + +.status-client { + background-color: rgba(108, 117, 125, 0.1) !important; + color: #373b3e !important; + border-color: rgba(108, 117, 125, 0.2) !important; +} + +.status-secondary { + background-color: rgba(108, 117, 125, 0.1) !important; + color: #373b3e !important; + border-color: rgba(108, 117, 125, 0.2) !important; +} + +.badge-soft-warning { + background: var(--warning-bg); + color: var(--warning-text); + border: 1px solid transparent; + font-weight: 600; + font-size: 0.75rem; + padding: 5px 10px; +} + +.client-link { + color: var(--text-heading); + text-decoration: none; + font-weight: 600; + cursor: pointer; + transition: color 0.2s; +} + +.client-link:hover { + color: var(--accent-color); +} + +.client-link i { + color: var(--text-muted); + transition: color 0.2s; +} + +.client-link:hover i { + color: var(--accent-color); +} + +/* Inputs */ +.form-control, +.form-select, +.search-input { + background-color: var(--bg-input); + border: 1px solid var(--border-color); + color: var(--text-heading); + border-radius: 6px; +} + +.form-control:focus, +.form-select:focus, +.search-input:focus { + background-color: var(--bg-input); + border-color: var(--accent-color); + color: var(--text-heading); + box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.15); + outline: none; +} + +.form-control::placeholder { + color: var(--text-muted); + opacity: 0.7; +} + +.form-label { + color: var(--text-heading); + font-weight: 500; +} + +.input-group-text { + background-color: var(--bg-element); + border: 1px solid var(--border-color); + color: var(--text-muted); +} + +/* Toggle Switch */ +.form-check-input { + background-color: var(--toggle-off-bg); + border-color: var(--toggle-off-border); + cursor: pointer; +} + +.form-check-input:checked { + background-color: #EC7C31; + border-color: #EC7C31; +} + +[data-theme="dark"] .form-check-input:not(:checked) { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%238b949e'/%3e%3c/svg%3e"); +} + +/* Sort Buttons */ +.sort-btn-group { + display: flex; +} + +.sort-btn { + background: var(--bg-input); + border: 1px solid var(--border-color); + padding: 6px 12px; + font-size: 0.85rem; + color: var(--text-main); + min-width: 100px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.sort-btn:first-child { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + border-right: none; +} + +.sort-btn:last-child { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +} + +.sort-btn:hover { + background-color: var(--bg-element-hover); +} + +.sort-btn.active { + background-color: var(--bg-element-hover); + color: var(--text-heading); + border-color: var(--text-muted); + font-weight: 600; + z-index: 2; +} + +/* Modals */ +.modal-content { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-main); + box-shadow: 0 15px 50px rgba(0, 0, 0, 0.5); +} + +.modal-header { + border-bottom: 1px solid var(--border-color); +} + +.modal-footer { + border-top: 1px solid var(--border-color); +} + +.btn-close { + filter: var(--btn-close-filter, none); +} + +[data-theme="dark"] .btn-close { + filter: invert(1) grayscale(100%) brightness(200%); +} + +.modal-backdrop.show { + opacity: 0.6; + background-color: #000; +} + +.chart-controls { + background: var(--bg-element); + padding: 12px; + border-radius: 6px; + border: 1px solid var(--border-color); + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + + +.bg-white-custom { + background-color: var(--bg-input) !important; + border-color: var(--border-color) !important; +} + +.chart-container { + position: relative; + height: 320px; + width: 100%; +} + +.modal-chart-container { + height: 400px; + width: 100%; + position: relative; +} + +.pie-container { + position: relative; + height: 220px; + width: 100%; + display: flex; + justify-content: center; +} + +.chart-header-controls { + display: flex; + align-items: center; + gap: 15px; +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + margin-right: 5px; +} + +/* Certificates Specifics */ +.category-header { + background: linear-gradient(135deg, var(--bg-element), var(--bg-element-hover)); + border-left: 4px solid; + padding: 12px 20px; + margin: 0; + font-weight: 600; +} + +.category-header.active { + border-left-color: var(--success-text); + color: var(--success-text); +} + +.category-header.expired { + border-left-color: var(--danger-text); + color: var(--danger-text); +} + +.certificate-file { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 2px; +} + +.empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); +} + +.empty-state i { + font-size: 3rem; + margin-bottom: 15px; + opacity: 0.5; +} + +.cert-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.cert-item:last-child { + border-bottom: none; +} + +/* Utilities */ +.refresh-indicator { + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* Responsive */ +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + width: 260px; + /* Keep fixed width when open */ + } + + .app-wrapper.mobile-nav-active .sidebar { + transform: translateX(0); + box-shadow: 0 0 50px rgba(0, 0, 0, 0.5); + } + + /* Force full-width and visibility even if sidebar.compact is present */ + .sidebar.compact { + width: 260px !important; + } + + .sidebar.compact .brand-text, + .sidebar.compact .nav-link span, + .sidebar.compact .nav-section-header, + .sidebar.compact .sidebar-header .brand-text { + display: block !important; + } + + .sidebar.compact .sidebar-header { + padding: 0 20px !important; + justify-content: flex-start !important; + } + + .sidebar.compact .sidebar-brand-icon { + margin-right: 10px !important; + } + + .sidebar.compact .nav-link { + justify-content: flex-start !important; + padding: 10px 15px !important; + } + + .sidebar.compact .nav-link i { + margin-right: 10px !important; + } + + .main-content, + .app-wrapper.sidebar-compact .main-content { + margin-left: 0 !important; + width: 100% !important; + } + + .header-controls { + flex-wrap: wrap; + gap: 8px; + } + + .stats-info { + flex-direction: column; + } + + .chart-controls { + flex-direction: column; + align-items: flex-start; + } + + .chart-controls>div { + width: 100%; + } + + .kpi-grid { + grid-template-columns: 1fr; + } + + .chart-header-controls { + flex-wrap: wrap; + margin-top: 10px; + } + + /* Overlay for mobile sidebar */ + .mobile-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + backdrop-filter: blur(2px); + } +} + +/* --- CONFIGURATION PAGES (PKI & VPN) --- */ + +/* Config Page Layout */ +.config-wrapper { + display: flex; + justify-content: center; + align-items: flex-start; + padding: 40px 0; + min-height: calc(100vh - 60px); + background-color: var(--bg-body); +} + +.config-card { + width: 640px; + max-width: 100%; + margin: 0 25px; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 24px; + background-color: var(--bg-card); + /* RESTORED: Specific shadow for config cards */ + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.04); +} + +[data-theme="dark"] .config-card { + box-shadow: none; +} + +/* Config Header */ +.config-header { + background-color: var(--bg-element); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 22px; + text-align: center; + margin-bottom: 28px; +} + +.config-header h1 { + font-size: 19px; + font-weight: 600; + color: var(--text-heading); + letter-spacing: 0.3px; + margin: 0; +} + +/* Form Grid */ +.config-row { + display: flex; + gap: 20px; + margin-bottom: 18px; +} + +.config-col { + flex: 1; + display: flex; + flex-direction: column; +} + +.config-divider { + height: 1px; + background-color: var(--border-color); + margin: 24px 0; + width: 100%; +} + +/* Inputs & Labels */ +.config-label { + font-size: 12px; + text-transform: uppercase; + color: var(--text-heading); + margin-bottom: 7px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.config-input, +.config-select { + height: 40px; + padding: 0 12px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 14px; + background-color: var(--bg-input); + color: var(--text-heading); + width: 100%; + box-sizing: border-box; + transition: all 0.2s ease; +} + +.config-input:focus, +.config-select:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.15); +} + +/* Unified Buttons */ +.btn-action-group { + display: flex; + justify-content: space-between; + gap: 16px; + margin-top: 32px; +} + +.btn-action { + flex: 1; + padding: 14px 0; + border-radius: 6px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.2s; + font-size: 15px; + letter-spacing: 0.3px; +} + +.btn-action i { + font-size: 16px; +} + +.btn-action-save { + background-color: var(--btn-restart-bg) !important; + color: var(--btn-restart-text) !important; + border-color: var(--btn-restart-border) !important; +} + +.btn-action-primary { + /* Init/Apply / Start */ + background-color: var(--btn-start-bg) !important; + color: var(--btn-start-text) !important; + border-color: var(--btn-start-border) !important; +} + +.btn-action-danger { + /* Clear / Stop */ + background-color: var(--btn-stop-bg) !important; + color: var(--btn-stop-text) !important; + border-color: var(--btn-stop-border) !important; +} + +.btn-action-secondary { + /* Cancel / Reset */ + background-color: transparent; + color: var(--text-muted); + border-color: var(--border-color); +} + +.btn-action-secondary:hover { + background-color: var(--bg-element-hover); + color: var(--text-heading); + border-color: var(--text-muted); +} + +.btn-action:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + filter: brightness(0.9); + /* More distinct darkening for light theme */ +} + +.btn-action:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +/* Small Action Button (Unified) */ +.btn-action.btn-sm { + flex: none; + padding: 0 20px; + height: 36px; + font-size: 0.85rem; + gap: 8px; +} + +.btn-action.btn-sm i { + font-size: 14px; +} + + +/* Custom Toggle Switch for Configs */ +.toggle-wrapper { + display: flex; + align-items: center; + height: 40px; + margin: 0; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 26px; + margin: 7px 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--toggle-off-bg); + transition: .3s; + border-radius: 34px; + border: 1px solid var(--border-color); +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 3px; + bottom: 2px; + background-color: var(--toggle-knob); + transition: .3s; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +input:checked+.toggle-slider { + background-color: var(--toggle-on-bg); + border-color: var(--toggle-on-border); +} + +input:checked+.toggle-slider:before { + transform: translateX(22px); +} + +/* Nested Container */ +.nested-box { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 20px; + margin-top: 10px; + margin-bottom: 10px; + background-color: var(--bg-card); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.nested-label { + color: var(--text-muted); + margin-bottom: 12px; +} + + +/* Dashed Button */ +.btn-dashed { + width: 100%; + margin-top: 10px; + padding: 10px; + background-color: var(--bg-element); + border: 1px dashed #EC7C31; + color: var(--accent-color); + border-radius: 6px; + cursor: pointer; + font-weight: 500; + transition: background 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + text-decoration: none; +} + +.btn-dashed:hover { + background-color: var(--bg-element-hover); + color: var(--accent-color); +} + +.btn-icon-sm { + width: 42px; + padding: 0; + flex-shrink: 0; + border: 1px solid var(--danger-bg); + background-color: var(--danger-bg); + color: var(--danger-text); + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + height: 40px; +} + +.btn-icon-sm:hover { + background-color: var(--bg-element-hover); + /* filter: brightness(0.95); */ +} + +/* 2FA Setup Refinements */ +.qr-setup-container { + max-width: 400px; + background-color: var(--bg-card-inner, var(--bg-element)); + padding: 30px; + border-radius: 12px; + border: 1px solid var(--border-color); +} + +.qr-code-wrapper { + background-color: #ffffff; + /* QR code needs white background for scanning */ + padding: 15px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] .qr-code-wrapper { + background-color: #f0f6fc; + /* Slightly off-white for dark mode to reduce glare */ +} + +.secret-value-box { + background-color: var(--bg-body); + color: var(--text-heading); + border: 1px solid var(--border-color) !important; + /* Enforce soft border */ + letter-spacing: 1px; + word-break: break-all; + transition: border-color 0.2s; +} + +.secret-value-box:hover { + border-color: var(--text-muted); + /* Subtle highlight on hover */ +} + +.verification-group .input-group-lg .form-control { + font-size: 1.5rem; + letter-spacing: 2px; + background-color: var(--bg-body); + border-color: var(--border-color); + height: 56px; + /* Explicit height to match button */ +} + +.verification-group .btn-action { + height: 56px; + border-radius: 0 6px 6px 0; + flex: none; + /* Prevent button from stretching unpredictably in input-group */ + min-width: 140px; +} + +[data-theme="dark"] .btn-action:hover { + filter: brightness(1.2); + /* Highlight instead of darken in dark mode */ + transform: translateY(-1px); +} + + +/* Toggle Container helper for HistoryModal */ +.chart-controls .border { + border-color: var(--border-color) !important; +} + +.config-input-group { + display: flex; + gap: 10px; + align-items: center; + width: 100%; +} + +.config-input-group .config-input { + width: auto; + flex: 1; +} + +/* Functional Number Input Spin Buttons (Wrapper Approach) */ +.number-input-container, +.select-container { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.number-input-container .config-input, +.select-container .config-select { + padding-right: 36px; +} + +.number-input-controls, +.select-arrow { + position: absolute; + right: 12px; + /* Standardized offset for all arrows */ + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + /* Perfectly center icons horizontally on the axis */ + width: 24px; + /* Fixed width ensures all icons stay on the same vertical line */ + pointer-events: none; +} + +.number-input-controls { + height: calc(100% - 2px); +} + +.select-arrow { + color: var(--text-muted); + font-size: 10px; + transition: all 0.2s ease; +} + +.number-input-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + padding: 0; + margin: 0; + cursor: pointer; + color: var(--text-muted); + transition: all 0.2s ease; + pointer-events: auto; + -webkit-user-select: none; + user-select: none; +} + +.number-input-btn:hover { + color: var(--text-heading); +} + +[data-theme="dark"] .number-input-btn, +[data-theme="dark"] .select-arrow { + color: var(--text-main); +} + +[data-theme="dark"] .number-input-btn:hover { + color: #ffffff; +} + +.number-input-btn i { + font-size: 8px; +} + +/* Ensure the input itself doesn't show text cursor on the buttons */ +.number-input-container .config-input { + cursor: text; +} + +/* Dropdown specific overrides */ +.config-select { + -webkit-appearance: none !important; + -moz-appearance: none !important; + appearance: none !important; + background: var(--bg-input); + cursor: pointer; +} + +/* Hide default spin buttons entirely */ +input[type="number"].config-input::-webkit-inner-spin-button, +input[type="number"].config-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="number"].config-input { + -moz-appearance: textfield; + appearance: textfield; +} + +/* Messages / Toast */ +.toast-message { + padding: 14px 22px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; + opacity: 0; + transform: translateY(-10px); + transition: all 0.3s ease; +} + +.toast-message.show { + opacity: 1; + transform: translateY(0); +} + +.toast-success { + background-color: #d1fae5; + color: #065f46; + border-left: 4px solid #10b981; +} + +.toast-warning { + background-color: #fef3c7; + color: #92400e; + border-left: 4px solid #f59e0b; +} + +.toast-error { + background-color: #fee2e2; + color: #991b1b; + border-left: 4px solid #ef4444; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-5px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in { + animation: fadeIn 0.3s ease-out forwards; +} + +/* --- COMPONENT-SPECIFIC STYLES (Centralized) --- */ + +/* BaseModal & Modals */ +.modal-content { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + box-shadow: 0 15px 50px rgba(0, 0, 0, 0.15); + border-radius: 12px; +} + +[data-theme="dark"] .modal-content { + box-shadow: 0 15px 50px rgba(0, 0, 0, 0.5); +} + +.modal-header .btn-close { + background-size: 0.8em; +} + +/* Login Page Stylings */ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg-body); + padding: 20px; +} + +.login-card { + width: 100%; + max-width: 400px; + background: var(--bg-card); + padding: 40px; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05); + border: 1px solid var(--border-color); +} + +.login-header { + text-align: center; + margin-bottom: 30px; +} + +.brand-logo { + width: 60px; + height: 60px; + background: var(--accent-color); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + border-radius: 12px; + margin: 0 auto 15px; +} + +.login-header h1 { + font-size: 24px; + font-weight: 700; + margin: 0; + color: var(--text-heading); +} + +.btn-primary { + background-color: #1652B8; + border-color: #1652B8; +} + +.btn-primary:hover { + background-color: #1449a5; + border-color: #1449a5; +} + +.btn-warning { + background-color: #EC7C31; + border-color: #EC7C31; + color: white; +} + +.btn-warning:hover { + background-color: #d66d2a; + border-color: #d66d2a; + color: white; +} + +/* Server Management Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; +} + +.stats-grid .stat-item { + background: var(--bg-body); + padding: 1.25rem; + border-radius: 8px; + border: 1px solid var(--border-color); + display: flex; + flex-direction: column; + justify-content: center; +} + +.stats-grid .stat-item.full-width { + grid-column: span 2; +} + +.stats-grid .stat-label { + display: block; + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 0.5rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; +} + +.stats-grid .stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-heading); +} + +/* Account & Security Helpers */ +.qr-code-wrapper { + transition: transform 0.2s ease; +} + +.qr-code-wrapper:hover { + transform: scale(1.02); +} + +/* Shared Utilities */ +.cursor-pointer { + cursor: pointer; +} + +.monospace { + font-family: 'Courier New', Courier, monospace; +} + +/* Additional Responsive Logic */ +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: 1fr; + } + + .stats-grid .stat-item.full-width { + grid-column: span 1; + } +} \ No newline at end of file diff --git a/APP_UI/src/components/BaseModal.vue b/APP_UI/src/components/BaseModal.vue new file mode 100644 index 0000000..9786114 --- /dev/null +++ b/APP_UI/src/components/BaseModal.vue @@ -0,0 +1,76 @@ + + + + + + + + {{ title }} + + + + + + + + + + + + + + + diff --git a/APP_UI/src/components/ConfirmModal.vue b/APP_UI/src/components/ConfirmModal.vue new file mode 100644 index 0000000..8884239 --- /dev/null +++ b/APP_UI/src/components/ConfirmModal.vue @@ -0,0 +1,49 @@ + + + + {{ message }} + + + Cancel + + {{ confirmText }} + + + + + + diff --git a/UI/client/src/components/HistoryModal.vue b/APP_UI/src/components/HistoryModal.vue similarity index 90% rename from UI/client/src/components/HistoryModal.vue rename to APP_UI/src/components/HistoryModal.vue index f5eec6a..c4fa565 100644 --- a/UI/client/src/components/HistoryModal.vue +++ b/APP_UI/src/components/HistoryModal.vue @@ -5,7 +5,7 @@ - + {{ clientName }} @@ -26,10 +26,14 @@ Metric: - - - + + + + + + + + {{ isSpeedMode ? 'Speed (Mbps)' : 'Data Volume' }} @@ -147,8 +151,8 @@ const renderChart = () => { { label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps', data: dataRx, - borderColor: '#3fb950', - backgroundColor: 'rgba(63, 185, 80, 0.15)', + borderColor: '#1652B8', // OpenVPN Blue + backgroundColor: 'rgba(22, 82, 184, 0.15)', borderWidth: 2, fill: true, tension: 0.3, @@ -158,8 +162,8 @@ const renderChart = () => { { label: !isSpeedMode.value ? 'Sent (MB)' : 'TX Mbps', data: dataTx, - borderColor: '#58a6ff', - backgroundColor: 'rgba(88, 166, 255, 0.15)', + borderColor: '#EC7C31', // OpenVPN Orange + backgroundColor: 'rgba(236, 124, 49, 0.15)', borderWidth: 2, fill: true, tension: 0.3, diff --git a/APP_UI/src/components/NewClientModal.vue b/APP_UI/src/components/NewClientModal.vue new file mode 100644 index 0000000..d62b4cc --- /dev/null +++ b/APP_UI/src/components/NewClientModal.vue @@ -0,0 +1,52 @@ + + + + + Client Name + + + + + + Use only alphanumeric characters, dashes, or underscores. + + + + + Cancel + + Create Client + + + + + + + diff --git a/APP_UI/src/composables/useApi.js b/APP_UI/src/composables/useApi.js new file mode 100644 index 0000000..b3e4b48 --- /dev/null +++ b/APP_UI/src/composables/useApi.js @@ -0,0 +1,73 @@ +import axios from 'axios'; +import { useAppConfig } from './useAppConfig'; + +// Singleton instances +const apiClient = axios.create(); +const profilesApiClient = axios.create(); + +// Helper to get base URLs +const getBaseUrl = (config) => config?.api_base_url || 'http://localhost:5001/api/v1'; +const getProfilesBaseUrl = (config) => config?.profiles_api_base_url || 'http://localhost:8000'; + +// Add interceptors to handle Auth and Dynamic Base URLs +const setupInterceptors = (instance, getBase) => { + instance.interceptors.request.use((reqConfig) => { + const { config } = useAppConfig(); + reqConfig.baseURL = getBase(config.value); + + const token = localStorage.getItem('ovpmon_token'); + if (token) { + reqConfig.headers.Authorization = `Bearer ${token}`; + } + return reqConfig; + }); + + instance.interceptors.response.use( + response => response, + error => { + if (error.response && error.response.status === 401) { + localStorage.removeItem('ovpmon_token'); + localStorage.removeItem('ovpmon_user'); + // Redirecting using window.location for absolute refresh + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } + } + return Promise.reject(error); + } + ); +}; + +setupInterceptors(apiClient, getBaseUrl); +setupInterceptors(profilesApiClient, getProfilesBaseUrl); + +export function useApi() { + const fetchStats = async () => { + const res = await apiClient.get('/stats'); + return res.data; + }; + + const fetchClientHistory = async (clientId, range) => { + const res = await apiClient.get(`/stats/${clientId}`, { params: { range } }); + return res.data; + }; + + const fetchAnalytics = async (range) => { + const res = await apiClient.get('/analytics', { params: { range } }); + return res.data; + }; + + const fetchCertificates = async () => { + const res = await apiClient.get('/certificates'); + return res.data; + }; + + return { + apiClient, + profilesApiClient, + fetchStats, + fetchClientHistory, + fetchAnalytics, + fetchCertificates + }; +} diff --git a/UI/client/src/composables/useAppConfig.js b/APP_UI/src/composables/useAppConfig.js similarity index 100% rename from UI/client/src/composables/useAppConfig.js rename to APP_UI/src/composables/useAppConfig.js diff --git a/UI/client/src/composables/useFormatters.js b/APP_UI/src/composables/useFormatters.js similarity index 100% rename from UI/client/src/composables/useFormatters.js rename to APP_UI/src/composables/useFormatters.js diff --git a/APP_UI/src/main.js b/APP_UI/src/main.js new file mode 100644 index 0000000..16f4950 --- /dev/null +++ b/APP_UI/src/main.js @@ -0,0 +1,32 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import router from './router'; + +// Import Bootstrap CSS and JS +import 'bootstrap/dist/css/bootstrap.min.css'; +import 'bootstrap/dist/js/bootstrap.bundle.min.js'; +import '@fortawesome/fontawesome-free/css/all.min.css'; + +import './assets/main.css'; +import axios from 'axios'; + +// Global Axios Defaults (No Base URL - handled by useApi.js) +// We keep global error handling for 401s as a fallback +axios.interceptors.response.use( + response => response, + error => { + if (error.response && error.response.status === 401) { + localStorage.removeItem('ovpmon_token'); + localStorage.removeItem('ovpmon_user'); + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } + } + return Promise.reject(error); + } +); + +const app = createApp(App); + +app.use(router); +app.mount('#app'); diff --git a/APP_UI/src/router/index.js b/APP_UI/src/router/index.js new file mode 100644 index 0000000..0f80daf --- /dev/null +++ b/APP_UI/src/router/index.js @@ -0,0 +1,67 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import Clients from '../views/Clients.vue'; +import Login from '../views/Login.vue'; + +const routes = [ + { + path: '/', + name: 'Analytics', + component: () => import('../views/Analytics.vue') + }, + { + path: '/clients', + name: 'Clients', + component: Clients + }, + { + path: '/certificates', + name: 'Certificates', + component: () => import('../views/Certificates.vue') + }, + + { + path: '/config/pki', + name: 'PKIConfig', + component: () => import('../views/PKIConfig.vue') + }, + { + path: '/config/vpn', + name: 'VPNConfig', + component: () => import('../views/VPNConfig.vue') + }, + { + path: '/server', + name: 'ServerManagement', + component: () => import('../views/ServerManagement.vue') + }, + { + path: '/account', + name: 'Account', + component: () => import('../views/Account.vue') + }, + { + path: '/login', + name: 'Login', + component: Login, + meta: { public: true } + } +]; + +const router = createRouter({ + history: createWebHistory(), + routes +}); + +router.beforeEach((to, from, next) => { + const isAuthenticated = !!localStorage.getItem('ovpmon_token'); + + if (!to.meta.public && !isAuthenticated) { + next({ name: 'Login' }); + } else if (to.name === 'Login' && isAuthenticated) { + next({ name: 'Analytics' }); + } else { + next(); + } +}); + +export default router; diff --git a/APP_UI/src/views/Account.vue b/APP_UI/src/views/Account.vue new file mode 100644 index 0000000..e96b8ca --- /dev/null +++ b/APP_UI/src/views/Account.vue @@ -0,0 +1,345 @@ + + + + + + + + Security Settings + + + + + + + + Update Credentials + + + Maintain your account security by updating your password regularly to prevent unauthorized access. + + + + Change Password + + + + + + + + + + + Two-Factor Authentication + + Active + + + + + + + + + + + Secure Your Account + + + Protect your OpenVPN Monitor dashboard with second-layer security using Google Authenticator or any TOTP app. + + + + Start Setup + + + + + + 1. Scan the QR Code + Open your authenticator app and scan this code or manually enter the secret. + + + + + + + + Manual Secret: + + {{ setupData.secret }} + + + + + + + 2. Verify Connection + + + + + + Enable + + + + + + + + + + + + + 2FA is Enabled + + + Your dashboard is now protected with two-factor authentication. + + + Disable 2FA + + + + + + + + + + + + + + + + + + + Current Password + + + + New Password + + + + Confirm New Password + + + + + + Cancel + + + Update Password + + + + + + + + diff --git a/UI/client/src/views/Analytics.vue b/APP_UI/src/views/Analytics.vue similarity index 92% rename from UI/client/src/views/Analytics.vue rename to APP_UI/src/views/Analytics.vue index a4443b1..e4978e3 100644 --- a/UI/client/src/views/Analytics.vue +++ b/APP_UI/src/views/Analytics.vue @@ -33,10 +33,14 @@ Last 1 Month - - - + + + + + + + + Speed @@ -53,7 +57,7 @@ - TOP-3 Active Clients + TOP-10 Active Clients @@ -80,7 +84,7 @@ - + {{ c.percent }}% @@ -110,7 +114,7 @@ {{ cert.common_name }} Expires: {{ cert.expiration_date }} - {{ cert.days_left }} days + {{ cert.days_left }} days @@ -126,12 +130,12 @@ - Download + Download {{ kpi.totalReceivedString }} - Upload + Upload {{ kpi.totalSentString }} @@ -294,13 +298,14 @@ const renderMainChart = () => { mainChartInstance = new Chart(ctx, { type: 'line', data: { + labels, labels, datasets: [ { label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps', data: dataRx, - borderColor: '#3fb950', // Legacy Green - backgroundColor: 'rgba(63, 185, 80, 0.15)', + borderColor: '#1652B8', // OpenVPN Blue + backgroundColor: 'rgba(22, 82, 184, 0.15)', borderWidth: 2, fill: true, tension: 0.3, @@ -310,8 +315,8 @@ const renderMainChart = () => { { label: !isSpeedMode.value ? 'Sent (MB)' : 'TX Mbps', data: dataTx, - borderColor: '#58a6ff', // Legacy Blue - backgroundColor: 'rgba(88, 166, 255, 0.15)', + borderColor: '#EC7C31', // Brand Orange + backgroundColor: 'rgba(236, 124, 49, 0.15)', borderWidth: 2, fill: true, tension: 0.3, @@ -347,7 +352,7 @@ const renderPieChart = () => { labels: ['Received', 'Sent'], datasets: [{ data: [kpi.totalReceived, kpi.totalSent], - backgroundColor: ['rgba(63, 185, 80, 0.8)', 'rgba(88, 166, 255, 0.8)'], + backgroundColor: ['#1652B8', '#EC7C31'], borderColor: bgColor, borderWidth: 2, hoverOffset: 4 diff --git a/APP_UI/src/views/Certificates.vue b/APP_UI/src/views/Certificates.vue new file mode 100644 index 0000000..3e177c8 --- /dev/null +++ b/APP_UI/src/views/Certificates.vue @@ -0,0 +1,331 @@ + + + + {{ totalCerts }} + Total Certificates + + + {{ activeCerts.length }} + Active Certificates + + + {{ expiringCount }} + Expiring in 30 days + + + {{ expiredCerts.length }} + Expired / Revoked + + + + + + + + + + + New Client + + + + + + + + + + + Hide Expired/Revoked + + + + + + Certificates List + + + + {{ activeCerts.length }} Active + + + {{ expiredCerts.length }} Revoked/Expired + + + + + + + + + Client Name + Type + Validity Not After + Days Remaining + Status + Actions + + + + + + + Loading... + + Loading profiles... + + + + + + + No certificates found + + + + + + + Active Certificates ({{ activeCerts.length }}) + + + + {{ cert.username }} + {{ cert.file_path || 'N/A' }} + + Client + {{ formatDate(cert.expiration_date) }} + + {{ cert.days_remaining }} + + + + + + + + + + + + + + + + Expired / Revoked ({{ expiredCerts.length }}) + + + + {{ cert.username }} + {{ cert.file_path || 'N/A' }} + + Client + {{ formatDate(cert.expiration_date) }} + + {{ cert.days_remaining }} + + + + + + + + + + + + + + + + + + + + + {{ toastMessage }} + + + + + diff --git a/UI/client/src/views/Clients.vue b/APP_UI/src/views/Clients.vue similarity index 94% rename from UI/client/src/views/Clients.vue rename to APP_UI/src/views/Clients.vue index 0631fc3..ba5fd9d 100644 --- a/UI/client/src/views/Clients.vue +++ b/APP_UI/src/views/Clients.vue @@ -29,9 +29,14 @@ - - - Hide Disconnected + + + + + + + + Hide Disconnected @@ -96,7 +101,7 @@ - + {{ c.current_sent_rate_mbps ? formatRate(c.current_sent_rate_mbps) : '0.000 Mbps' }} - @@ -215,8 +220,3 @@ onUnmounted(() => { }); - diff --git a/APP_UI/src/views/Login.vue b/APP_UI/src/views/Login.vue new file mode 100644 index 0000000..3be4cdc --- /dev/null +++ b/APP_UI/src/views/Login.vue @@ -0,0 +1,161 @@ + + + + + + + + OpenVPN Monitor + Secure Access Control + + + + + + + Username + + + + + + + + Password + + + + + + + + {{ error }} + + + + + Sign In + + + + + + + + Two-Factor Authentication + Enter the 6-digit code from your authenticator app. + + + + + + + + {{ error }} + + + + + Verify & Continue + + + + Back to login + + + + + + + + + + + diff --git a/APP_UI/src/views/Login.vue.bak b/APP_UI/src/views/Login.vue.bak new file mode 100644 index 0000000..cb985d3 --- /dev/null +++ b/APP_UI/src/views/Login.vue.bak @@ -0,0 +1,251 @@ + + + + + + + + OpenVPN Monitor + Secure Access Control + + + + + + + Username + + + + + + + + Password + + + + + + + + {{ error }} + + + + + Sign In + + + + + + + + Two-Factor Authentication + Enter the 6-digit code from your authenticator app. + + + + + + + + {{ error }} + + + + + Verify & Continue + + + + Back to login + + + + + + + + + + + + diff --git a/APP_UI/src/views/PKIConfig.vue b/APP_UI/src/views/PKIConfig.vue new file mode 100644 index 0000000..6054b9a --- /dev/null +++ b/APP_UI/src/views/PKIConfig.vue @@ -0,0 +1,292 @@ + + + + + PKI Infrastructure Configuration + + + + {{ message.text }} + + + + + + FQDN: CA Cert CN + + + + FQDN: Server Cert CN + + + + + + + + + EASYRSA DN + + + CN_ONLY + ORG + + + + + + + + + + + + + Country + + + + Province / State + + + + + + + City + + + + Organization + + + + + + + E-mail + + + + Organization Unit + + + + + + + + + Key Size Length (Bits) + + + 2048 + 4096 + + + + + + + + CA Expire (Days) + + + + + + + + + + + + + Cert Expire (Days) + + + + + + + + + + Cert Renew (Days) + + + + + + + + + + + + + CRL Valid (Days) + + + + + + + + + + Batch + + + + + + + + + + + + + + + + Save PKI + + + + + Init PKI + + + + + Clear PKI + + + + + + + + + diff --git a/APP_UI/src/views/ServerManagement.vue b/APP_UI/src/views/ServerManagement.vue new file mode 100644 index 0000000..3e8a80e --- /dev/null +++ b/APP_UI/src/views/ServerManagement.vue @@ -0,0 +1,169 @@ + + + + + Server Process Management + + + + {{ message.text }} + + + + + + + + + + CPU Usage + {{ stats.cpu_percent }}% + + + + + + + + + Status + + + {{ stats.status.toUpperCase() }} + + + + + + + Process ID + {{ stats.pid || 'N/A' }} + + + + + Memory (RSS) + {{ stats.memory_mb }} MB + + + + + Uptime + {{ stats.uptime || 'N/A' }} + + + + + + + + + + Start Service + + + + + + + Restart Service + + + + + + + Stop Service + + + + + + + + + diff --git a/APP_UI/src/views/VPNConfig.vue b/APP_UI/src/views/VPNConfig.vue new file mode 100644 index 0000000..d914fef --- /dev/null +++ b/APP_UI/src/views/VPNConfig.vue @@ -0,0 +1,396 @@ + + + + + Server Infrastructure Configuration + + + + {{ message.text }} + + + + + + + Tunnel Protocol + + + TCP + UDP + + + + + + + + Tunnel Port + + + + + + + + + + + + + VPN Subnet (CIDR Format) + + + + Public IP + + + + + + + Tunnel MTU + + + + + + + + + + MSS FIX + + + + + + + + + + + + + + + + Enable Split Tunnel (Default FULL) + + + + + + + + + + + + + Split Routes (CIDR Format) + + + + + + + + + + + + Add Route + + + + + + + + + + + Enable User-Defined DNS Servers + + + + + + + + + + + + + DNS Servers + + + + + + + + + + Add DNS + + + + + + + + + + + Duplicate CN + + + + + + + + + Client-to-Client + + + + + + + + + CRL Verify + + + + + + + + + + + + + + + Enable Connection Scripts + + + + + + + + + + + + + Path to Connect Script + + + + + + Path to Disconnect Script + + + + + + + + Enable Management Interface + + + + + + + + + + + + + Listener Address + + + + Port + + + + + + + + + + + + + + + + + + + Save Server Configuration + + + + + Apply Configuration + + + + + + + + diff --git a/APP_UI/vite.config.js b/APP_UI/vite.config.js new file mode 100644 index 0000000..33b7854 --- /dev/null +++ b/APP_UI/vite.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + build: { + rollupOptions: { + output: { + manualChunks: { + 'vendor-charts': ['chart.js'], + 'vendor-ui': ['bootstrap', 'sweetalert2'] + } + } + } + } +}) diff --git a/DEV/task.md b/DEV/task.md deleted file mode 100644 index 3e83fc3..0000000 --- a/DEV/task.md +++ /dev/null @@ -1,30 +0,0 @@ -# Application Analysis Task List - -- [x] Analyze Python Backend - - [x] Review `APP/openvpn_api_v3.py` for API structure and endpoints - - [x] Review `APP/openvpn_gatherer_v3.py` for logic and data handling - - [x] Review `APP/config.ini` for configuration -- [x] Analyze PHP Frontend - - [x] Review `UI/index.php` - - [x] Review `UI/dashboard.php` - - [x] Review `UI/certificates.php` - - [x] Check API/Database usage in PHP files -- [ ] Refactor Frontend - - [x] Create `UI/config.php` - - [x] Create `UI/css/style.css` - - [x] Create `UI/js/utils.js` - - [x] Update `UI/index.php` - - [x] Update `UI/dashboard.php` - - [x] Update `UI/certificates.php` -- [ ] Refactor Backend - - [x] Create `APP/requirements.txt` - - [x] Create `APP/db.py` - - [x] Update `APP/openvpn_api_v3.py` - - [x] Update `APP/openvpn_gatherer_v3.py` -- [x] Verify Integration - - [x] Check syntax of modified files - - [x] Create walkthrough - - [x] Match PHP API calls to Python endpoints - - [x] Check for shared resources (DB, files) consistency -- [x] Generate Report - - [x] Summarize findings on structural, logical, and integration integrity diff --git a/DEV/task.md.resolved b/DEV/task.md.resolved deleted file mode 100644 index febc5e7..0000000 --- a/DEV/task.md.resolved +++ /dev/null @@ -1,30 +0,0 @@ -# Application Analysis Task List - -- [x] Analyze Python Backend - - [x] Review [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py) for API structure and endpoints - - [x] Review [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py) for logic and data handling - - [x] Review [APP/config.ini](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/config.ini) for configuration -- [x] Analyze PHP Frontend - - [x] Review [UI/index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php) - - [x] Review [UI/dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php) - - [x] Review [UI/certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php) - - [x] Check API/Database usage in PHP files -- [ ] Refactor Frontend - - [x] Create [UI/config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php) - - [x] Create [UI/css/style.css](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/css/style.css) - - [x] Create [UI/js/utils.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/js/utils.js) - - [x] Update [UI/index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php) - - [x] Update [UI/dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php) - - [x] Update [UI/certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php) -- [ ] Refactor Backend - - [x] Create [APP/requirements.txt](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/requirements.txt) - - [x] Create [APP/db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py) - - [x] Update [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py) - - [x] Update [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py) -- [x] Verify Integration - - [x] Check syntax of modified files - - [x] Create walkthrough - - [x] Match PHP API calls to Python endpoints - - [x] Check for shared resources (DB, files) consistency -- [x] Generate Report - - [x] Summarize findings on structural, logical, and integration integrity diff --git a/DEV/task.md.resolved.10 b/DEV/task.md.resolved.10 deleted file mode 100644 index febc5e7..0000000 --- a/DEV/task.md.resolved.10 +++ /dev/null @@ -1,30 +0,0 @@ -# Application Analysis Task List - -- [x] Analyze Python Backend - - [x] Review [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py) for API structure and endpoints - - [x] Review [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py) for logic and data handling - - [x] Review [APP/config.ini](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/config.ini) for configuration -- [x] Analyze PHP Frontend - - [x] Review [UI/index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php) - - [x] Review [UI/dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php) - - [x] Review [UI/certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php) - - [x] Check API/Database usage in PHP files -- [ ] Refactor Frontend - - [x] Create [UI/config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php) - - [x] Create [UI/css/style.css](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/css/style.css) - - [x] Create [UI/js/utils.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/js/utils.js) - - [x] Update [UI/index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php) - - [x] Update [UI/dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php) - - [x] Update [UI/certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php) -- [ ] Refactor Backend - - [x] Create [APP/requirements.txt](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/requirements.txt) - - [x] Create [APP/db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py) - - [x] Update [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py) - - [x] Update [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py) -- [x] Verify Integration - - [x] Check syntax of modified files - - [x] Create walkthrough - - [x] Match PHP API calls to Python endpoints - - [x] Check for shared resources (DB, files) consistency -- [x] Generate Report - - [x] Summarize findings on structural, logical, and integration integrity diff --git a/DEV/walkthrough.md b/DEV/walkthrough.md deleted file mode 100644 index 396fec4..0000000 --- a/DEV/walkthrough.md +++ /dev/null @@ -1,68 +0,0 @@ -# Walkthrough - Refactoring OpenVPN Monitor - -I have successfully refactored both the Frontend and Backend components of the OpenVPN Monitor application. - -## Changes - -### Backend (`APP/`) - -* **[NEW] [db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py)**: Created a `DatabaseManager` class to centralize database connection and schema initialization. This removes duplicated logic from the gatherer and API scripts. -* **[NEW] [requirements.txt](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/requirements.txt)**: Added a requirements file for Python dependencies. -* **[MODIFY] [openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py)**: Updated to use `DatabaseManager` for database connections. -* **[MODIFY] [openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py)**: Updated to use `DatabaseManager` for database connections and schema initialization. Removed local `init_database` and `get_db_connection` methods. - -### Frontend (`UI/`) - -* **[NEW] [config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php)**: Centralized configuration for API URLs and refresh intervals. -* **[NEW] [style.css](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/css/style.css)**: Centralized styles, including theme support. -* **[NEW] [utils.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/js/utils.js)**: Centralized JavaScript utilities for formatting and theme management. -* **[MODIFY] [index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php)**, **[dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php)**, **[certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php)**: Updated to include the new configuration, styles, and scripts. - -## Verification Results - -### Automated Checks -* **PHP Syntax**: (Assumed valid as no PHP linting tool was available, but files were edited carefully) -* **Python Syntax**: Ran `python3 -m py_compile` on all modified backend files. - * `APP/db.py`: OK - * `APP/openvpn_api_v3.py`: OK - * `APP/openvpn_gatherer_v3.py`: OK - -### Manual Verification -The refactoring preserves the existing functionality while improving code structure. -- **Database**: The schema initialization logic is now in one place (`db.py`). -- **Configuration**: Frontend config is in `config.php`, Backend DB path is in `db.py` (via `config.ini`). - -## Next Steps -- Run the application to ensure runtime integration works as expected. -- Monitor logs for any database connection issues. - -## Startup Instructions - -To run the updated assembly, follow these steps: - -1. **Backend Setup**: - ```bash - cd APP - pip install -r requirements.txt - ``` - -2. **Start Data Gatherer** (Initializes DB and collects stats): - ```bash - # Run in background or separate terminal - python3 openvpn_gatherer_v3.py - ``` - -3. **Start API Server**: - ```bash - # Run in background or separate terminal - python3 openvpn_api_v3.py - ``` - -4. **Frontend Setup**: - - Ensure your web server (Apache/Nginx) points to the `UI/` directory. - - If testing locally with PHP installed: - ```bash - cd ../UI - php -S 0.0.0.0:8080 - ``` - - Open `http://localhost:8080` (or your web server URL) in the browser. diff --git a/DEV/walkthrough.md.resolved b/DEV/walkthrough.md.resolved deleted file mode 100644 index 01aec7d..0000000 --- a/DEV/walkthrough.md.resolved +++ /dev/null @@ -1,68 +0,0 @@ -# Walkthrough - Refactoring OpenVPN Monitor - -I have successfully refactored both the Frontend and Backend components of the OpenVPN Monitor application. - -## Changes - -### Backend (`APP/`) - -* **[NEW] [db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py)**: Created a [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) class to centralize database connection and schema initialization. This removes duplicated logic from the gatherer and API scripts. -* **[NEW] [requirements.txt](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/requirements.txt)**: Added a requirements file for Python dependencies. -* **[MODIFY] [openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py)**: Updated to use [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) for database connections. -* **[MODIFY] [openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py)**: Updated to use [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) for database connections and schema initialization. Removed local [init_database](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#22-92) and [get_db_connection](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py#31-34) methods. - -### Frontend (`UI/`) - -* **[NEW] [config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php)**: Centralized configuration for API URLs and refresh intervals. -* **[NEW] [style.css](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/css/style.css)**: Centralized styles, including theme support. -* **[NEW] [utils.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/js/utils.js)**: Centralized JavaScript utilities for formatting and theme management. -* **[MODIFY] [index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php)**, **[dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php)**, **[certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php)**: Updated to include the new configuration, styles, and scripts. - -## Verification Results - -### Automated Checks -* **PHP Syntax**: (Assumed valid as no PHP linting tool was available, but files were edited carefully) -* **Python Syntax**: Ran `python3 -m py_compile` on all modified backend files. - * [APP/db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py): OK - * [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py): OK - * [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py): OK - -### Manual Verification -The refactoring preserves the existing functionality while improving code structure. -- **Database**: The schema initialization logic is now in one place ([db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py)). -- **Configuration**: Frontend config is in [config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php), Backend DB path is in [db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py) (via [config.ini](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/config.ini)). - -## Next Steps -- Run the application to ensure runtime integration works as expected. -- Monitor logs for any database connection issues. - -## Startup Instructions - -To run the updated assembly, follow these steps: - -1. **Backend Setup**: - ```bash - cd APP - pip install -r requirements.txt - ``` - -2. **Start Data Gatherer** (Initializes DB and collects stats): - ```bash - # Run in background or separate terminal - python3 openvpn_gatherer_v3.py - ``` - -3. **Start API Server**: - ```bash - # Run in background or separate terminal - python3 openvpn_api_v3.py - ``` - -4. **Frontend Setup**: - - Ensure your web server (Apache/Nginx) points to the `UI/` directory. - - If testing locally with PHP installed: - ```bash - cd ../UI - php -S 0.0.0.0:8080 - ``` - - Open `http://localhost:8080` (or your web server URL) in the browser. diff --git a/DEV/walkthrough.md.resolved.1 b/DEV/walkthrough.md.resolved.1 deleted file mode 100644 index 01aec7d..0000000 --- a/DEV/walkthrough.md.resolved.1 +++ /dev/null @@ -1,68 +0,0 @@ -# Walkthrough - Refactoring OpenVPN Monitor - -I have successfully refactored both the Frontend and Backend components of the OpenVPN Monitor application. - -## Changes - -### Backend (`APP/`) - -* **[NEW] [db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py)**: Created a [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) class to centralize database connection and schema initialization. This removes duplicated logic from the gatherer and API scripts. -* **[NEW] [requirements.txt](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/requirements.txt)**: Added a requirements file for Python dependencies. -* **[MODIFY] [openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py)**: Updated to use [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) for database connections. -* **[MODIFY] [openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py)**: Updated to use [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) for database connections and schema initialization. Removed local [init_database](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#22-92) and [get_db_connection](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py#31-34) methods. - -### Frontend (`UI/`) - -* **[NEW] [config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php)**: Centralized configuration for API URLs and refresh intervals. -* **[NEW] [style.css](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/css/style.css)**: Centralized styles, including theme support. -* **[NEW] [utils.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/js/utils.js)**: Centralized JavaScript utilities for formatting and theme management. -* **[MODIFY] [index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php)**, **[dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php)**, **[certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php)**: Updated to include the new configuration, styles, and scripts. - -## Verification Results - -### Automated Checks -* **PHP Syntax**: (Assumed valid as no PHP linting tool was available, but files were edited carefully) -* **Python Syntax**: Ran `python3 -m py_compile` on all modified backend files. - * [APP/db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py): OK - * [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py): OK - * [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py): OK - -### Manual Verification -The refactoring preserves the existing functionality while improving code structure. -- **Database**: The schema initialization logic is now in one place ([db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py)). -- **Configuration**: Frontend config is in [config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php), Backend DB path is in [db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py) (via [config.ini](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/config.ini)). - -## Next Steps -- Run the application to ensure runtime integration works as expected. -- Monitor logs for any database connection issues. - -## Startup Instructions - -To run the updated assembly, follow these steps: - -1. **Backend Setup**: - ```bash - cd APP - pip install -r requirements.txt - ``` - -2. **Start Data Gatherer** (Initializes DB and collects stats): - ```bash - # Run in background or separate terminal - python3 openvpn_gatherer_v3.py - ``` - -3. **Start API Server**: - ```bash - # Run in background or separate terminal - python3 openvpn_api_v3.py - ``` - -4. **Frontend Setup**: - - Ensure your web server (Apache/Nginx) points to the `UI/` directory. - - If testing locally with PHP installed: - ```bash - cd ../UI - php -S 0.0.0.0:8080 - ``` - - Open `http://localhost:8080` (or your web server URL) in the browser. diff --git a/DOCS/api_v3_endpoints.md b/DOCS/Core_Monitoring/API_Reference.md similarity index 89% rename from DOCS/api_v3_endpoints.md rename to DOCS/Core_Monitoring/API_Reference.md index 4d214f1..7c4f853 100644 --- a/DOCS/api_v3_endpoints.md +++ b/DOCS/Core_Monitoring/API_Reference.md @@ -204,4 +204,33 @@ SSL Certificate expiration tracking. Simple list of clients (Common Name + Status) for UI dropdowns. ### `GET /health` -Database connectivity check. Returns `{"status": "healthy"}`. \ No newline at end of file +Database connectivity check. Returns `{"status": "healthy"}`. + +--- + +## 7. Active Sessions + +Real-time list of currently connected clients. + +### `GET /sessions` + +#### Example Response +```json +{ + "success": true, + "count": 1, + "data": [ + { + "client_id": 5, + "common_name": "user-bob", + "real_address": "192.168.1.50", + "connected_since": "2026-01-09 10:00:00", + "last_seen": "2026-01-09 12:00:00", + "bytes_received": 1048576, + "bytes_sent": 524288, + "received_mb": 1.0, + "sent_mb": 0.5 + } + ] +} +``` \ No newline at end of file diff --git a/DOCS/Core_Monitoring/Authentication.md b/DOCS/Core_Monitoring/Authentication.md new file mode 100644 index 0000000..6837d52 --- /dev/null +++ b/DOCS/Core_Monitoring/Authentication.md @@ -0,0 +1,114 @@ +# Процесс аутентификации в OpenVPN Monitor + +Аутентификация в приложении реализована с использованием **Flask** и **JWT (JSON Web Tokens)**. Ниже приведено подробное описание механизмов и примеры использования API из консоли. + +--- + +## 1. Логика работы + +Механизм аутентификации поддерживает два режима: стандартный вход и вход с двухфакторной аутентификацией (2FA). + +### Основные этапы: +1. **Запрос на вход (`POST /api/auth/login`)**: + * Клиент отправляет `username` и `password`. + * Сервер проверяет хеш пароля с использованием **BCrypt**. + * **Защита (Rate Limiting)**: После 5 неудачных попыток IP-адрес блокируется на 15 минут. +2. **Выдача токена**: + * **Если 2FA отключена**: Сервер возвращает финальный JWT-токен (валиден 8 часов). + * **Если 2FA включена**: Сервер возвращает `temp_token` (валиден 5 минут) и флаг `requires_2fa: true`. +3. **Верификация 2FA (`POST /api/auth/verify-2fa`)**: + * Клиент отправляет `temp_token` и 6-значный код (OTP). + * Сервер проверяет код с помощью библиотеки `pyotp`. + * При успешной проверке выдается финальный JWT-токен. + +--- + +## 2. Использование API через консоль + +Для выполнения прямых вызовов API необходимо получить JWT-токен и передавать его в заголовке `Authorization`. + +### Шаг 1: Аутентификация + +#### Вариант А: 2FA отключена +Выполните запрос для получения токена: +```bash +curl -X POST http://:5001/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "ваш_пароль"}' +``` +В ответе придет JSON с полем `"token"`. + +#### Вариант Б: 2FA включена (двухэтапный вход) +1. Получите временный токен: +```bash +curl -X POST http://:5001/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "ваш_пароль"}' +``` +Скопируйте `temp_token` из ответа. + +2. Подтвердите вход кодом OTP: +```bash +curl -X POST http://:5001/api/auth/verify-2fa \ + -H "Content-Type: application/json" \ + -d '{"temp_token": "ВАШ_TEMP_TOKEN", "otp": "123456"}' +``` +Скопируйте финальный `token` из ответа. + +### Шаг 2: Вызов защищенных методов + +Используйте полученный токен в заголовке `Bearer`: + +**Пример: Получение списка клиентов** +```bash +curl -H "Authorization: Bearer ВАШ_ТОКЕН" \ + http://:5001/api/v1/stats +``` + +**Пример: Просмотр сертификатов** +```bash +curl -H "Authorization: Bearer ВАШ_ТОКЕН" \ + http://:5001/api/v1/certificates +``` + +--- + +## 3. Быстрое получение токена из браузера + +Если вы уже вошли в веб-интерфейс, токен можно быстро скопировать без лишних запросов: +1. Откройте панель разработчика (**F12**). +2. Перейдите на вкладку **Application** (или **Storage**). +4. Найдите ключ `ovpmon_token` — это и есть ваш текущий JWT-токен. + +--- + +## 4. Account Management & 2FA Configuration + +Endpoints for managing the current user's security settings. + +### User Profile +`GET /api/v1/user/me` +Returns current user info and 2FA status. +```json +{ "success": true, "username": "admin", "is_2fa_enabled": false } +``` + +### Password Change +`POST /api/auth/change-password` +**Payload**: `{"current_password": "old", "new_password": "new"}` + +### 2FA Setup Flow + +1. **Initiate Setup** + `POST /api/auth/setup-2fa` + Returns a secret and a `otpauth://` URI for QR code generation. + +2. **Enable 2FA** + `POST /api/auth/enable-2fa` + **Payload**: `{"secret": "GENERATED_SECRET", "otp": "123456"}` + Verifies the code and enables 2FA for the user. + +3. **Disable 2FA** + `POST /api/auth/disable-2fa` + Disables 2FA (No payload required). + diff --git a/DOCS/data_gathering_report.md b/DOCS/Core_Monitoring/Data_Architecture.md similarity index 97% rename from DOCS/data_gathering_report.md rename to DOCS/Core_Monitoring/Data_Architecture.md index 8a402c2..ca1b8b5 100644 --- a/DOCS/data_gathering_report.md +++ b/DOCS/Core_Monitoring/Data_Architecture.md @@ -39,7 +39,7 @@ To support long-term statistics without storing billions of rows, the `TimeSerie A cleanup job runs once every 24 hours (on day change). - It executes `DELETE FROM table WHERE timestamp < cutoff_date`. -- Thresholds are configurable in `config.ini` under `[retention]`. +- Thresholds are configurable in `APP_CORE/config.ini` under `[retention]`. ## Summary The system employs a "Write-Optimized" approach. Instead of calculating heavy aggregates on-read (which would be slow), it pre-calculates them on-write. This ensures instant dashboard loading times even with years of historical data. diff --git a/DOCS/General/Deployment.md b/DOCS/General/Deployment.md new file mode 100644 index 0000000..eb5d6ee --- /dev/null +++ b/DOCS/General/Deployment.md @@ -0,0 +1,110 @@ +# Deployment Guide: OpenVPN Monitor & Profiler + +This guide describes how to deploy the full suite on a fresh Linux server (Ubuntu/Debian). + +## Architecture Overview +- **Frontend**: Vue.js (Built and served by Nginx) - `APP_UI` +- **Monitoring API (APP_CORE)**: Flask (Port 5000) - Real-time statistics. +- **Profiler API (APP_PROFILER)**: FastAPI (Port 8000) - Profile & Server management. + +--- + +## 1. Prerequisites +- Python 3.10+ +- Nginx +- OpenVPN & Easy-RSA (for the Profiler) +- Node.js & NPM (only for building the UI) + +--- + +## 2. Shared Security Setup (Critical) +Both API services must share the same `SECRET_KEY` to recognize the same JWT tokens. + +### A. Environment Variable (Recommended) +Add this to your shell profile (`~/.bashrc`) or your Systemd service files: +```bash +export OVPMON_SECRET_KEY='your-very-long-random-secret-key' +``` + +### B. Configuration File +Alternatively, set it in `APP_CORE/config.ini`: +```ini +[api] +secret_key = your-very-long-random-secret-key +``` + +--- + +## 3. Backend Deployment + +### Monitoring API (Flask) +1. Navigate to `APP_CORE/`. +2. Create virtual environment: `python3 -m venv venv`. +3. Install dependencies: `venv/bin/pip install -r requirements.txt`. +4. Run with Gunicorn (production): + ```bash + venv/bin/gunicorn -w 4 -b 127.0.0.1:5000 openvpn_api_v3:app + ``` + +### Profiler API (FastAPI) +1. Navigate to `APP_PROFILER/`. +2. Create virtual environment: `python3 -m venv venv`. +3. **Important**: Uninstall potential conflicts and install PyJWT: + ```bash + venv/bin/pip uninstall jwt PyJWT + venv/bin/pip install -r requirements.txt PyJWT + ``` +4. Run with Uvicorn: + ```bash + venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 + ``` + +--- + +## 4. Frontend Deployment (Nginx) + +### Build the UI +1. Navigate to `UI/client`. +2. Install: `npm install`. +3. Build: `npm run build`. +4. Copy `dist/` contents to `/var/www/ovpmon/`. + +### Nginx Configuration +Create `/etc/nginx/sites-available/ovpmon`: + +```nginx +server { + listen 80; + server_name your_domain_or_ip; + + root /var/www/ovpmon; + index index.html; + + # Frontend Routing + location / { + try_files $uri $uri/ /index.html; + } + + # Monitoring API (Flask) + location /api/v1/ { + proxy_pass http://127.0.0.1:5000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Profiler API (FastAPI) + location /profiles-api/ { + proxy_pass http://127.0.0.1:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +--- + +## 5. First Run & Initialization +1. Access the UI via browser. +2. Login with default credentials: `admin` / `password`. +3. **Immediately** change the password and set up 2FA in the Settings/Profile section. +4. If using the Profiler, ensure the `easy-rsa` directory is present and initialized via the UI. diff --git a/DOCS/General/Index.md b/DOCS/General/Index.md new file mode 100644 index 0000000..5b1f9d6 --- /dev/null +++ b/DOCS/General/Index.md @@ -0,0 +1,22 @@ +# OpenVPN Monitor & Profiler Documentation + +Welcome to the documentation for the OpenVPN Monitor suite. + +## 📚 General +- [Deployment Guide](Deployment.md): How to install and configure the application on a Linux server. +- [Service Management](Service_Management.md): Setting up systemd/OpenRC services. +- [Security Architecture](Security_Architecture.md): Details on Authentication, 2FA, and Security features. + +## 🔍 Core Monitoring (`APP_CORE`) +The core module responsible for log parsing, real-time statistics, and the primary API. +- [API Reference](../Core_Monitoring/API_Reference.md): Endpoints for monitoring data. +- [Authentication](../Core_Monitoring/Authentication.md): How the Login and 2FA flows work. +- [Data Architecture](../Core_Monitoring/Data_Architecture.md): Internals of the Data Gatherer and TSDB. + +## ⚙️ Profiler Management (`APP_PROFILER`) +The management module for PKI, Certificates, and User Profiles. +- [Overview](../Profiler_Management/Overview.md): Features and usage of the Profiler API. + +## 💻 User Interface (`APP_UI`) +The Vue.js frontend application. +- [Architecture](../UI/Architecture.md): UI Tech stack and project structure. diff --git a/DOCS/General/Nginx_Configuration.md b/DOCS/General/Nginx_Configuration.md new file mode 100644 index 0000000..1dcb77c --- /dev/null +++ b/DOCS/General/Nginx_Configuration.md @@ -0,0 +1,124 @@ +# Nginx Configuration Guide + +This guide details how to configure Nginx as a reverse proxy for the OpenVPN Monitor & Profiler application. Nginx is **required** in production to serve the frontend and route API requests to the appropriate backend services. + +## Architecture Recap + +- **Frontend (`APP_UI`)**: Static files (HTML, JS, CSS) served from `/var/www/ovpmon` (or similar). +- **Core API (`APP_CORE`)**: Python/Flask service running on **127.0.0.1:5001**. +- **Profiler API (`APP_PROFILER`)**: Python/FastAPI service running on **127.0.0.1:8000**. + +## 1. Alpine Linux Setup + +### Installation +```bash +apk add nginx +rc-update add nginx default +``` + +### Configuration +Create a new configuration file (e.g., `/etc/nginx/http.d/ovpmon.conf`). + +```nginx +server { + listen 80; + server_name your-server-domain.com; # Replace with your IP or Domain + + root /var/www/ovpmon; + index index.html; + + # Gzip Compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # 1. Frontend (SPA Routing) + location / { + try_files $uri $uri/ /index.html; + } + + # 2. Core Monitoring API (Flask :5001) + # Routes: /api/v1/stats, /api/auth, etc. + location /api/v1/ { + proxy_pass http://127.0.0.1:5001/api/v1/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + location /api/auth/ { + proxy_pass http://127.0.0.1:5001/api/auth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # 3. Profiler Management API (FastAPI :8000) + # Routes: /api/profiles, /api/config, etc. + # Note: We capture /api/ but exclude /api/v1 (handled above) + location /api/ { + # Ensure this doesn't conflict with /api/v1. Nginx matching order: + # Longest prefix matches first. So /api/v1/ wins over /api/. + proxy_pass http://127.0.0.1:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +### Apply Changes +```bash +rc-service nginx restart +``` + +--- + +## 2. Debian / Ubuntu Setup + +### Installation +```bash +sudo apt update +sudo apt install nginx +``` + +### Configuration +1. Create a configuration file in `/etc/nginx/sites-available/ovpmon`: + *(Use the same Nginx configuration block provided in the Alpine section above)* + +2. Enable the site: + ```bash + sudo ln -s /etc/nginx/sites-available/ovpmon /etc/nginx/sites-enabled/ + sudo rm /etc/nginx/sites-enabled/default # Optional: Remove default site + ``` + +3. Test and Restart: + ```bash + sudo nginx -t + sudo systemctl restart nginx + ``` + +--- + +## 3. Deployment Checklist + +1. **Frontend Build**: + Ensure you have built the UI and copied the files to your web root: + ```bash + cd APP_UI + npm run build + sudo mkdir -p /var/www/ovpmon + sudo cp -r dist/* /var/www/ovpmon/ + ``` + +2. **Permissions**: + Ensure Nginx can read the web files: + ```bash + sudo chown -R nginx:nginx /var/www/ovpmon # Alpine + # OR + sudo chown -R www-data:www-data /var/www/ovpmon # Debian/Ubuntu + ``` + +3. **SELinux (RedHat/CentOS only)**: + If using SELinux, allow Nginx to make network connections: + ```bash + setsebool -P httpd_can_network_connect 1 + ``` diff --git a/DOCS/General/Security_Architecture.md b/DOCS/General/Security_Architecture.md new file mode 100644 index 0000000..a8e2972 --- /dev/null +++ b/DOCS/General/Security_Architecture.md @@ -0,0 +1,85 @@ +# Implementation Plan - Authentication & Security + +## Goal Description +Add secure authentication to the OpenVPN Monitor application. +This includes: +- **Database Storage**: Store users and credentials in the existing SQLite database. +- **2FA**: Support Google Authenticator (TOTP) for two-factor authentication. +- **Brute-force Protection**: Rate limiting on login attempts. +- **Universal Access Control**: Secure all UI routes and API endpoints. + +## User Review Required +> [!IMPORTANT] +> **Default Credentials**: We will create a default admin user (e.g., `admin` / `password`) on first run if no users exist. The user MUST change this immediately. + +> [!WARNING] +> **Breaking Change**: Access to the current dashboard will be blocked until the user logs in. + +## Proposed Changes + +### Backend (Python/Flask) +#### [MODIFY] [requirements.txt](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP_CORE/requirements.txt) +- Add `pyjwt`, `pyotp`, `qrcode`, `bcrypt`, `flask-bcrypt` (or `werkzeug.security`). + +#### [MODIFY] [db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP_CORE/db.py) +- Update `init_database` to create: + - `users` table: `id`, `username`, `password_hash`, `totp_secret`, `is_2fa_enabled`. + - `login_attempts` table (for brute-force protection): `ip_address`, `attempts`, `last_attempt`. + +#### [MODIFY] [openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP_CORE/openvpn_api_v3.py) +- **New Imports**: `jwt`, `pyotp`, `functools.wraps`. +- **Helper Functions**: + - `check_rate_limit(ip)`: Verify login attempts. + - `token_required(f)`: Decorator to check `Authorization` header. +- **New Routes**: + - `POST /api/auth/login`: Validate user/pass. Returns JWT (or 2FA required status). + - `POST /api/auth/verify-2fa`: Validate TOTP. Returns access JWT. + - `POST /api/auth/setup-2fa`: Generate secret & QR code. + - `POST /api/auth/enable-2fa`: Confirm and save secret. +- **Protect Routes**: Apply `@token_required` to all existing API routes (except auth). + +### Frontend (Vue.js) +#### [NEW] [Login.vue](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP_UI/src/views/Login.vue) +- Login form (Username/Password). +- 2FA Input (conditional, appears if server responses "2FA required"). + +#### [NEW] [Setup2FA.vue](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP_UI/src/views/Setup2FA.vue) +- Screen to show QR code and verify OTP to enable 2FA for the first time. + +#### [MODIFY] [router/index.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP_UI/src/router/index.js) +- Add `/login` route. +- Add global `beforeEach` guard: + - Check if route `requiresAuth`. + - Check if token exists in `localStorage`. + - Redirect to `/login` if unauthorized. + +#### [MODIFY] [App.vue](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP_UI/src/App.vue) +- Add `Logout` button to the sidebar. +- Conditionally render Sidebar only if logged in (optional, or just redirect). + +#### [MODIFY] [main.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP_UI/src/main.js) +- Setup `axios` interceptors: + - **Request**: Add `Authorization: Bearer `. + - **Response**: On `401 Unauthorized`, clear token and redirect to `/login`. + +## Verification Plan + +### Automated Tests +Since this project does not have a comprehensive test suite, we will verify manually and with targeted scripts. + +### Manual Verification +1. **Initial Setup**: + - Start backend and frontend. + - Visit root URL -> Should redirect to `/login`. +2. **Login Flow**: + - Attempt login with wrong password -> Should show error. + - Attempt brute force (5x wrong) -> Should block for X minutes. + - Login with `admin` / `password` -> Should succeed. +3. **2FA Setup**: + - Go to 2FA Setup page (or trigger via API). + - Scan QR code with Google Auth. + - enter code -> Success. + - Logout and Login again -> Should ask for 2FA code. +4. **API Security**: + - Try `curl http://localhost:5000/api/v1/stats` without header -> Should return 401. + - Try with header -> Should return 200. diff --git a/DOCS/General/Service_Management.md b/DOCS/General/Service_Management.md new file mode 100644 index 0000000..9ffac62 --- /dev/null +++ b/DOCS/General/Service_Management.md @@ -0,0 +1,93 @@ +# Service Setup Guide + +This guide describes how to set up the OpenVPN Monitor components as system services. + +## Components + +1. **ovpmon-api**: The main Flask API (`APP/openvpn_api_v3.py`). +2. **ovpmon-gatherer**: The background data gatherer (`APP/openvpn_gatherer_v3.py`). +3. **ovpmon-profiler**: The new FastAPI profiler module (`NEW_MODULES/main.py`). + +## Common Prerequisites + +- **Install Directory**: `/opt/ovpmon` (Recommended) +- **Virtual Environment**: `/opt/ovpmon/venv` + +--- + +## 1. Alpine Linux (OpenRC) + +### Installation + +1. **Copy Service Scripts**: + Copy the scripts from `Deployment/APP/openrc/` to `/etc/init.d/`. + + ```sh + cp DOCS/General/openrc/ovpmon-api /etc/init.d/ + cp DOCS/General/openrc/ovpmon-gatherer /etc/init.d/ + cp DOCS/General/openrc/ovpmon-profiler /etc/init.d/ + ``` + +2. **Set Permissions**: + ```sh + chmod +x /etc/init.d/ovpmon-* + ``` + +3. **Enable Services**: + ```sh + rc-update add ovpmon-api default + rc-update add ovpmon-gatherer default + rc-update add ovpmon-profiler default + ``` + +4. **Start Services**: + ```sh + rc-service ovpmon-api start + rc-service ovpmon-gatherer start + rc-service ovpmon-profiler start + ``` + +### Configuration + +To override defaults (e.g., if you installed to a different directory), create files in `/etc/conf.d/`: + +**File:** `/etc/conf.d/ovpmon-api` (example) +```sh +directory="/var/www/my-monitoring" +command_args="/var/www/my-monitoring/APP_CORE/openvpn_api_v3.py" +``` + +--- + +## 2. Debian / Ubuntu (Systemd) + +### Installation Steps + +1. **Copy Service Files**: + Copy the provided service files from `DOCS/General/systemd/` to `/etc/systemd/system/`. + + ```bash + cp DOCS/General/systemd/ovpmon-api.service /etc/systemd/system/ + cp DOCS/General/systemd/ovpmon-gatherer.service /etc/systemd/system/ + cp DOCS/General/systemd/ovpmon-profiler.service /etc/systemd/system/ + ``` + +2. **Reload Daemon**: + ```bash + systemctl daemon-reload + ``` + +3. **Enable Services** (Start on boot): + ```bash + systemctl enable ovpmon-api ovpmon-gatherer ovpmon-profiler + ``` + +4. **Start Services**: + ```bash + systemctl start ovpmon-api ovpmon-gatherer ovpmon-profiler + ``` + +5. **Check Status**: + ```bash + systemctl status ovpmon-api + ``` diff --git a/Deployment/APP/openrc/INSTALL.md b/DOCS/General/openrc/INSTALL.md similarity index 100% rename from Deployment/APP/openrc/INSTALL.md rename to DOCS/General/openrc/INSTALL.md diff --git a/Deployment/APP/openrc/ovpmon-api b/DOCS/General/openrc/ovpmon-api similarity index 100% rename from Deployment/APP/openrc/ovpmon-api rename to DOCS/General/openrc/ovpmon-api diff --git a/Deployment/APP/openrc/ovpmon-gatherer b/DOCS/General/openrc/ovpmon-gatherer similarity index 100% rename from Deployment/APP/openrc/ovpmon-gatherer rename to DOCS/General/openrc/ovpmon-gatherer diff --git a/DOCS/General/openrc/ovpmon-profiler b/DOCS/General/openrc/ovpmon-profiler new file mode 100644 index 0000000..2579b1a --- /dev/null +++ b/DOCS/General/openrc/ovpmon-profiler @@ -0,0 +1,16 @@ +#!/sbin/openrc-run + +name="ovpmon-profiler" +description="OpenVPN Monitor Profiler Service (FastAPI)" +supervisor="supervise-daemon" + +: ${directory:="/opt/ovpmon/NEW_MODULES"} +: ${command_user:="root"} + +command="/opt/ovpmon/venv/bin/python" +command_args="/opt/ovpmon/NEW_MODULES/main.py" + +depend() { + need net + after firewall +} diff --git a/DOCS/General/systemd/ovpmon-api.service b/DOCS/General/systemd/ovpmon-api.service new file mode 100644 index 0000000..ba2e2e6 --- /dev/null +++ b/DOCS/General/systemd/ovpmon-api.service @@ -0,0 +1,14 @@ +[Unit] +Description=OpenVPN Monitor API +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/ovpmon/APP_CORE +ExecStart=/opt/ovpmon/venv/bin/python /opt/ovpmon/APP_CORE/openvpn_api_v3.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/DOCS/General/systemd/ovpmon-gatherer.service b/DOCS/General/systemd/ovpmon-gatherer.service new file mode 100644 index 0000000..737043d --- /dev/null +++ b/DOCS/General/systemd/ovpmon-gatherer.service @@ -0,0 +1,14 @@ +[Unit] +Description=OpenVPN Monitor Gatherer +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/ovpmon/APP_CORE +ExecStart=/opt/ovpmon/venv/bin/python /opt/ovpmon/APP_CORE/openvpn_gatherer_v3.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/DOCS/General/systemd/ovpmon-profiler.service b/DOCS/General/systemd/ovpmon-profiler.service new file mode 100644 index 0000000..5e863c5 --- /dev/null +++ b/DOCS/General/systemd/ovpmon-profiler.service @@ -0,0 +1,15 @@ +[Unit] +Description=OpenVPN Profiler API +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/ovpmon/APP_PROFILER +# Running directly via python as main.py has uvicorn.run +ExecStart=/opt/ovpmon/venv/bin/python /opt/ovpmon/APP_PROFILER/main.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/DOCS/Profiler_Management/API_Reference.md b/DOCS/Profiler_Management/API_Reference.md new file mode 100644 index 0000000..532485b --- /dev/null +++ b/DOCS/Profiler_Management/API_Reference.md @@ -0,0 +1,77 @@ +# OpenVPN Profiler API Reference + +This module (`APP_PROFILER`) is built with **FastAPI** and provides management capabilities. + +**Base URL**: `http://:8000/api` + +## Authentication +All endpoints (except initial setup) require a Bearer Token. +**Header**: `Authorization: Bearer ` + +*Note: The token is shared with the Core Monitoring API.* + +--- + +## 1. User Profiles + +Manage OpenVPN Client profiles (`.ovpn` configs and certificates). + +### `GET /profiles` +List all user profiles. +- **Response**: Array of profile objects (id, username, status, expiration_date, etc.). + +### `POST /profiles` +Create a new user profile. +- **Body**: `{"username": "jdoe"}` +- **Action**: Generates keys, requests certificate, builds `.ovpn` file. + +### `DELETE /profiles/{id}` +Revoke a user profile. +- **Action**: Revokes certificate in CRL and marks profile as revoked in DB. + +### `GET /profiles/{id}/download` +Download the `.ovpn` configuration file for a user. +- **Response**: File stream (application/x-openvpn-profile). + +--- + +## 2. System Configuration + +Manage global settings for the server and PKI. + +### `GET /config` +Get current configuration. +- **Query Params**: `section` (optional: 'server' or 'pki') +- **Response**: `{ "server": {...}, "pki": {...} }` + +### `PUT /config/server` +Update OpenVPN Server settings (e.g., protocol, port, DNS). +- **Body**: JSON object matching `SystemSettings` schema. + +### `PUT /config/pki` +Update PKI settings (e.g., Key Size, Certificate Expiry). +- **Body**: JSON object matching `PKISetting` schema. + +### `POST /system/init` +Initialize the PKI infrastructure (InitCA, GenDH, BuildServerCert). +- **Note**: Only runs if PKI is empty. + +### `DELETE /system/pki` +**DANGER**: Completely wipes the PKI directory. + +--- + +## 3. Server Management + +### `POST /server/configure` +Generate the `server.conf` file based on current database settings. +- **Response**: `{"message": "Server configuration generated", "path": "/etc/openvpn/server.conf"}` + +### `POST /server/process/{action}` +Control the OpenVPN system service. +- **Path Param**: `action` (start, stop, restart) +- **Response**: Status of the command execution. + +### `GET /server/process/stats` +Get telemetry for the OpenVPN process. +- **Response**: `{ "status": "running", "cpu_percent": 1.2, "memory_mb": 45.0 }` diff --git a/DOCS/Profiler_Management/Overview.md b/DOCS/Profiler_Management/Overview.md new file mode 100644 index 0000000..fd3e8e3 --- /dev/null +++ b/DOCS/Profiler_Management/Overview.md @@ -0,0 +1,49 @@ +# OpenVPN Profiler API + +A modern, Python-based REST API for managing OpenVPN servers, Public Key Infrastructure (PKI), and user profiles. This component is located in `APP_PROFILER/`. + +## Features + +* **REST API**: Built with FastAPI for robust performance and automatic documentation. +* **Database Storage**: Configurations and user profiles are stored in SQLite (extensible to other DBs via SQLAlchemy). +* **PKI Management**: Integrated management of EasyRSA for CA, Server, and Client certificate generation. +* **Dynamic Configuration**: Templated generation of `server.conf` and client `.ovpn` files using Jinja2. + +## Quick Start + +### Prerequisites + +* Python 3.10 or higher +* OpenVPN (installed and available in PATH) +* Easy-RSA 3 (must be present in the `easy-rsa` directory in the project root) + +### Usage + +Once the server is running (see [Deployment Guide](../General/Deployment.md)), the full interactive API documentation is available at: +* **Swagger UI**: `http://:8000/docs` +* **ReDoc**: `http://:8000/redoc` + +### Common Operations + +**Create a new User Profile:** +```bash +curl -X POST "http://localhost:8000/profiles" \ + -H "Content-Type: application/json" \ + -d '{"username": "jdoe"}' +``` + +**Download User Config:** +```bash +# Get the ID from the profile creation response or list +curl -O -J http://localhost:8000/profiles/1/download +``` + +**Revoke User:** +```bash +curl -X DELETE http://localhost:8000/profiles/1 +``` + +**Get System Configuration:** +```bash +curl http://localhost:8000/config +``` diff --git a/DOCS/UI/Architecture.md b/DOCS/UI/Architecture.md new file mode 100644 index 0000000..e71a4d6 --- /dev/null +++ b/DOCS/UI/Architecture.md @@ -0,0 +1,35 @@ +# UI Architecture + +The frontend is a Single Page Application (SPA) built with **Vue 3** and **Vite**. It is located in `APP_UI/`. + +## Technology Stack +- **Framework**: Vue 3 (Composition API, Script Setup) +- **Build Tool**: Vite +- **Styling**: Bootstrap 5 + Custom CSS (`src/assets/main.css`) +- **Routing**: Vue Router +- **HTTP Client**: Axios + +## Key Features +- **Responsive Design**: Mobile-friendly sidebar and layouts. +- **Theme Support**: Built-in Light/Dark mode toggling. +- **Real-Time Data**: Polls the Monitoring API (`APP_CORE`) for live statistics. +- **Authentication**: JWT-based auth flow with support for 2FA. + +## Configuration +Run-time configuration is loaded from `/public/config.json`. This allows the Vue app to be built once and deployed to any environment. + +**File Structure (`config.json`):** +```json +{ + "api_base_url": "/api/v1", // Proxy path to Core Monitoring API + "profiles_api_base_url": "/api", // Proxy path to Profiler API + "refresh_interval": 30000 // Poll interval in ms +} +``` + +## Integration +The UI is served by Nginx in production and proxies API requests to: +- `/api/v1/` -> **APP_CORE** (Flask, Port 5000) +- `/profiles-api/` -> **APP_PROFILER** (FastAPI, Port 8000) + +See [Deployment Guide](../General/Deployment.md) for Nginx configuration details. diff --git a/README.md b/README.md index f791b4a..eb49362 100644 --- a/README.md +++ b/README.md @@ -1,249 +1,62 @@ -# OpenVPN Monitor UI & API +# OpenVPN Monitor & Profiler -A modern, reactive dashboard for monitoring OpenVPN server status, traffic history, and certificate validity. Built with Vue.js 3 and Python (Flask). +A modern, full-stack management solution for OpenVPN servers. It combines real-time traffic monitoring, historical analytics, and comprehensive user profile/PKI management into a unified web interface. -## 🚀 Quick Start +## �️ Project Architecture -### Prerequisites -* **Backend**: Python 3.9+ (`pip`, `venv`) -* **Frontend**: Node.js 18+ (for building only), any Web Server (Nginx/Apache) for production. +The project is modularized into three core components: -### 1. Backend Setup -Run the API and Data Gatherer. +| Component | Directory | Description | +| :--- | :--- | :--- | +| **Core Monitoring** | `APP_CORE/` | Flask-based API (v3) for log parsing, real-time stats, and historical TSDB. | +| **Profiler** | `APP_PROFILER/` | FastAPI-based module for managing PKI, Certificates, and Server Configs. | +| **User Interface** | `APP_UI/` | Vue 3 + Vite Single Page Application (SPA) serving as the unified dashboard. | +## 📚 Documentation + +Detailed documentation has been moved to the `DOCS/` directory. + +- **[Installation & Deployment](DOCS/General/Deployment.md)**: Setup guide for Linux (Alpine/Debian). +- **[Service Management](DOCS/General/Service_Management.md)**: Configuring Systemd/OpenRC services. +- **[Security & Auth](DOCS/General/Security_Architecture.md)**: 2FA, JWT, and Security details. + +### API References +- **[Core Monitoring API](DOCS/Core_Monitoring/API_Reference.md)**: Endpoints for stats, sessions, and history. +- **[Profiler Management API](DOCS/Profiler_Management/API_Reference.md)**: Endpoints for profiles, system config, and control. + +## 🚀 Quick Start (Dev Mode) + +### 1. Core API (Flask) ```bash -# Ubuntu/Debian -sudo apt update && sudo apt install python3-venv python3-pip - -# Alpine -apk add python3 py3-pip - -# Setup -cd /path/to/app/APP +cd APP_CORE python3 -m venv venv source venv/bin/activate pip install -r requirements.txt - -# Run (Manual testing) -python3 openvpn_api_v3.py & -python3 openvpn_gatherer_v3.py & +python3 openvpn_api_v3.py +# Runs on :5001 (Monitoring) ``` -### 2. Frontend Setup -Build the SPA and deploy to your web server. - +### 2. Profiler API (FastAPI) ```bash -cd /path/to/app/UI/client +cd APP_PROFILER +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +python3 main.py +# Runs on :8000 (Management) +``` + +### 3. Frontend (Vue 3) +```bash +cd APP_UI npm install -npm run build - -# Deploy (Example) -sudo cp -r dist/* /var/www/html/ +npm run dev +# Runs on localhost:5173 ``` --- -## 🛠 Service Configuration +## ⚠️ Important Notes -### Debian / Ubuntu (Systemd) -Create service files in `/etc/systemd/system/`. - -**1. API Service (`/etc/systemd/system/ovpmon-api.service`)** -```ini -[Unit] -Description=OpenVPN Monitor API -After=network.target - -[Service] -User=root -WorkingDirectory=/opt/ovpmon/APP -ExecStart=/opt/ovpmon/APP/venv/bin/python3 openvpn_api_v3.py -Restart=always - -[Install] -WantedBy=multi-user.target -``` - -**2. Gatherer Service (`/etc/systemd/system/ovpmon-gatherer.service`)** -```ini -[Unit] -Description=OpenVPN Monitor Data Gatherer -After=network.target - -[Service] -User=root -WorkingDirectory=/opt/ovpmon/APP -ExecStart=/opt/ovpmon/APP/venv/bin/python3 openvpn_gatherer_v3.py -Restart=always - -[Install] -WantedBy=multi-user.target -``` - -**Enable & Start:** -```bash -sudo systemctl daemon-reload -sudo systemctl enable --now ovpmon-api ovpmon-gatherer -``` - -### Alpine Linux (OpenRC) -For Alpine, create scripts in `/etc/init.d/` (e.g., `ovpmon-api`) using `openrc-run`. -```bash -#!/sbin/openrc-run -description="OpenVPN Monitor API" -command="/opt/ovpmon/APP/venv/bin/python3" -command_args="/opt/ovpmon/APP/openvpn_api_v3.py" -directory="/opt/ovpmon/APP" -command_background=true -pidfile="/run/ovpmon-api.pid" -``` -Make executable (`chmod +x`) and start: `rc-service ovpmon-api start`. - ---- - -## 🌐 Web Server Configuration - -**Recommendation: Nginx** is preferred for its performance and simple SPA configuration (`try_files`). - -### Nginx Config -```nginx -server { - listen 80; - server_name vpn-monitor.local; - root /var/www/html; - index index.html; - - # SPA Fallback - location / { - try_files $uri $uri/ /index.html; - } - - # Proxy API requests (Optional, if not exposing 5001 directly) - location /api/ { - proxy_pass http://127.0.0.1:5001; - } -} -``` - -### Apache Config - -**Setup (Alpine Linux):** -```bash -apk add apache2 apache2-proxy -rc-service apache2 restart -``` - -**Setup (Debian/Ubuntu):** -```bash -sudo a2enmod rewrite proxy proxy_http -sudo systemctl restart apache2 -``` - -Ensure `mod_rewrite`, `mod_proxy`, and `mod_proxy_http` are enabled. - -**VirtualHost Config:** -```apache - - DocumentRoot "/var/www/html" - - Options Indexes FollowSymLinks - AllowOverride All # CRITICAL for .htaccess - Require all granted - - - # Proxy API requests (Optional, if not exposing 5001 directly) - - ProxyPreserveHost On - ProxyPass "http://127.0.0.1:5001/api/" - ProxyPassReverse "http://127.0.0.1:5001/api/" - - -``` - ---- - -## 🧹 Database Management - -### Resetting Statistics -To completely reset all traffic statistics and start fresh: - -1. **Stop Services**: - ```bash - # Systemd - sudo systemctl stop ovpmon-gatherer ovpmon-api - - # OpenRC (Alpine) - rc-service ovpmon-gatherer stop - rc-service ovpmon-api stop - ``` - -2. **Remove Database**: - Navigate to the application directory (e.g., `/opt/ovpmon/APP`) and delete or rename the database file: - ```bash - rm openvpn_monitor.db - ``` - -3. **Restart Services**: - The system will automatically recreate the database with a fresh schema. - ```bash - # Systemd - sudo systemctl start ovpmon-gatherer ovpmon-api - - # OpenRC (Alpine) - rc-service ovpmon-gatherer start - rc-service ovpmon-api start - ``` - -### Advanced: Reset Stats (Keep Client List) -To reset counters but keep the known list of clients, run this SQL command: -```bash -sqlite3 openvpn_monitor.db " -DELETE FROM usage_history; -DELETE FROM stats_5min; -DELETE FROM stats_15min; -DELETE FROM stats_hourly; -DELETE FROM stats_6h; -DELETE FROM stats_daily; -DELETE FROM active_sessions; -UPDATE clients SET - total_bytes_received = 0, - total_bytes_sent = 0, - last_bytes_received = 0, - last_bytes_sent = 0, - status = 'Disconnected'; -VACUUM;" -``` - -### Remove a Specific User -To completely remove a user (e.g., `UNDEF`) and their history: -```bash -sqlite3 openvpn_monitor.db " -DELETE FROM usage_history WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); -DELETE FROM stats_5min WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); -DELETE FROM stats_15min WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); -DELETE FROM stats_hourly WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); -DELETE FROM stats_6h WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); -DELETE FROM stats_daily WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); -DELETE FROM active_sessions WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF'); -DELETE FROM clients WHERE common_name = 'UNDEF'; -VACUUM;" -``` - ---- - -## 📚 API Reference - -**Base URL:** `http://:5001/api/v1` - -| Method | Endpoint | Description | -| :--- | :--- | :--- | -| **GET** | `/stats` | Current status of all clients (Real-time). | -| **GET** | `/sessions` | **New** List of all active sessions (supports multi-device/user). | -| **GET** | `/stats/system` | Server-wide totals (Total traffic, active count). | -| **GET** | `/stats/` | Detailed client stats + History. Params: `range` (24h, 7d), `resolution`. | -| **GET** | `/certificates` | List of all certificates with expiration status. **Cached (Fast)**. | -| **GET** | `/analytics` | Dashboard data (Trends, Traffic distribution, Top clients). | -| **GET** | `/health` | API Health check. | - ---- -*Generated by Antigravity Agent* +1. **Environment**: Production deployment relies on Nginx to proxy requests to the backend services. See the [Deployment Guide](DOCS/General/Deployment.md). +2. **Permissions**: The backend requires `sudo` or root privileges to manage OpenVPN processes and write to `/etc/openvpn`. diff --git a/UI/artifacts/certificates.php b/UI/artifacts/certificates.php deleted file mode 100644 index c88fbbd..0000000 --- a/UI/artifacts/certificates.php +++ /dev/null @@ -1,142 +0,0 @@ - 30000. Let's use config. - -$timezone_abbr = date('T'); -$timezone_offset = date('P'); -?> - - - - - - - OpenVPN Certificate Statistics - - - - - - - - - - - - - OpenVPN Certificate Statistics - Certificate validity and expiration monitoring - - - - Clients - - - Certificates - - - Analytics - - 0 certificates - - - - - - - - - - - - - - - - 0 - Total Certificates - - - 0 - Active Certificates - - - 0 - Expiring in 30 days - - - 0 - Expired Certificates - - - - - - - - - - - - - - Hide Expired - Certificates - - - - - - - Certificates List - - 0 - Active - 0 - Expired - - - - - - - Client Name - Validity Not After - Days Remaining - Status - - - - - - - Loading... - - Loading certificates... - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/UI/artifacts/config.php b/UI/artifacts/config.php deleted file mode 100644 index 331b8d6..0000000 --- a/UI/artifacts/config.php +++ /dev/null @@ -1,30 +0,0 @@ - "{$api_base_url}/stats", - 'analytics_url' => "{$api_base_url}/analytics", - 'certificates_url' => "{$api_base_url}/certificates", - 'refresh_interval' => 30000, // 30 seconds -]; - -// Timezone Configuration -$local_timezone = 'Europe/Moscow'; - -// Apply Timezone -try { - if ($local_timezone) { - date_default_timezone_set($local_timezone); - } -} catch (Exception $e) { - date_default_timezone_set('UTC'); - error_log("Invalid timezone '$local_timezone', falling back to UTC"); -} -?> diff --git a/UI/artifacts/css/style.css b/UI/artifacts/css/style.css deleted file mode 100644 index a7dd23b..0000000 --- a/UI/artifacts/css/style.css +++ /dev/null @@ -1,637 +0,0 @@ -/* --- THEME VARIABLES --- */ -:root { - /* Light Theme */ - --bg-body: #f6f8fa; - --bg-card: #ffffff; - --bg-element: #f6f8fa; - --bg-element-hover: #f1f3f5; - --bg-input: #ffffff; - - --text-heading: #24292f; - --text-main: #57606a; - --text-muted: #8c959f; - - --border-color: #d0d7de; - --border-subtle: #e9ecef; - - --badge-border-active: #92bea5; - --badge-border-disconnected: #d47e80; - - --accent-color: #0969da; - --success-bg: rgba(39, 174, 96, 0.15); - --success-text: #1a7f37; - --danger-bg: rgba(231, 76, 60, 0.15); - --danger-text: #cf222e; - --warning-bg: rgba(255, 193, 7, 0.15); - --warning-text: #9a6700; - - --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - --toggle-off-bg: #e9ecef; - --toggle-off-border: #d0d7de; -} - -/* Dark Theme (Soft & Low Contrast) */ -[data-theme="dark"] { - --bg-body: #0d1117; - --bg-card: #161b22; - --bg-element: #21262d; - - /* Используем прозрачность для hover, чтобы текст не сливался */ - --bg-element-hover: rgba(255, 255, 255, 0.03); - - --bg-input: #0d1117; - - --text-heading: #e6edf3; - /* Светлее для заголовков */ - --text-main: #8b949e; - /* Мягкий серый для текста */ - --text-muted: #6e7681; - - /* ОЧЕНЬ мягкие границы (8% прозрачности белого) */ - --border-color: rgba(240, 246, 252, 0.1); - --border-subtle: rgba(240, 246, 252, 0.05); - - --badge-border-active: #3e6f40; - --badge-border-disconnected: #793837; - - --accent-color: #58a6ff; - --success-bg: rgba(35, 134, 54, 0.15); - --success-text: #3fb950; - --danger-bg: rgba(218, 54, 51, 0.15); - --danger-text: #f85149; - --warning-bg: rgba(210, 153, 34, 0.15); - --warning-text: #d29922; - - --shadow: none; - - --toggle-off-bg: rgba(110, 118, 129, 0.1); - --toggle-off-border: rgba(240, 246, 252, 0.1); -} - -body { - background-color: var(--bg-body); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - font-size: 0.9rem; - color: var(--text-main); - padding: 20px 0; - transition: background-color 0.3s ease, color 0.3s ease; -} - -.container { - max-width: 95%; - margin: 0 auto; -} - -@media (min-width: 1400px) { - .container { - max-width: 75%; - } -} - -/* Layout Elements */ -.header, -.card { - background-color: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 6px; - box-shadow: var(--shadow); - margin-bottom: 20px; - transition: border-color 0.3s ease; -} - -.header { - padding: 20px; -} - -.border-bottom { - border-bottom: 1px solid var(--border-color) !important; -} - -.card-header { - background: transparent; - border-bottom: 1px solid var(--border-color); - padding: 15px 20px; - font-weight: 600; - color: var(--text-heading); -} - -h1, -h2, -h3, -h4, -h5, -h6 { - color: var(--text-heading); - font-weight: 600; -} - -.text-muted { - color: var(--text-muted) !important; -} - -/* Blur Effect */ -body.modal-open .main-content-wrapper { - filter: blur(8px) grayscale(20%); - transform: scale(0.99); - opacity: 0.6; - transition: all 0.4s ease; - pointer-events: none; -} - -.main-content-wrapper { - transition: all 0.4s ease; - transform: scale(1); - filter: blur(0); - opacity: 1; -} - -/* Buttons & Controls */ -.btn-nav, -.btn-header, -.header-badge, -.header-timezone { - background: var(--bg-element); - border: 1px solid var(--border-color); - color: var(--text-heading); - transition: all 0.2s ease; - font-size: 0.85rem; - display: inline-flex; - align-items: center; - justify-content: center; - height: 36px; - /* Fixed height for consistency */ - vertical-align: middle; -} - -.btn-nav:hover, -.btn-header:hover { - background: var(--bg-element-hover); - border-color: var(--text-muted); - color: var(--text-heading); -} - -.btn-nav.active { - background: var(--accent-color); - color: #ffffff; - border-color: var(--accent-color); -} - -.btn-header { - padding: 0 12px; - border-radius: 6px; - min-width: 36px; -} - - -.btn-nav { - padding: 0 12px; - border-radius: 6px; - display: inline-flex; - align-items: center; -} - -.header-badge, -.header-timezone { - padding: 0 12px; - border-radius: 6px; - display: inline-flex; - align-items: center; -} - -/* Stats Cards & KPI */ -.stats-info, -.kpi-grid { - display: flex; - gap: 15px; - flex-wrap: wrap; - margin-bottom: 15px; -} - -.kpi-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 20px; -} - -.stat-item { - background: var(--bg-card); - padding: 15px; - border-radius: 6px; - border: 1px solid var(--border-color); - flex: 1; - min-width: 180px; - box-shadow: var(--shadow); -} - -.kpi-grid .stat-item { - padding: 20px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.stat-value { - font-size: 1.4rem; - font-weight: 600; - color: var(--text-heading); - line-height: 1.2; -} - -.kpi-grid .stat-content h3 { - font-size: 1.6rem; - font-weight: 700; - color: var(--text-heading); - margin: 0; - line-height: 1.2; -} - -.stat-label { - font-size: 0.85rem; - color: var(--text-muted); - margin-top: 5px; -} - -/* Tables */ -.table { - --bs-table-bg: transparent; - --bs-table-color: var(--text-main); - --bs-table-border-color: var(--border-color); - margin-bottom: 0; -} - -.table th { - background-color: var(--bg-card); - color: var(--text-muted); - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 0.5px; - border-bottom: 1px solid var(--border-color); - position: sticky; - top: 0; - z-index: 10; - cursor: pointer; - user-select: none; - padding: 12px 10px; -} - -.table th:hover { - color: var(--text-heading); -} - -.table th.active-sort { - color: var(--accent-color); - border-bottom-color: var(--accent-color); -} - -.table td { - padding: 12px 10px; - border-bottom: 1px solid var(--border-subtle); - vertical-align: middle; -} - -.table-hover tbody tr:hover { - background-color: var(--bg-element-hover); -} - -.table-hover tbody tr:hover td, -.table-hover tbody tr:hover .font-monospace { - color: var(--text-heading); -} - -.font-monospace { - color: var(--text-main); - font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; -} - -/* Section Divider */ -.section-divider td { - background-color: var(--bg-element) !important; - font-weight: 600; - color: var(--text-heading); - padding: 8px 15px; - border-top: 1px solid var(--border-color); - border-bottom: 1px solid var(--border-color); - font-size: 0.8rem; -} - -/* Badges */ -.status-badge { - padding: 4px 8px; - border-radius: 4px; - font-weight: 500; - font-size: 0.75rem; - border: 1px solid transparent; -} - -.status-active, -.status-valid { - background-color: var(--success-bg); - color: var(--success-text); - border-color: var(--badge-border-active); -} - -.status-disconnected, -.status-expired { - background-color: var(--danger-bg); - color: var(--danger-text); - border-color: var(--badge-border-disconnected); -} - -.status-expiring { - background-color: var(--warning-bg); - color: var(--warning-text); - border: 1px solid transparent; -} - -.badge-soft-warning { - background: var(--warning-bg); - color: var(--warning-text); - border: 1px solid transparent; - font-weight: 600; - font-size: 0.75rem; - padding: 5px 10px; -} - -.client-link { - color: var(--text-heading); - text-decoration: none; - font-weight: 600; - cursor: pointer; - transition: color 0.2s; -} - -.client-link:hover { - color: var(--accent-color); -} - -.client-link i { - color: var(--text-muted); - transition: color 0.2s; -} - -.client-link:hover i { - color: var(--accent-color); -} - -/* Inputs */ -.form-control, -.form-select, -.search-input { - background-color: var(--bg-input); - border: 1px solid var(--border-color); - color: var(--text-heading); - border-radius: 6px; -} - -.form-control:focus, -.form-select:focus, -.search-input:focus { - background-color: var(--bg-input); - border-color: var(--accent-color); - color: var(--text-heading); - box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.15); - outline: none; -} - -.form-control::placeholder { - color: var(--text-muted); - opacity: 0.7; -} - -.input-group-text { - background-color: var(--bg-element); - border: 1px solid var(--border-color); - color: var(--text-muted); -} - -/* Toggle Switch */ -.form-check-input { - background-color: var(--toggle-off-bg); - border-color: var(--toggle-off-border); - cursor: pointer; -} - -.form-check-input:checked { - background-color: var(--accent-color); - border-color: var(--accent-color); -} - -[data-theme="dark"] .form-check-input:not(:checked) { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%238b949e'/%3e%3c/svg%3e"); -} - -/* Sort Buttons */ -.sort-btn-group { - display: flex; -} - -.sort-btn { - background: var(--bg-input); - border: 1px solid var(--border-color); - padding: 6px 12px; - font-size: 0.85rem; - color: var(--text-main); - min-width: 100px; - text-align: center; - cursor: pointer; - transition: all 0.2s; -} - -.sort-btn:first-child { - border-top-left-radius: 6px; - border-bottom-left-radius: 6px; - border-right: none; -} - -.sort-btn:last-child { - border-top-right-radius: 6px; - border-bottom-right-radius: 6px; -} - -.sort-btn:hover { - background-color: var(--bg-element-hover); -} - -.sort-btn.active { - background-color: var(--bg-element-hover); - color: var(--text-heading); - border-color: var(--text-muted); - font-weight: 600; - z-index: 2; -} - -/* Modals */ -.modal-content { - background-color: var(--bg-card); - border: 1px solid var(--border-color); - color: var(--text-main); - box-shadow: 0 15px 50px rgba(0, 0, 0, 0.5); -} - -.modal-header { - border-bottom: 1px solid var(--border-color); -} - -.modal-footer { - border-top: 1px solid var(--border-color); -} - -.btn-close { - filter: var(--btn-close-filter, none); -} - -[data-theme="dark"] .btn-close { - filter: invert(1) grayscale(100%) brightness(200%); -} - -.modal-backdrop.show { - opacity: 0.6; - background-color: #000; -} - -.chart-controls { - background: var(--bg-element); - padding: 12px; - border-radius: 6px; - border: 1px solid var(--border-color); - margin-bottom: 20px; - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 10px; -} - -.bg-white-custom { - background-color: var(--bg-input) !important; - border-color: var(--border-color) !important; -} - -/* Charts & Dashboard Specifics */ -.chart-container { - position: relative; - height: 320px; - width: 100%; -} - -.modal-chart-container { - height: 400px; - width: 100%; - position: relative; -} - -.pie-container { - position: relative; - height: 220px; - width: 100%; - display: flex; - justify-content: center; -} - -.chart-header-controls { - display: flex; - align-items: center; - gap: 15px; -} - -.legend-dot { - width: 10px; - height: 10px; - border-radius: 50%; - display: inline-block; - margin-right: 5px; -} - -/* Certificates Specifics */ -.category-header { - background: linear-gradient(135deg, var(--bg-element), var(--bg-element-hover)); - border-left: 4px solid; - padding: 12px 20px; - margin: 0; - font-weight: 600; -} - -.category-header.active { - border-left-color: var(--success-text); - color: var(--success-text); -} - -.category-header.expired { - border-left-color: var(--danger-text); - color: var(--danger-text); -} - -.certificate-file { - font-size: 0.8rem; - color: var(--text-muted); - margin-top: 2px; -} - -.empty-state { - text-align: center; - padding: 40px 20px; - color: var(--text-muted); -} - -.empty-state i { - font-size: 3rem; - margin-bottom: 15px; - opacity: 0.5; -} - -.cert-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 0; - border-bottom: 1px solid var(--border-subtle); -} - -.cert-item:last-child { - border-bottom: none; -} - -/* Utilities */ -.refresh-indicator { - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} - -/* Responsive */ -@media (max-width: 768px) { - .header-controls { - flex-wrap: wrap; - gap: 8px; - } - - .stats-info { - flex-direction: column; - } - - .chart-controls { - flex-direction: column; - align-items: flex-start; - } - - .chart-controls>div { - width: 100%; - } - - .kpi-grid { - grid-template-columns: 1fr; - } - - .chart-header-controls { - flex-wrap: wrap; - margin-top: 10px; - } -} \ No newline at end of file diff --git a/UI/artifacts/dashboard.php b/UI/artifacts/dashboard.php deleted file mode 100644 index 1565194..0000000 --- a/UI/artifacts/dashboard.php +++ /dev/null @@ -1,184 +0,0 @@ - - - - - - - - OpenVPN Analytics Dashboard - - - - - - - - - - - - - Analytics Dashboard - System performance overview - - - - Clients - - - Certificates - - - Analytics - - - System - - - - - - - - - - - - - - - - - - - - Concurrent Users (Peak) - - - - - - - - Traffic Volume (Total) - - - - - - - - Expiring Soon (In 45 Days) - - - - - - - Traffic Overview - - - - Last 24 Hours - Last 7 Days - Last 1 Month - - - - - Speed - - - - - - - - - - - - - - - TOP-3 Active Clients - - - - - - - Client Name - Total Data - Activity Share - - - - - Loading... - - - - - - - - - - - - Certificate Alerts - Next 45 Days - - - Checking certificates... - - - - - - Traffic Distribution - - - - - - - - Download - - - - - Upload - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/UI/artifacts/index.php b/UI/artifacts/index.php deleted file mode 100644 index 6ca41a1..0000000 --- a/UI/artifacts/index.php +++ /dev/null @@ -1,198 +0,0 @@ - - - - - - - - OpenVPN Client Statistics - - - - - - - - - - - - - - OpenVPN Monitor - Real-time traffic & connection statistics - - - - Clients - - - Certificates - - - Analytics - - 0 clients - - - - - - - - - - - - - - - - 0 B - Total Received - - - 0 B - Total Sent - - - 0 - Active Clients - - - - - - - Received - Sent - - - - - - - - - - - Hide - Disconnected - - - - - - Clients List - Updating... - - - - - - Client Name - Real Address - Status - Received - Sent - Max 30s DL - Max 30s UL - Last Activity - - - - - Loading... - - - - - - - - - - - - - - - Client Name - - - - - - - - - Range: - - Last 1 Hour (30s agg) - Last 3 Hours (1m agg) - Last 6 Hours (1m agg) - Last 12 Hours (1m agg) - Last 24 Hours (1m agg) - ────────── - Last 1 Day (15m agg) - Last 2 Days (15m agg) - Last 3 Days (15m agg) - ────────── - Last 4 Days (1h agg) - Last 5 Days (1h agg) - Last 6 Days (1h agg) - Last 7 Days (1h agg) - Last 14 Days (1h agg) - Last 1 Month (1h agg) - - - - - Metric: - - - Data - Volume - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/UI/artifacts/js/pages/certificates.js b/UI/artifacts/js/pages/certificates.js deleted file mode 100644 index 9a5d1e9..0000000 --- a/UI/artifacts/js/pages/certificates.js +++ /dev/null @@ -1,228 +0,0 @@ -// certificates.js - OpenVPN Certificate Statistics Logic - -// Access globals defined in PHP -const { apiUrl, refreshTime } = window.AppConfig; - -let refreshInterval; -let allCertificatesData = []; -let hideExpired = false; - -function getStatusBadge(daysRemaining) { - if (!daysRemaining || daysRemaining === 'N/A') { - return 'Unknown'; - } - - if (daysRemaining.includes('Expired')) { - return 'Expired'; - } else if (daysRemaining.includes('days')) { - const days = parseInt(daysRemaining); - if (days <= 30) { - return 'Expiring Soon'; - } else { - return 'Valid'; - } - } - return 'Unknown'; -} - -function categorizeCertificates(certificates) { - const active = []; - const expired = []; - - certificates.forEach(cert => { - if (!cert.days_remaining || cert.days_remaining === 'N/A') { - // If days_remaining is not available, check not_after date - if (cert.not_after && cert.not_after !== 'N/A') { - try { - const expDate = new Date(cert.not_after); - const now = new Date(); - if (expDate < now) { - expired.push(cert); - } else { - active.push(cert); - } - } catch (e) { - // If we can't parse the date, consider it active - active.push(cert); - } - } else { - // If no expiration info, consider it active - active.push(cert); - } - } else if (cert.days_remaining.includes('Expired')) { - expired.push(cert); - } else { - active.push(cert); - } - }); - - // Sort active certificates by days remaining (ascending) - active.sort((a, b) => { - try { - const aDays = a.days_remaining && a.days_remaining !== 'N/A' ? parseInt(a.days_remaining) : 9999; - const bDays = b.days_remaining && b.days_remaining !== 'N/A' ? parseInt(b.days_remaining) : 9999; - return aDays - bDays; - } catch (e) { - return 0; - } - }); - - // Sort expired certificates by days expired (descending) - expired.sort((a, b) => { - try { - const aMatch = a.days_remaining ? a.days_remaining.match(/\d+/) : [0]; - const bMatch = b.days_remaining ? b.days_remaining.match(/\d+/) : [0]; - const aDays = parseInt(aMatch[0]); - const bDays = parseInt(bMatch[0]); - return bDays - aDays; - } catch (e) { - return 0; - } - }); - - return { active, expired }; -} - -function renderSingleTable(active, expired, tableId) { - const tableBody = document.getElementById(tableId); - if ((!active || active.length === 0) && (!expired || expired.length === 0)) { - tableBody.innerHTML = ` - - - - No certificates found - - - `; - return; - } - - let html = ''; - - // Render Active - if (active && active.length > 0) { - html += `Active Certificates (${active.length})`; - active.forEach(cert => { - html += generateRow(cert, false); - }); - } - - // Render Expired - if (expired && expired.length > 0 && !hideExpired) { - html += `Expired Certificates (${expired.length})`; - expired.forEach(cert => { - html += generateRow(cert, true); - }); - } - - tableBody.innerHTML = html; -} - -function generateRow(cert, isExpired) { - const commonName = cert.common_name || cert.subject || 'N/A'; - const clientName = commonName.replace('CN=', '').trim(); - let daysText = cert.days_remaining || 'N/A'; - - if (isExpired && daysText.includes('Expired')) { - daysText = daysText.replace('Expired (', '').replace(' days ago)', '') + ' days ago'; - } - - return ` - - - ${clientName} - ${cert.file || 'N/A'} - - ${formatCertDate(cert.not_after)} - - ${daysText} - - ${getStatusBadge(cert.days_remaining)} - - `; -} - -function updateSummaryStats(certificates) { - const totalCertificates = document.getElementById('totalCertificates'); - const activeCertificates = document.getElementById('activeCertificates'); - const expiredCertificates = document.getElementById('expiredCertificates'); - const expiringSoon = document.getElementById('expiringSoon'); - const activeCount = document.getElementById('activeCount'); - const expiredCount = document.getElementById('expiredCount'); - const certCount = document.getElementById('certCount'); - - const { active, expired } = categorizeCertificates(certificates); - - let expiringSoonCount = 0; - active.forEach(cert => { - if (cert.days_remaining && cert.days_remaining !== 'N/A') { - const days = parseInt(cert.days_remaining); - if (days <= 30) { - expiringSoonCount++; - } - } - }); - - totalCertificates.textContent = certificates.length; - activeCertificates.textContent = active.length; - expiredCertificates.textContent = expired.length; - expiringSoon.textContent = expiringSoonCount; - // Update counts in badges - document.getElementById('activeCount').textContent = active.length; - document.getElementById('expiredCount').textContent = expired.length; - certCount.textContent = certificates.length + ' certificate' + (certificates.length !== 1 ? 's' : ''); -} - -function filterCertificates() { - const searchTerm = document.getElementById('searchInput').value.toLowerCase(); - - let filteredCertificates = allCertificatesData; - - if (searchTerm) { - filteredCertificates = allCertificatesData.filter(cert => { - const commonName = (cert.common_name || cert.subject || '').toLowerCase(); - const fileName = (cert.file || '').toLowerCase(); - return commonName.includes(searchTerm) || fileName.includes(searchTerm); - }); - } - - const { active, expired } = categorizeCertificates(filteredCertificates); - - renderSingleTable(active, expired, 'certificatesTable'); - updateSummaryStats(filteredCertificates); -} - -function toggleExpiredCertificates() { - hideExpired = document.getElementById('hideExpired').checked; - const expiredCard = document.getElementById('certificatesCard'); // We just re-render - filterCertificates(); -} - -async function fetchData() { - document.getElementById('refreshIcon').classList.add('refresh-indicator'); - try { - const response = await fetch(apiUrl); - const json = await response.json(); - if (json.success) { - allCertificatesData = json.data; - filterCertificates(); // This also renders tables - } - } catch (e) { - console.error("Fetch error:", e); - } finally { - document.getElementById('refreshIcon').classList.remove('refresh-indicator'); - } -} - -// Ensure functionality is available for HTML event attributes -window.fetchData = fetchData; -window.filterCertificates = filterCertificates; -window.toggleExpiredCertificates = toggleExpiredCertificates; - -document.addEventListener('DOMContentLoaded', () => { - initTheme(); - fetchData(); - refreshInterval = setInterval(fetchData, refreshTime); -}); diff --git a/UI/artifacts/js/pages/dashboard.js b/UI/artifacts/js/pages/dashboard.js deleted file mode 100644 index bdf1156..0000000 --- a/UI/artifacts/js/pages/dashboard.js +++ /dev/null @@ -1,269 +0,0 @@ -// dashboard.js - OpenVPN Analytics Dashboard Logic - -// Access globals defined in PHP via window.AppConfig -const { apiAnalytics, apiCerts } = window.AppConfig; - -let mainChart = null; -let pieChart = null; -let globalHistory = []; -let vizMode = 'volume'; // 'volume' or 'speed' - -// --- Data Loading --- -async function loadData() { - const icon = document.getElementById('refreshIcon'); - icon.classList.add('refresh-indicator'); - - // Get selected range - const range = document.getElementById('globalRange').value; - - try { - const [resA, resC] = await Promise.all([ - fetch(apiAnalytics + '?range=' + range), - fetch(apiCerts) - ]); - - const jsonA = await resA.json(); - const jsonC = await resC.json(); - - if (jsonA.success) updateDashboard(jsonA.data); - if (jsonC.success) updateCerts(jsonC.data); - - } catch (e) { - console.error("Dashboard Load Error:", e); - } finally { - icon.classList.remove('refresh-indicator'); - } -} - -function updateDashboard(data) { - // 1. KPI - document.getElementById('kpiMaxClients').textContent = data.max_concurrent_24h; - const totalT = (data.traffic_distribution.rx + data.traffic_distribution.tx); - document.getElementById('kpiTotalTraffic').textContent = formatBytes(totalT); - - // 2. Main Chart - globalHistory = data.global_history_24h; - renderMainChart(); - - // 3. Top Clients - const tbody = document.getElementById('topClientsTable'); - tbody.innerHTML = ''; - if (data.top_clients_24h.length === 0) { - tbody.innerHTML = 'No activity recorded'; - } else { - const maxVal = data.top_clients_24h[0].total_traffic; - data.top_clients_24h.forEach(c => { - const pct = (c.total_traffic / maxVal) * 100; - const row = ` - - ${c.common_name} - ${formatBytes(c.total_traffic)} - - - - - - `; - tbody.innerHTML += row; - }); - } - - // 4. Pie Chart - renderPieChart(data.traffic_distribution); -} - -function updateCerts(certs) { - const container = document.getElementById('certsList'); - const alertCountEl = document.getElementById('kpiExpiringCerts'); - - // Filter: Active only, Expires in <= 45 days - let expiring = certs.filter(c => { - if (c.is_expired) return false; // Hide expired as requested - const days = parseInt(c.days_remaining); - return !isNaN(days) && days <= 45 && days >= 0; - }); - - expiring.sort((a, b) => parseInt(a.days_remaining) - parseInt(b.days_remaining)); - - alertCountEl.textContent = expiring.length; - if (expiring.length > 0) alertCountEl.style.color = 'var(--warning-text)'; - else alertCountEl.style.color = 'var(--text-heading)'; - - if (expiring.length === 0) { - container.innerHTML = ` - - - No certificates expiring soon - `; - return; - } - - let html = ''; - expiring.forEach(c => { - html += ` - - - ${c.common_name || 'Unknown'} - Expires: ${c.not_after} - - ${c.days_remaining} - `; - }); - container.innerHTML = html; -} - -// --- Charts --- -function toggleMainChart() { - vizMode = document.getElementById('vizToggle').checked ? 'speed' : 'volume'; - document.getElementById('vizLabel').textContent = vizMode === 'volume' ? 'Data Volume' : 'Speed (Mbps)'; - renderMainChart(); -} - -function getChartColors(isDark) { - return { - grid: isDark ? 'rgba(240, 246, 252, 0.05)' : 'rgba(0, 0, 0, 0.04)', - text: isDark ? '#8b949e' : '#6c757d', - bg: isDark ? '#161b22' : '#ffffff', - border: isDark ? '#30363d' : '#d0d7de', - title: isDark ? '#c9d1d9' : '#24292f' - }; -} - -function renderMainChart() { - const ctx = document.getElementById('mainChart').getContext('2d'); - if (mainChart) mainChart.destroy(); - - // Downsample - const MAX_POINTS = 48; - let displayData = []; - - if (globalHistory.length > MAX_POINTS) { - const blockSize = Math.ceil(globalHistory.length / MAX_POINTS); - for (let i = 0; i < globalHistory.length; i += blockSize) { - const chunk = globalHistory.slice(i, i + blockSize); - let rx = 0, tx = 0, rxR = 0, txR = 0; - chunk.forEach(p => { - rx += p.total_rx; tx += p.total_tx; - rxR = Math.max(rxR, p.total_rx_rate || 0); - txR = Math.max(txR, p.total_tx_rate || 0); - }); - displayData.push({ - timestamp: chunk[0].timestamp, - rx: rx, tx: tx, rxR: rxR, txR: txR - }); - } - } else { - displayData = globalHistory.map(p => ({ - timestamp: p.timestamp, - rx: p.total_rx, tx: p.total_tx, - rxR: p.total_rx_rate, txR: p.total_tx_rate - })); - } - - const labels = displayData.map(p => { - const d = parseServerDate(p.timestamp); - return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - }); - - const dsRx = displayData.map(p => vizMode === 'volume' ? p.rx / (1024 * 1024) : p.rxR); - const dsTx = displayData.map(p => vizMode === 'volume' ? p.tx / (1024 * 1024) : p.txR); - - const isDark = currentTheme === 'dark'; - const colors = getChartColors(isDark); - - mainChart = new Chart(ctx, { - type: 'line', - data: { - labels: labels, - datasets: [ - { - label: 'Total Download', - data: dsRx, - borderColor: '#3fb950', - backgroundColor: 'rgba(63, 185, 80, 0.15)', - borderWidth: 2, fill: true, tension: 0.3, pointRadius: 0, pointHoverRadius: 4 - }, - { - label: 'Total Upload', - data: dsTx, - borderColor: '#58a6ff', - backgroundColor: 'rgba(88, 166, 255, 0.15)', - borderWidth: 2, fill: true, tension: 0.3, pointRadius: 0, pointHoverRadius: 4 - } - ] - }, - options: { - responsive: true, maintainAspectRatio: false, - interaction: { mode: 'index', intersect: false }, - plugins: { legend: { labels: { color: colors.text } } }, - scales: { - x: { ticks: { color: colors.text, maxTicksLimit: 8 }, grid: { color: colors.grid, borderColor: 'transparent' } }, - y: { - beginAtZero: true, - ticks: { color: colors.text }, - grid: { color: colors.grid, borderColor: 'transparent' }, - title: { display: true, text: vizMode === 'volume' ? 'MB' : 'Mbps', color: colors.text } - } - } - } - }); -} - -function renderPieChart(dist) { - const ctx = document.getElementById('pieChart').getContext('2d'); - if (pieChart) pieChart.destroy(); - - document.getElementById('pieRxVal').textContent = formatBytes(dist.rx); - document.getElementById('pieTxVal').textContent = formatBytes(dist.tx); - - const isDark = currentTheme === 'dark'; - const colors = getChartColors(isDark); - - pieChart = new Chart(ctx, { - type: 'doughnut', - data: { - labels: ['Download', 'Upload'], - datasets: [{ - data: [dist.rx, dist.tx], - backgroundColor: ['rgba(63, 185, 80, 0.8)', 'rgba(88, 166, 255, 0.8)'], - borderColor: colors.bg, // Add border to match background for better separation - borderWidth: 2 - }] - }, - options: { - responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: false } }, - cutout: '70%' - } - }); -} - -function renderCharts() { - if (globalHistory.length) renderMainChart(); - // Trigger load to refresh Pie Chart colors - loadData(); -} - -// Expose functions to window for HTML attributes -window.loadData = loadData; -window.toggleMainChart = toggleMainChart; -window.renderCharts = renderCharts; - -document.addEventListener('DOMContentLoaded', () => { - initTheme(); - loadData(); - setInterval(loadData, 60000); - - // Override global toggleTheme to update charts - // We hook into the global toggleTheme defined in utils.js - // but the actual onclick calls toggleTheme(). - // We need to overwrite window.toggleTheme - const _baseToggleTheme = window.toggleTheme; - window.toggleTheme = function () { - _baseToggleTheme(() => { - // Re-render charts with new theme colors - if (globalHistory.length) renderMainChart(); - loadData(); // This refreshes everything including pie chart - }); - }; -}); diff --git a/UI/artifacts/js/pages/index.js b/UI/artifacts/js/pages/index.js deleted file mode 100644 index 7014fe4..0000000 --- a/UI/artifacts/js/pages/index.js +++ /dev/null @@ -1,365 +0,0 @@ -// index.js - OpenVPN Monitor Client List Logic - -// Access globals defined in PHP -const { apiUrl, refreshTime } = window.AppConfig; - -const MAX_CHART_POINTS = 48; - -let allClientsData = []; -let currentSort = 'sent'; -let hideDisconnected = false; -let searchQuery = ''; -let refreshIntervalId; - -// Chart Globals -let trafficChart = null; -let currentClientName = ''; -let currentRange = '24h'; -let vizMode = 'volume'; -let cachedHistoryData = null; - -// --- MAIN DASHBOARD LOGIC --- - -async function fetchData() { - document.getElementById('refreshIcon').classList.add('refresh-indicator'); - try { - const response = await fetch(apiUrl); - const json = await response.json(); - if (json.success) { - allClientsData = json.data; - renderDashboard(); - } - } catch (e) { - console.error("Fetch error:", e); - } finally { - document.getElementById('refreshIcon').classList.remove('refresh-indicator'); - } -} - -function handleSearch(val) { - searchQuery = val.toLowerCase().trim(); - renderDashboard(); -} - -function renderDashboard() { - let totalRx = 0, totalTx = 0, activeCnt = 0; - allClientsData.forEach(c => { - totalRx += c.total_bytes_received || 0; - totalTx += c.total_bytes_sent || 0; - if (c.status === 'Active') activeCnt++; - }); - - document.getElementById('totalReceived').textContent = formatBytes(totalRx); - document.getElementById('totalSent').textContent = formatBytes(totalTx); - document.getElementById('activeClients').textContent = activeCnt; - document.getElementById('clientCount').textContent = allClientsData.length + ' clients'; - - const now = new Date(); - document.getElementById('lastUpdated').textContent = 'Updated: ' + now.toLocaleTimeString(); - - let displayData = allClientsData.filter(c => { - if (hideDisconnected && c.status !== 'Active') return false; - if (searchQuery && !c.common_name.toLowerCase().includes(searchQuery)) return false; - return true; - }); - - displayData.sort((a, b) => { - if (a.status === 'Active' && b.status !== 'Active') return -1; - if (a.status !== 'Active' && b.status === 'Active') return 1; - const valA = currentSort === 'received' ? a.total_bytes_received : a.total_bytes_sent; - const valB = currentSort === 'received' ? b.total_bytes_received : b.total_bytes_sent; - return valB - valA; - }); - - const tbody = document.getElementById('statsTable'); - tbody.innerHTML = ''; - - const thRecv = document.getElementById('thRecv'); - const thSent = document.getElementById('thSent'); - if (currentSort === 'received') { - thRecv.classList.add('active-sort'); thRecv.innerHTML = 'Received '; - thSent.classList.remove('active-sort'); thSent.innerHTML = 'Sent'; - } else { - thSent.classList.add('active-sort'); thSent.innerHTML = 'Sent '; - thRecv.classList.remove('active-sort'); thRecv.innerHTML = 'Received'; - } - - if (displayData.length === 0) { - tbody.innerHTML = 'No clients match your filter'; - return; - } - - let currentStatus = null; - - displayData.forEach(c => { - if (c.status !== currentStatus) { - currentStatus = c.status; - const dividerRow = document.createElement('tr'); - dividerRow.className = 'section-divider'; - const iconClass = c.status === 'Active' ? 'fa-circle text-success' : 'fa-circle text-danger'; - dividerRow.innerHTML = ` - - ${c.status} Clients - - `; - tbody.appendChild(dividerRow); - } - - let lastActivity = 'N/A'; - if (c.last_activity && c.last_activity !== 'N/A') { - const d = parseServerDate(c.last_activity); - if (!isNaN(d)) lastActivity = d.toLocaleString(); - } - - const isConnected = c.status === 'Active'; - - // Speed Visualization Logic - const downSpeed = isConnected - ? ` - ${c.current_recv_rate_mbps ? formatRate(c.current_recv_rate_mbps) : '0.000 Mbps'} - ` - : '-'; - - const upSpeed = isConnected - ? ` - ${c.current_sent_rate_mbps ? formatRate(c.current_sent_rate_mbps) : '0.000 Mbps'} - ` - : '-'; - - const row = document.createElement('tr'); - row.innerHTML = ` - - - ${c.common_name} - - - ${c.real_address || '-'} - - - ${c.status} - - - ${formatBytes(c.total_bytes_received)} - ${formatBytes(c.total_bytes_sent)} - ${downSpeed} - ${upSpeed} - ${lastActivity} - `; - tbody.appendChild(row); - }); -} - -function changeSort(mode) { - currentSort = mode; - document.getElementById('sortRecv').className = `sort-btn ${mode === 'received' ? 'active' : ''}`; - document.getElementById('sortSent').className = `sort-btn ${mode === 'sent' ? 'active' : ''}`; - renderDashboard(); -} - -function toggleDisconnected() { - hideDisconnected = document.getElementById('hideDisconnected').checked; - renderDashboard(); -} - -// --- HISTORY CHART LOGIC --- - -// Expose openHistoryModal to global scope because it's called from HTML inline onclick -window.openHistoryModal = function (clientName) { - currentClientName = clientName; - document.getElementById('modalClientName').textContent = clientName; - document.getElementById('historyRange').value = '3h'; - document.getElementById('vizToggle').checked = false; - vizMode = 'volume'; - document.getElementById('vizLabel').textContent = 'Data Volume'; - currentRange = '24h'; - - const modal = new bootstrap.Modal(document.getElementById('historyModal')); - modal.show(); - loadHistoryData(); -}; - -window.updateHistoryRange = function () { - currentRange = document.getElementById('historyRange').value; - loadHistoryData(); -}; - -window.toggleVizMode = function () { - const isChecked = document.getElementById('vizToggle').checked; - vizMode = isChecked ? 'speed' : 'volume'; - document.getElementById('vizLabel').textContent = isChecked ? 'Speed (Mbps)' : 'Data Volume'; - - if (cachedHistoryData) { - const downsampled = downsampleData(cachedHistoryData, MAX_CHART_POINTS); - renderChart(downsampled); - } else { - loadHistoryData(); - } -}; - -async function loadHistoryData() { - const loader = document.getElementById('chartLoader'); - loader.style.display = 'block'; - - const url = `${apiUrl}/${currentClientName}?range=${currentRange}`; - - try { - const res = await fetch(url); - const json = await res.json(); - - if (json.success && json.data.history) { - cachedHistoryData = json.data.history; - const downsampled = downsampleData(cachedHistoryData, MAX_CHART_POINTS); - renderChart(downsampled); - } else { - console.warn("No history data found"); - if (trafficChart) trafficChart.destroy(); - } - } catch (e) { - console.error("History fetch error:", e); - } finally { - loader.style.display = 'none'; - } -} - -function downsampleData(data, maxPoints) { - if (!data || data.length === 0) return []; - if (data.length <= maxPoints) return data; - - const blockSize = Math.ceil(data.length / maxPoints); - const result = []; - - for (let i = 0; i < data.length; i += blockSize) { - const chunk = data.slice(i, i + blockSize); - if (chunk.length === 0) continue; - - let sumRx = 0, sumTx = 0; - let maxRxRate = 0, maxTxRate = 0; - - chunk.forEach(pt => { - sumRx += (pt.bytes_received || 0); - sumTx += (pt.bytes_sent || 0); - maxRxRate = Math.max(maxRxRate, pt.bytes_received_rate_mbps || 0); - maxTxRate = Math.max(maxTxRate, pt.bytes_sent_rate_mbps || 0); - }); - - result.push({ - timestamp: chunk[0].timestamp, - bytes_received: sumRx, - bytes_sent: sumTx, - bytes_received_rate_mbps: maxRxRate, - bytes_sent_rate_mbps: maxTxRate - }); - } - return result; -} - -function renderChart(history) { - const ctx = document.getElementById('trafficChart').getContext('2d'); - if (trafficChart) trafficChart.destroy(); - - const labels = []; - const dataRx = []; - const dataTx = []; - - for (let i = 0; i < history.length; i++) { - const point = history[i]; - const d = parseServerDate(point.timestamp); - - let label = ''; - if (currentRange.includes('h') || currentRange === '1d') { - label = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else { - label = d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + - d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } - labels.push(label); - - if (vizMode === 'volume') { - dataRx.push(point.bytes_received / (1024 * 1024)); // MB - dataTx.push(point.bytes_sent / (1024 * 1024)); // MB - } else { - let rxRate = point.bytes_received_rate_mbps; - let txRate = point.bytes_sent_rate_mbps; - dataRx.push(rxRate); - dataTx.push(txRate); - } - } - - const isDark = currentTheme === 'dark'; - const gridColor = isDark ? 'rgba(240, 246, 252, 0.1)' : 'rgba(0,0,0,0.05)'; - const textColor = isDark ? '#8b949e' : '#6c757d'; - - trafficChart = new Chart(ctx, { - type: 'line', - data: { - labels: labels, - datasets: [ - { - label: vizMode === 'volume' ? 'Received (MB)' : 'RX Mbps', - data: dataRx, - borderColor: '#27ae60', - backgroundColor: 'rgba(39, 174, 96, 0.1)', - borderWidth: 2, - fill: true, - tension: 0.3 - }, - { - label: vizMode === 'volume' ? 'Sent (MB)' : 'TX Mbps', - data: dataTx, - borderColor: '#2980b9', - backgroundColor: 'rgba(41, 128, 185, 0.1)', - borderWidth: 2, - fill: true, - tension: 0.3 - } - ] - }, - options: { - responsive: true, - maintainAspectRatio: false, - interaction: { - mode: 'index', - intersect: false, - }, - plugins: { - legend: { labels: { color: textColor } } - }, - scales: { - x: { - ticks: { color: textColor, maxTicksLimit: 8 }, - grid: { color: gridColor } - }, - y: { - beginAtZero: true, - ticks: { color: textColor }, - grid: { color: gridColor } - } - } - } - }); -} - -// Expose other functions used in HTML onclick attributes -window.changeSort = changeSort; -window.toggleDisconnected = toggleDisconnected; -window.handleSearch = handleSearch; -window.fetchData = fetchData; - -document.addEventListener('DOMContentLoaded', () => { - initTheme(); - fetchData(); - // Start Auto-Refresh - refreshIntervalId = setInterval(fetchData, refreshTime); - - // Re-render chart on theme change - // The helper 'toggleTheme' in utils.js takes a callback - // Override global toggleTheme to include chart re-rendering - const _baseToggleTheme = window.toggleTheme; - window.toggleTheme = function () { - _baseToggleTheme(() => { - if (trafficChart) { - renderChart(cachedHistoryData ? downsampleData(cachedHistoryData, MAX_CHART_POINTS) : []); - } - }); - }; -}); diff --git a/UI/artifacts/js/utils.js b/UI/artifacts/js/utils.js deleted file mode 100644 index 6c5265e..0000000 --- a/UI/artifacts/js/utils.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Common Utilities for OpenVPN Monitor - */ - -// --- Global Variables --- -var currentTheme = localStorage.getItem('theme') || 'light'; - -// --- Theme Functions --- -function initTheme() { - document.documentElement.setAttribute('data-theme', currentTheme); - const icon = document.getElementById('themeIcon'); - if (icon) { - if (currentTheme === 'dark') { - // In dark mode we want Sun icon (to switch to light) - if (icon.classList.contains('fa-moon')) icon.classList.replace('fa-moon', 'fa-sun'); - else icon.classList = 'fas fa-sun'; - } else { - // In light mode we want Moon icon (to switch to dark) - if (icon.classList.contains('fa-sun')) icon.classList.replace('fa-sun', 'fa-moon'); - else icon.classList = 'fas fa-moon'; - } - } -} - -function toggleTheme(callback = null) { - currentTheme = currentTheme === 'light' ? 'dark' : 'light'; - localStorage.setItem('theme', currentTheme); - initTheme(); - if (callback) callback(); -} - -// --- Formatting Functions --- -function formatBytes(bytes, decimals = 2) { - if (bytes === 0) return '0 B'; - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; -} - -function formatRate(rate) { - return parseFloat(rate).toFixed(3) + ' Mbps'; -} - -function parseServerDate(dateStr) { - if (!dateStr) return null; - let isoStr = dateStr.replace(' ', 'T'); - if (!isoStr.endsWith('Z') && !isoStr.includes('+')) { - isoStr += 'Z'; - } - return new Date(isoStr); -} - -function formatCertDate(dateString) { - if (!dateString || dateString === 'N/A') return 'N/A'; - - try { - // Handle OpenSSL date format: "Jun 9 08:37:28 2024 GMT" - if (dateString.includes('GMT')) { - const months = { - 'Jan': '01', 'Feb': '02', 'Mar': '03', 'Apr': '04', - 'May': '05', 'Jun': '06', 'Jul': '07', 'Aug': '08', - 'Sep': '09', 'Oct': '10', 'Nov': '11', 'Dec': '12' - }; - - const parts = dateString.split(' '); - const month = months[parts[0]]; - const day = parts[1].padStart(2, '0'); - const year = parts[3]; - const time = parts[2]; - - return `${day}-${month}-${year} ${time}`; - } - - // Try to parse as ISO date or other format - const date = new Date(dateString); - if (!isNaN(date.getTime())) { - return date.toLocaleDateString('en-GB', { - day: '2-digit', - month: 'short', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false - }).replace(',', ''); - } - - return dateString; - } catch (error) { - console.log('Date formatting error:', error, 'for date:', dateString); - return dateString; - } -} diff --git a/UI/client/README.md b/UI/client/README.md deleted file mode 100644 index 1511959..0000000 --- a/UI/client/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Vue 3 + Vite - -This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` diff --git a/UI/client/src/assets/main.css b/UI/client/src/assets/main.css deleted file mode 100644 index c63e19b..0000000 --- a/UI/client/src/assets/main.css +++ /dev/null @@ -1,799 +0,0 @@ -/* --- THEME VARIABLES --- */ -:root { - /* Light Theme */ - --bg-body: #f6f8fa; - --bg-card: #ffffff; - --bg-element: #f6f8fa; - --bg-element-hover: #f1f3f5; - --bg-input: #ffffff; - - --text-heading: #24292f; - --text-main: #57606a; - --text-muted: #8c959f; - - --border-color: #d0d7de; - --border-subtle: #e9ecef; - - --badge-border-active: #92bea5; - --badge-border-disconnected: #d47e80; - - --accent-color: #0969da; - --success-bg: rgba(39, 174, 96, 0.15); - --success-text: #1a7f37; - --danger-bg: rgba(231, 76, 60, 0.15); - --danger-text: #cf222e; - --warning-bg: rgba(255, 193, 7, 0.15); - --warning-text: #9a6700; - - --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - --toggle-off-bg: #e9ecef; - --toggle-off-border: #d0d7de; -} - -/* Dark Theme (Soft & Low Contrast) */ -[data-theme="dark"] { - --bg-body: #0d1117; - --bg-card: #161b22; - --bg-element: #21262d; - - /* Используем прозрачность для hover, чтобы текст не сливался */ - --bg-element-hover: rgba(255, 255, 255, 0.08); - - --bg-input: #0d1117; - - --text-heading: #f0f6fc; - /* Светлее для заголовков */ - --text-main: #c9d1d9; - /* Мягкий серый для текста */ - --text-muted: #8b949e; - - /* ОЧЕНЬ мягкие границы (8% прозрачности белого) */ - --border-color: rgba(240, 246, 252, 0.1); - --border-subtle: rgba(240, 246, 252, 0.05); - - --badge-border-active: #3e6f40; - --badge-border-disconnected: #793837; - - --accent-color: #58a6ff; - --success-bg: rgba(35, 134, 54, 0.15); - --success-text: #3fb950; - --danger-bg: rgba(218, 54, 51, 0.15); - --danger-text: #f85149; - --warning-bg: rgba(210, 153, 34, 0.15); - --warning-text: #d29922; - - --shadow: none; - - --toggle-off-bg: rgba(110, 118, 129, 0.1); - --toggle-off-border: rgba(240, 246, 252, 0.1); -} - -body { - background-color: var(--bg-body); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - font-size: 0.9rem; - color: var(--text-main); - margin: 0; - padding: 0; - overflow-x: hidden; - transition: background-color 0.3s ease, color 0.3s ease; -} - -/* --- NEW APP LAYOUT --- */ -.app-wrapper { - display: flex; - min-height: 100vh; -} - -/* Sidebar */ -.sidebar { - width: 260px; - background-color: var(--bg-card); - border-right: 1px solid var(--border-color); - display: flex; - flex-direction: column; - position: fixed; - height: 100vh; - z-index: 1000; - transition: width 0.3s ease, background-color 0.3s ease, border-color 0.3s ease; - box-shadow: 4px 0 24px rgba(0, 0, 0, 0.04); - /* Shading added */ -} - -.sidebar-header { - height: 64px; - display: flex; - align-items: center; - padding: 0 20px; - border-bottom: 1px solid var(--border-color); - font-weight: 700; - font-size: 1.1rem; - color: var(--text-heading); -} - -.sidebar-brand-icon { - color: var(--accent-color); - font-size: 1.4rem; - margin-right: 10px; -} - -.sidebar-menu { - flex: 1; - padding: 20px 10px; - overflow-y: auto; -} - -.nav-link { - display: flex; - align-items: center; - padding: 10px 15px; - color: var(--text-main); - text-decoration: none; - border-radius: 6px; - margin-bottom: 5px; - transition: all 0.2s ease; - font-weight: 500; -} - -.nav-link:hover { - background-color: var(--bg-element-hover); - color: var(--text-heading); -} - -.nav-link.active { - background-color: var(--accent-color); - color: #ffffff; -} - -.nav-link i { - width: 24px; - text-align: center; - margin-right: 10px; - font-size: 1rem; -} - -.nav-link.active i { - color: #ffffff; -} - -/* Main Content Area */ -.main-content { - flex: 1; - margin-left: 260px; - /* Width of sidebar */ - width: calc(100% - 260px); - display: flex; - flex-direction: column; - min-height: 100vh; -} - -/* Top Navigation Bar (Glassmorphism) */ -.top-navbar { - height: 64px; - background-color: rgba(255, 255, 255, 0.6); - /* Semi-transparent Light */ - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 30px; - position: sticky; - top: 0; - z-index: 900; - transition: background-color 0.3s ease, border-color 0.3s ease; -} - -[data-theme="dark"] .top-navbar { - background-color: rgba(22, 27, 34, 0.6); - /* Semi-transparent Dark */ -} - - - -.page-title h2 { - margin: 0; - font-size: 1.25rem; - color: var(--text-heading); -} - -/* Content Container */ -.content-wrapper { - padding: 30px; - flex: 1; -} - -/* Adapt existing container */ -.container { - max-width: 100% !important; - margin: 0 !important; - padding: 0 !important; -} - -/* Layout Elements */ -.card { - background-color: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 6px; - box-shadow: var(--shadow); - margin-bottom: 20px; - transition: border-color 0.3s ease; -} - -.card-header { - background: transparent; - border-bottom: 1px solid var(--border-color); - padding: 15px 20px; - font-weight: 600; - color: var(--text-heading) !important; -} - -h1, -h2, -h3, -h4, -h5, -h6 { - color: var(--text-heading); - font-weight: 600; -} - -.text-muted { - color: var(--text-muted) !important; -} - -/* Navbar Actions */ -.navbar-actions { - display: flex; - align-items: center; - gap: 10px; -} - -/* Buttons & Controls */ -.btn-nav, -.btn-header, -.header-badge, -.header-timezone { - background: var(--bg-element); - border: 1px solid var(--border-color); - color: var(--text-heading); - transition: all 0.2s ease; - font-size: 0.85rem; - display: inline-flex; - align-items: center; - justify-content: center; - height: 36px; - /* Fixed height for consistency */ - vertical-align: middle; -} - -.btn-nav:hover, -.btn-header:hover { - background: var(--bg-element-hover); - border-color: var(--text-muted); - color: var(--text-heading); -} - -.btn-nav.active { - background: var(--accent-color); - color: #ffffff; - border-color: var(--accent-color); -} - -.btn-header { - padding: 0 12px; - border-radius: 6px; - min-width: 36px; -} - - -.btn-nav { - padding: 0 12px; - border-radius: 6px; - display: inline-flex; - align-items: center; - text-decoration: none; - margin-right: 0.5rem; -} - -.header-badge, -.header-timezone { - padding: 0 12px; - border-radius: 6px; - display: inline-flex; - align-items: center; -} - -/* Stats Cards & KPI */ -.stats-info, -.kpi-grid { - display: flex; - gap: 15px; - flex-wrap: wrap; - margin-bottom: 15px; -} - -.kpi-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 20px; -} - -.stat-item { - background: var(--bg-card); - padding: 15px; - border-radius: 6px; - border: 1px solid var(--border-color); - flex: 1; - min-width: 180px; - box-shadow: var(--shadow); -} - -.kpi-grid .stat-item { - padding: 20px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.stat-value { - font-size: 1.4rem; - font-weight: 600; - color: var(--text-heading); - line-height: 1.2; -} - -.kpi-grid .stat-content h3 { - font-size: 1.6rem; - font-weight: 700; - color: var(--text-heading); - margin: 0; - line-height: 1.2; -} - -.stat-label { - font-size: 0.85rem; - color: var(--text-muted); - margin-top: 5px; -} - -/* Tables */ -.table { - --bs-table-bg: transparent; - --bs-table-color: var(--text-main); - --bs-table-border-color: var(--border-color); - margin-bottom: 0; -} - -.table th { - background-color: var(--bg-card); - color: var(--text-muted); - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 0.5px; - border-bottom: 1px solid var(--border-color); - position: sticky; - top: 0; - z-index: 10; - cursor: pointer; - user-select: none; - padding: 12px 10px; -} - -.table th:hover { - color: var(--text-heading); -} - -.table th.active-sort { - color: var(--accent-color); - border-bottom-color: var(--accent-color); -} - -.table td { - padding: 12px 10px; - border-bottom: 1px solid var(--border-subtle); - vertical-align: middle; -} - -.table-hover tbody tr:hover { - background-color: var(--bg-element-hover); -} - -.table-hover tbody tr:hover td, -.table-hover tbody tr:hover .font-monospace { - color: var(--text-heading); -} - -.font-monospace { - color: var(--text-main); - font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; -} - -/* Section Divider */ -.section-divider td { - background-color: var(--bg-element) !important; - font-weight: 600; - color: var(--text-heading); - padding: 8px 15px; - border-top: 1px solid var(--border-color); - border-bottom: 1px solid var(--border-color); - font-size: 0.8rem; -} - -/* Badges */ -.status-badge { - padding: 4px 8px; - border-radius: 4px; - font-size: 13px; - font-weight: 500; - display: inline-block; - line-height: normal; - border: 1px solid transparent; -} - -/* Using !important to ensure override of any bootstrap or generic styles */ -.status-valid, -.status-active { - background-color: rgba(40, 167, 69, 0.1) !important; - color: #28a745 !important; - border-color: rgba(40, 167, 69, 0.2) !important; -} - -.status-expired, -.status-disconnected { - background-color: rgba(220, 53, 69, 0.1) !important; - color: #dc3545 !important; - border-color: rgba(220, 53, 69, 0.2) !important; -} - -.status-warning, -.status-expiring { - background-color: rgba(255, 193, 7, 0.1) !important; - color: #856404 !important; - border-color: rgba(255, 193, 7, 0.2) !important; -} - -.status-server { - background-color: rgba(13, 202, 240, 0.1) !important; - color: #0c5460 !important; - border-color: rgba(13, 202, 240, 0.2) !important; -} - -.status-client { - background-color: rgba(108, 117, 125, 0.1) !important; - color: #373b3e !important; - border-color: rgba(108, 117, 125, 0.2) !important; -} - -.status-secondary { - background-color: rgba(108, 117, 125, 0.1) !important; - color: #373b3e !important; - border-color: rgba(108, 117, 125, 0.2) !important; -} - -.badge-soft-warning { - background: var(--warning-bg); - color: var(--warning-text); - border: 1px solid transparent; - font-weight: 600; - font-size: 0.75rem; - padding: 5px 10px; -} - -.client-link { - color: var(--text-heading); - text-decoration: none; - font-weight: 600; - cursor: pointer; - transition: color 0.2s; -} - -.client-link:hover { - color: var(--accent-color); -} - -.client-link i { - color: var(--text-muted); - transition: color 0.2s; -} - -.client-link:hover i { - color: var(--accent-color); -} - -/* Inputs */ -.form-control, -.form-select, -.search-input { - background-color: var(--bg-input); - border: 1px solid var(--border-color); - color: var(--text-heading); - border-radius: 6px; -} - -.form-control:focus, -.form-select:focus, -.search-input:focus { - background-color: var(--bg-input); - border-color: var(--accent-color); - color: var(--text-heading); - box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.15); - outline: none; -} - -.form-control::placeholder { - color: var(--text-muted); - opacity: 0.7; -} - -.form-label { - color: var(--text-heading); - font-weight: 500; -} - -.input-group-text { - background-color: var(--bg-element); - border: 1px solid var(--border-color); - color: var(--text-muted); -} - -/* Toggle Switch */ -.form-check-input { - background-color: var(--toggle-off-bg); - border-color: var(--toggle-off-border); - cursor: pointer; -} - -.form-check-input:checked { - background-color: var(--accent-color); - border-color: var(--accent-color); -} - -[data-theme="dark"] .form-check-input:not(:checked) { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%238b949e'/%3e%3c/svg%3e"); -} - -/* Sort Buttons */ -.sort-btn-group { - display: flex; -} - -.sort-btn { - background: var(--bg-input); - border: 1px solid var(--border-color); - padding: 6px 12px; - font-size: 0.85rem; - color: var(--text-main); - min-width: 100px; - text-align: center; - cursor: pointer; - transition: all 0.2s; -} - -.sort-btn:first-child { - border-top-left-radius: 6px; - border-bottom-left-radius: 6px; - border-right: none; -} - -.sort-btn:last-child { - border-top-right-radius: 6px; - border-bottom-right-radius: 6px; -} - -.sort-btn:hover { - background-color: var(--bg-element-hover); -} - -.sort-btn.active { - background-color: var(--bg-element-hover); - color: var(--text-heading); - border-color: var(--text-muted); - font-weight: 600; - z-index: 2; -} - -/* Modals */ -.modal-content { - background-color: var(--bg-card); - border: 1px solid var(--border-color); - color: var(--text-main); - box-shadow: 0 15px 50px rgba(0, 0, 0, 0.5); -} - -.modal-header { - border-bottom: 1px solid var(--border-color); -} - -.modal-footer { - border-top: 1px solid var(--border-color); -} - -.btn-close { - filter: var(--btn-close-filter, none); -} - -[data-theme="dark"] .btn-close { - filter: invert(1) grayscale(100%) brightness(200%); -} - -.modal-backdrop.show { - opacity: 0.6; - background-color: #000; -} - -.chart-controls { - background: var(--bg-element); - padding: 12px; - border-radius: 6px; - border: 1px solid var(--border-color); - margin-bottom: 20px; - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 10px; -} - -.bg-white-custom { - background-color: var(--bg-input) !important; - border-color: var(--border-color) !important; -} - -/* Charts & Dashboard Specifics */ -.chart-container { - position: relative; - height: 320px; - width: 100%; -} - -.modal-chart-container { - height: 400px; - width: 100%; - position: relative; -} - -.pie-container { - position: relative; - height: 220px; - width: 100%; - display: flex; - justify-content: center; -} - -.chart-header-controls { - display: flex; - align-items: center; - gap: 15px; -} - -.legend-dot { - width: 10px; - height: 10px; - border-radius: 50%; - display: inline-block; - margin-right: 5px; -} - -/* Certificates Specifics */ -.category-header { - background: linear-gradient(135deg, var(--bg-element), var(--bg-element-hover)); - border-left: 4px solid; - padding: 12px 20px; - margin: 0; - font-weight: 600; -} - -.category-header.active { - border-left-color: var(--success-text); - color: var(--success-text); -} - -.category-header.expired { - border-left-color: var(--danger-text); - color: var(--danger-text); -} - -.certificate-file { - font-size: 0.8rem; - color: var(--text-muted); - margin-top: 2px; -} - -.empty-state { - text-align: center; - padding: 40px 20px; - color: var(--text-muted); -} - -.empty-state i { - font-size: 3rem; - margin-bottom: 15px; - opacity: 0.5; -} - -.cert-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 0; - border-bottom: 1px solid var(--border-subtle); -} - -.cert-item:last-child { - border-bottom: none; -} - -/* Utilities */ -.refresh-indicator { - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} - -/* Responsive */ -@media (max-width: 768px) { - .sidebar { - transform: translateX(-100%); - width: 260px; - /* Keep fixed width when open */ - } - - .app-wrapper.mobile-nav-active .sidebar { - transform: translateX(0); - box-shadow: 0 0 50px rgba(0, 0, 0, 0.5); - } - - .main-content { - margin-left: 0; - width: 100%; - } - - .header-controls { - flex-wrap: wrap; - gap: 8px; - } - - .stats-info { - flex-direction: column; - } - - .chart-controls { - flex-direction: column; - align-items: flex-start; - } - - .chart-controls>div { - width: 100%; - } - - .kpi-grid { - grid-template-columns: 1fr; - } - - .chart-header-controls { - flex-wrap: wrap; - margin-top: 10px; - } - - /* Overlay for mobile sidebar */ - .mobile-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 999; - backdrop-filter: blur(2px); - } -} \ No newline at end of file diff --git a/UI/client/src/composables/useApi.js b/UI/client/src/composables/useApi.js deleted file mode 100644 index e0c346b..0000000 --- a/UI/client/src/composables/useApi.js +++ /dev/null @@ -1,79 +0,0 @@ -import axios from 'axios'; -import { useAppConfig } from './useAppConfig'; - -export function useApi() { - const { config } = useAppConfig(); - - const getBaseUrl = () => { - return config.value?.api_base_url || '/api/v1'; - }; - - // Create axios instance - // Note: We create it dynamically or return a getter if config changes dynamically, - // but usually base URL is static or set once. - // For simplicity in this logical scope, we'll create a simple wrapper or instance. - - // However, if config.value.api_base_url changes, a static instance won't update. - // For now, let's assume standard /api path proxies or defined base. - - const apiClient = axios.create({ - baseURL: getBaseUrl() // Initial base - }); - - // Interceptor to update base URL if it changes (optional, but good practice if config is async) - apiClient.interceptors.request.use((reqConfig) => { - reqConfig.baseURL = getBaseUrl(); - return reqConfig; - }); - - // Wrapper methods to match existing interface where used, - // but also exposing apiClient for new components. - - const fetchStats = async () => { - try { - const res = await apiClient.get('/stats'); - return res.data; - } catch (e) { - console.error('Fetch Stats Error:', e); - throw e; - } - }; - - const fetchClientHistory = async (clientId, range) => { - try { - const res = await apiClient.get(`/stats/${clientId}`, { params: { range } }); - return res.data; - } catch (e) { - console.error('Fetch History Error:', e); - throw e; - } - }; - - const fetchAnalytics = async (range) => { - try { - const res = await apiClient.get('/analytics', { params: { range } }); - return res.data; - } catch (e) { - console.error('Fetch Analytics Error:', e); - throw e; - } - }; - - const fetchCertificates = async () => { - try { - const res = await apiClient.get('/certificates'); - return res.data; - } catch (e) { - console.error('Fetch Certificates Error:', e); - throw e; - } - }; - - return { - apiClient, // Export the axios instance - fetchStats, - fetchClientHistory, - fetchAnalytics, - fetchCertificates - }; -} diff --git a/UI/client/src/main.js b/UI/client/src/main.js deleted file mode 100644 index 532f00c..0000000 --- a/UI/client/src/main.js +++ /dev/null @@ -1,15 +0,0 @@ -import { createApp } from 'vue'; -import App from './App.vue'; -import router from './router'; - -// Import Bootstrap CSS and JS -import 'bootstrap/dist/css/bootstrap.min.css'; -import 'bootstrap/dist/js/bootstrap.bundle.min.js'; -import '@fortawesome/fontawesome-free/css/all.min.css'; - -import './assets/main.css'; - -const app = createApp(App); - -app.use(router); -app.mount('#app'); diff --git a/UI/client/src/router/index.js b/UI/client/src/router/index.js deleted file mode 100644 index 7076dc9..0000000 --- a/UI/client/src/router/index.js +++ /dev/null @@ -1,32 +0,0 @@ -import { createRouter, createWebHistory } from 'vue-router'; -import Clients from '../views/Clients.vue'; - -const routes = [ - { - path: '/', - name: 'Clients', - component: Clients - }, - { - path: '/analytics', - name: 'Analytics', - component: () => import('../views/Analytics.vue') - }, - { - path: '/certificates', - name: 'Certificates', - component: () => import('../views/Certificates.vue') - }, - { - path: '/settings', - name: 'Settings', - component: () => import('../views/Settings.vue') - } -]; - -const router = createRouter({ - history: createWebHistory(), - routes -}); - -export default router; diff --git a/UI/client/src/views/Certificates.vue b/UI/client/src/views/Certificates.vue deleted file mode 100644 index 53a86bc..0000000 --- a/UI/client/src/views/Certificates.vue +++ /dev/null @@ -1,396 +0,0 @@ - - - - {{ totalCerts }} - Total Certificates - - - {{ activeCerts.length }} - Active Certificates - - - {{ expiringCount }} - Expiring in 30 days - - - {{ expiredCerts.length }} - Expired Certificates - - - - - - - - - - - New Client - - - - - - Hide Expired Certificates - - - - - - Certificates List - - - - {{ activeCerts.length }} Active - - - {{ expiredCerts.length }} Expired - - - - - - - - - Client Name - Type - Validity Not After - Days Remaining - Status - Actions - - - - - - - Loading... - - Loading certificates... - - - - - - - No certificates found - - - - - - - Active Certificates ({{ activeCerts.length }}) - - - - {{ getClientName(cert) }} - {{ cert.file || 'N/A' }} - - {{ cert.type }} - {{ formatDate(cert.not_after || cert.expires_iso) }} - - {{ cert.days_remaining || 'N/A' }} - - - - - - - - - - - - - - - - Expired Certificates ({{ expiredCerts.length }}) - - - - {{ getClientName(cert) }} - {{ cert.file || 'N/A' }} - - {{ cert.type }} - {{ formatDate(cert.not_after || cert.expires_iso) }} - - {{ formatExpiredDays(cert.days_remaining) }} - - - Expired - - - - - - - - - - - - - - - - - - diff --git a/UI/client/src/views/Settings.vue b/UI/client/src/views/Settings.vue deleted file mode 100644 index ac92faf..0000000 --- a/UI/client/src/views/Settings.vue +++ /dev/null @@ -1,603 +0,0 @@ - - - - System Settings - - - - - - PKI & Certificates - - - - - Server Configuration - - - - - Service Control - - - - - - - - - - - - PKI Environment - - - - - PKI Location - - - - Connect/Validate - - - Point to an existing PKI directory to use it, or define where to initialize a new one. - - - - EasyRSA Location (Optional) - - Path to the easyrsa script. Leave empty to auto-detect based on PKI location. - - - - - - - CA Common Name (CN) - - - - Key Size - - 2048 bits - 4096 bits - - - - - CA Expiration (Days) - - - - Cert Expiration (Days) - - - - CRL Expiration (Days) - - - - - Country (2 letters) - - - - Organization - - - - Province / State - - - - City - - - - Email - - - - Organizational Unit - - - - - Initialization - - - - Force Initialize (Wipe existing PKI including CA) - - - - Initialize / Update PKI - - - Updates 'vars' and generates CA/Keys if missing. - - - - - - - - - - - - OpenVPN Server Settings - - - - - - Save Configuration - - - - - - General Configuration - - - Server Config File Path - - - - Path to reading/writing the server configuration file. - - - Public Hostname / IP - - Address clients use to connect (remote directive). - - - - Network & Transport - - - Protocol - - UDP - TCP - - - - Port - - - - Topology - - Subnet (Recommended) - Point-to-Point - - - - Device - - - - - VPN Network - - - / - - - - - - TUN MTU - - - - MSS-FIX - - Helps with fragmentation - - - - - Routing & Tunneling - - - Tunnel Mode - - - - - Full Tunnel (Redirect Gateway) - - - - - - Split Tunnel - - - - - - - Split Tunnel Routes (CIDR) - Traffic to these networks will be routed through the VPN. - - - - - Add Route - - - - - - Allow Client-to-Client Communication - - - - - DNS Servers - - - - - Add DNS Server - - - - - Encryption, Security & Logging - - - Cipher - - AES-256-GCM (Recommended) - AES-128-GCM - AES-256-CBC - CHACHA20-POLY1305 - CHACHA20-POLY1305:AES-256-GCM:AES-256-CBC (Fallback) - - - - Auth Algorithm - - SHA256 (Default) - SHA512 - SHA1 (Legacy - Not Recommended) - - HMAC algorithm for packet authentication - - - - Data Ciphers (NCP) - - - - Use Modern Preset - - - Colon-separated list of allowed ciphers for negotiation. - - - - Data Ciphers Fallback - - Cipher used for older clients that don't support NCP (OpenVPN < 2.4). - - - - - - Use CRL (Certificate Revocation List) - Reject connections from revoked clients. - - - - - IPP Path (ifconfig-pool-persist) - - - - - Status Log Path - - - - Main Log Path - - - - - - - - - - - - - Service Management - - - - Status: - - {{ serviceStatus.toUpperCase() }} - - - - - - Start - - - Restart - - - Stop - - - - - - - - - - - - - - diff --git a/UI/client/vite.config.js b/UI/client/vite.config.js deleted file mode 100644 index bbcf80c..0000000 --- a/UI/client/vite.config.js +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [vue()], -})
{{ message }}
Maintain your account security by updating your password regularly to prevent unauthorized access.
Protect your OpenVPN Monitor dashboard with second-layer security using Google Authenticator or any TOTP app.
Open your authenticator app and scan this code or manually enter the secret.
Your dashboard is now protected with two-factor authentication.
Loading profiles...
No certificates found
Secure Access Control
Enter the 6-digit code from your authenticator app.
Certificate validity and expiration monitoring
Loading certificates...
System performance overview
Concurrent Users (Peak)
Traffic Volume (Total)
Expiring Soon (In 45 Days)
Checking certificates...
Real-time traffic & connection statistics
No certificates expiring soon
easyrsa
remote
Traffic to these networks will be routed through the VPN.