new awesome build

This commit is contained in:
Антон
2026-01-28 22:37:47 +03:00
parent 848646003c
commit fcb8f6bac7
119 changed files with 7291 additions and 5575 deletions

Binary file not shown.

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
Flask==3.0.0
Flask-Cors==4.0.0

View File

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

View File

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

View File

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

38
APP_CORE/README.md Normal file
View File

@@ -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`.

View File

@@ -2,6 +2,7 @@
host = 0.0.0.0 host = 0.0.0.0
port = 5000 port = 5000
debug = false debug = false
secret_key = ovpmon-secret-change-me
[openvpn_monitor] [openvpn_monitor]
log_path = /etc/openvpn/openvpn-status.log log_path = /etc/openvpn/openvpn-status.log

View File

@@ -34,6 +34,15 @@ class DatabaseManager:
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() 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: try:
# 1. Clients Table # 1. Clients Table
cursor.execute(''' 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 # 3. Aggregated Stats Tables
tables = ['stats_5min', 'stats_15min', 'stats_hourly', 'stats_6h', 'stats_daily'] tables = ['stats_5min', 'stats_15min', 'stats_hourly', 'stats_6h', 'stats_daily']
@@ -97,8 +127,15 @@ class DatabaseManager:
''') ''')
cursor.execute(f'CREATE INDEX IF NOT EXISTS idx_{table}_ts ON {table}(timestamp)') 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() conn.commit()
self.logger.info("Database initialized with full schema") self.logger.info("Database initialized with full schema and migrations")
except Exception as e: except Exception as e:
self.logger.error(f"Database initialization error: {e}") self.logger.error(f"Database initialization error: {e}")
finally: finally:

View File

@@ -8,12 +8,17 @@ import subprocess
import os import os
from pathlib import Path from pathlib import Path
import re import re
import jwt
import pyotp
import bcrypt
from functools import wraps
from db import DatabaseManager from db import DatabaseManager
from pki_manager import PKIManager
from config_manager import ConfigManager
from service_manager import ServiceManager
import io import io
# Set up logging # Set up logging
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -22,11 +27,13 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
app = Flask(__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: class OpenVPNAPI:
def __init__(self, config_file='config.ini'): def __init__(self, config_file='config.ini'):
self.db_manager = DatabaseManager(config_file) self.db_manager = DatabaseManager(config_file)
self.db_manager.init_database()
self.config = configparser.ConfigParser() self.config = configparser.ConfigParser()
self.config.read(config_file) 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_extensions = self.config.get('certificates', 'certificate_extensions', fallback='crt,pem,key').split(',')
self._cert_cache = {} 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 # Managers
self.pki = PKIManager(self.easyrsa_path, self.pki_path)
self.conf_mgr = ConfigManager(self.templates_path, self.server_config_dir)
self.conf_mgr.server_conf_path = Path(self.server_config_path) # Override with specific path
self.service = ServiceManager('openvpn') # Or openvpn@server for systemd multi-instance
def get_db_connection(self): def get_db_connection(self):
"""Get a database connection""" """Get a database connection"""
return self.db_manager.get_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): def parse_openssl_date(self, date_str):
try: try:
@@ -591,7 +668,7 @@ class OpenVPNAPI:
WHERE t.timestamp >= datetime('now', '-{hours} hours') WHERE t.timestamp >= datetime('now', '-{hours} hours')
GROUP BY c.id GROUP BY c.id
ORDER BY total_traffic DESC ORDER BY total_traffic DESC
LIMIT 3 LIMIT 10
''' '''
cursor.execute(query_top) cursor.execute(query_top)
top_cols = [col[0] for col in cursor.description] top_cols = [col[0] for col in cursor.description]
@@ -660,9 +737,271 @@ class OpenVPNAPI:
# Initialize API instance # Initialize API instance
api = OpenVPNAPI() 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']) @app.route('/api/v1/stats', methods=['GET'])
@token_required
def get_stats(): def get_stats():
"""Get current statistics for all clients""" """Get current statistics for all clients"""
try: try:
@@ -686,6 +1025,7 @@ def get_stats():
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/stats/system', methods=['GET']) @app.route('/api/v1/stats/system', methods=['GET'])
@token_required
def get_system_stats(): def get_system_stats():
"""Get system-wide statistics""" """Get system-wide statistics"""
try: try:
@@ -699,6 +1039,7 @@ def get_system_stats():
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/stats/<string:common_name>', methods=['GET']) @app.route('/api/v1/stats/<string:common_name>', methods=['GET'])
@token_required
def get_client_stats(common_name): def get_client_stats(common_name):
""" """
Get detailed stats for a client. Get detailed stats for a client.
@@ -769,6 +1110,7 @@ def get_client_stats(common_name):
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/certificates', methods=['GET']) @app.route('/api/v1/certificates', methods=['GET'])
@token_required
def get_certificates(): def get_certificates():
try: try:
data = api.get_certificates_info() data = api.get_certificates_info()
@@ -777,6 +1119,7 @@ def get_certificates():
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/clients', methods=['GET']) @app.route('/api/v1/clients', methods=['GET'])
@token_required
def get_clients_list(): def get_clients_list():
try: try:
data = api.get_current_stats() data = api.get_current_stats()
@@ -795,6 +1138,7 @@ def health_check():
return jsonify({'success': False, 'status': 'unhealthy', 'error': str(e)}), 500 return jsonify({'success': False, 'status': 'unhealthy', 'error': str(e)}), 500
@app.route('/api/v1/analytics', methods=['GET']) @app.route('/api/v1/analytics', methods=['GET'])
@token_required
def get_analytics(): def get_analytics():
"""Get dashboard analytics data""" """Get dashboard analytics data"""
try: try:
@@ -816,6 +1160,7 @@ def get_analytics():
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/sessions', methods=['GET']) @app.route('/api/v1/sessions', methods=['GET'])
@token_required
def get_sessions(): def get_sessions():
"""Get all currently active sessions (real-time)""" """Get all currently active sessions (real-time)"""
try: try:
@@ -830,323 +1175,9 @@ def get_sessions():
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
# --- PKI MANAGEMENT ROUTES ---
@app.route('/api/v1/pki/init', methods=['POST'])
def init_pki():
"""Initialize PKI environment"""
try:
force = request.json.get('force', False)
pki_vars = request.json.get('vars', {})
# 0. Update Vars if provided
if pki_vars:
api.pki.update_vars(pki_vars)
# 1. Clean/Init PKI
success, msg = api.pki.init_pki(force=force)
if not success: return jsonify({'success': False, 'error': msg}), 400
# 2. Build CA
# Use CN from vars if available, else default
ca_cn = pki_vars.get('EASYRSA_REQ_CN', 'OpenVPN-CA')
api.pki.build_ca(ca_cn)
# 3. Build Server Cert
api.pki.build_server("server")
# 4. Gen DH
api.pki.gen_dh()
# 5. Gen TA Key
# Ensure pki dir exists
ta_path = Path(api.pki_path) / 'ta.key'
api.pki.gen_ta_key(ta_path)
# 6. Gen CRL
api.pki.gen_crl()
return jsonify({'success': True, 'message': 'PKI initialized successfully'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/pki/validate', methods=['POST'])
def validate_pki():
"""Validate PKI path"""
try:
path = request.json.get('path')
if not path: return jsonify({'success': False, 'error': 'Path required'}), 400
success, msg = api.pki.validate_pki_path(path)
return jsonify({'success': success, 'message': msg})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/pki/config', methods=['GET', 'POST'])
def handle_pki_config():
"""Get or Save PKI path configuration"""
try:
if request.method == 'GET':
return jsonify({
'success': True,
'data': {
'easyrsa_path': api.easyrsa_path,
'pki_path': api.pki_path
}
})
# POST
path_str = request.json.get('path')
if not path_str: return jsonify({'success': False, 'error': 'Path required'}), 400
path = Path(path_str).resolve()
if not path.exists(): return jsonify({'success': False, 'error': 'Path invalid'}), 400
# Heuristic to determine easyrsa_path and pki_path
# User supplied 'path' is likely the PKI directory (containing ca.crt or being empty/prepared)
pki_path = path
easyrsa_path = path.parent # Default assumption: script is in parent
# 1. Search for easyrsa binary (Heuristic)
potential_bins = [
path / 'easyrsa', # Inside path
path.parent / 'easyrsa', # Parent
path.parent / 'easy-rsa' / 'easyrsa', # Sibling easy-rsa
Path('/usr/share/easy-rsa/easyrsa'), # System
Path('/etc/openvpn/easy-rsa/easyrsa') # System
]
found_bin = None
for bin_path in potential_bins:
if bin_path.exists():
easyrsa_path = bin_path.parent
found_bin = bin_path
break
# Override with explicit easyrsa_path if provided
explicit_easyrsa = request.json.get('easyrsa_path')
if explicit_easyrsa:
epath = Path(explicit_easyrsa)
if epath.is_file(): # Path to script
easyrsa_path = epath.parent
found_bin = epath
elif (epath / 'easyrsa').exists(): # Path to dir
easyrsa_path = epath
found_bin = epath / 'easyrsa'
if not found_bin:
# Fallback: assume typical layout if not found yet
pass
# If user pointed to root (containing pki subdir)
if (path / 'pki' / 'ca.crt').exists() or ((path / 'pki').exists() and not (path / 'ca.crt').exists()):
pki_path = path / 'pki'
# Only adjust easyrsa_path if not explicitly set/found yet
if not explicit_easyrsa and not found_bin and (path / 'easyrsa').exists():
easyrsa_path = path
# Update Config
if not api.config.has_section('pki'):
api.config.add_section('pki')
api.config.set('pki', 'easyrsa_path', str(easyrsa_path))
api.config.set('pki', 'pki_path', str(pki_path))
# Write config.ini
with open('config.ini', 'w') as f:
api.config.write(f)
# Reload PKI Manager
api.easyrsa_path = str(easyrsa_path)
api.pki_path = str(pki_path)
api.pki = PKIManager(api.easyrsa_path, api.pki_path)
return jsonify({
'success': True,
'message': f'PKI Conf saved',
'details': {
'easyrsa_path': str(easyrsa_path),
'pki_path': str(pki_path)
}
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/pki/client/<string:name>/config', methods=['GET'])
def get_client_config(name):
"""Get client config (generate on fly)"""
try:
# Get defaults from active server config
server_conf = api.conf_mgr.read_server_config()
# Determine public host
host = request.args.get('server_ip')
if not host:
host = server_conf.get('public_ip')
if not host:
try:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
host = s.getsockname()[0]
s.close()
except:
host = '127.0.0.1'
extra_params = {
'remote_host': host,
'remote_port': request.args.get('port') or server_conf.get('port', 1194),
'proto': request.args.get('proto') or server_conf.get('proto', 'udp')
}
succ_conf, conf_content = api.conf_mgr.generate_client_config(
name, api.pki_path, server_conf, extra_params
)
if not succ_conf: return jsonify({'success': False, 'error': conf_content}), 500
return jsonify({'success': True, 'config': conf_content, 'filename': f"{name}.ovpn"})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/pki/client', methods=['POST'])
def create_client():
"""Create new client and return config"""
try:
data = request.json
name = data.get('name')
if not name: return jsonify({'success': False, 'error': 'Name is required'}), 400
# 1. Build Cert
success, output = api.pki.build_client(name)
if not success: return jsonify({'success': False, 'error': output}), 500
# 2. Generate Config (Just to verify it works, but we don't strictly need to return it if UI doesn't download it automatically.
# However, it's good practice to return it in creation response too, in case UI changes mind)
server_ip = data.get('server_ip') or api.public_ip or '127.0.0.1'
# Get defaults from active server config
server_conf = api.conf_mgr.read_server_config()
def_port = server_conf.get('port', 1194)
def_proto = server_conf.get('proto', 'udp')
succ_conf, conf_content = api.conf_mgr.generate_client_config(
name, api.pki_path, server_ip, data.get('port', def_port), data.get('proto', def_proto)
)
if not succ_conf: return jsonify({'success': False, 'error': conf_content}), 500
return jsonify({'success': True, 'config': conf_content, 'filename': f"{name}.ovpn"})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/pki/client/<string:name>', methods=['DELETE'])
def revoke_client(name):
"""Revoke client certificate"""
try:
success, output = api.pki.revoke_client(name)
if not success: return jsonify({'success': False, 'error': output}), 500
return jsonify({'success': True, 'message': 'Client revoked'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# --- SERVER MANAGEMENT ROUTES ---
@app.route('/api/v1/server/config', methods=['GET', 'POST'])
def manage_server_config():
"""Get or Save server.conf"""
try:
if request.method == 'GET':
# Check for path override (Reload from specific file)
path_arg = request.args.get('path')
if path_arg:
# Update path preference if requested
new_path_str = str(path_arg)
if new_path_str != str(api.conf_mgr.server_conf_path):
api.server_config_path = new_path_str
api.conf_mgr.server_conf_path = Path(new_path_str)
if not api.config.has_section('server'): api.config.add_section('server')
api.config.set('server', 'config_path', new_path_str)
with open('config.ini', 'w') as f:
api.config.write(f)
current_conf = api.conf_mgr.read_server_config()
# Enriched with meta-config
current_conf['config_path'] = str(api.conf_mgr.server_conf_path)
current_conf['public_ip'] = api.public_ip
return jsonify({'success': True, 'data': current_conf})
# POST
params = request.json
# Basic validation
if not params.get('port'): return jsonify({'success': False, 'error': 'Port required'}), 400
# Check/Update Config Path and Public IP
new_path = params.get('config_path')
new_ip = params.get('public_ip')
config_updated = False
if new_path and str(new_path) != str(api.conf_mgr.server_conf_path):
api.server_config_path = str(new_path)
api.conf_mgr.server_conf_path = Path(new_path)
if not api.config.has_section('server'): api.config.add_section('server')
api.config.set('server', 'config_path', str(new_path))
config_updated = True
if new_ip is not None and new_ip != api.public_ip: # Allow empty string
api.public_ip = new_ip
if not api.config.has_section('openvpn_monitor'): api.config.add_section('openvpn_monitor')
api.config.set('openvpn_monitor', 'public_ip', new_ip)
config_updated = True
if config_updated:
with open('config.ini', 'w') as f:
api.config.write(f)
# Define paths
params['ca_path'] = str(Path(api.pki_path) / 'ca.crt')
params['cert_path'] = str(Path(api.pki_path) / 'issued/server.crt')
params['key_path'] = str(Path(api.pki_path) / 'private/server.key')
params['dh_path'] = str(Path(api.pki_path) / 'dh.pem')
params['ta_path'] = str(Path(api.pki_path) / 'ta.key')
params['crl_path'] = str(Path(api.pki_path) / 'crl.pem')
success, msg = api.conf_mgr.generate_server_config(params)
if not success: return jsonify({'success': False, 'error': msg}), 500
return jsonify({'success': True, 'path': msg})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/server/action', methods=['POST'])
def server_action():
"""Start/Stop/Restart OpenVPN service"""
try:
action = request.json.get('action')
if action == 'start':
success, msg = api.service.start()
elif action == 'stop':
success, msg = api.service.stop()
elif action == 'restart':
success, msg = api.service.restart()
else:
return jsonify({'success': False, 'error': 'Invalid action'}), 400
if not success: return jsonify({'success': False, 'error': msg}), 500
return jsonify({'success': True, 'message': msg})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/server/status', methods=['GET'])
def server_status():
"""Get service status"""
try:
status = api.service.get_status()
return jsonify({'success': True, 'status': status})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
if __name__ == "__main__": if __name__ == "__main__":
host = api.config.get('api', 'host', fallback='0.0.0.0') host = api.config.get('api', 'host', fallback='0.0.0.0')

View File

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

26
APP_PROFILER/README.md Normal file
View File

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

View File

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

19
APP_PROFILER/database.py Normal file
View File

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

45
APP_PROFILER/main.py Normal file
View File

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

63
APP_PROFILER/models.py Normal file
View File

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

View File

View File

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

View File

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

View File

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

View File

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

86
APP_PROFILER/schemas.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

@@ -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/<username>.crt
# key: pki/private/<username>.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

View File

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

View File

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

View File

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

View File

@@ -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>
{{ ca_cert }}
</ca>
<cert>
{{ client_cert }}
</cert>
<key>
{{ client_key }}
</key>
<tls-auth>
{{ tls_auth }}
</tls-auth>

View File

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

View File

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

View File

View File

@@ -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"},
)

View File

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

25
APP_UI/README.md Normal file
View File

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

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenVPN Monitor</title> <title>OpenVPN Controller</title>
</head> </head>
<body> <body>

29
APP_UI/jsconfig.json Normal file
View File

@@ -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"
]
}

View File

@@ -12,6 +12,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"qrcode.vue": "^3.6.0",
"sass": "^1.97.2", "sass": "^1.97.2",
"sweetalert2": "^11.26.17", "sweetalert2": "^11.26.17",
"vue": "^3.5.24", "vue": "^3.5.24",
@@ -1936,6 +1937,15 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT" "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": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",

View File

@@ -13,6 +13,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"qrcode.vue": "^3.6.0",
"sass": "^1.97.2", "sass": "^1.97.2",
"sweetalert2": "^11.26.17", "sweetalert2": "^11.26.17",
"vue": "^3.5.24", "vue": "^3.5.24",

View File

@@ -0,0 +1,5 @@
{
"api_base_url": "/api/v1",
"profiles_api_base_url": "/profiles-api",
"refresh_interval": 30000
}

4
APP_UI/public/logo.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="119" height="119" viewBox="0 0 119 119" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M78.8326 58.0426C78.8765 48.6428 71.4389 40.699 61.5235 39.5555C51.6081 38.4119 42.3519 44.4304 39.982 53.5619C37.6121 62.6934 42.8784 72.049 52.2379 75.3346L44.6584 113.322H73.7264L66.2799 75.2595C73.8362 72.5045 78.8207 65.6679 78.8326 58.0426Z" fill="white"/>
<path d="M118.249 57.7933C117.583 25.6833 91.3083 0 59.1246 0C26.9408 0 0.665851 25.6833 3.09067e-08 57.7933C-0.000675955 78.6062 11.0875 97.8498 29.1129 108.319L32.9103 83.2079C27.0781 76.837 23.8452 68.5192 23.8473 59.8902C24.3594 40.8148 40.0026 25.6167 59.1246 25.6167C78.2465 25.6167 93.8897 40.8148 94.4018 59.8902C94.405 68.5881 91.1238 76.9678 85.2123 83.3594L88.9843 108.395C107.107 97.9657 118.266 78.67 118.249 57.7933Z" fill="#ED7F22"/>
</svg>

After

Width:  |  Height:  |  Size: 828 B

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

179
APP_UI/src/App.vue Normal file
View File

@@ -0,0 +1,179 @@
<template>
<div v-if="!isLoaded" class="d-flex justify-content-center align-items-center vh-100">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else class="app-wrapper" :class="{ 'mobile-nav-active': isSidebarOpen, 'sidebar-compact': isCompact, 'no-sidebar': !isAuthenticated }">
<!-- Mobile Overlay -->
<div class="mobile-overlay" @click="isSidebarOpen = false" v-if="isSidebarOpen && isAuthenticated"></div>
<!-- Sidebar -->
<aside v-if="isAuthenticated" class="sidebar" :class="{ 'compact': isCompact }">
<div class="sidebar-header">
<img src="./assets/logo.svg" alt="OpenVPN" class="sidebar-brand-icon">
<span class="brand-text">VPN Controller</span>
<button class="btn btn-link text-muted d-md-none ms-auto" @click="isSidebarOpen = false">
<i class="fas fa-times"></i>
</button>
</div>
<nav class="sidebar-menu">
<!-- SECTION: MONITORING -->
<div class="nav-section-header">MONITORING</div>
<p></p>
<router-link to="/" class="nav-link" active-class="active" title="Analytics">
<i class="fas fa-chart-line"></i> <span>Analytics</span>
</router-link>
<router-link to="/clients" class="nav-link" active-class="active" title="Clients">
<i class="fas fa-network-wired"></i> <span>Clients</span>
</router-link>
<!-- SECTION: CONFIGURATION -->
<div class="nav-section-header mt-3">CONFIGURATION</div>
<p></p>
<router-link to="/config/pki" class="nav-link" active-class="active" title="PKI">
<i class="fas fa-fingerprint"></i> <span>PKI</span>
</router-link>
<router-link to="/config/vpn" class="nav-link" active-class="active" title="VPN">
<i class="fas fa-server"></i> <span>VPN</span>
</router-link>
<!-- SECTION: MANAGEMENT -->
<div class="nav-section-header mt-3">MANAGEMENT</div>
<p></p>
<router-link to="/certificates" class="nav-link" active-class="active" title="Certificates">
<i class="fas fa-certificate"></i> <span>Certificates</span>
</router-link>
<router-link to="/server" class="nav-link" active-class="active" title="Server Process">
<i class="fas fa-terminal"></i> <span>Server Process</span>
</router-link>
<!-- SECTION: ACCOUNT -->
<div class="nav-section-header mt-3">ACCOUNT</div>
<p></p>
<router-link to="/account" class="nav-link" active-class="active" title="Account Settings">
<i class="fas fa-user-cog"></i> <span>Account Settings</span>
</router-link>
<!-- SETTINGS (General/App) -->
<div class="mt-auto"></div>
</nav>
<div class="sidebar-footer">
<button class="btn btn-link toggle-btn d-none d-md-block w-100" @click="toggleSidebar" title="Toggle Sidebar">
<i class="fas" :class="isCompact ? 'fa-chevron-right' : 'fa-chevron-left'"></i>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Top Navbar -->
<header class="top-navbar" v-if="isAuthenticated">
<div class="d-flex align-items-center gap-3">
<button class="btn-header d-md-none" @click="isSidebarOpen = !isSidebarOpen">
<i class="fas fa-bars"></i>
</button>
<div class="page-title">
<!-- Dynamic Title Could Go Here -->
</div>
</div>
<div class="navbar-actions">
<span class="header-timezone d-none d-lg-inline-flex">
<i class="fas fa-globe me-1 text-muted"></i>{{ timezoneAbbr }}
</span>
<button class="btn-header" @click="toggleTheme" title="Toggle Theme">
<i class="fas" :class="isDark ? 'fa-sun' : 'fa-moon'" id="themeIcon"></i>
</button>
<button class="btn-header me-2" @click="refreshPage" title="Refresh">
<i class="fas fa-sync-alt" id="refreshIcon"></i>
</button>
<div class="header-divider d-none d-md-block"></div>
<div class="user-profile ms-2">
<div class="user-avatar-small bg-primary text-white">
{{ username[0]?.toUpperCase() || 'A' }}
</div>
<div class="user-meta d-none d-md-block">
<span class="username">{{ username }}</span>
</div>
</div>
<button class="btn-header btn-logout ms-2" @click="handleLogout" title="Logout">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</header>
<!-- Content Area -->
<div class="content-wrapper">
<router-view :key="$route.fullPath + '-' + refreshKey"></router-view>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import { useAppConfig } from './composables/useAppConfig';
import { useRoute, useRouter } from 'vue-router';
const { loadConfig, isLoaded } = useAppConfig();
const timezoneAbbr = ref(new Date().toLocaleTimeString('en-us',{timeZoneName:'short'}).split(' ')[2] || 'UTC');
const isDark = ref(false);
const refreshKey = ref(0);
const isSidebarOpen = ref(false);
const isCompact = ref(false);
const route = useRoute();
const router = useRouter();
const isAuthenticated = computed(() => route.name !== 'Login');
const username = ref(localStorage.getItem('ovpmon_user') || 'Admin');
const handleLogout = () => {
localStorage.removeItem('ovpmon_token');
localStorage.removeItem('ovpmon_user');
router.push('/login');
};
const toggleTheme = () => {
isDark.value = !isDark.value;
const theme = isDark.value ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
};
const toggleSidebar = () => {
isCompact.value = !isCompact.value;
localStorage.setItem('sidebarCompact', isCompact.value);
};
const refreshPage = () => {
refreshKey.value++;
};
// Close sidebar on route change
watch(() => route.path, () => {
isSidebarOpen.value = false;
});
onMounted(async () => {
await loadConfig();
// Init Theme
const savedTheme = localStorage.getItem('theme') || 'light';
isDark.value = savedTheme === 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
// Init Sidebar
const savedCompact = localStorage.getItem('sidebarCompact');
isCompact.value = savedCompact === 'true';
});
</script>

View File

@@ -0,0 +1,4 @@
<svg width="119" height="119" viewBox="0 0 119 119" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M78.8326 58.0426C78.8765 48.6428 71.4389 40.699 61.5235 39.5555C51.6081 38.4119 42.3519 44.4304 39.982 53.5619C37.6121 62.6934 42.8784 72.049 52.2379 75.3346L44.6584 113.322H73.7264L66.2799 75.2595C73.8362 72.5045 78.8207 65.6679 78.8326 58.0426Z" fill="white"/>
<path d="M118.249 57.7933C117.583 25.6833 91.3083 0 59.1246 0C26.9408 0 0.665851 25.6833 3.09067e-08 57.7933C-0.000675955 78.6062 11.0875 97.8498 29.1129 108.319L32.9103 83.2079C27.0781 76.837 23.8452 68.5192 23.8473 59.8902C24.3594 40.8148 40.0026 25.6167 59.1246 25.6167C78.2465 25.6167 93.8897 40.8148 94.4018 59.8902C94.405 68.5881 91.1238 76.9678 85.2123 83.3594L88.9843 108.395C107.107 97.9657 118.266 78.67 118.249 57.7933Z" fill="#ED7F22"/>
</svg>

After

Width:  |  Height:  |  Size: 828 B

View File

@@ -0,0 +1,4 @@
<svg width="119" height="119" viewBox="0 0 119 119" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M78.8326 58.0426C78.8765 48.6428 71.4389 40.699 61.5235 39.5555C51.6081 38.4119 42.3519 44.4304 39.982 53.5619C37.6121 62.6934 42.8784 72.049 52.2379 75.3346L44.6584 113.322H73.7264L66.2799 75.2595C73.8362 72.5045 78.8207 65.6679 78.8326 58.0426Z" fill="#1A3967"/>
<path d="M118.249 57.7933C117.583 25.6833 91.3083 0 59.1246 0C26.9408 0 0.665851 25.6833 3.09067e-08 57.7933C-0.000675955 78.6062 11.0875 97.8498 29.1129 108.319L32.9103 83.2079C27.0781 76.837 23.8452 68.5192 23.8473 59.8902C24.3594 40.8148 40.0026 25.6167 59.1246 25.6167C78.2465 25.6167 93.8897 40.8148 94.4018 59.8902C94.405 68.5881 91.1238 76.9678 85.2123 83.3594L88.9843 108.395C107.107 97.9657 118.266 78.67 118.249 57.7933Z" fill="#ED7F22"/>
</svg>

After

Width:  |  Height:  |  Size: 830 B

1819
APP_UI/src/assets/main.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
<template>
<Teleport to="body">
<div class="modal fade" :id="id" tabindex="-1" aria-hidden="true" ref="modalRef">
<div class="modal-dialog modal-dialog-centered" :class="sizeClass">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<slot name="header">
<h5 class="modal-title fw-bold" style="color: var(--text-heading);">{{ title }}</h5>
</slot>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<slot name="body"></slot>
</div>
<div class="modal-footer border-0 pt-0" v-if="$slots.footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { Modal } from 'bootstrap';
const props = defineProps({
id: {
type: String,
required: true
},
title: {
type: String,
default: ''
},
size: {
type: String,
default: '' // 'modal-sm', 'modal-lg', 'modal-xl'
}
});
const emit = defineEmits(['close', 'opened', 'closed']);
const modalRef = ref(null);
let bsModal = null;
const sizeClass = props.size || '';
const show = () => {
bsModal?.show();
};
const hide = () => {
bsModal?.hide();
};
onMounted(() => {
bsModal = new Modal(modalRef.value);
modalRef.value.addEventListener('shown.bs.modal', () => {
emit('opened');
});
modalRef.value.addEventListener('hidden.bs.modal', () => {
emit('closed');
emit('close');
});
});
onUnmounted(() => {
bsModal?.dispose();
});
defineExpose({ show, hide });
</script>

View File

@@ -0,0 +1,49 @@
<template>
<BaseModal id="confirmModal" :title="title" ref="modal">
<template #body>
<p class="text-main">{{ message }}</p>
</template>
<template #footer>
<button v-if="showCancel" type="button" class="btn-action btn-action-secondary" @click="close">Cancel</button>
<button type="button" class="btn-action" :class="confirmBtnClass" @click="confirm">
{{ confirmText }}
</button>
</template>
</BaseModal>
</template>
<script setup>
import { ref } from 'vue';
import BaseModal from './BaseModal.vue';
const emit = defineEmits(['confirm']);
const modal = ref(null);
const title = ref('');
const message = ref('');
const confirmText = ref('Confirm');
const confirmBtnClass = ref('btn-action-danger');
const showCancel = ref(true);
const data = ref(null);
const open = (opts) => {
title.value = opts.title || 'Are you sure?';
message.value = opts.message || '';
confirmText.value = opts.confirmText || 'Confirm';
confirmBtnClass.value = opts.confirmBtnClass || 'btn-action-danger';
showCancel.value = opts.showCancel !== false;
data.value = opts.data || null;
modal.value.show();
};
const close = () => {
modal.value.hide();
};
const confirm = () => {
emit('confirm', data.value);
close();
};
defineExpose({ open, close });
</script>

View File

@@ -5,7 +5,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title"> <h5 class="modal-title">
<i class="fas fa-chart-area me-2" style="color: var(--accent-color);"></i> <i class="fas fa-chart-area me-2 text-primary"></i>
<span>{{ clientName }}</span> <span>{{ clientName }}</span>
</h5> </h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
@@ -26,10 +26,14 @@
<div class="d-flex align-items-center gap-3 bg-white-custom px-3 py-1 border rounded"> <div class="d-flex align-items-center gap-3 bg-white-custom px-3 py-1 border rounded">
<span class="small fw-bold text-muted">Metric:</span> <span class="small fw-bold text-muted">Metric:</span>
<div class="form-check form-switch mb-0"> <div class="d-flex align-items-center">
<input class="form-check-input" type="checkbox" role="switch" id="vizToggle" v-model="isSpeedMode" <div class="toggle-wrapper me-2">
@change="renderChart"> <label class="toggle-switch">
<label class="form-check-label text-main" for="vizToggle"> <input type="checkbox" id="vizToggle" v-model="isSpeedMode" @change="renderChart">
<span class="toggle-slider"></span>
</label>
</div>
<label class="text-main user-select-none" style="cursor: pointer;" for="vizToggle">
{{ isSpeedMode ? 'Speed (Mbps)' : 'Data Volume' }} {{ isSpeedMode ? 'Speed (Mbps)' : 'Data Volume' }}
</label> </label>
</div> </div>
@@ -147,8 +151,8 @@ const renderChart = () => {
{ {
label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps', label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps',
data: dataRx, data: dataRx,
borderColor: '#3fb950', borderColor: '#1652B8', // OpenVPN Blue
backgroundColor: 'rgba(63, 185, 80, 0.15)', backgroundColor: 'rgba(22, 82, 184, 0.15)',
borderWidth: 2, borderWidth: 2,
fill: true, fill: true,
tension: 0.3, tension: 0.3,
@@ -158,8 +162,8 @@ const renderChart = () => {
{ {
label: !isSpeedMode.value ? 'Sent (MB)' : 'TX Mbps', label: !isSpeedMode.value ? 'Sent (MB)' : 'TX Mbps',
data: dataTx, data: dataTx,
borderColor: '#58a6ff', borderColor: '#EC7C31', // OpenVPN Orange
backgroundColor: 'rgba(88, 166, 255, 0.15)', backgroundColor: 'rgba(236, 124, 49, 0.15)',
borderWidth: 2, borderWidth: 2,
fill: true, fill: true,
tension: 0.3, tension: 0.3,

View File

@@ -0,0 +1,52 @@
<template>
<BaseModal id="newClientModal" title="Create New Client" ref="modal">
<template #body>
<div class="mb-3">
<label for="clientName" class="form-label small text-muted text-uppercase fw-bold">Client Name</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-user"></i></span>
<input type="text" class="form-control" id="clientName" v-model="clientName" placeholder="e.g. laptop-user" @keyup.enter="confirm" ref="inputRef">
</div>
<div class="form-text mt-2 text-muted small">
<i class="fas fa-info-circle me-1"></i> Use only alphanumeric characters, dashes, or underscores.
</div>
</div>
</template>
<template #footer>
<button type="button" class="btn-action btn-action-secondary" @click="close">Cancel</button>
<button type="button" class="btn-action btn-action-save" @click="confirm" :disabled="!clientName">
Create Client
</button>
</template>
</BaseModal>
</template>
<script setup>
import { ref } from 'vue';
import BaseModal from './BaseModal.vue';
const emit = defineEmits(['create']);
const modal = ref(null);
const clientName = ref('');
const inputRef = ref(null);
const open = () => {
clientName.value = '';
modal.value.show();
setTimeout(() => inputRef.value?.focus(), 500);
};
const close = () => {
modal.value.hide();
};
const confirm = () => {
if (clientName.value) {
emit('create', clientName.value);
close();
}
};
defineExpose({ open, close });
</script>

View File

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

32
APP_UI/src/main.js Normal file
View File

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

View File

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

View File

@@ -0,0 +1,345 @@
<template>
<div class="user-account-container mx-auto" style="max-width: 1000px;">
<div class="row g-4">
<!-- Security Card -->
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-user-shield me-2"></i>Security Settings</span>
</div>
<div class="card-body p-4">
<div class="text-center py-4 d-flex flex-column align-items-center">
<div class="card-interior-icon">
<i class="fas fa-user-check fa-3x text-primary"></i>
</div>
<div class="card-interior-title">
<h4 class="m-0 text-heading">Update Credentials</h4>
</div>
<div class="card-interior-description">
<p class="text-muted m-0">Maintain your account security by updating your password regularly to prevent unauthorized access.</p>
</div>
<button class="btn btn-action btn-action-primary py-2 fw-bold btn-account-action" @click="showPwModal">
Change Password
</button>
</div>
</div>
</div>
</div>
<!-- 2FA Card -->
<div class="col-lg-6">
<div class="card p-0 h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-key me-2"></i>Two-Factor Authentication</span>
<span v-if="isEnabled" class="status-badge status-valid">
<i class="fas fa-check-circle me-1"></i>Active
</span>
</div>
<div class="card-body p-4">
<div v-if="!isEnabled">
<div v-if="!step2" class="text-center py-4 d-flex flex-column align-items-center">
<div class="card-interior-icon">
<i class="fas fa-shield-alt fa-3x text-primary"></i>
</div>
<div class="card-interior-title">
<h4 class="m-0 text-heading">Secure Your Account</h4>
</div>
<div class="card-interior-description">
<p class="text-muted m-0">Protect your OpenVPN Monitor dashboard with second-layer security using Google Authenticator or any TOTP app.</p>
</div>
<button class="btn btn-action btn-action-primary py-2 fw-bold btn-account-action" @click="initSetup" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Start Setup
</button>
</div>
<div v-else class="setup-steps fade-in">
<div class="step-item mb-5 text-center">
<h5 class="mb-3 text-heading">1. Scan the QR Code</h5>
<p class="text-muted small mb-4">Open your authenticator app and scan this code or manually enter the secret.</p>
<div class="qr-setup-container mx-auto">
<div class="qr-code-wrapper p-3 d-inline-block rounded shadow-sm bg-white">
<qrcode-vue :value="setupData.uri" :size="150" level="H" :background="'#ffffff'" foreground="#24292f" />
</div>
<div class="secret-box mt-3">
<div class="small fw-bold text-muted mb-2">Manual Secret:</div>
<div class="user-select-all font-monospace p-2 secret-value-box rounded small d-inline-block w-100">
{{ setupData.secret }}
</div>
</div>
</div>
</div>
<div class="step-item text-center">
<h5 class="mb-3 text-heading">2. Verify Connection</h5>
<div class="verification-group mx-auto" style="max-width: 300px;">
<div class="input-group mb-3 shadow-sm rounded">
<input
type="text"
class="form-control text-center fw-bold"
v-model="otp"
placeholder="000000"
maxlength="6"
>
<button class="btn btn-action btn-action-primary px-4 fw-bold" @click="verifyAndEnable" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Enable
</button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-4 d-flex flex-column align-items-center">
<div class="card-interior-icon">
<i class="fas fa-key fa-3x text-primary"></i>
</div>
<div class="card-interior-title">
<h4 class="m-0 text-heading">2FA is Enabled</h4>
</div>
<div class="card-interior-description">
<p class="text-muted m-0">Your dashboard is now protected with two-factor authentication.</p>
</div>
<button class="btn btn-action btn-action-danger py-2 fw-bold btn-account-action" @click="disableConfirm">
<i class="fas fa-ban me-2"></i>Disable 2FA
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Alerts/Notifications Modal -->
<ConfirmModal ref="confirmModal" />
<!-- Action Confirmations Modal -->
<ConfirmModal ref="disable2FAModal" @confirm="handleDisable2FA" />
<!-- Password Change Modal -->
<BaseModal id="passwordChangeModal" title="Change Account Password" ref="pwModal">
<template #body>
<form @submit.prevent="handleChangePassword" id="pwForm">
<div class="mb-3">
<label class="form-label small fw-bold text-muted text-uppercase">Current Password</label>
<input
type="password"
class="form-control"
v-model="passwordForm.current_password"
placeholder="Enter current password"
required
>
</div>
<div class="mb-3">
<label class="form-label small fw-bold text-muted text-uppercase">New Password</label>
<input
type="password"
class="form-control"
v-model="passwordForm.new_password"
placeholder="Min. 8 characters"
required
>
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted text-uppercase">Confirm New Password</label>
<input
type="password"
class="form-control"
v-model="passwordForm.confirm_password"
placeholder="Confirm new password"
required
>
</div>
</form>
</template>
<template #footer>
<button type="button" class="btn-action btn-action-secondary" @click="pwModal.hide()">Cancel</button>
<button type="submit" form="pwForm" class="btn-action btn-action-save" :disabled="pwLoading">
<span v-if="pwLoading" class="spinner-border spinner-border-sm me-2"></span>
Update Password
</button>
</template>
</BaseModal>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue';
import { useApi } from '../composables/useApi';
import QrcodeVue from 'qrcode.vue';
import Swal from 'sweetalert2';
import BaseModal from '../components/BaseModal.vue';
import ConfirmModal from '../components/ConfirmModal.vue';
const { apiClient } = useApi();
// UI Refs
const pwModal = ref(null);
const confirmModal = ref(null);
const disable2FAModal = ref(null);
// Password Logic
const pwLoading = ref(false);
const passwordForm = reactive({
current_password: '',
new_password: '',
confirm_password: ''
});
const showPwModal = () => {
passwordForm.current_password = '';
passwordForm.new_password = '';
passwordForm.confirm_password = '';
pwModal.value.show();
};
const handleChangePassword = async () => {
if (passwordForm.new_password !== passwordForm.confirm_password) {
Swal.fire({
title: 'Error!',
text: 'New passwords do not match',
icon: 'error',
confirmButtonColor: '#EC7C31'
});
return;
}
pwLoading.value = true;
try {
await apiClient.post('../auth/change-password', {
current_password: passwordForm.current_password,
new_password: passwordForm.new_password
});
pwModal.value.hide();
Swal.fire({
title: 'Success!',
text: 'Password updated successfully.',
icon: 'success',
confirmButtonColor: '#1652B8'
});
} catch (err) {
Swal.fire({
title: 'Failed',
text: err.response?.data?.error || 'Failed to update password',
icon: 'error',
confirmButtonColor: '#cf222e'
});
} finally {
pwLoading.value = false;
}
};
// 2FA Logic
const isEnabled = ref(false);
const step2 = ref(false);
const loading = ref(false);
const otp = ref('');
const setupData = ref({ secret: '', uri: '' });
const isDark = ref(document.documentElement.getAttribute('data-theme') === 'dark');
const fetchUserStatus = async () => {
loading.value = true;
try {
const res = await apiClient.get('/user/me');
isEnabled.value = res.data.is_2fa_enabled;
} catch (err) {
console.error('Failed to fetch user status:', err);
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchUserStatus();
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'data-theme') {
isDark.value = document.documentElement.getAttribute('data-theme') === 'dark';
}
});
});
observer.observe(document.documentElement, { attributes: true });
});
const initSetup = async () => {
loading.value = true;
try {
const res = await apiClient.post('../auth/setup-2fa');
setupData.value = res.data;
step2.value = true;
} catch (err) {
Swal.fire('Error', 'Failed to initialize 2FA setup.', 'error');
} finally {
loading.value = false;
}
};
const verifyAndEnable = async () => {
if (!otp.value || otp.value.length !== 6) return;
loading.value = true;
try {
await apiClient.post('../auth/enable-2fa', {
secret: setupData.value.secret,
otp: otp.value
});
isEnabled.value = true;
confirmModal.value.open({
title: 'Success!',
message: 'Two-factor authentication has been enabled successfully. Your dashboard is now more secure.',
confirmText: 'Great!',
confirmBtnClass: 'btn-action-save',
showCancel: false
});
} catch (err) {
confirmModal.value.open({
title: 'Verification Failed',
message: err.response?.data?.error || 'The code you entered is invalid. Please check your authenticator app and try again.',
confirmText: 'Try Again',
confirmBtnClass: 'btn-action-danger',
showCancel: false
});
} finally {
loading.value = false;
}
};
const disableConfirm = () => {
disable2FAModal.value.open({
title: 'Disable 2FA?',
message: 'This will reduce your account security by removing the second-layer protection from your dashboard.',
confirmText: 'Yes, disable',
confirmBtnClass: 'btn-action-danger'
});
};
const handleDisable2FA = async () => {
try {
await apiClient.post('../auth/disable-2fa');
isEnabled.value = false;
step2.value = false;
confirmModal.value.open({
title: '2FA Disabled',
message: 'Two-factor authentication has been disabled. Your account is now using standard password protection.',
confirmText: 'OK',
confirmBtnClass: 'btn-action-primary',
showCancel: false
});
} catch (err) {
confirmModal.value.open({
title: 'Error',
message: err.response?.data?.error || 'Failed to disable two-factor authentication. Please try again.',
confirmText: 'OK',
confirmBtnClass: 'btn-action-danger',
showCancel: false
});
}
};
</script>

View File

@@ -33,10 +33,14 @@
<option value="30d">Last 1 Month</option> <option value="30d">Last 1 Month</option>
</select> </select>
<div class="form-check form-switch mb-0"> <div class="d-flex align-items-center">
<input class="form-check-input" type="checkbox" role="switch" id="vizToggle" v-model="isSpeedMode" <div class="toggle-wrapper me-2">
@change="renderMainChart"> <label class="toggle-switch">
<label class="form-check-label small fw-bold" style="color: var(--text-heading);" for="vizToggle"> <input type="checkbox" id="vizToggle" v-model="isSpeedMode" @change="renderMainChart">
<span class="toggle-slider"></span>
</label>
</div>
<label class="small fw-bold user-select-none" style="color: var(--text-heading); cursor: pointer;" for="vizToggle">
Speed Speed
</label> </label>
</div> </div>
@@ -53,7 +57,7 @@
<div class="col-lg-7"> <div class="col-lg-7">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<i class="fas fa-trophy me-2"></i>TOP-3 Active Clients <i class="fas fa-trophy me-2"></i>TOP-10 Active Clients
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
@@ -80,7 +84,7 @@
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="progress flex-grow-1" style="height: 6px;"> <div class="progress flex-grow-1" style="height: 6px;">
<div class="progress-bar" role="progressbar" :style="{ width: c.percent + '%' }"></div> <div class="progress-bar" role="progressbar" :style="{ width: c.percent + '%', backgroundColor: '#EC7C31' }"></div>
</div> </div>
<span class="ms-2 small text-muted w-25 text-end">{{ c.percent }}%</span> <span class="ms-2 small text-muted w-25 text-end">{{ c.percent }}%</span>
</div> </div>
@@ -110,7 +114,7 @@
<div class="fw-bold small">{{ cert.common_name }}</div> <div class="fw-bold small">{{ cert.common_name }}</div>
<div class="text-muted" style="font-size: 0.75rem;">Expires: {{ cert.expiration_date }}</div> <div class="text-muted" style="font-size: 0.75rem;">Expires: {{ cert.expiration_date }}</div>
</div> </div>
<span class="badge bg-warning text-dark">{{ cert.days_left }} days</span> <span class="badge status-warning text-dark">{{ cert.days_left }} days</span>
</div> </div>
</div> </div>
</div> </div>
@@ -126,12 +130,12 @@
</div> </div>
<div class="ms-3 flex-grow-1"> <div class="ms-3 flex-grow-1">
<div class="mb-3"> <div class="mb-3">
<div class="small text-muted mb-1"><span class="legend-dot" style="background:#3fb950"></span>Download <div class="small text-muted mb-1"><span class="legend-dot" style="background:#1652B8"></span>Download
</div> </div>
<div class="h5 mb-0" style="color: var(--text-heading);">{{ kpi.totalReceivedString }}</div> <div class="h5 mb-0" style="color: var(--text-heading);">{{ kpi.totalReceivedString }}</div>
</div> </div>
<div> <div>
<div class="small text-muted mb-1"><span class="legend-dot" style="background:#58a6ff"></span>Upload</div> <div class="small text-muted mb-1"><span class="legend-dot" style="background:#EC7C31"></span>Upload</div>
<div class="h5 mb-0" style="color: var(--text-heading);">{{ kpi.totalSentString }}</div> <div class="h5 mb-0" style="color: var(--text-heading);">{{ kpi.totalSentString }}</div>
</div> </div>
</div> </div>
@@ -294,13 +298,14 @@ const renderMainChart = () => {
mainChartInstance = new Chart(ctx, { mainChartInstance = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels,
labels, labels,
datasets: [ datasets: [
{ {
label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps', label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps',
data: dataRx, data: dataRx,
borderColor: '#3fb950', // Legacy Green borderColor: '#1652B8', // OpenVPN Blue
backgroundColor: 'rgba(63, 185, 80, 0.15)', backgroundColor: 'rgba(22, 82, 184, 0.15)',
borderWidth: 2, borderWidth: 2,
fill: true, fill: true,
tension: 0.3, tension: 0.3,
@@ -310,8 +315,8 @@ const renderMainChart = () => {
{ {
label: !isSpeedMode.value ? 'Sent (MB)' : 'TX Mbps', label: !isSpeedMode.value ? 'Sent (MB)' : 'TX Mbps',
data: dataTx, data: dataTx,
borderColor: '#58a6ff', // Legacy Blue borderColor: '#EC7C31', // Brand Orange
backgroundColor: 'rgba(88, 166, 255, 0.15)', backgroundColor: 'rgba(236, 124, 49, 0.15)',
borderWidth: 2, borderWidth: 2,
fill: true, fill: true,
tension: 0.3, tension: 0.3,
@@ -347,7 +352,7 @@ const renderPieChart = () => {
labels: ['Received', 'Sent'], labels: ['Received', 'Sent'],
datasets: [{ datasets: [{
data: [kpi.totalReceived, kpi.totalSent], data: [kpi.totalReceived, kpi.totalSent],
backgroundColor: ['rgba(63, 185, 80, 0.8)', 'rgba(88, 166, 255, 0.8)'], backgroundColor: ['#1652B8', '#EC7C31'],
borderColor: bgColor, borderColor: bgColor,
borderWidth: 2, borderWidth: 2,
hoverOffset: 4 hoverOffset: 4

View File

@@ -0,0 +1,331 @@
<template>
<div class="stats-info mb-4" id="statsInfo">
<div class="stat-item">
<div class="stat-value">{{ totalCerts }}</div>
<div class="stat-label">Total Certificates</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ activeCerts.length }}</div>
<div class="stat-label">Active Certificates</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ expiringCount }}</div>
<div class="stat-label">Expiring in 30 days</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ expiredCerts.length }}</div>
<div class="stat-label">Expired / Revoked</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-3">
<div class="d-flex gap-3 align-items-center flex-wrap">
<div class="input-group input-group-sm" style="width: 250px;">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" placeholder="Search by client name..." v-model="searchQuery">
</div>
<button class="btn-action btn-action-save btn-sm" @click="showNewClientModal">
<i class="fas fa-plus me-1"></i> New Client
</button>
</div>
<div class="d-flex align-items-center">
<div class="toggle-wrapper me-2">
<label class="toggle-switch">
<input type="checkbox" id="hideExpired" v-model="hideExpired">
<span class="toggle-slider"></span>
</label>
</div>
<label class="user-select-none text-muted" for="hideExpired" style="cursor: pointer;">Hide Expired/Revoked</label>
</div>
</div>
<div class="card" id="certificatesCard">
<div class="card-header d-flex justify-content-between align-items-center bg-transparent">
<span><i class="fas fa-certificate me-2"></i>Certificates List</span>
<div>
<div>
<span class="status-badge status-valid me-1">
<i class="fas fa-check-circle me-1"></i><span>{{ activeCerts.length }}</span> Active
</span>
<span class="status-badge status-expired">
<i class="fas fa-times-circle me-1"></i><span>{{ expiredCerts.length }}</span> Revoked/Expired
</span>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Client Name</th>
<th>Type</th> <!-- Default to Client -->
<th>Validity Not After</th>
<th>Days Remaining</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="6" class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 mb-0">Loading profiles...</p>
</td>
</tr>
<tr v-else-if="activeCerts.length === 0 && expiredCerts.length === 0">
<td colspan="6" class="empty-state text-center py-5">
<i class="fas fa-certificate fa-2x mb-3 text-muted"></i>
<p class="text-muted">No certificates found</p>
</td>
</tr>
<template v-else>
<!-- Active Section -->
<tr v-if="activeCerts.length > 0" class="section-divider">
<td colspan="6">Active Certificates ({{ activeCerts.length }})</td>
</tr>
<tr v-for="cert in activeCerts" :key="cert.id + '_active'">
<td>
<div class="fw-semibold" style="color: var(--text-heading);">{{ cert.username }}</div>
<div class="certificate-file text-muted small">{{ cert.file_path || 'N/A' }}</div>
</td>
<td><span class="status-badge status-client">Client</span></td>
<td>{{ formatDate(cert.expiration_date) }}</td>
<td class="fw-semibold" :class="getDaysClass(cert.days_remaining)">
{{ cert.days_remaining }}
</td>
<td v-html="getStatusBadgeHTML(cert)"></td>
<td>
<button class="btn btn-sm btn-link text-warning p-0 me-2" title="Download Config" @click="downloadConfig(cert)">
<i class="fas fa-download"></i>
</button>
<button class="btn btn-sm btn-link text-danger p-0" title="Revoke" @click="revokeClient(cert)">
<i class="fas fa-ban"></i>
</button>
</td>
</tr>
<!-- Expired/Revoked Section -->
<template v-if="!hideExpired && expiredCerts.length > 0">
<tr class="section-divider">
<td colspan="6">Expired / Revoked ({{ expiredCerts.length }})</td>
</tr>
<tr v-for="cert in expiredCerts" :key="cert.id + '_expired'">
<td>
<div class="fw-semibold" style="color: var(--text-heading);">{{ cert.username }}</div>
<div class="certificate-file text-muted small">{{ cert.file_path || 'N/A' }}</div>
</td>
<td><span class="status-badge status-client">Client</span></td>
<td>{{ formatDate(cert.expiration_date) }}</td>
<td class="fw-semibold text-muted">
{{ cert.days_remaining }}
</td>
<td v-html="getStatusBadgeHTML(cert)"></td>
<td>
<button class="btn btn-sm btn-link text-danger p-0" title="Revoke" @click="revokeClient(cert)" :disabled="cert.is_revoked">
<i class="fas fa-ban"></i>
</button>
</td>
</tr>
</template>
</template>
</tbody>
</table>
</div>
</div>
<NewClientModal ref="newClientModal" @create="createClient" />
<ConfirmModal ref="confirmModal" @confirm="confirmRevoke" />
<div v-if="toastMessage" class="toast-message show" :class="'toast-' + toastType" style="position: fixed; bottom: 20px; right: 20px; z-index: 9999;">
<i class="fas" :class="toastType === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle'"></i>
{{ toastMessage }}
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useApi } from '../composables/useApi';
import NewClientModal from '../components/NewClientModal.vue';
import ConfirmModal from '../components/ConfirmModal.vue';
const { profilesApiClient } = useApi();
const loading = ref(true);
const allProfiles = ref([]);
const searchQuery = ref('');
const hideExpired = ref(false);
const newClientModal = ref(null);
const confirmModal = ref(null);
const toastMessage = ref('');
const toastType = ref('success');
const showToast = (msg, type = 'success') => {
toastMessage.value = msg;
toastType.value = type;
setTimeout(() => toastMessage.value = '', 3000);
};
const formatDate = (dateStr) => {
if (!dateStr) return 'N/A';
try {
const d = new Date(dateStr);
if(isNaN(d)) return dateStr;
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
} catch(e) { return dateStr; }
};
const getDaysClass = (days) => {
if (days === null || days === undefined) return '';
if (days <= 0) return 'text-danger';
if (days <= 30) return 'text-warning';
return 'text-success';
};
const getStatusBadgeHTML = (cert) => {
if (cert.is_revoked) {
return '<span class="status-badge status-expired"><i class="fas fa-ban me-1"></i>Revoked</span>';
}
if (cert.is_expired) {
return '<span class="status-badge status-expired"><i class="fas fa-times-circle me-1"></i>Expired</span>';
}
// Days check logic for expiring soon warning
if (cert.days_remaining !== null && cert.days_remaining <= 30) {
return '<span class="status-badge status-warning"><i class="fas fa-exclamation-triangle me-1"></i>Expiring Soon</span>';
}
return '<span class="status-badge status-valid"><i class="fas fa-check-circle me-1"></i>Active</span>';
};
// Data Processing
const filteredData = computed(() => {
let data = allProfiles.value;
if (searchQuery.value) {
const term = searchQuery.value.toLowerCase();
data = data.filter(c => {
const username = (c.username || '').toLowerCase();
return username.includes(term);
});
}
return data;
});
const categorized = computed(() => {
const active = [];
const expired = [];
filteredData.value.forEach(cert => {
// Categorize based on revoked or expired flags
if (cert.is_revoked || cert.is_expired) {
expired.push(cert);
} else {
active.push(cert);
}
});
// Sort logic
active.sort((a,b) => (a.days_remaining || 9999) - (b.days_remaining || 9999));
expired.sort((a,b) => (b.days_remaining || -9999) - (a.days_remaining || -9999));
return { active, expired };
});
const activeCerts = computed(() => categorized.value.active);
const expiredCerts = computed(() => categorized.value.expired);
const totalCerts = computed(() => allProfiles.value.length);
const expiringCount = computed(() => {
return activeCerts.value.filter(c => {
return c.days_remaining !== null && c.days_remaining <= 30;
}).length;
});
const loadCerts = async () => {
loading.value = true;
try {
const response = await profilesApiClient.get('/profiles');
allProfiles.value = response.data || [];
} catch(e) {
console.error(e);
showToast('Failed to load profiles', 'error');
} finally {
loading.value = false;
}
};
const showNewClientModal = () => {
newClientModal.value.open();
};
const createClient = async (username) => {
try {
loading.value = true;
await profilesApiClient.post('/profiles', { username });
showToast('Client created successfully.', 'success');
loadCerts();
} catch(e) {
showToast(e.response?.data?.detail || e.message, 'error');
} finally {
loading.value = false;
}
};
const downloadConfig = async (cert) => {
if (!cert.id) return;
try {
const res = await profilesApiClient.get(`/profiles/${cert.id}/download`, { responseType: 'blob' });
const contentDisposition = res.headers['content-disposition'];
let filename = `${cert.username}.ovpn`;
if (contentDisposition) {
const match = contentDisposition.match(/filename="?([^"]+)"?/);
if (match && match[1]) filename = match[1];
}
const url = window.URL.createObjectURL(new Blob([res.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch(e) {
showToast('Could not download config: ' + (e.response?.data?.detail || e.message), 'error');
}
};
const revokeClient = (cert) => {
if (cert.is_revoked) return;
confirmModal.value.open({
title: 'Revoke Certificate?',
message: `Are you sure you want to revoke access for ${cert.username}? This action cannot be undone.`,
confirmText: 'Yes, Revoke',
confirmBtnClass: 'btn-action-danger',
data: cert
});
};
const confirmRevoke = async (cert) => {
try {
loading.value = true;
await profilesApiClient.delete(`/profiles/${cert.id}`);
showToast(`${cert.username} has been revoked.`, 'success');
loadCerts();
} catch(e) {
showToast(e.response?.data?.detail || e.message, 'error');
} finally {
loading.value = false;
}
};
onMounted(() => {
loadCerts();
});
</script>

View File

@@ -29,9 +29,14 @@
</div> </div>
</div> </div>
<div class="form-check form-switch"> <div class="d-flex align-items-center">
<input class="form-check-input" type="checkbox" id="hideDisconnected" v-model="hideDisconnected"> <div class="toggle-wrapper me-2">
<label class="form-check-label user-select-none text-muted" for="hideDisconnected">Hide Disconnected</label> <label class="toggle-switch">
<input type="checkbox" id="hideDisconnected" v-model="hideDisconnected">
<span class="toggle-slider"></span>
</label>
</div>
<label class="user-select-none text-muted" for="hideDisconnected" style="cursor: pointer;">Hide Disconnected</label>
</div> </div>
</div> </div>
@@ -96,7 +101,7 @@
</td> </td>
<td class="font-monospace small"> <td class="font-monospace small">
<span v-if="c.status === 'Active'" :class="c.current_sent_rate_mbps > 0.01 ? 'text-primary fw-bold' : 'text-muted opacity-75'"> <span v-if="c.status === 'Active'" :class="c.current_sent_rate_mbps > 0.01 ? 'text-warning fw-bold' : 'text-muted opacity-75'">
{{ c.current_sent_rate_mbps ? formatRate(c.current_sent_rate_mbps) : '0.000 Mbps' }} {{ c.current_sent_rate_mbps ? formatRate(c.current_sent_rate_mbps) : '0.000 Mbps' }}
</span> </span>
<span v-else class="text-muted opacity-25">-</span> <span v-else class="text-muted opacity-25">-</span>
@@ -215,8 +220,3 @@ onUnmounted(() => {
}); });
</script> </script>
<style scoped>
.cursor-pointer {
cursor: pointer;
}
</style>

161
APP_UI/src/views/Login.vue Normal file
View File

@@ -0,0 +1,161 @@
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<div class="brand-logo">
<i class="fas fa-shield-alt"></i>
</div>
<h1>OpenVPN Monitor</h1>
<p class="text-muted">Secure Access Control</p>
</div>
<div class="login-body">
<!-- Standard Login -->
<form v-if="!requires2FA" @submit.prevent="handleLogin">
<div class="mb-4">
<label class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-user"></i></span>
<input
type="text"
class="form-control"
v-model="form.username"
placeholder="Enter username"
required
>
</div>
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input
type="password"
class="form-control"
v-model="form.password"
placeholder="Enter password"
required
>
</div>
</div>
<div v-if="error" class="alert alert-danger fade show mb-4">
<i class="fas fa-exclamation-circle me-2"></i>{{ error }}
</div>
<button type="submit" class="btn btn-primary w-100 py-2 fw-bold" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Sign In
</button>
</form>
<!-- 2FA Verification -->
<form v-else @submit.prevent="handleVerify2FA">
<div class="text-center mb-4">
<i class="fas fa-key fa-3x text-warning mb-3"></i>
<h3>Two-Factor Authentication</h3>
<p class="text-muted">Enter the 6-digit code from your authenticator app.</p>
</div>
<div class="mb-4">
<input
type="text"
class="form-control form-control-lg text-center fw-bold"
v-model="otp"
placeholder="000 000"
maxlength="6"
required
autofocus
>
</div>
<div v-if="error" class="alert alert-danger fade show mb-4">
<i class="fas fa-exclamation-circle me-2"></i>{{ error }}
</div>
<button type="submit" class="btn btn-warning w-100 py-2 fw-bold" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Verify & Continue
</button>
<button type="button" class="btn btn-link w-100 mt-2 text-muted" @click="resetLogin">
Back to login
</button>
</form>
</div>
<div class="login-footer text-center mt-4">
<p class="small text-muted">&copy; 2026 Admin Dashboard</p>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useApi } from '../composables/useApi';
const router = useRouter();
const { apiClient } = useApi();
const loading = ref(false);
const error = ref('');
const requires2FA = ref(false);
const tempToken = ref('');
const otp = ref('');
const form = reactive({
username: '',
password: ''
});
const handleLogin = async () => {
loading.value = true;
error.value = '';
try {
// Auth routes are under /api/auth, while apiClient defaults to /api/v1
// We use a relative path with '..' to go up to /api
const response = await apiClient.post('../auth/login', form);
if (response.data.requires_2fa) {
requires2FA.value = true;
tempToken.value = response.data.temp_token;
} else {
localStorage.setItem('ovpmon_token', response.data.token);
localStorage.setItem('ovpmon_user', response.data.username);
router.push('/');
}
} catch (err) {
console.error('Login Error:', err);
error.value = err.response?.data?.error || 'Login failed. Please check your connection.';
} finally {
loading.value = false;
}
};
const handleVerify2FA = async () => {
loading.value = true;
error.value = '';
try {
const response = await apiClient.post('../auth/verify-2fa', {
temp_token: tempToken.value,
otp: otp.value
});
localStorage.setItem('ovpmon_token', response.data.token);
localStorage.setItem('ovpmon_user', response.data.username);
router.push('/');
} catch (err) {
error.value = err.response?.data?.error || 'Verification failed.';
} finally {
loading.value = false;
}
};
const resetLogin = () => {
requires2FA.value = false;
tempToken.value = '';
otp.value = '';
error.value = '';
};
</script>

View File

@@ -0,0 +1,251 @@
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<div class="brand-logo">
<i class="fas fa-shield-alt"></i>
</div>
<h1>OpenVPN Monitor</h1>
<p class="text-muted">Secure Access Control</p>
</div>
<div class="login-body">
<!-- Standard Login -->
<form v-if="!requires2FA" @submit.prevent="handleLogin">
<div class="mb-4">
<label class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-user"></i></span>
<input
type="text"
class="form-control"
v-model="form.username"
placeholder="Enter username"
required
>
</div>
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input
type="password"
class="form-control"
v-model="form.password"
placeholder="Enter password"
required
>
</div>
</div>
<div v-if="error" class="alert alert-danger fade show mb-4">
<i class="fas fa-exclamation-circle me-2"></i>{{ error }}
</div>
<button type="submit" class="btn btn-primary w-100 py-2 fw-bold" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Sign In
</button>
</form>
<!-- 2FA Verification -->
<form v-else @submit.prevent="handleVerify2FA">
<div class="text-center mb-4">
<i class="fas fa-key fa-3x text-warning mb-3"></i>
<h3>Two-Factor Authentication</h3>
<p class="text-muted">Enter the 6-digit code from your authenticator app.</p>
</div>
<div class="mb-4">
<input
type="text"
class="form-control form-control-lg text-center fw-bold"
v-model="otp"
placeholder="000 000"
maxlength="6"
required
autofocus
>
</div>
<div v-if="error" class="alert alert-danger fade show mb-4">
<i class="fas fa-exclamation-circle me-2"></i>{{ error }}
</div>
<button type="submit" class="btn btn-warning w-100 py-2 fw-bold" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Verify & Continue
</button>
<button type="button" class="btn btn-link w-100 mt-2 text-muted" @click="resetLogin">
Back to login
</button>
</form>
</div>
<div class="login-footer text-center mt-4">
<p class="small text-muted">&copy; 2026 Admin Dashboard</p>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useApi } from '../composables/useApi';
const router = useRouter();
const { apiClient } = useApi();
const loading = ref(false);
const error = ref('');
const requires2FA = ref(false);
const tempToken = ref('');
const otp = ref('');
const form = reactive({
username: '',
password: ''
});
const handleLogin = async () => {
loading.value = true;
error.value = '';
try {
// Auth routes are under /api/auth, while apiClient defaults to /api/v1
// We use a relative path with '..' to go up to /api
const response = await apiClient.post('../auth/login', form);
if (response.data.requires_2fa) {
requires2FA.value = true;
tempToken.value = response.data.temp_token;
} else {
localStorage.setItem('ovpmon_token', response.data.token);
localStorage.setItem('ovpmon_user', response.data.username);
router.push('/');
}
} catch (err) {
console.error('Login Error:', err);
error.value = err.response?.data?.error || 'Login failed. Please check your connection.';
} finally {
loading.value = false;
}
};
const handleVerify2FA = async () => {
loading.value = true;
error.value = '';
try {
const response = await apiClient.post('../auth/verify-2fa', {
temp_token: tempToken.value,
otp: otp.value
});
localStorage.setItem('ovpmon_token', response.data.token);
localStorage.setItem('ovpmon_user', response.data.username);
router.push('/');
} catch (err) {
error.value = err.response?.data?.error || 'Verification failed.';
} finally {
loading.value = false;
}
};
const resetLogin = () => {
requires2FA.value = false;
tempToken.value = '';
otp.value = '';
error.value = '';
};
</script>
<style scoped>
.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);
}
.form-label {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-heading);
}
.input-group-text {
background: var(--bg-element);
border-color: var(--border-color);
color: var(--text-muted);
}
.form-control {
background: var(--bg-input);
border-color: var(--border-color);
color: var(--text-main);
}
.form-control:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.1);
}
.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;
}
</style>

View File

@@ -0,0 +1,292 @@
<template>
<div class="config-wrapper">
<div class="config-card">
<div class="config-header">
<h1>PKI Infrastructure Configuration</h1>
</div>
<div v-if="message.text" :class="['toast-message', 'toast-' + message.type, 'show']">
<i :class="['fas', message.icon]"></i> {{ message.text }}
</div>
<form @submit.prevent="handleSave">
<div class="config-row">
<div class="config-col">
<label class="config-label">FQDN: CA Cert CN</label>
<input type="text" class="config-input" v-model="form.fqdn_ca">
</div>
<div class="config-col">
<label class="config-label">FQDN: Server Cert CN</label>
<input type="text" class="config-input" v-model="form.fqdn_server">
</div>
</div>
<div class="config-divider"></div>
<div class="config-row">
<div class="config-col" style="max-width: 50%;">
<label class="config-label">EASYRSA DN</label>
<div class="select-container">
<select class="config-select" v-model="form.easyrsa_dn">
<option value="cn_only">CN_ONLY</option>
<option value="org">ORG</option>
</select>
<div class="select-arrow">
<i class="fas fa-chevron-down"></i>
</div>
</div>
</div>
<div class="config-col" style="max-width: 50%;">
</div>
</div>
<div class="config-row">
<div class="config-col">
<label class="config-label">Country</label>
<input type="text" class="config-input" v-model="form.easyrsa_req_country">
</div>
<div class="config-col">
<label class="config-label">Province / State</label>
<input type="text" class="config-input" v-model="form.easyrsa_req_province">
</div>
</div>
<div class="config-row">
<div class="config-col">
<label class="config-label">City</label>
<input type="text" class="config-input" v-model="form.easyrsa_req_city">
</div>
<div class="config-col">
<label class="config-label">Organization</label>
<input type="text" class="config-input" v-model="form.easyrsa_req_org">
</div>
</div>
<div class="config-row">
<div class="config-col">
<label class="config-label">E-mail</label>
<input type="email" class="config-input" v-model="form.easyrsa_req_email">
</div>
<div class="config-col">
<label class="config-label">Organization Unit</label>
<input type="text" class="config-input" v-model="form.easyrsa_req_ou">
</div>
</div>
<div class="config-divider"></div>
<div class="config-row">
<div class="config-col">
<label class="config-label">Key Size Length (Bits)</label>
<div class="select-container">
<select class="config-select" v-model="form.easyrsa_key_size">
<option value="2048">2048</option>
<option value="4096">4096</option>
</select>
<div class="select-arrow">
<i class="fas fa-chevron-down"></i>
</div>
</div>
</div>
<div class="config-col">
<label class="config-label">CA Expire (Days)</label>
<div class="number-input-container">
<input type="number" class="config-input" v-model.number="form.easyrsa_ca_expire">
<div class="number-input-controls">
<button type="button" class="number-input-btn" @click="form.easyrsa_ca_expire++"><i class="fas fa-chevron-up"></i></button>
<button type="button" class="number-input-btn" @click="form.easyrsa_ca_expire--"><i class="fas fa-chevron-down"></i></button>
</div>
</div>
</div>
</div>
<div class="config-row">
<div class="config-col">
<label class="config-label">Cert Expire (Days)</label>
<div class="number-input-container">
<input type="number" class="config-input" v-model.number="form.easyrsa_cert_expire">
<div class="number-input-controls">
<button type="button" class="number-input-btn" @click="form.easyrsa_cert_expire++"><i class="fas fa-chevron-up"></i></button>
<button type="button" class="number-input-btn" @click="form.easyrsa_cert_expire--"><i class="fas fa-chevron-down"></i></button>
</div>
</div>
</div>
<div class="config-col">
<label class="config-label">Cert Renew (Days)</label>
<div class="number-input-container">
<input type="number" class="config-input" v-model.number="form.easyrsa_cert_renew">
<div class="number-input-controls">
<button type="button" class="number-input-btn" @click="form.easyrsa_cert_renew++"><i class="fas fa-chevron-up"></i></button>
<button type="button" class="number-input-btn" @click="form.easyrsa_cert_renew--"><i class="fas fa-chevron-down"></i></button>
</div>
</div>
</div>
</div>
<div class="config-row">
<div class="config-col">
<label class="config-label">CRL Valid (Days)</label>
<div class="number-input-container">
<input type="number" class="config-input" v-model.number="form.easyrsa_crl_days">
<div class="number-input-controls">
<button type="button" class="number-input-btn" @click="form.easyrsa_crl_days++"><i class="fas fa-chevron-up"></i></button>
<button type="button" class="number-input-btn" @click="form.easyrsa_crl_days--"><i class="fas fa-chevron-down"></i></button>
</div>
</div>
</div>
<div class="config-col">
<label class="config-label">Batch</label>
<div class="toggle-wrapper">
<label class="toggle-switch">
<input type="checkbox" v-model="form.easyrsa_batch">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="config-divider"></div>
<div class="btn-action-group">
<button type="button" class="btn-action btn-action-save" @click="handleSave" :disabled="isLoading">
<i class="fas fa-save"></i>
Save PKI
</button>
<button type="button" class="btn-action btn-action-primary" @click="handleInit" :disabled="isLoading">
<i v-if="isLoading && action==='init'" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-play-circle"></i>
Init PKI
</button>
<button type="button" class="btn-action btn-action-danger" @click="handleClear" :disabled="isLoading">
<i v-if="isLoading && action==='clear'" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-trash-alt"></i>
Clear PKI
</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue';
import { useApi } from '../composables/useApi';
const { profilesApiClient } = useApi();
// State
const form = reactive({
fqdn_ca: '',
fqdn_server: '',
easyrsa_dn: 'cn_only',
easyrsa_req_country: '',
easyrsa_req_province: '',
easyrsa_req_city: '',
easyrsa_req_org: '',
easyrsa_req_email: '',
easyrsa_req_ou: '',
easyrsa_key_size: 2048,
easyrsa_ca_expire: null,
easyrsa_cert_expire: null,
easyrsa_cert_renew: null,
easyrsa_crl_days: null,
easyrsa_batch: true
});
const message = reactive({
text: '',
type: 'success', // success, warning, error (mapped to css classes)
icon: 'fa-check-circle'
});
const isLoading = ref(false);
const action = ref(''); // 'save', 'init', 'clear'
// Methods
const showMessage = (text, type = 'success') => {
message.text = text;
message.type = type;
message.icon = type === 'success' ? 'fa-check-circle' : type === 'warning' ? 'fa-exclamation-triangle' : 'fa-exclamation-circle';
// Auto hide
setTimeout(() => {
message.text = '';
}, 4000);
};
// API Fetch Wrapper
// API Wrapper removed, using profilesApiClient directly
const loadConfig = async () => {
try {
const res = await profilesApiClient.get('/config', { params: { section: 'pki' } });
const payload = res.data;
const data = payload.pki ? payload.pki : payload;
// Populate form
Object.keys(data).forEach(key => {
if (form.hasOwnProperty(key)) {
form[key] = data[key];
}
});
} catch (err) {
console.error(err);
showMessage('Failed to load PKI config from Management API.', 'error');
}
};
const handleSave = async () => {
isLoading.value = true;
action.value = 'save';
try {
await profilesApiClient.put('/config/pki', form);
showMessage('Configuration saved successfully!', 'success');
} catch (err) {
showMessage(`Error saving: ${err.message}`, 'error');
} finally {
isLoading.value = false;
action.value = '';
}
};
const handleInit = async () => {
if (!confirm('Are you sure you want to Initialize system? This will start the PKI initialization process.')) return;
isLoading.value = true;
action.value = 'init';
try {
const res = await profilesApiClient.post('/system/init');
const data = res.data;
showMessage(data.message || 'System initialized successfully!', 'success');
} catch (err) {
showMessage(`Error initializing: ${err.message}`, 'error');
} finally {
isLoading.value = false;
action.value = '';
}
};
const handleClear = async () => {
if (!confirm('Are you sure you want to CLEAR PKI? This will delete all certificates and cannot be undone.')) return;
isLoading.value = true;
action.value = 'clear';
try {
const res = await profilesApiClient.delete('/system/pki');
const data = res.data;
showMessage(data.message || 'PKI cleared successfully!', 'warning');
} catch (err) {
showMessage(`Error clearing PKI: ${err.message}`, 'error');
} finally {
isLoading.value = false;
action.value = '';
}
};
onMounted(() => {
loadConfig();
});
</script>

View File

@@ -0,0 +1,169 @@
<template>
<div class="config-wrapper">
<div class="config-card">
<div class="config-header">
<h1>Server Process Management</h1>
</div>
<div v-if="message.text" :class="['toast-message', 'toast-' + message.type, 'show']">
<i :class="['fas', message.icon]"></i> {{ message.text }}
</div>
<!-- Stats Grid -->
<!-- User requested specific order: CPU (full), Status, PID, Memory, Uptime -->
<div class="stats-grid mb-4">
<!-- CPU Usage (Full Width) -->
<div class="stat-item full-width cpu-progress">
<label class="stat-label d-flex justify-content-between">
<span>CPU Usage</span>
<span>{{ stats.cpu_percent }}%</span>
</label>
<div class="progress">
<div class="progress-bar progress-bar-brand" role="progressbar"
:style="{ width: Math.min(stats.cpu_percent, 100) + '%' }"
:aria-valuenow="stats.cpu_percent" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
<!-- Status -->
<div class="stat-item">
<label class="stat-label">Status</label>
<div class="d-flex align-items-center">
<span :class="['status-badge', 'status-' + (stats.status === 'running' ? 'active' : 'revoked')]">
{{ stats.status.toUpperCase() }}
</span>
</div>
</div>
<!-- PID -->
<div class="stat-item">
<label class="stat-label">Process ID</label>
<div class="stat-value">{{ stats.pid || 'N/A' }}</div>
</div>
<!-- Memory -->
<div class="stat-item">
<label class="stat-label">Memory (RSS)</label>
<div class="stat-value">{{ stats.memory_mb }} MB</div>
</div>
<!-- Uptime -->
<div class="stat-item">
<label class="stat-label">Uptime</label>
<div class="stat-value monospace">{{ stats.uptime || 'N/A' }}</div>
</div>
</div>
<!-- Action Buttons -->
<div class="btn-action-group justify-content-center">
<!-- Start -->
<button type="button" class="btn-action btn-action-primary"
@click="handleControl('start')"
:disabled="isLoading || stats.status === 'running'">
<i v-if="isLoading && action==='start'" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-play"></i>
Start Service
</button>
<!-- Restart -->
<button type="button" class="btn-action btn-action-save"
@click="handleControl('restart')"
:disabled="isLoading">
<i v-if="isLoading && action==='restart'" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-sync-alt"></i>
Restart Service
</button>
<!-- Stop -->
<button type="button" class="btn-action btn-action-danger"
@click="handleControl('stop')"
:disabled="isLoading || stats.status !== 'running'">
<i v-if="isLoading && action==='stop'" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-stop"></i>
Stop Service
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import { useApi } from '../composables/useApi';
const { profilesApiClient } = useApi();
// State
const stats = reactive({
status: 'unknown',
pid: null,
cpu_percent: 0.0,
memory_mb: 0.0,
uptime: null
});
const message = reactive({
text: '',
type: 'success',
icon: 'fa-check-circle'
});
const isLoading = ref(false);
const action = ref(''); // 'start', 'stop', 'restart'
let pollInterval = null;
// Methods
const showMessage = (text, type = 'success') => {
message.text = text;
message.type = type;
message.icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
setTimeout(() => {
message.text = '';
}, 4000);
};
const fetchStats = async () => {
try {
const res = await profilesApiClient.get('/server/process/stats');
Object.assign(stats, res.data);
} catch (err) {
console.error("Failed to fetch stats:", err);
// Don't show error toast on every poll failure to avoid spam
}
};
const handleControl = async (cmd) => {
if (!confirm(`Are you sure you want to ${cmd.toUpperCase()} the OpenVPN service?`)) return;
isLoading.value = true;
action.value = cmd;
try {
const res = await profilesApiClient.post(`/server/process/${cmd}`);
showMessage(res.data.message || `Service ${cmd} successful`, 'success');
// Refresh stats immediately after action
setTimeout(fetchStats, 1000);
} catch (err) {
const errMsg = err.response?.data?.detail || err.message;
showMessage(`Failed to ${cmd}: ${errMsg}`, 'error');
} finally {
isLoading.value = false;
action.value = '';
}
};
onMounted(() => {
fetchStats();
// Poll every 5 seconds
pollInterval = setInterval(fetchStats, 5000);
});
onUnmounted(() => {
if (pollInterval) clearInterval(pollInterval);
});
</script>

View File

@@ -0,0 +1,396 @@
<template>
<div class="config-wrapper">
<div class="config-card">
<div class="config-header">
<h1>Server Infrastructure Configuration</h1>
</div>
<div v-if="message.text" :class="['toast-message', 'toast-' + message.type, 'show']">
<i :class="['fas', message.icon]"></i> {{ message.text }}
</div>
<form @submit.prevent="handleSave">
<!-- Basic Network -->
<div class="config-row">
<div class="config-col">
<label class="config-label">Tunnel Protocol</label>
<div class="select-container">
<select class="config-select" v-model="form.protocol">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<div class="select-arrow">
<i class="fas fa-chevron-down"></i>
</div>
</div>
</div>
<div class="config-col">
<label class="config-label">Tunnel Port</label>
<div class="number-input-container">
<input type="number" class="config-input" v-model.number="form.port">
<div class="number-input-controls">
<button type="button" class="number-input-btn" @click="form.port++"><i class="fas fa-chevron-up"></i></button>
<button type="button" class="number-input-btn" @click="form.port--"><i class="fas fa-chevron-down"></i></button>
</div>
</div>
</div>
</div>
<div class="config-row">
<div class="config-col">
<label class="config-label">VPN Subnet (CIDR Format)</label>
<input type="text" class="config-input" v-model="form.vpn_network" placeholder="172.20.1.0/24">
</div>
<div class="config-col">
<label class="config-label">Public IP</label>
<input type="text" class="config-input" v-model="form.public_ip">
</div>
</div>
<div class="config-row">
<div class="config-col">
<label class="config-label">Tunnel MTU</label>
<div class="number-input-container">
<input type="number" class="config-input" v-model.number="form.tun_mtu" placeholder="0 for default">
<div class="number-input-controls">
<button type="button" class="number-input-btn" @click="form.tun_mtu++"><i class="fas fa-chevron-up"></i></button>
<button type="button" class="number-input-btn" @click="form.tun_mtu--"><i class="fas fa-chevron-down"></i></button>
</div>
</div>
</div>
<div class="config-col">
<label class="config-label">MSS FIX</label>
<div class="number-input-container">
<input type="number" class="config-input" v-model.number="form.mssfix" placeholder="0 for default">
<div class="number-input-controls">
<button type="button" class="number-input-btn" @click="form.mssfix++"><i class="fas fa-chevron-up"></i></button>
<button type="button" class="number-input-btn" @click="form.mssfix--"><i class="fas fa-chevron-down"></i></button>
</div>
</div>
</div>
</div>
<div class="config-divider"></div>
<!-- Split Tunnel -->
<div class="config-row">
<div class="config-col">
<label class="config-label">Enable Split Tunnel (Default FULL)</label>
<div class="toggle-wrapper">
<label class="toggle-switch">
<input type="checkbox" v-model="isSplitTunnel">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div v-if="isSplitTunnel" class="nested-box fade-in">
<div class="config-row mb-2">
<div class="config-col">
<label class="nested-label">Split Routes (CIDR Format)</label>
<div class="list-container">
<div v-for="(route, index) in form.split_routes" :key="index" class="config-input-group mb-2">
<input type="text" class="config-input" v-model="form.split_routes[index]" placeholder="10.0.0.0/24">
<button type="button" class="btn-icon-sm" @click="removeRoute(index)">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<button type="button" class="btn-dashed text-primary" @click="addRoute">
<i class="fas fa-plus text-primary"></i> Add Route
</button>
</div>
</div>
</div>
<div class="config-divider"></div>
<!-- DNS -->
<div class="config-row">
<div class="config-col">
<label class="config-label">Enable User-Defined DNS Servers</label>
<div class="toggle-wrapper">
<label class="toggle-switch">
<input type="checkbox" v-model="form.user_defined_dns">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div v-if="form.user_defined_dns" class="nested-box fade-in">
<div class="config-row mb-2">
<div class="config-col">
<label class="nested-label">DNS Servers</label>
<div class="list-container">
<div v-for="(dns, index) in form.dns_servers" :key="index" class="config-input-group mb-2">
<input type="text" class="config-input" v-model="form.dns_servers[index]" placeholder="8.8.8.8">
<button type="button" class="btn-icon-sm" @click="removeDns(index)">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<button type="button" class="btn-dashed text-primary" @click="addDns">
<i class="fas fa-plus text-primary"></i> Add DNS
</button>
</div>
</div>
</div>
<div class="config-divider"></div>
<!-- Advanced Flags -->
<div class="config-row">
<div class="config-col">
<label class="config-label">Duplicate CN</label>
<div class="toggle-wrapper">
<label class="toggle-switch">
<input type="checkbox" v-model="form.duplicate_cn">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="config-col">
<label class="config-label">Client-to-Client</label>
<div class="toggle-wrapper">
<label class="toggle-switch">
<input type="checkbox" v-model="form.client_to_client">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="config-col">
<label class="config-label">CRL Verify</label>
<div class="toggle-wrapper">
<label class="toggle-switch">
<input type="checkbox" v-model="form.crl_verify">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="config-divider"></div>
<!-- Scripts -->
<div class="config-row">
<div class="config-col">
<label class="config-label">Enable Connection Scripts</label>
<div class="toggle-wrapper">
<label class="toggle-switch">
<input type="checkbox" v-model="form.user_defined_cdscripts">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div v-if="form.user_defined_cdscripts" class="nested-box fade-in">
<div class="config-row">
<div class="config-col">
<label class="config-label">Path to Connect Script</label>
<input type="text" class="config-input" v-model="form.connect_script">
</div>
</div>
<div class="config-row mt-3">
<div class="config-col">
<label class="config-label">Path to Disconnect Script</label>
<input type="text" class="config-input" v-model="form.disconnect_script">
</div>
</div>
</div>
<div class="config-row mt-4">
<div class="config-col">
<label class="config-label">Enable Management Interface</label>
<div class="toggle-wrapper">
<label class="toggle-switch">
<input type="checkbox" v-model="form.management_interface">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div v-if="form.management_interface" class="nested-box fade-in">
<div class="config-row">
<div class="config-col">
<label class="config-label">Listener Address</label>
<input type="text" class="config-input" v-model="form.management_interface_address" placeholder="127.0.0.1">
</div>
<div class="config-col">
<label class="config-label">Port</label>
<div class="number-input-container">
<input type="number" class="config-input" v-model.number="form.management_port" placeholder="7505">
<div class="number-input-controls">
<button type="button" class="number-input-btn" @click="form.management_port++"><i class="fas fa-chevron-up"></i></button>
<button type="button" class="number-input-btn" @click="form.management_port--"><i class="fas fa-chevron-down"></i></button>
</div>
</div>
</div>
</div>
</div>
<div class="config-divider"></div>
<!-- Actions -->
<div class="btn-action-group">
<button type="submit" class="btn-action btn-action-save" :disabled="isLoading">
<i v-if="isLoading && action==='save'" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-save"></i>
Save Server Configuration
</button>
<button type="button" class="btn-action btn-action-primary" @click="handleApply" :disabled="isLoading">
<i v-if="isLoading && action==='apply'" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-rocket"></i>
Apply Configuration
</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive, watch } from 'vue';
import { useApi } from '../composables/useApi';
const { profilesApiClient } = useApi();
// State
const form = reactive({
protocol: 'udp',
port: 1194,
vpn_network: '',
public_ip: '',
tun_mtu: 0,
mssfix: 0,
tunnel_type: 'FULL',
split_routes: [],
user_defined_dns: false,
dns_servers: [],
duplicate_cn: false,
client_to_client: false,
crl_verify: false,
user_defined_cdscripts: false,
connect_script: '',
disconnect_script: '',
management_interface: false,
management_interface_address: '',
management_port: 7505
});
const isSplitTunnel = ref(false);
const message = reactive({
text: '',
type: 'success',
icon: 'fa-check-circle'
});
const isLoading = ref(false);
const action = ref('');
// Watchers
watch(isSplitTunnel, (val) => {
form.tunnel_type = val ? 'SPLIT' : 'FULL';
});
// Helpers
function maskToCidr(mask) {
if (!mask) return '';
const parts = mask.split('.');
let bits = 0;
const map = {255:8, 254:7, 252:6, 248:5, 240:4, 224:3, 192:2, 128:1, 0:0};
for(let p of parts) bits += map[parseInt(p)] || 0;
return bits;
}
// Methods
const showMessage = (text, type = 'success') => {
message.text = text;
message.type = type;
message.icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
setTimeout(() => message.text = '', 5000);
};
const addRoute = () => form.split_routes.push('');
const removeRoute = (idx) => form.split_routes.splice(idx, 1);
const addDns = () => form.dns_servers.push('');
const removeDns = (idx) => form.dns_servers.splice(idx, 1);
// API Wrapper removed, using profilesApiClient directly
const loadConfig = async () => {
try {
const res = await profilesApiClient.get('/config', { params: { section: 'server' } });
const payload = res.data;
const data = payload.server ? payload.server : payload;
Object.keys(data).forEach(key => {
if (form.hasOwnProperty(key)) {
if (key === 'tunnel_type') {
isSplitTunnel.value = data[key] === 'SPLIT';
}
form[key] = data[key];
}
});
if (data.vpn_network && data.vpn_netmask) {
const cidrSuffix = maskToCidr(data.vpn_netmask);
if (cidrSuffix) {
form.vpn_network = `${data.vpn_network}/${cidrSuffix}`;
}
}
} catch (err) {
console.error(err);
showMessage('Failed to load Server config from Management API.', 'error');
}
};
const handleSave = async () => {
isLoading.value = true;
action.value = 'save';
const payload = { ...form };
payload.split_routes = payload.split_routes.filter(r => r && r.trim() !== '');
payload.dns_servers = payload.dns_servers.filter(d => d && d.trim() !== '');
if (payload.vpn_network.includes('/')) {
const [ip, suffix] = payload.vpn_network.split('/');
payload.vpn_network = ip;
}
try {
await profilesApiClient.put('/config/server', payload);
showMessage('Server configuration saved!', 'success');
} catch (err) {
showMessage(`Error saving: ${err.message}`, 'error');
} finally {
isLoading.value = false;
action.value = '';
}
};
const handleApply = async () => {
isLoading.value = true;
action.value = 'apply';
try {
const res = await profilesApiClient.post('/server/configure');
const data = res.data;
showMessage(data.message || 'Configuration applied!', 'success');
} catch (err) {
showMessage(`Error applying: ${err.message}`, 'error');
} finally {
isLoading.value = false;
action.value = '';
}
};
onMounted(() => {
loadConfig();
});
</script>

17
APP_UI/vite.config.js Normal file
View File

@@ -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']
}
}
}
}
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -204,4 +204,33 @@ SSL Certificate expiration tracking.
Simple list of clients (Common Name + Status) for UI dropdowns. Simple list of clients (Common Name + Status) for UI dropdowns.
### `GET /health` ### `GET /health`
Database connectivity check. Returns `{"status": "healthy"}`. 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
}
]
}
```

