new awesome build
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
Flask==3.0.0
|
|
||||||
Flask-Cors==4.0.0
|
|
||||||
@@ -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'
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
38
APP_CORE/README.md
Normal 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`.
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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')
|
||||||
7
APP_CORE/requirements.txt
Normal file
7
APP_CORE/requirements.txt
Normal 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
26
APP_PROFILER/README.md
Normal 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
|
||||||
|
```
|
||||||
|
|
||||||
51
APP_PROFILER/add_columns.py
Normal file
51
APP_PROFILER/add_columns.py
Normal 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
19
APP_PROFILER/database.py
Normal 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
45
APP_PROFILER/main.py
Normal 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
63
APP_PROFILER/models.py
Normal 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)
|
||||||
0
APP_PROFILER/routers/__init__.py
Normal file
0
APP_PROFILER/routers/__init__.py
Normal file
137
APP_PROFILER/routers/profiles.py
Normal file
137
APP_PROFILER/routers/profiles.py
Normal 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))
|
||||||
25
APP_PROFILER/routers/server.py
Normal file
25
APP_PROFILER/routers/server.py
Normal 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))
|
||||||
44
APP_PROFILER/routers/server_process.py
Normal file
44
APP_PROFILER/routers/server_process.py
Normal 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()
|
||||||
50
APP_PROFILER/routers/system.py
Normal file
50
APP_PROFILER/routers/system.py
Normal 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
86
APP_PROFILER/schemas.py
Normal 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
|
||||||
35
APP_PROFILER/scripts/add_mtu_mss_columns.py
Normal file
35
APP_PROFILER/scripts/add_mtu_mss_columns.py
Normal 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()
|
||||||
28
APP_PROFILER/scripts/add_public_ip_column.py
Normal file
28
APP_PROFILER/scripts/add_public_ip_column.py
Normal 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()
|
||||||
179
APP_PROFILER/scripts/migrate_from_bash.py
Normal file
179
APP_PROFILER/scripts/migrate_from_bash.py
Normal 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()
|
||||||
0
APP_PROFILER/services/__init__.py
Normal file
0
APP_PROFILER/services/__init__.py
Normal file
37
APP_PROFILER/services/config.py
Normal file
37
APP_PROFILER/services/config.py
Normal 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
|
||||||
108
APP_PROFILER/services/generator.py
Normal file
108
APP_PROFILER/services/generator.py
Normal 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
|
||||||
181
APP_PROFILER/services/pki.py
Normal file
181
APP_PROFILER/services/pki.py
Normal 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
|
||||||
140
APP_PROFILER/services/process.py
Normal file
140
APP_PROFILER/services/process.py
Normal 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)
|
||||||
29
APP_PROFILER/services/utils.py
Normal file
29
APP_PROFILER/services/utils.py
Normal 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"
|
||||||
44
APP_PROFILER/templates/client.ovpn.j2
Normal file
44
APP_PROFILER/templates/client.ovpn.j2
Normal 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>
|
||||||
110
APP_PROFILER/templates/server.conf.j2
Normal file
110
APP_PROFILER/templates/server.conf.j2
Normal 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 %}
|
||||||
33
APP_PROFILER/test_server_process.py
Normal file
33
APP_PROFILER/test_server_process.py
Normal 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}")
|
||||||
0
APP_PROFILER/utils/__init__.py
Normal file
0
APP_PROFILER/utils/__init__.py
Normal file
94
APP_PROFILER/utils/auth.py
Normal file
94
APP_PROFILER/utils/auth.py
Normal 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"},
|
||||||
|
)
|
||||||
12
APP_PROFILER/utils/logging.py
Normal file
12
APP_PROFILER/utils/logging.py
Normal 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
25
APP_UI/README.md
Normal 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.
|
||||||
|
|
||||||
@@ -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
29
APP_UI/jsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
5
APP_UI/public/config.json
Normal file
5
APP_UI/public/config.json
Normal 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
4
APP_UI/public/logo.svg
Normal 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 |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
179
APP_UI/src/App.vue
Normal file
179
APP_UI/src/App.vue
Normal 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>
|
||||||
4
APP_UI/src/assets/logo.svg
Normal file
4
APP_UI/src/assets/logo.svg
Normal 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 |
4
APP_UI/src/assets/logo_dark.svg
Normal file
4
APP_UI/src/assets/logo_dark.svg
Normal 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
1819
APP_UI/src/assets/main.css
Normal file
File diff suppressed because it is too large
Load Diff
76
APP_UI/src/components/BaseModal.vue
Normal file
76
APP_UI/src/components/BaseModal.vue
Normal 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>
|
||||||
|
|
||||||
49
APP_UI/src/components/ConfirmModal.vue
Normal file
49
APP_UI/src/components/ConfirmModal.vue
Normal 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>
|
||||||
@@ -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,
|
||||||
52
APP_UI/src/components/NewClientModal.vue
Normal file
52
APP_UI/src/components/NewClientModal.vue
Normal 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>
|
||||||
|
|
||||||
73
APP_UI/src/composables/useApi.js
Normal file
73
APP_UI/src/composables/useApi.js
Normal 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
32
APP_UI/src/main.js
Normal 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');
|
||||||
67
APP_UI/src/router/index.js
Normal file
67
APP_UI/src/router/index.js
Normal 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;
|
||||||
345
APP_UI/src/views/Account.vue
Normal file
345
APP_UI/src/views/Account.vue
Normal 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>
|
||||||
|
|
||||||
@@ -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
|
||||||
331
APP_UI/src/views/Certificates.vue
Normal file
331
APP_UI/src/views/Certificates.vue
Normal 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>
|
||||||
|
|
||||||
@@ -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
161
APP_UI/src/views/Login.vue
Normal 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">© 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>
|
||||||
|
|
||||||
251
APP_UI/src/views/Login.vue.bak
Normal file
251
APP_UI/src/views/Login.vue.bak
Normal 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">© 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>
|
||||||
292
APP_UI/src/views/PKIConfig.vue
Normal file
292
APP_UI/src/views/PKIConfig.vue
Normal 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>
|
||||||
169
APP_UI/src/views/ServerManagement.vue
Normal file
169
APP_UI/src/views/ServerManagement.vue
Normal 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>
|
||||||
|
|
||||||
396
APP_UI/src/views/VPNConfig.vue
Normal file
396
APP_UI/src/views/VPNConfig.vue
Normal 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
17
APP_UI/vite.config.js
Normal 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']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
30
DEV/task.md
30
DEV/task.md
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
114
DOCS/Core_Monitoring/Authentication.md
Normal file
114
DOCS/Core_Monitoring/Authentication.md
Normal 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).
|
||||||
|
|
||||||
@@ -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
110
DOCS/General/Deployment.md
Normal 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
22
DOCS/General/Index.md
Normal 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.
|
||||||
124
DOCS/General/Nginx_Configuration.md
Normal file
124
DOCS/General/Nginx_Configuration.md
Normal 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
|
||||||
|
```
|
||||||
85
DOCS/General/Security_Architecture.md
Normal file
85
DOCS/General/Security_Architecture.md
Normal 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.
|
||||||
93
DOCS/General/Service_Management.md
Normal file
93
DOCS/General/Service_Management.md
Normal 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
|
||||||
|
```
|
||||||
16
DOCS/General/openrc/ovpmon-profiler
Normal file
16
DOCS/General/openrc/ovpmon-profiler
Normal 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
|
||||||
|
}
|
||||||
14
DOCS/General/systemd/ovpmon-api.service
Normal file
14
DOCS/General/systemd/ovpmon-api.service
Normal 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
|
||||||
14
DOCS/General/systemd/ovpmon-gatherer.service
Normal file
14
DOCS/General/systemd/ovpmon-gatherer.service
Normal 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
|
||||||
15
DOCS/General/systemd/ovpmon-profiler.service
Normal file
15
DOCS/General/systemd/ovpmon-profiler.service
Normal 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
|
||||||
77
DOCS/Profiler_Management/API_Reference.md
Normal file
77
DOCS/Profiler_Management/API_Reference.md
Normal 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 }`
|
||||||
49
DOCS/Profiler_Management/Overview.md
Normal file
49
DOCS/Profiler_Management/Overview.md
Normal 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
35
DOCS/UI/Architecture.md
Normal 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
275
README.md
@@ -1,249 +1,62 @@
|
|||||||
# OpenVPN Monitor UI & API
|
# OpenVPN Monitor & Profiler
|
||||||
|
|
||||||
A modern, reactive dashboard for monitoring OpenVPN server status, traffic history, and certificate validity. Built with Vue.js 3 and Python (Flask).
|
A modern, full-stack management solution for OpenVPN servers. It combines real-time traffic monitoring, historical analytics, and comprehensive user profile/PKI management into a unified web interface.
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## <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
Reference in New Issue
Block a user