View File

@@ -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://<SERVER_IP>:5001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "ваш_пароль"}'
```
В ответе придет JSON с полем `"token"`.
#### Вариант Б: 2FA включена (двухэтапный вход)
1. Получите временный токен:
```bash
curl -X POST http://<SERVER_IP>:5001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "ваш_пароль"}'
```
Скопируйте `temp_token` из ответа.
2. Подтвердите вход кодом OTP:
```bash
curl -X POST http://<SERVER_IP>: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://<SERVER_IP>:5001/api/v1/stats
```
**Пример: Просмотр сертификатов**
```bash
curl -H "Authorization: Bearer ВАШ_ТОКЕН" \
http://<SERVER_IP>: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).

View File

@@ -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). A cleanup job runs once every 24 hours (on day change).
- It executes `DELETE FROM table WHERE timestamp < cutoff_date`. - 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 ## 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. 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.

110
DOCS/General/Deployment.md Normal file
View File

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

22
DOCS/General/Index.md Normal file
View File

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

View File

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

View File

@@ -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 <token>`.
- **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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
# OpenVPN Profiler API Reference
This module (`APP_PROFILER`) is built with **FastAPI** and provides management capabilities.
**Base URL**: `http://<your-server>:8000/api`
## Authentication
All endpoints (except initial setup) require a Bearer Token.
**Header**: `Authorization: Bearer <JWT_TOKEN>`
*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 }`

View File

@@ -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://<your-server>:8000/docs`
* **ReDoc**: `http://<your-server>: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
```

35
DOCS/UI/Architecture.md Normal file
View File

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

275
README.md
View File

@@ -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 ## <EFBFBD> Project Architecture
### Prerequisites The project is modularized into three core components:
* **Backend**: Python 3.9+ (`pip`, `venv`)
* **Frontend**: Node.js 18+ (for building only), any Web Server (Nginx/Apache) for production.
### 1. Backend Setup | Component | Directory | Description |
Run the API and Data Gatherer. | :--- | :--- | :--- |
| **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 ```bash
# Ubuntu/Debian cd APP_CORE
sudo apt update && sudo apt install python3-venv python3-pip
# Alpine
apk add python3 py3-pip
# Setup
cd /path/to/app/APP
python3 -m venv venv python3 -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
python3 openvpn_api_v3.py
# Run (Manual testing) # Runs on :5001 (Monitoring)
python3 openvpn_api_v3.py &
python3 openvpn_gatherer_v3.py &
``` ```
### 2. Frontend Setup ### 2. Profiler API (FastAPI)
Build the SPA and deploy to your web server.
```bash ```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 install
npm run build npm run dev
# Runs on localhost:5173
# Deploy (Example)
sudo cp -r dist/* /var/www/html/
``` ```
--- ---
## 🛠 Service Configuration ## ⚠️ Important Notes
### Debian / Ubuntu (Systemd) 1. **Environment**: Production deployment relies on Nginx to proxy requests to the backend services. See the [Deployment Guide](DOCS/General/Deployment.md).
Create service files in `/etc/systemd/system/`. 2. **Permissions**: The backend requires `sudo` or root privileges to manage OpenVPN processes and write to `/etc/openvpn`.
**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
<VirtualHost *:80>
DocumentRoot "/var/www/html"
<Directory "/var/www/html">
Options Indexes FollowSymLinks
AllowOverride All # CRITICAL for .htaccess
Require all granted
</Directory>
# Proxy API requests (Optional, if not exposing 5001 directly)
<Location "/api/">
ProxyPreserveHost On
ProxyPass "http://127.0.0.1:5001/api/"
ProxyPassReverse "http://127.0.0.1:5001/api/"
</Location>
</VirtualHost>
```
---
## 🧹 Database Management
### Resetting Statistics
To completely reset all traffic statistics and start fresh:
1. **Stop Services**:
```bash
# Systemd
sudo systemctl stop ovpmon-gatherer ovpmon-api
# OpenRC (Alpine)
rc-service ovpmon-gatherer stop
rc-service ovpmon-api stop
```
2. **Remove Database**:
Navigate to the application directory (e.g., `/opt/ovpmon/APP`) and delete or rename the database file:
```bash
rm openvpn_monitor.db
```
3. **Restart Services**:
The system will automatically recreate the database with a fresh schema.
```bash
# Systemd
sudo systemctl start ovpmon-gatherer ovpmon-api
# OpenRC (Alpine)
rc-service ovpmon-gatherer start
rc-service ovpmon-api start
```
### Advanced: Reset Stats (Keep Client List)
To reset counters but keep the known list of clients, run this SQL command:
```bash
sqlite3 openvpn_monitor.db "
DELETE FROM usage_history;
DELETE FROM stats_5min;
DELETE FROM stats_15min;
DELETE FROM stats_hourly;
DELETE FROM stats_6h;
DELETE FROM stats_daily;
DELETE FROM active_sessions;
UPDATE clients SET
total_bytes_received = 0,
total_bytes_sent = 0,
last_bytes_received = 0,
last_bytes_sent = 0,
status = 'Disconnected';
VACUUM;"
```
### Remove a Specific User
To completely remove a user (e.g., `UNDEF`) and their history:
```bash
sqlite3 openvpn_monitor.db "
DELETE FROM usage_history WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF');
DELETE FROM stats_5min WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF');
DELETE FROM stats_15min WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF');
DELETE FROM stats_hourly WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF');
DELETE FROM stats_6h WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF');
DELETE FROM stats_daily WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF');
DELETE FROM active_sessions WHERE client_id IN (SELECT id FROM clients WHERE common_name = 'UNDEF');
DELETE FROM clients WHERE common_name = 'UNDEF';
VACUUM;"
```
---
## 📚 API Reference
**Base URL:** `http://<server-ip>:5001/api/v1`
| 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/<common_name>` | 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*

Some files were not shown because too many files have changed in this diff Show More