From fcb8f6bac75b37a5069fdad54a2b758f731e9cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD?= Date: Wed, 28 Jan 2026 22:37:47 +0300 Subject: [PATCH] new awesome build --- APP/__pycache__/db.cpython-314.pyc | Bin 5208 -> 0 bytes .../openvpn_api_v3.cpython-314.pyc | Bin 32241 -> 0 bytes .../openvpn_gatherer_v3.cpython-314.pyc | Bin 23365 -> 0 bytes APP/config_manager.py | 155 -- APP/pki_manager.py | 149 -- APP/requirements.txt | 2 - APP/service_manager.py | 70 - APP/templates/client.ovpn.j2 | 40 - APP/templates/server.conf.j2 | 73 - APP_CORE/README.md | 38 + {APP => APP_CORE}/config.ini | 1 + {APP => APP_CORE}/db.py | 39 +- {APP => APP_CORE}/openvpn_api_v3.py | 679 +++--- {APP => APP_CORE}/openvpn_gatherer_v3.py | 0 APP_CORE/requirements.txt | 7 + APP_PROFILER/README.md | 26 + APP_PROFILER/add_columns.py | 51 + APP_PROFILER/database.py | 19 + APP_PROFILER/main.py | 45 + APP_PROFILER/models.py | 63 + APP_PROFILER/routers/__init__.py | 0 APP_PROFILER/routers/profiles.py | 137 ++ APP_PROFILER/routers/server.py | 25 + APP_PROFILER/routers/server_process.py | 44 + APP_PROFILER/routers/system.py | 50 + APP_PROFILER/schemas.py | 86 + APP_PROFILER/scripts/add_mtu_mss_columns.py | 35 + APP_PROFILER/scripts/add_public_ip_column.py | 28 + APP_PROFILER/scripts/migrate_from_bash.py | 179 ++ APP_PROFILER/services/__init__.py | 0 APP_PROFILER/services/config.py | 37 + APP_PROFILER/services/generator.py | 108 + APP_PROFILER/services/pki.py | 181 ++ APP_PROFILER/services/process.py | 140 ++ APP_PROFILER/services/utils.py | 29 + APP_PROFILER/templates/client.ovpn.j2 | 44 + APP_PROFILER/templates/server.conf.j2 | 110 + APP_PROFILER/test_server_process.py | 33 + APP_PROFILER/utils/__init__.py | 0 APP_PROFILER/utils/auth.py | 94 + APP_PROFILER/utils/logging.py | 12 + {UI/client => APP_UI}/.gitignore | 0 {UI/client => APP_UI}/.vscode/extensions.json | 0 APP_UI/README.md | 25 + {UI/client => APP_UI}/index.html | 2 +- APP_UI/jsconfig.json | 29 + {UI/client => APP_UI}/package-lock.json | 10 + {UI/client => APP_UI}/package.json | 1 + {UI/client => APP_UI}/public/.htaccess | 0 APP_UI/public/config.json | 5 + APP_UI/public/logo.svg | 4 + {UI/client => APP_UI}/public/vite.svg | 0 APP_UI/src/App.vue | 179 ++ APP_UI/src/assets/logo.svg | 4 + APP_UI/src/assets/logo_dark.svg | 4 + APP_UI/src/assets/main.css | 1819 +++++++++++++++++ APP_UI/src/components/BaseModal.vue | 76 + APP_UI/src/components/ConfirmModal.vue | 49 + .../src/components/HistoryModal.vue | 22 +- APP_UI/src/components/NewClientModal.vue | 52 + APP_UI/src/composables/useApi.js | 73 + .../src/composables/useAppConfig.js | 0 .../src/composables/useFormatters.js | 0 APP_UI/src/main.js | 32 + APP_UI/src/router/index.js | 67 + APP_UI/src/views/Account.vue | 345 ++++ {UI/client => APP_UI}/src/views/Analytics.vue | 33 +- APP_UI/src/views/Certificates.vue | 331 +++ {UI/client => APP_UI}/src/views/Clients.vue | 18 +- APP_UI/src/views/Login.vue | 161 ++ APP_UI/src/views/Login.vue.bak | 251 +++ APP_UI/src/views/PKIConfig.vue | 292 +++ APP_UI/src/views/ServerManagement.vue | 169 ++ APP_UI/src/views/VPNConfig.vue | 396 ++++ APP_UI/vite.config.js | 17 + DEV/task.md | 30 - DEV/task.md.resolved | 30 - DEV/task.md.resolved.10 | 30 - DEV/walkthrough.md | 68 - DEV/walkthrough.md.resolved | 68 - DEV/walkthrough.md.resolved.1 | 68 - .../API_Reference.md} | 31 +- DOCS/Core_Monitoring/Authentication.md | 114 ++ .../Data_Architecture.md} | 2 +- DOCS/General/Deployment.md | 110 + DOCS/General/Index.md | 22 + DOCS/General/Nginx_Configuration.md | 124 ++ DOCS/General/Security_Architecture.md | 85 + DOCS/General/Service_Management.md | 93 + .../APP => DOCS/General}/openrc/INSTALL.md | 0 .../APP => DOCS/General}/openrc/ovpmon-api | 0 .../General}/openrc/ovpmon-gatherer | 0 DOCS/General/openrc/ovpmon-profiler | 16 + DOCS/General/systemd/ovpmon-api.service | 14 + DOCS/General/systemd/ovpmon-gatherer.service | 14 + DOCS/General/systemd/ovpmon-profiler.service | 15 + DOCS/Profiler_Management/API_Reference.md | 77 + DOCS/Profiler_Management/Overview.md | 49 + DOCS/UI/Architecture.md | 35 + README.md | 275 +-- UI/artifacts/certificates.php | 142 -- UI/artifacts/config.php | 30 - UI/artifacts/css/style.css | 637 ------ UI/artifacts/dashboard.php | 184 -- UI/artifacts/index.php | 198 -- UI/artifacts/js/pages/certificates.js | 228 --- UI/artifacts/js/pages/dashboard.js | 269 --- UI/artifacts/js/pages/index.js | 365 ---- UI/artifacts/js/utils.js | 94 - UI/client/README.md | 5 - UI/client/public/config.json | 6 - UI/client/src/App.vue | 112 - UI/client/src/assets/main.css | 799 -------- UI/client/src/composables/useApi.js | 79 - UI/client/src/main.js | 15 - UI/client/src/router/index.js | 32 - UI/client/src/views/Certificates.vue | 396 ---- UI/client/src/views/Settings.vue | 603 ------ UI/client/vite.config.js | 7 - 119 files changed, 7291 insertions(+), 5575 deletions(-) delete mode 100644 APP/__pycache__/db.cpython-314.pyc delete mode 100644 APP/__pycache__/openvpn_api_v3.cpython-314.pyc delete mode 100644 APP/__pycache__/openvpn_gatherer_v3.cpython-314.pyc delete mode 100644 APP/config_manager.py delete mode 100644 APP/pki_manager.py delete mode 100644 APP/requirements.txt delete mode 100644 APP/service_manager.py delete mode 100644 APP/templates/client.ovpn.j2 delete mode 100644 APP/templates/server.conf.j2 create mode 100644 APP_CORE/README.md rename {APP => APP_CORE}/config.ini (94%) rename {APP => APP_CORE}/db.py (70%) rename {APP => APP_CORE}/openvpn_api_v3.py (72%) rename {APP => APP_CORE}/openvpn_gatherer_v3.py (100%) create mode 100644 APP_CORE/requirements.txt create mode 100644 APP_PROFILER/README.md create mode 100644 APP_PROFILER/add_columns.py create mode 100644 APP_PROFILER/database.py create mode 100644 APP_PROFILER/main.py create mode 100644 APP_PROFILER/models.py create mode 100644 APP_PROFILER/routers/__init__.py create mode 100644 APP_PROFILER/routers/profiles.py create mode 100644 APP_PROFILER/routers/server.py create mode 100644 APP_PROFILER/routers/server_process.py create mode 100644 APP_PROFILER/routers/system.py create mode 100644 APP_PROFILER/schemas.py create mode 100644 APP_PROFILER/scripts/add_mtu_mss_columns.py create mode 100644 APP_PROFILER/scripts/add_public_ip_column.py create mode 100644 APP_PROFILER/scripts/migrate_from_bash.py create mode 100644 APP_PROFILER/services/__init__.py create mode 100644 APP_PROFILER/services/config.py create mode 100644 APP_PROFILER/services/generator.py create mode 100644 APP_PROFILER/services/pki.py create mode 100644 APP_PROFILER/services/process.py create mode 100644 APP_PROFILER/services/utils.py create mode 100644 APP_PROFILER/templates/client.ovpn.j2 create mode 100644 APP_PROFILER/templates/server.conf.j2 create mode 100644 APP_PROFILER/test_server_process.py create mode 100644 APP_PROFILER/utils/__init__.py create mode 100644 APP_PROFILER/utils/auth.py create mode 100644 APP_PROFILER/utils/logging.py rename {UI/client => APP_UI}/.gitignore (100%) rename {UI/client => APP_UI}/.vscode/extensions.json (100%) create mode 100644 APP_UI/README.md rename {UI/client => APP_UI}/index.html (89%) create mode 100644 APP_UI/jsconfig.json rename {UI/client => APP_UI}/package-lock.json (99%) rename {UI/client => APP_UI}/package.json (94%) rename {UI/client => APP_UI}/public/.htaccess (100%) create mode 100644 APP_UI/public/config.json create mode 100644 APP_UI/public/logo.svg rename {UI/client => APP_UI}/public/vite.svg (100%) create mode 100644 APP_UI/src/App.vue create mode 100644 APP_UI/src/assets/logo.svg create mode 100644 APP_UI/src/assets/logo_dark.svg create mode 100644 APP_UI/src/assets/main.css create mode 100644 APP_UI/src/components/BaseModal.vue create mode 100644 APP_UI/src/components/ConfirmModal.vue rename {UI/client => APP_UI}/src/components/HistoryModal.vue (90%) create mode 100644 APP_UI/src/components/NewClientModal.vue create mode 100644 APP_UI/src/composables/useApi.js rename {UI/client => APP_UI}/src/composables/useAppConfig.js (100%) rename {UI/client => APP_UI}/src/composables/useFormatters.js (100%) create mode 100644 APP_UI/src/main.js create mode 100644 APP_UI/src/router/index.js create mode 100644 APP_UI/src/views/Account.vue rename {UI/client => APP_UI}/src/views/Analytics.vue (92%) create mode 100644 APP_UI/src/views/Certificates.vue rename {UI/client => APP_UI}/src/views/Clients.vue (94%) create mode 100644 APP_UI/src/views/Login.vue create mode 100644 APP_UI/src/views/Login.vue.bak create mode 100644 APP_UI/src/views/PKIConfig.vue create mode 100644 APP_UI/src/views/ServerManagement.vue create mode 100644 APP_UI/src/views/VPNConfig.vue create mode 100644 APP_UI/vite.config.js delete mode 100644 DEV/task.md delete mode 100644 DEV/task.md.resolved delete mode 100644 DEV/task.md.resolved.10 delete mode 100644 DEV/walkthrough.md delete mode 100644 DEV/walkthrough.md.resolved delete mode 100644 DEV/walkthrough.md.resolved.1 rename DOCS/{api_v3_endpoints.md => Core_Monitoring/API_Reference.md} (89%) create mode 100644 DOCS/Core_Monitoring/Authentication.md rename DOCS/{data_gathering_report.md => Core_Monitoring/Data_Architecture.md} (97%) create mode 100644 DOCS/General/Deployment.md create mode 100644 DOCS/General/Index.md create mode 100644 DOCS/General/Nginx_Configuration.md create mode 100644 DOCS/General/Security_Architecture.md create mode 100644 DOCS/General/Service_Management.md rename {Deployment/APP => DOCS/General}/openrc/INSTALL.md (100%) rename {Deployment/APP => DOCS/General}/openrc/ovpmon-api (100%) rename {Deployment/APP => DOCS/General}/openrc/ovpmon-gatherer (100%) create mode 100644 DOCS/General/openrc/ovpmon-profiler create mode 100644 DOCS/General/systemd/ovpmon-api.service create mode 100644 DOCS/General/systemd/ovpmon-gatherer.service create mode 100644 DOCS/General/systemd/ovpmon-profiler.service create mode 100644 DOCS/Profiler_Management/API_Reference.md create mode 100644 DOCS/Profiler_Management/Overview.md create mode 100644 DOCS/UI/Architecture.md delete mode 100644 UI/artifacts/certificates.php delete mode 100644 UI/artifacts/config.php delete mode 100644 UI/artifacts/css/style.css delete mode 100644 UI/artifacts/dashboard.php delete mode 100644 UI/artifacts/index.php delete mode 100644 UI/artifacts/js/pages/certificates.js delete mode 100644 UI/artifacts/js/pages/dashboard.js delete mode 100644 UI/artifacts/js/pages/index.js delete mode 100644 UI/artifacts/js/utils.js delete mode 100644 UI/client/README.md delete mode 100644 UI/client/public/config.json delete mode 100644 UI/client/src/App.vue delete mode 100644 UI/client/src/assets/main.css delete mode 100644 UI/client/src/composables/useApi.js delete mode 100644 UI/client/src/main.js delete mode 100644 UI/client/src/router/index.js delete mode 100644 UI/client/src/views/Certificates.vue delete mode 100644 UI/client/src/views/Settings.vue delete mode 100644 UI/client/vite.config.js diff --git a/APP/__pycache__/db.cpython-314.pyc b/APP/__pycache__/db.cpython-314.pyc deleted file mode 100644 index 57ab2601d3ccf5d57bcfbea23d9193de7bae959b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5208 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 DuG`RV diff --git a/APP/__pycache__/openvpn_gatherer_v3.cpython-314.pyc b/APP/__pycache__/openvpn_gatherer_v3.cpython-314.pyc deleted file mode 100644 index 255eb9b5223b4b374253f36a46e09c1b51c26335..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23365 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 diff --git a/APP/config_manager.py b/APP/config_manager.py deleted file mode 100644 index a45f063..0000000 --- a/APP/config_manager.py +++ /dev/null @@ -1,155 +0,0 @@ -import os -import re -from pathlib import Path -from jinja2 import Environment, FileSystemLoader - -class ConfigManager: - def __init__(self, template_dir, output_dir): - self.template_dir = template_dir - self.output_dir = output_dir - self.env = Environment(loader=FileSystemLoader(template_dir)) - self.server_conf_path = Path(output_dir) / "server.conf" - - def read_server_config(self): - """Parse existing server config into a dictionary""" - if not self.server_conf_path.exists(): - return {} - - config = {} - try: - with open(self.server_conf_path, 'r') as f: - content = f.read() - - # Regex mappings for simple key-value pairs - mappings = { - 'port': r'^port\s+(\d+)', - 'proto': r'^proto\s+(\w+)', - 'dev': r'^dev\s+(\w+)', - 'server_network': r'^server\s+([\d\.]+)', - 'server_netmask': r'^server\s+[\d\.]+\s+([\d\.]+)', - 'topology': r'^topology\s+(\w+)', - 'cipher': r'^cipher\s+([\w\-]+)', - 'data_ciphers': r'^data-ciphers\s+([\w\-:]+)', - 'data_ciphers_fallback': r'^data-ciphers-fallback\s+([\w\-]+)', - 'status_log': r'^status\s+(.+)', - 'log_file': r'^log-append\s+(.+)', - 'ipp_path': r'^ifconfig-pool-persist\s+(.+)', - 'auth_algo': r'^auth\s+(\w+)', - 'tun_mtu': r'^tun-mtu\s+(\d+)', - 'mssfix': r'^mssfix\s+(\d+)' - } - - for key, pattern in mappings.items(): - match = re.search(pattern, content, re.MULTILINE) - if match: - config[key] = match.group(1) - - # Boolean flags - config['client_to_client'] = bool(re.search(r'^client-to-client', content, re.MULTILINE)) - # redirect-gateway is usually pushed - config['redirect_gateway'] = bool(re.search(r'push "redirect-gateway', content, re.MULTILINE)) - config['crl_verify'] = bool(re.search(r'^crl-verify', content, re.MULTILINE)) - - # DNS - # push "dhcp-option DNS 8.8.8.8" - dns_matches = re.findall(r'push "dhcp-option DNS ([\d\.]+)"', content) - if dns_matches: - config['dns_servers'] = dns_matches - - # Routes - # push "route 192.168.1.0 255.255.255.0" - route_matches = re.findall(r'push "route ([\d\.]+ [\d\.]+)"', content) - if route_matches: - config['routes'] = route_matches - - return config - except Exception as e: - print(f"Error reading config: {e}") - return {} - - def generate_server_config(self, params): - """Generate server.conf from template""" - # Defaults - defaults = { - 'port': 1194, - 'proto': 'udp', - 'server_network': '10.8.0.0', - 'server_netmask': '255.255.255.0', - 'topology': 'subnet', - 'cipher': 'AES-256-GCM', - 'auth_algo': 'SHA256', - 'data_ciphers': 'AES-256-GCM:AES-128-GCM', - 'data_ciphers_fallback': None, - 'status_log': '/var/log/openvpn/openvpn-status.log', - 'log_file': '/var/log/openvpn/openvpn.log', - 'crl_verify': True, - 'client_to_client': False, - 'redirect_gateway': True, - 'dns_servers': ['8.8.8.8', '8.8.4.4'], - 'routes': [], - 'tun_mtu': None, - 'mssfix': None - } - - # Merge params - ctx = {**defaults, **params} - - try: - template = self.env.get_template('server.conf.j2') - output = template.render(ctx) - - with open(self.server_conf_path, 'w') as f: - f.write(output) - - return True, str(self.server_conf_path) - except Exception as e: - return False, str(e) - - def generate_client_config(self, client_name, pki_path, server_config=None, extra_params=None): - """Generate client .ovpn content - server_config: dict of server security/network settings - extra_params: dict of specific overrides (remote_host, port, proto) - """ - # Checks - pki = Path(pki_path) - ca_path = pki / "ca.crt" - cert_path = pki / "issued" / f"{client_name}.crt" - key_path = pki / "private" / f"{client_name}.key" - ta_path = pki / "ta.key" - - if not (ca_path.exists() and cert_path.exists() and key_path.exists()): - return False, "Certificate files missing" - - try: - # Read contents - ca = ca_path.read_text().strip() - cert = cert_path.read_text().strip() - # Cert file often contains text before -----BEGIN CERTIFICATE----- - if "-----BEGIN CERTIFICATE-----" in cert: - cert = cert[cert.find("-----BEGIN CERTIFICATE-----"):] - - key = key_path.read_text().strip() - ta = ta_path.read_text().strip() if ta_path.exists() else None - - ctx = { - 'client_name': client_name, - 'ca': ca, - 'cert': cert, - 'key': key, - 'tls_auth': ta - } - - # Merge server config if present - if server_config: - ctx.update(server_config) - - # Merge extra params (host, port, proto) - takes precedence - if extra_params: - ctx.update(extra_params) - - template = self.env.get_template('client.ovpn.j2') - output = template.render(ctx) - return True, output - - except Exception as e: - return False, str(e) diff --git a/APP/pki_manager.py b/APP/pki_manager.py deleted file mode 100644 index ad7391e..0000000 --- a/APP/pki_manager.py +++ /dev/null @@ -1,149 +0,0 @@ -import os -import subprocess -from pathlib import Path -import shutil - -class PKIManager: - def __init__(self, easyrsa_path, pki_path): - self.easyrsa_dir = Path(easyrsa_path) - self.pki_path = Path(pki_path) - self.easyrsa_bin = self.easyrsa_dir / 'easyrsa' - - # Ensure easyrsa script is executable - if self.easyrsa_bin.exists(): - os.chmod(self.easyrsa_bin, 0o755) - - def run_easyrsa(self, args): - """Run easyrsa command""" - cmd = [str(self.easyrsa_bin)] + args - env = os.environ.copy() - # Ensure we point to the correct PKI dir if flexible - # But EasyRSA usually expects to be run inside the dir or have env var? - # Standard: run in easyrsa_dir, but PKI might be elsewhere. - # usually invoke like: easyrsa --pki-dir=/path/to/pki cmd - - # We'll use the --pki-dir arg if supported or just chdir if needed. - # EasyRSA 3 supports --pki-dir key. - - final_cmd = [str(self.easyrsa_bin), f'--pki-dir={self.pki_path}'] + args - - try: - # We run from easyrsa dir so it finds openssl-easyrsa.cnf etc if needed - result = subprocess.run( - final_cmd, - cwd=self.easyrsa_dir, - capture_output=True, - text=True, - check=True - ) - return True, result.stdout - except subprocess.CalledProcessError as e: - return False, e.stderr + "\n" + e.stdout - - def validate_pki_path(self, path_str): - """Check if a path contains a valid initialized PKI or EasyRSA structure""" - path = Path(path_str) - if not path.exists(): - return False, "Path does not exist" - - # Check for essential items: pki dir or easyrsa script inside - # Or if it IS the pki dir (contains ca.crt, issued, private) - - is_pki_root = (path / "ca.crt").exists() and (path / "private").exists() - has_pki_subdir = (path / "pki" / "ca.crt").exists() - - if is_pki_root or has_pki_subdir: - return True, "Valid PKI structure found" - return False, "No PKI structure found (missing ca.crt or private key dir)" - - def init_pki(self, force=False): - """Initialize PKI""" - if force and self.pki_path.exists(): - shutil.rmtree(self.pki_path) - - if not self.pki_path.exists(): - return self.run_easyrsa(['init-pki']) - - if (self.pki_path / "private").exists(): - return True, "PKI already initialized" - - return self.run_easyrsa(['init-pki']) - - def update_vars(self, vars_dict): - """Update vars file with provided dictionary""" - vars_path = self.easyrsa_dir / 'vars' - - # Ensure vars file is created in the EasyRSA directory that we run commands from - # Note: If we use --pki-dir, easyrsa might look for vars in the pki dir or the basedir. - # Usually it looks in the directory we invoke it from (cwd). - - # Base content - content = [ - "# Easy-RSA 3 vars file", - "set_var EASYRSA_DN \"org\"", - "set_var EASYRSA_BATCH \"1\"" - ] - - # Map of keys to allow - allowed_keys = [ - 'EASYRSA_REQ_COUNTRY', 'EASYRSA_REQ_PROVINCE', 'EASYRSA_REQ_CITY', - 'EASYRSA_REQ_ORG', 'EASYRSA_REQ_EMAIL', 'EASYRSA_REQ_OU', - 'EASYRSA_KEY_SIZE', 'EASYRSA_CA_EXPIRE', 'EASYRSA_CERT_EXPIRE', - 'EASYRSA_CRL_DAYS', 'EASYRSA_REQ_CN' - ] - - for key, val in vars_dict.items(): - if key in allowed_keys and val: - content.append(f"set_var {key} \"{val}\"") - - try: - with open(vars_path, 'w') as f: - f.write('\n'.join(content)) - return True - except Exception as e: - return False - - def build_ca(self, cn="OpenVPN-CA"): - """Build CA""" - # EasyRSA 3 uses 'build-ca nopass' and takes CN from vars or interactive. - # With batch mode, we rely on vars. But CN is special. - # We can pass --req-cn=NAME (if supported) or rely on vars having EASYRSA_REQ_CN? - # Actually in batch mode `build-ca nopass` uses the common name from vars/env. - - # If we updated vars with EASYRSA_REQ_CN, then just run it. - # But to be safe, we can try to set it via env var too. - # args: build-ca nopass - return self.run_easyrsa(['build-ca', 'nopass']) - - def build_server(self, name="server"): - """Build Server Cert""" - return self.run_easyrsa(['build-server-full', name, 'nopass']) - - def build_client(self, name): - """Build Client Cert""" - return self.run_easyrsa(['build-client-full', name, 'nopass']) - - def gen_dh(self): - """Generate Diffie-Hellman""" - return self.run_easyrsa(['gen-dh']) - - def gen_crl(self): - """Generate CRL""" - return self.run_easyrsa(['gen-crl']) - - def revoke_client(self, name): - """Revoke Client""" - # 1. Revoke - succ, out = self.run_easyrsa(['revoke', name]) - if not succ: return False, out - # 2. Update CRL - return self.gen_crl() - - def gen_ta_key(self, path): - """Generate TA Key using openvpn directly""" - try: - # openvpn --genkey --secret path - subprocess.run(['openvpn', '--genkey', '--secret', str(path)], check=True) - return True, "TA key generated" - except Exception as e: - return False, str(e) diff --git a/APP/requirements.txt b/APP/requirements.txt deleted file mode 100644 index 375d26c..0000000 --- a/APP/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Flask==3.0.0 -Flask-Cors==4.0.0 diff --git a/APP/service_manager.py b/APP/service_manager.py deleted file mode 100644 index 1871f59..0000000 --- a/APP/service_manager.py +++ /dev/null @@ -1,70 +0,0 @@ -import subprocess -import logging -import shutil - -logger = logging.getLogger(__name__) - -class ServiceManager: - def __init__(self, service_name='openvpn'): - self.service_name = service_name - self.init_system = self._detect_init_system() - - def _detect_init_system(self): - """Detect if systemd or openrc is used.""" - if shutil.which('systemctl'): - return 'systemd' - elif shutil.which('rc-service'): - return 'openrc' - else: - return 'unknown' - - def _run_cmd(self, cmd): - try: - subprocess.run(cmd, check=True, capture_output=True, text=True) - return True, "Success" - except subprocess.CalledProcessError as e: - return False, e.stderr.strip() - except Exception as e: - return False, str(e) - - def start(self): - if self.init_system == 'systemd': - return self._run_cmd(['sudo', 'systemctl', 'start', self.service_name]) - elif self.init_system == 'openrc': - return self._run_cmd(['sudo', 'rc-service', self.service_name, 'start']) - return False, "Unknown init system" - - def stop(self): - if self.init_system == 'systemd': - return self._run_cmd(['sudo', 'systemctl', 'stop', self.service_name]) - elif self.init_system == 'openrc': - return self._run_cmd(['sudo', 'rc-service', self.service_name, 'stop']) - return False, "Unknown init system" - - def restart(self): - if self.init_system == 'systemd': - return self._run_cmd(['sudo', 'systemctl', 'restart', self.service_name]) - elif self.init_system == 'openrc': - return self._run_cmd(['sudo', 'rc-service', self.service_name, 'restart']) - return False, "Unknown init system" - - def get_status(self): - """Return 'active', 'inactive', or 'error'""" - if self.init_system == 'systemd': - # systemctl is-active returns 0 if active, non-zero otherwise - try: - subprocess.run(['systemctl', 'is-active', self.service_name], check=True, capture_output=True) - return 'active' - except subprocess.CalledProcessError: - return 'inactive' - - elif self.init_system == 'openrc': - try: - res = subprocess.run(['rc-service', self.service_name, 'status'], capture_output=True, text=True) - if 'started' in res.stdout or 'running' in res.stdout: - return 'active' - return 'inactive' - except: - return 'error' - - return 'unknown' diff --git a/APP/templates/client.ovpn.j2 b/APP/templates/client.ovpn.j2 deleted file mode 100644 index 3785b76..0000000 --- a/APP/templates/client.ovpn.j2 +++ /dev/null @@ -1,40 +0,0 @@ -client -dev tun -windows-driver wintun -proto {{ proto }} -remote {{ remote_host }} {{ remote_port }} -resolv-retry infinite -nobind -persist-key -persist-tun -{% if 'tcp' in proto %} -tls-client -{% endif %} -mute-replay-warnings -remote-cert-tls server - -# Encryption Config -cipher {{ cipher | default('AES-256-GCM') }} -{% if data_ciphers %} -data-ciphers {{ data_ciphers }} -{% endif %} -{% if data_ciphers_fallback %} -data-ciphers-fallback {{ data_ciphers_fallback }} -{% endif %} -auth {{ auth_algo | default('SHA256') }} -verb 3 - -# Certificates Config - -{{ ca }} - - -{{ cert }} - - -{{ key }} - -key-direction 1 - -{{ tls_auth }} - \ No newline at end of file diff --git a/APP/templates/server.conf.j2 b/APP/templates/server.conf.j2 deleted file mode 100644 index 75bcbd3..0000000 --- a/APP/templates/server.conf.j2 +++ /dev/null @@ -1,73 +0,0 @@ -port {{ port }} -proto {{ proto }} -dev tun - -ca {{ ca_path }} -cert {{ cert_path }} -key {{ key_path }} -dh {{ dh_path }} -tls-auth {{ ta_path }} 0 - -server {{ server_network }} {{ server_netmask }} - -{% if topology %} -topology {{ topology }} -{% endif %} - -{% if ipp_path %} -ifconfig-pool-persist {{ ipp_path }} -{% endif %} - -{% if routes %} -{% for route in routes %} -push "route {{ route }}" -{% endfor %} -{% endif %} - -{% if redirect_gateway %} -push "redirect-gateway def1 bypass-dhcp" -{% endif %} - -{% if dns_servers %} -{% for dns in dns_servers %} -push "dhcp-option DNS {{ dns }}" -{% endfor %} -{% endif %} - -{% if client_to_client %} -client-to-client -{% endif %} - -keepalive 10 120 - -cipher {{ cipher }} -{% if data_ciphers %} -data-ciphers {{ data_ciphers }} -{% endif %} -{% if data_ciphers_fallback %} -data-ciphers-fallback {{ data_ciphers_fallback }} -{% endif %} - -auth {{ auth_algo }} -user nobody -group nogroup -persist-key -persist-tun - -status {{ status_log }} -log-append {{ log_file }} - -verb 3 -explicit-exit-notify 1 - -{% if crl_verify %} -crl-verify {{ crl_path }} -{% endif %} - -{% if tun_mtu %} -tun-mtu {{ tun_mtu }} -{% endif %} - -{% if mssfix %} -mssfix {{ mssfix }} -{% endif %} diff --git a/APP_CORE/README.md b/APP_CORE/README.md new file mode 100644 index 0000000..47d4d1f --- /dev/null +++ b/APP_CORE/README.md @@ -0,0 +1,38 @@ +# Core Monitoring Module (`APP_CORE`) + +The **Core Monitoring** module provides the backend logic for collecting, extracting, and serving OpenVPN usage statistics. + +## Components + +1. **Mining API (`openvpn_api_v3.py`)**: + - A Flask-based REST API running on port `5001`. + - Serves real-time data, authentication (JWT), and historical statistics. + - Connects to the SQLite database `ovpmon.db`. + +2. **Data Gatherer (`openvpn_gatherer_v3.py`)**: + - A background service/daemon. + - Parses OpenVPN server logs (`status.log`). + - Aggregates bandwidth usage into time-series tables (`usage_history`, `stats_hourly`, etc.). + +## Configuration + +Configuration is handled via `config.ini` (typically located in the project root or `/etc/openvpn/monitor/`). + +## Development + +```bash +# Setup Environment +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Run API +python3 openvpn_api_v3.py + +# Run Gatherer (in separate terminal) +python3 openvpn_gatherer_v3.py +``` + +## API Documentation + +Full API documentation is available in `DOCS/Core_Monitoring/API_Reference.md`. diff --git a/APP/config.ini b/APP_CORE/config.ini similarity index 94% rename from APP/config.ini rename to APP_CORE/config.ini index 74cd778..3e7e5aa 100644 --- a/APP/config.ini +++ b/APP_CORE/config.ini @@ -2,6 +2,7 @@ host = 0.0.0.0 port = 5000 debug = false +secret_key = ovpmon-secret-change-me [openvpn_monitor] log_path = /etc/openvpn/openvpn-status.log diff --git a/APP/db.py b/APP_CORE/db.py similarity index 70% rename from APP/db.py rename to APP_CORE/db.py index de510b1..eebd7e1 100644 --- a/APP/db.py +++ b/APP_CORE/db.py @@ -34,6 +34,15 @@ class DatabaseManager: conn = self.get_connection() cursor = conn.cursor() + def _column_exists(table, column): + cursor.execute(f"PRAGMA table_info({table})") + return any(row[1] == column for row in cursor.fetchall()) + + def _ensure_column(table, column, type_def): + if not _column_exists(table, column): + self.logger.info(f"Adding missing column {column} to table {table}") + cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {type_def}") + try: # 1. Clients Table cursor.execute(''' @@ -81,6 +90,27 @@ class DatabaseManager: ) ''') + # 2.2 Users and Auth + cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + totp_secret TEXT, + is_2fa_enabled INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 2.3 Login Attempts (Brute-force protection) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS login_attempts ( + ip_address TEXT PRIMARY KEY, + attempts INTEGER DEFAULT 0, + last_attempt TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + # 3. Aggregated Stats Tables tables = ['stats_5min', 'stats_15min', 'stats_hourly', 'stats_6h', 'stats_daily'] @@ -97,8 +127,15 @@ class DatabaseManager: ''') cursor.execute(f'CREATE INDEX IF NOT EXISTS idx_{table}_ts ON {table}(timestamp)') + # 4. Migrations (Ensure columns in existing tables) + # If users table existed but was old, ensure it has 2FA columns + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") + if cursor.fetchone(): + _ensure_column('users', 'totp_secret', 'TEXT') + _ensure_column('users', 'is_2fa_enabled', 'INTEGER DEFAULT 0') + conn.commit() - self.logger.info("Database initialized with full schema") + self.logger.info("Database initialized with full schema and migrations") except Exception as e: self.logger.error(f"Database initialization error: {e}") finally: diff --git a/APP/openvpn_api_v3.py b/APP_CORE/openvpn_api_v3.py similarity index 72% rename from APP/openvpn_api_v3.py rename to APP_CORE/openvpn_api_v3.py index df9b015..44019d2 100644 --- a/APP/openvpn_api_v3.py +++ b/APP_CORE/openvpn_api_v3.py @@ -8,12 +8,17 @@ import subprocess import os from pathlib import Path import re +import jwt +import pyotp +import bcrypt +from functools import wraps from db import DatabaseManager -from pki_manager import PKIManager -from config_manager import ConfigManager -from service_manager import ServiceManager + + import io + + # Set up logging logging.basicConfig( level=logging.INFO, @@ -22,11 +27,13 @@ logging.basicConfig( logger = logging.getLogger(__name__) app = Flask(__name__) -CORS(app) # Enable CORS for all routes +# Enable CORS for all routes with specific headers support +CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True) class OpenVPNAPI: def __init__(self, config_file='config.ini'): self.db_manager = DatabaseManager(config_file) + self.db_manager.init_database() self.config = configparser.ConfigParser() self.config.read(config_file) @@ -42,16 +49,86 @@ class OpenVPNAPI: self.cert_extensions = self.config.get('certificates', 'certificate_extensions', fallback='crt,pem,key').split(',') self._cert_cache = {} + # Security + # Priority 1: Environment Variable + # Priority 2: Config file + self.secret_key = os.getenv('OVPMON_SECRET_KEY') or self.config.get('api', 'secret_key', fallback='ovpmon-secret-change-me') + app.config['SECRET_KEY'] = self.secret_key + + # Ensure at least one user exists + self.ensure_default_admin() + # Managers - self.pki = PKIManager(self.easyrsa_path, self.pki_path) - self.conf_mgr = ConfigManager(self.templates_path, self.server_config_dir) - self.conf_mgr.server_conf_path = Path(self.server_config_path) # Override with specific path - self.service = ServiceManager('openvpn') # Or openvpn@server for systemd multi-instance + + + + + def get_db_connection(self): """Get a database connection""" return self.db_manager.get_connection() + def ensure_default_admin(self): + """Create a default admin user if no users exist""" + conn = self.get_db_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT COUNT(*) FROM users") + if cursor.fetchone()[0] == 0: + # Default: admin / password + password_hash = bcrypt.hashpw('password'.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + cursor.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", ('admin', password_hash)) + conn.commit() + logger.info("Default admin user created (admin/password)") + except Exception as e: + logger.error(f"Error ensuring default admin: {e}") + finally: + conn.close() + + def check_rate_limit(self, ip): + """Verify login attempts for an IP (max 5 attempts, 15m lockout)""" + conn = self.get_db_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT attempts, last_attempt FROM login_attempts WHERE ip_address = ?", (ip,)) + row = cursor.fetchone() + if row: + attempts, last_attempt = row + last_dt = datetime.strptime(last_attempt, '%Y-%m-%d %H:%M:%S') + if attempts >= 5 and datetime.utcnow() - last_dt < timedelta(minutes=15): + return False + # If lockout expired, reset + if datetime.utcnow() - last_dt >= timedelta(minutes=15): + cursor.execute("UPDATE login_attempts SET attempts = 0 WHERE ip_address = ?", (ip,)) + conn.commit() + return True + except Exception as e: + logger.error(f"Rate limit error: {e}") + return True # Allow login if rate limiting fails + finally: + conn.close() + + def record_login_attempt(self, ip, success): + """Update login attempts for an IP""" + conn = self.get_db_connection() + cursor = conn.cursor() + try: + if success: + cursor.execute("DELETE FROM login_attempts WHERE ip_address = ?", (ip,)) + else: + cursor.execute(''' + INSERT INTO login_attempts (ip_address, attempts, last_attempt) + VALUES (?, 1, datetime('now')) + ON CONFLICT(ip_address) DO UPDATE SET + attempts = attempts + 1, last_attempt = datetime('now') + ''', (ip,)) + conn.commit() + except Exception as e: + logger.error(f"Error recording login attempt: {e}") + finally: + conn.close() + # --- БЛОК РАБОТЫ С СЕРТИФИКАТАМИ (Оставлен без изменений) --- def parse_openssl_date(self, date_str): try: @@ -591,7 +668,7 @@ class OpenVPNAPI: WHERE t.timestamp >= datetime('now', '-{hours} hours') GROUP BY c.id ORDER BY total_traffic DESC - LIMIT 3 + LIMIT 10 ''' cursor.execute(query_top) top_cols = [col[0] for col in cursor.description] @@ -660,9 +737,271 @@ class OpenVPNAPI: # Initialize API instance api = OpenVPNAPI() -# --- ROUTES --- +# --- SECURITY DECORATORS --- + +def token_required(f): + @wraps(f) + def decorated(*args, **kwargs): + token = None + if 'Authorization' in request.headers: + auth_header = request.headers['Authorization'] + if auth_header.startswith('Bearer '): + token = auth_header.split(' ')[1] + + if not token: + return jsonify({'success': False, 'error': 'Token is missing'}), 401 + + try: + data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + # In a real app, you might want to verify user still exists in DB + except jwt.ExpiredSignatureError: + return jsonify({'success': False, 'error': 'Token has expired'}), 401 + except Exception: + return jsonify({'success': False, 'error': 'Token is invalid'}), 401 + + return f(*args, **kwargs) + return decorated + +# --- AUTH ROUTES --- + + +@app.route('/api/auth/login', methods=['POST']) +def login(): + data = request.get_json() + if not data or not data.get('username') or not data.get('password'): + return jsonify({'success': False, 'error': 'Missing credentials'}), 400 + + ip = request.remote_addr + if not api.check_rate_limit(ip): + return jsonify({'success': False, 'error': 'Too many login attempts. Try again in 15 minutes.'}), 429 + + conn = api.get_db_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT id, password_hash, totp_secret, is_2fa_enabled FROM users WHERE username = ?", (data['username'],)) + user = cursor.fetchone() + + if user and bcrypt.checkpw(data['password'].encode('utf-8'), user[1].encode('utf-8')): + api.record_login_attempt(ip, True) + + # If 2FA enabled, don't issue final token yet + if user[3]: # is_2fa_enabled + # Issue a short-lived temporary token for 2FA verification + temp_token = jwt.encode({ + 'user_id': user[0], + 'is_2fa_pending': True, + 'exp': datetime.utcnow() + timedelta(minutes=5) + }, app.config['SECRET_KEY'], algorithm="HS256") + return jsonify({'success': True, 'requires_2fa': True, 'temp_token': temp_token}) + + # Standard login + token = jwt.encode({ + 'user_id': user[0], + 'exp': datetime.utcnow() + timedelta(hours=8) + }, app.config['SECRET_KEY'], algorithm="HS256") + + return jsonify({'success': True, 'token': token, 'username': data['username']}) + + api.record_login_attempt(ip, False) + return jsonify({'success': False, 'error': 'Invalid username or password'}), 401 + except Exception as e: + logger.error(f"Login error: {e}") + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + finally: + conn.close() + +@app.route('/api/auth/verify-2fa', methods=['POST']) +def verify_2fa(): + data = request.get_json() + token = data.get('temp_token') + otp = data.get('otp') + + if not token or not otp: + return jsonify({'success': False, 'error': 'Missing data'}), 400 + + try: + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + if not decoded.get('is_2fa_pending'): + raise ValueError("Invalid token type") + + user_id = decoded['user_id'] + conn = api.get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT username, totp_secret FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + conn.close() + + if not user: + return jsonify({'success': False, 'error': 'User not found'}), 404 + + totp = pyotp.TOTP(user[1]) + if totp.verify(otp): + final_token = jwt.encode({ + 'user_id': user_id, + 'exp': datetime.utcnow() + timedelta(hours=8) + }, app.config['SECRET_KEY'], algorithm="HS256") + return jsonify({'success': True, 'token': final_token, 'username': user[0]}) + + return jsonify({'success': False, 'error': 'Invalid 2FA code'}), 401 + except Exception as e: + return jsonify({'success': False, 'error': 'Session expired or invalid'}), 401 + +@app.route('/api/auth/setup-2fa', methods=['POST']) +@token_required +def setup_2fa(): + # This route is called to generate a new secret + token = request.headers['Authorization'].split(' ')[1] + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + user_id = decoded['user_id'] + + secret = pyotp.random_base32() + totp = pyotp.TOTP(secret) + provisioning_uri = totp.provisioning_uri(name="admin", issuer_name="OpenVPN-Monitor") + + return jsonify({ + 'success': True, + 'secret': secret, + 'uri': provisioning_uri + }) + +@app.route('/api/auth/enable-2fa', methods=['POST']) +@token_required +def enable_2fa(): + try: + data = request.get_json() + secret = data.get('secret') + otp = data.get('otp') + + if not secret or not otp: + return jsonify({'success': False, 'error': 'Missing data'}), 400 + + logger.info(f"Attempting 2FA activation. User OTP: {otp}, Secret: {secret}") + totp = pyotp.TOTP(secret) + + # Adding valid_window=1 to allow ±30 seconds clock drift + if totp.verify(otp, valid_window=1): + token = request.headers['Authorization'].split(' ')[1] + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + user_id = decoded['user_id'] + + conn = api.get_db_connection() + cursor = conn.cursor() + cursor.execute("UPDATE users SET totp_secret = ?, is_2fa_enabled = 1 WHERE id = ?", (secret, user_id)) + conn.commit() + conn.close() + + logger.info(f"2FA enabled successfully for user ID: {user_id}") + return jsonify({'success': True, 'message': '2FA enabled successfully'}) + + # TIME DRIFT DIAGNOSTIC + current_utc = datetime.now(timezone.utc) + logger.warning(f"Invalid 2FA code provided. Server time (UTC): {current_utc.strftime('%Y-%m-%d %H:%M:%S')}") + + # Check if code matches a different hour (Common timezone issue) + found_drift = False + for h in range(-12, 13): + if h == 0: continue + if totp.verify(otp, for_time=(current_utc + timedelta(hours=h))): + logger.error(f"CRITICAL TIME MISMATCH: The provided OTP matches server time WITH A {h} HOUR OFFSET. " + f"Please ensure the phone is set to 'Automatic Date and Time' and the correct Timezone.") + found_drift = True + break + + if not found_drift: + logger.info("No simple hour-offset matches found. The code might be for a different secret or time is completely desynced.") + + return jsonify({'success': False, 'error': 'Invalid 2FA code. Check your phone clock synchronization.'}), 400 + except Exception as e: + logger.error(f"Error in enable_2fa: {e}") + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + +@app.route('/api/auth/disable-2fa', methods=['POST']) +@token_required +def disable_2fa(): + try: + token = request.headers['Authorization'].split(' ')[1] + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + user_id = decoded['user_id'] + + conn = api.get_db_connection() + cursor = conn.cursor() + cursor.execute("UPDATE users SET totp_secret = NULL, is_2fa_enabled = 0 WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + + logger.info(f"2FA disabled for user ID: {user_id}") + return jsonify({'success': True, 'message': '2FA disabled successfully'}) + except Exception as e: + logger.error(f"Error in disable_2fa: {e}") + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + +@app.route('/api/auth/change-password', methods=['POST']) +@token_required +def change_password(): + data = request.get_json() + current_password = data.get('current_password') + new_password = data.get('new_password') + + if not current_password or not new_password: + return jsonify({'success': False, 'error': 'Missing password data'}), 400 + + token = request.headers['Authorization'].split(' ')[1] + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + user_id = decoded['user_id'] + + conn = api.get_db_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT password_hash FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + + if not user or not bcrypt.checkpw(current_password.encode('utf-8'), user[0].encode('utf-8')): + return jsonify({'success': False, 'error': 'Invalid current password'}), 401 + + new_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user_id)) + conn.commit() + + logger.info(f"Password changed for user ID: {user_id}") + return jsonify({'success': True, 'message': 'Password changed successfully'}) + except Exception as e: + logger.error(f"Error changing password: {e}") + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + finally: + conn.close() + +# --- USER ROUTES --- + +@app.route('/api/v1/user/me', methods=['GET']) +@token_required +def get_me(): + try: + token = request.headers['Authorization'].split(' ')[1] + decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"]) + user_id = decoded['user_id'] + + conn = api.get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT username, is_2fa_enabled FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + conn.close() + + if not user: + return jsonify({'success': False, 'error': 'User not found'}), 404 + + return jsonify({ + 'success': True, + 'username': user[0], + 'is_2fa_enabled': bool(user[1]) + }) + except Exception as e: + logger.error(f"Error in get_me: {e}") + return jsonify({'success': False, 'error': 'Internal server error'}), 500 + +# --- PROTECTED ROUTES --- @app.route('/api/v1/stats', methods=['GET']) +@token_required def get_stats(): """Get current statistics for all clients""" try: @@ -686,6 +1025,7 @@ def get_stats(): return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/v1/stats/system', methods=['GET']) +@token_required def get_system_stats(): """Get system-wide statistics""" try: @@ -699,6 +1039,7 @@ def get_system_stats(): return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/v1/stats/', methods=['GET']) +@token_required def get_client_stats(common_name): """ Get detailed stats for a client. @@ -769,6 +1110,7 @@ def get_client_stats(common_name): return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/v1/certificates', methods=['GET']) +@token_required def get_certificates(): try: data = api.get_certificates_info() @@ -777,6 +1119,7 @@ def get_certificates(): return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/v1/clients', methods=['GET']) +@token_required def get_clients_list(): try: data = api.get_current_stats() @@ -795,6 +1138,7 @@ def health_check(): return jsonify({'success': False, 'status': 'unhealthy', 'error': str(e)}), 500 @app.route('/api/v1/analytics', methods=['GET']) +@token_required def get_analytics(): """Get dashboard analytics data""" try: @@ -816,6 +1160,7 @@ def get_analytics(): return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/v1/sessions', methods=['GET']) +@token_required def get_sessions(): """Get all currently active sessions (real-time)""" try: @@ -830,323 +1175,9 @@ def get_sessions(): return jsonify({'success': False, 'error': str(e)}), 500 -# --- PKI MANAGEMENT ROUTES --- -@app.route('/api/v1/pki/init', methods=['POST']) -def init_pki(): - """Initialize PKI environment""" - try: - force = request.json.get('force', False) - pki_vars = request.json.get('vars', {}) - - # 0. Update Vars if provided - if pki_vars: - api.pki.update_vars(pki_vars) - - # 1. Clean/Init PKI - success, msg = api.pki.init_pki(force=force) - if not success: return jsonify({'success': False, 'error': msg}), 400 - - # 2. Build CA - # Use CN from vars if available, else default - ca_cn = pki_vars.get('EASYRSA_REQ_CN', 'OpenVPN-CA') - api.pki.build_ca(ca_cn) - - # 3. Build Server Cert - api.pki.build_server("server") - - # 4. Gen DH - api.pki.gen_dh() - - # 5. Gen TA Key - # Ensure pki dir exists - ta_path = Path(api.pki_path) / 'ta.key' - api.pki.gen_ta_key(ta_path) - - # 6. Gen CRL - api.pki.gen_crl() - return jsonify({'success': True, 'message': 'PKI initialized successfully'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 -@app.route('/api/v1/pki/validate', methods=['POST']) -def validate_pki(): - """Validate PKI path""" - try: - path = request.json.get('path') - if not path: return jsonify({'success': False, 'error': 'Path required'}), 400 - success, msg = api.pki.validate_pki_path(path) - return jsonify({'success': success, 'message': msg}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/pki/config', methods=['GET', 'POST']) -def handle_pki_config(): - """Get or Save PKI path configuration""" - try: - if request.method == 'GET': - return jsonify({ - 'success': True, - 'data': { - 'easyrsa_path': api.easyrsa_path, - 'pki_path': api.pki_path - } - }) - - # POST - path_str = request.json.get('path') - if not path_str: return jsonify({'success': False, 'error': 'Path required'}), 400 - - path = Path(path_str).resolve() - if not path.exists(): return jsonify({'success': False, 'error': 'Path invalid'}), 400 - - # Heuristic to determine easyrsa_path and pki_path - # User supplied 'path' is likely the PKI directory (containing ca.crt or being empty/prepared) - pki_path = path - easyrsa_path = path.parent # Default assumption: script is in parent - - # 1. Search for easyrsa binary (Heuristic) - potential_bins = [ - path / 'easyrsa', # Inside path - path.parent / 'easyrsa', # Parent - path.parent / 'easy-rsa' / 'easyrsa', # Sibling easy-rsa - Path('/usr/share/easy-rsa/easyrsa'), # System - Path('/etc/openvpn/easy-rsa/easyrsa') # System - ] - - found_bin = None - for bin_path in potential_bins: - if bin_path.exists(): - easyrsa_path = bin_path.parent - found_bin = bin_path - break - - # Override with explicit easyrsa_path if provided - explicit_easyrsa = request.json.get('easyrsa_path') - if explicit_easyrsa: - epath = Path(explicit_easyrsa) - if epath.is_file(): # Path to script - easyrsa_path = epath.parent - found_bin = epath - elif (epath / 'easyrsa').exists(): # Path to dir - easyrsa_path = epath - found_bin = epath / 'easyrsa' - - if not found_bin: - # Fallback: assume typical layout if not found yet - pass - - # If user pointed to root (containing pki subdir) - if (path / 'pki' / 'ca.crt').exists() or ((path / 'pki').exists() and not (path / 'ca.crt').exists()): - pki_path = path / 'pki' - # Only adjust easyrsa_path if not explicitly set/found yet - if not explicit_easyrsa and not found_bin and (path / 'easyrsa').exists(): - easyrsa_path = path - - # Update Config - if not api.config.has_section('pki'): - api.config.add_section('pki') - - api.config.set('pki', 'easyrsa_path', str(easyrsa_path)) - api.config.set('pki', 'pki_path', str(pki_path)) - - # Write config.ini - with open('config.ini', 'w') as f: - api.config.write(f) - - # Reload PKI Manager - api.easyrsa_path = str(easyrsa_path) - api.pki_path = str(pki_path) - api.pki = PKIManager(api.easyrsa_path, api.pki_path) - - return jsonify({ - 'success': True, - 'message': f'PKI Conf saved', - 'details': { - 'easyrsa_path': str(easyrsa_path), - 'pki_path': str(pki_path) - } - }) - - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/pki/client//config', methods=['GET']) -def get_client_config(name): - """Get client config (generate on fly)""" - try: - # Get defaults from active server config - server_conf = api.conf_mgr.read_server_config() - - # Determine public host - host = request.args.get('server_ip') - if not host: - host = server_conf.get('public_ip') - if not host: - try: - import socket - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - host = s.getsockname()[0] - s.close() - except: - host = '127.0.0.1' - - extra_params = { - 'remote_host': host, - 'remote_port': request.args.get('port') or server_conf.get('port', 1194), - 'proto': request.args.get('proto') or server_conf.get('proto', 'udp') - } - - succ_conf, conf_content = api.conf_mgr.generate_client_config( - name, api.pki_path, server_conf, extra_params - ) - - if not succ_conf: return jsonify({'success': False, 'error': conf_content}), 500 - - return jsonify({'success': True, 'config': conf_content, 'filename': f"{name}.ovpn"}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/pki/client', methods=['POST']) -def create_client(): - """Create new client and return config""" - try: - data = request.json - name = data.get('name') - if not name: return jsonify({'success': False, 'error': 'Name is required'}), 400 - - # 1. Build Cert - success, output = api.pki.build_client(name) - if not success: return jsonify({'success': False, 'error': output}), 500 - - # 2. Generate Config (Just to verify it works, but we don't strictly need to return it if UI doesn't download it automatically. - # However, it's good practice to return it in creation response too, in case UI changes mind) - server_ip = data.get('server_ip') or api.public_ip or '127.0.0.1' - - # Get defaults from active server config - server_conf = api.conf_mgr.read_server_config() - def_port = server_conf.get('port', 1194) - def_proto = server_conf.get('proto', 'udp') - - succ_conf, conf_content = api.conf_mgr.generate_client_config( - name, api.pki_path, server_ip, data.get('port', def_port), data.get('proto', def_proto) - ) - - if not succ_conf: return jsonify({'success': False, 'error': conf_content}), 500 - - return jsonify({'success': True, 'config': conf_content, 'filename': f"{name}.ovpn"}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/pki/client/', methods=['DELETE']) -def revoke_client(name): - """Revoke client certificate""" - try: - success, output = api.pki.revoke_client(name) - if not success: return jsonify({'success': False, 'error': output}), 500 - return jsonify({'success': True, 'message': 'Client revoked'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -# --- SERVER MANAGEMENT ROUTES --- - -@app.route('/api/v1/server/config', methods=['GET', 'POST']) -def manage_server_config(): - """Get or Save server.conf""" - try: - if request.method == 'GET': - # Check for path override (Reload from specific file) - path_arg = request.args.get('path') - - if path_arg: - # Update path preference if requested - new_path_str = str(path_arg) - if new_path_str != str(api.conf_mgr.server_conf_path): - api.server_config_path = new_path_str - api.conf_mgr.server_conf_path = Path(new_path_str) - - if not api.config.has_section('server'): api.config.add_section('server') - api.config.set('server', 'config_path', new_path_str) - with open('config.ini', 'w') as f: - api.config.write(f) - - current_conf = api.conf_mgr.read_server_config() - # Enriched with meta-config - current_conf['config_path'] = str(api.conf_mgr.server_conf_path) - current_conf['public_ip'] = api.public_ip - return jsonify({'success': True, 'data': current_conf}) - - # POST - params = request.json - # Basic validation - if not params.get('port'): return jsonify({'success': False, 'error': 'Port required'}), 400 - - # Check/Update Config Path and Public IP - new_path = params.get('config_path') - new_ip = params.get('public_ip') - - config_updated = False - if new_path and str(new_path) != str(api.conf_mgr.server_conf_path): - api.server_config_path = str(new_path) - api.conf_mgr.server_conf_path = Path(new_path) - if not api.config.has_section('server'): api.config.add_section('server') - api.config.set('server', 'config_path', str(new_path)) - config_updated = True - - if new_ip is not None and new_ip != api.public_ip: # Allow empty string - api.public_ip = new_ip - if not api.config.has_section('openvpn_monitor'): api.config.add_section('openvpn_monitor') - api.config.set('openvpn_monitor', 'public_ip', new_ip) - config_updated = True - - if config_updated: - with open('config.ini', 'w') as f: - api.config.write(f) - - # Define paths - params['ca_path'] = str(Path(api.pki_path) / 'ca.crt') - params['cert_path'] = str(Path(api.pki_path) / 'issued/server.crt') - params['key_path'] = str(Path(api.pki_path) / 'private/server.key') - params['dh_path'] = str(Path(api.pki_path) / 'dh.pem') - params['ta_path'] = str(Path(api.pki_path) / 'ta.key') - params['crl_path'] = str(Path(api.pki_path) / 'crl.pem') - - success, msg = api.conf_mgr.generate_server_config(params) - if not success: return jsonify({'success': False, 'error': msg}), 500 - - return jsonify({'success': True, 'path': msg}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/server/action', methods=['POST']) -def server_action(): - """Start/Stop/Restart OpenVPN service""" - try: - action = request.json.get('action') - if action == 'start': - success, msg = api.service.start() - elif action == 'stop': - success, msg = api.service.stop() - elif action == 'restart': - success, msg = api.service.restart() - else: - return jsonify({'success': False, 'error': 'Invalid action'}), 400 - - if not success: return jsonify({'success': False, 'error': msg}), 500 - return jsonify({'success': True, 'message': msg}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route('/api/v1/server/status', methods=['GET']) -def server_status(): - """Get service status""" - try: - status = api.service.get_status() - return jsonify({'success': True, 'status': status}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 if __name__ == "__main__": host = api.config.get('api', 'host', fallback='0.0.0.0') diff --git a/APP/openvpn_gatherer_v3.py b/APP_CORE/openvpn_gatherer_v3.py similarity index 100% rename from APP/openvpn_gatherer_v3.py rename to APP_CORE/openvpn_gatherer_v3.py diff --git a/APP_CORE/requirements.txt b/APP_CORE/requirements.txt new file mode 100644 index 0000000..b7ab2a1 --- /dev/null +++ b/APP_CORE/requirements.txt @@ -0,0 +1,7 @@ +Flask>=3.0.0 +Flask-Cors>=4.0.0 +PyJWT>=2.10.0 +pyotp>=2.9.0 +qrcode>=7.4.2 +bcrypt>=4.2.0 + diff --git a/APP_PROFILER/README.md b/APP_PROFILER/README.md new file mode 100644 index 0000000..6558285 --- /dev/null +++ b/APP_PROFILER/README.md @@ -0,0 +1,26 @@ +# OpenVPN Profiler Module (`APP_PROFILER`) + +The **Profiler** module is a FastAPI-based service (`port 8000`) dedicated to management tasks: +- Public Key Infrastructure (PKI) management (EasyRSA wrapper). +- Client Profile (`.ovpn`) generation. +- Server Configuration management. +- Process control (Start/Stop OpenVPN service). + +## Documentation + +- **API Reference**: See `DOCS/Profiler_Management/API_Reference.md`. +- **Overview**: See `DOCS/Profiler_Management/Overview.md`. + +## Quick Start (Dev) + +```bash +# Setup +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Run +python3 main.py +# Swagger UI available at http://localhost:8000/docs +``` + diff --git a/APP_PROFILER/add_columns.py b/APP_PROFILER/add_columns.py new file mode 100644 index 0000000..32e1f51 --- /dev/null +++ b/APP_PROFILER/add_columns.py @@ -0,0 +1,51 @@ +import sqlite3 +import os + +DB_FILE = "ovpn_profiler.db" + +def migrate_db(): + if not os.path.exists(DB_FILE): + print(f"Database file {DB_FILE} not found!") + return + + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + + try: + cursor.execute("PRAGMA table_info(user_profiles)") + columns = [info[1] for info in cursor.fetchall()] + + # Add is_revoked + if "is_revoked" not in columns: + print("Adding 'is_revoked' column...") + cursor.execute("ALTER TABLE user_profiles ADD COLUMN is_revoked BOOLEAN DEFAULT 0") + else: + print("'is_revoked' column already exists.") + + # Add is_expired + if "is_expired" not in columns: + print("Adding 'is_expired' column...") + cursor.execute("ALTER TABLE user_profiles ADD COLUMN is_expired BOOLEAN DEFAULT 0") + else: + print("'is_expired' column already exists.") + + # Ensure expiration_date exists + if "expiration_date" not in columns: + print("Adding 'expiration_date' column...") + cursor.execute("ALTER TABLE user_profiles ADD COLUMN expiration_date DATETIME") + else: + print("'expiration_date' column already exists.") + + # Note: We do NOT remove 'expired_at' via ADD COLUMN script. + # SQLite does not support DROP COLUMN in older versions easily, + # and keeping it harmless is safer than complex migration logic. + print("Migration successful.") + conn.commit() + + except Exception as e: + print(f"Migration failed: {e}") + finally: + conn.close() + +if __name__ == "__main__": + migrate_db() diff --git a/APP_PROFILER/database.py b/APP_PROFILER/database.py new file mode 100644 index 0000000..41184bd --- /dev/null +++ b/APP_PROFILER/database.py @@ -0,0 +1,19 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +SQLALCHEMY_DATABASE_URL = "sqlite:///./ovpn_profiler.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/APP_PROFILER/main.py b/APP_PROFILER/main.py new file mode 100644 index 0000000..65d4e7b --- /dev/null +++ b/APP_PROFILER/main.py @@ -0,0 +1,45 @@ +import uvicorn +from fastapi import FastAPI +import sys +import os + +# Add project root to sys.path explicitly to ensure absolute imports work +import os +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from database import engine, Base +from routers import system, server, profiles, server_process +from utils.logging import setup_logging +from fastapi.middleware.cors import CORSMiddleware + +# Create Database Tables +Base.metadata.create_all(bind=engine) + +setup_logging() + +app = FastAPI( + title="OpenVPN Profiler API", + description="REST API for managing OpenVPN profiles and configuration", + version="1.0.0" +) + +# Enable CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(system.router, prefix="/api", tags=["System"]) +app.include_router(server.router, prefix="/api", tags=["Server"]) +app.include_router(profiles.router, prefix="/api", tags=["Profiles"]) +app.include_router(server_process.router, prefix="/api", tags=["Process Control"]) + +@app.get("/") +def read_root(): + return {"message": "Welcome to OpenVPN Profiler API"} + +if __name__ == "__main__": + uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True) diff --git a/APP_PROFILER/models.py b/APP_PROFILER/models.py new file mode 100644 index 0000000..9e978d4 --- /dev/null +++ b/APP_PROFILER/models.py @@ -0,0 +1,63 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON +from sqlalchemy.sql import func +from database import Base +from datetime import datetime + +class PKISetting(Base): + __tablename__ = "pki_settings" + + id = Column(Integer, primary_key=True, index=True) + fqdn_ca = Column(String, default="ovpn-ca") + fqdn_server = Column(String, default="ovpn-srv") + easyrsa_dn = Column(String, default="cn_only") + easyrsa_req_country = Column(String, default="RU") + easyrsa_req_province = Column(String, default="Moscow") + easyrsa_req_city = Column(String, default="Moscow") + easyrsa_req_org = Column(String, default="SomeORG") + easyrsa_req_email = Column(String, default="info@someorg.local") + easyrsa_req_ou = Column(String, default="IT") + easyrsa_key_size = Column(Integer, default=2048) + easyrsa_ca_expire = Column(Integer, default=3650) + easyrsa_cert_expire = Column(Integer, default=3649) + easyrsa_cert_renew = Column(Integer, default=30) + easyrsa_crl_days = Column(Integer, default=3649) + easyrsa_batch = Column(Boolean, default=True) + +class SystemSettings(Base): + __tablename__ = "system_settings" + + id = Column(Integer, primary_key=True, index=True) + protocol = Column(String, default="udp") + port = Column(Integer, default=1194) + vpn_network = Column(String, default="172.20.1.0") + vpn_netmask = Column(String, default="255.255.255.0") + tunnel_type = Column(String, default="FULL") # FULL or SPLIT + split_routes = Column(JSON, default=list) + duplicate_cn = Column(Boolean, default=False) + crl_verify = Column(Boolean, default=False) + client_to_client = Column(Boolean, default=False) + user_defined_dns = Column(Boolean, default=False) + dns_servers = Column(JSON, default=list) + user_defined_cdscripts = Column(Boolean, default=False) + connect_script = Column(String, default="") + disconnect_script = Column(String, default="") + management_interface = Column(Boolean, default=False) + management_interface_address = Column(String, default="127.0.0.1") + management_port = Column(Integer, default=7505) + public_ip = Column(String, nullable=True) + tun_mtu = Column(Integer, nullable=True) + mssfix = Column(Integer, nullable=True) + +class UserProfile(Base): + __tablename__ = "user_profiles" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, unique=True, index=True) + status = Column(String, default="active") # active, revoked + created_at = Column(DateTime, default=datetime.utcnow) + revoked_at = Column(DateTime, nullable=True) + # expired_at removed as per request + expiration_date = Column(DateTime, nullable=True) + is_revoked = Column(Boolean, default=False) + is_expired = Column(Boolean, default=False) + file_path = Column(String, nullable=True) diff --git a/APP_PROFILER/routers/__init__.py b/APP_PROFILER/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/APP_PROFILER/routers/profiles.py b/APP_PROFILER/routers/profiles.py new file mode 100644 index 0000000..3f4e5fe --- /dev/null +++ b/APP_PROFILER/routers/profiles.py @@ -0,0 +1,137 @@ +import os +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from database import get_db +from utils.auth import verify_token +from models import UserProfile +from schemas import UserProfile as UserProfileSchema, UserProfileCreate +from services import pki, generator +from datetime import datetime + +router = APIRouter(dependencies=[Depends(verify_token)]) + +@router.get("/profiles", response_model=list[UserProfileSchema]) +def list_profiles(db: Session = Depends(get_db)): + # 1. Fetch profiles from DB + profiles = db.query(UserProfile).all() + + # 2. Get PKI Data (Index mapping: CN -> Expiration Date) + pki_data = pki.get_pki_index_data(db) + + now = datetime.utcnow() + + updated_profiles = [] + + for profile in profiles: + # Sync expiration if available in PKI data + if profile.username in pki_data: + exp_date = pki_data[profile.username] + # Update DB if different + if profile.expiration_date != exp_date: + profile.expiration_date = exp_date + db.add(profile) + + # Calculate derived fields + + # 1. is_expired + is_expired = False + if profile.expiration_date: + if now > profile.expiration_date: + is_expired = True + + # 2. is_revoked + # (Assuming status='revoked' in DB is the source of truth) + is_revoked = profile.status == 'revoked' + + # 3. days_remaining (computed field) + days_remaining = None + if profile.expiration_date: + delta = profile.expiration_date - now + days_remaining = delta.days + + # Update DB fields for persistence if they differ + if profile.is_expired != is_expired: + profile.is_expired = is_expired + db.add(profile) + + if profile.is_revoked != is_revoked: + profile.is_revoked = is_revoked + db.add(profile) + + # Inject computed fields for response schema + # Since 'days_remaining' is not a DB column, we attach it to the object instance + setattr(profile, 'days_remaining', days_remaining) + + updated_profiles.append(profile) + + db.commit() # Save any updates + + return updated_profiles + +@router.post("/profiles", response_model=UserProfileSchema) +def create_profile( + profile_in: UserProfileCreate, + db: Session = Depends(get_db) +): + # Check existing + existing = db.query(UserProfile).filter(UserProfile.username == profile_in.username).first() + if existing: + raise HTTPException(status_code=400, detail="User already exists") + + # Build PKI + try: + pki.build_client(profile_in.username, db) + except Exception as e: + raise HTTPException(status_code=500, detail=f"PKI Build failed: {str(e)}") + + # Generate Config + client_conf_dir = "client-config" + os.makedirs(client_conf_dir, exist_ok=True) + file_path = os.path.join(client_conf_dir, f"{profile_in.username}.ovpn") + + try: + generator.generate_client_config(db, profile_in.username, file_path) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Config Generation failed: {str(e)}") + + # Create DB Entry + new_profile = UserProfile( + username=profile_in.username, + status="active", + created_at=datetime.utcnow(), + file_path=file_path + # expired_at would be extracted from cert in a real robust implementation + ) + db.add(new_profile) + db.commit() + db.refresh(new_profile) + return new_profile + +@router.delete("/profiles/{profile_id}") +def revoke_profile(profile_id: int, db: Session = Depends(get_db)): + profile = db.query(UserProfile).filter(UserProfile.id == profile_id).first() + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + try: + pki.revoke_client(profile.username, db) + except Exception as e: + # Log but maybe continue to update DB status? + raise HTTPException(status_code=500, detail=f"Revocation failed: {str(e)}") + + profile.status = "revoked" + profile.revoked_at = datetime.utcnow() + db.commit() + return {"message": f"Profile {profile.username} revoked"} + +@router.get("/profiles/{profile_id}/download") +def download_profile(profile_id: int, db: Session = Depends(get_db)): + profile = db.query(UserProfile).filter(UserProfile.id == profile_id).first() + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + if not profile.file_path or not os.path.exists(profile.file_path): + raise HTTPException(status_code=404, detail="Config file not found") + + return FileResponse(profile.file_path, filename=os.path.basename(profile.file_path)) diff --git a/APP_PROFILER/routers/server.py b/APP_PROFILER/routers/server.py new file mode 100644 index 0000000..4c8b26f --- /dev/null +++ b/APP_PROFILER/routers/server.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from database import get_db +from utils.auth import verify_token +from services import generator + +router = APIRouter(dependencies=[Depends(verify_token)]) + +@router.post("/server/configure") +def configure_server(db: Session = Depends(get_db)): + try: + # Generate to a temporary location or standard location + # As per plan, we behave like srvconf + output_path = "/etc/openvpn/server.conf" + # Since running locally for dev, maybe output to staging + import os + if not os.path.exists("/etc/openvpn"): + # For local dev safety, don't try to write to /etc/openvpn if not root or not existing + output_path = "staging/server.conf" + os.makedirs("staging", exist_ok=True) + + content = generator.generate_server_config(db, output_path=output_path) + return {"message": "Server configuration generated", "path": output_path} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/APP_PROFILER/routers/server_process.py b/APP_PROFILER/routers/server_process.py new file mode 100644 index 0000000..e048871 --- /dev/null +++ b/APP_PROFILER/routers/server_process.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from services import process +from utils.auth import verify_token +from typing import Optional +from fastapi import Depends + +router = APIRouter(dependencies=[Depends(verify_token)]) + +class ProcessActionResponse(BaseModel): + status: str + message: str + stdout: Optional[str] = None + stderr: Optional[str] = None + +class ProcessStats(BaseModel): + status: str + pid: Optional[int] = None + cpu_percent: float + memory_mb: float + uptime: Optional[str] = None + +@router.post("/server/process/{action}", response_model=ProcessActionResponse) +def manage_process(action: str): + """ + Control the OpenVPN server process. + Action: start, stop, restart + """ + if action not in ["start", "stop", "restart"]: + raise HTTPException(status_code=400, detail="Invalid action. Use start, stop, or restart") + + result = process.control_service(action) + + if result["status"] == "error": + raise HTTPException(status_code=500, detail=result["message"]) + + return result + +@router.get("/server/process/stats", response_model=ProcessStats) +def get_process_stats(): + """ + Get current telemetry for the OpenVPN process. + """ + return process.get_process_stats() diff --git a/APP_PROFILER/routers/system.py b/APP_PROFILER/routers/system.py new file mode 100644 index 0000000..b8e21dd --- /dev/null +++ b/APP_PROFILER/routers/system.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from database import get_db +from utils.auth import verify_token +from schemas import ( + ConfigResponse, SystemSettings, PKISetting, + SystemSettingsUpdate, PKISettingUpdate +) +from services import config, pki + +router = APIRouter(dependencies=[Depends(verify_token)]) + +@router.get("/config", response_model=ConfigResponse) +def get_config( + section: str = Query(None, enum=["server", "pki"]), + db: Session = Depends(get_db) +): + response = ConfigResponse() + if section is None or section == "server": + response.server = config.get_system_settings(db) + if section is None or section == "pki": + response.pki = config.get_pki_settings(db) + return response + +@router.put("/config/server", response_model=SystemSettings) +def update_server_config( + settings: SystemSettingsUpdate, + db: Session = Depends(get_db) +): + return config.update_system_settings(db, settings) + +@router.put("/config/pki", response_model=PKISetting) +def update_pki_config( + settings: PKISettingUpdate, + db: Session = Depends(get_db) +): + return config.update_pki_settings(db, settings) + +@router.post("/system/init") +def init_system_pki(db: Session = Depends(get_db)): + try: + msg = pki.init_pki(db) + return {"message": msg} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/system/pki") +def clear_system_pki(db: Session = Depends(get_db)): + msg = pki.clear_pki(db) + return {"message": msg} diff --git a/APP_PROFILER/schemas.py b/APP_PROFILER/schemas.py new file mode 100644 index 0000000..9c541e2 --- /dev/null +++ b/APP_PROFILER/schemas.py @@ -0,0 +1,86 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Literal +from datetime import datetime + +# --- PKI Settings Schemas --- +class PKISettingBase(BaseModel): + fqdn_ca: str = "ovpn-ca" + fqdn_server: str = "ovpn-srv" + easyrsa_dn: str = "cn_only" + easyrsa_req_country: str = "RU" + easyrsa_req_province: str = "Moscow" + easyrsa_req_city: str = "Moscow" + easyrsa_req_org: str = "SomeORG" + easyrsa_req_email: str = "info@someorg.local" + easyrsa_req_ou: str = "IT" + easyrsa_key_size: int = 2048 + easyrsa_ca_expire: int = 3650 + easyrsa_cert_expire: int = 3649 + easyrsa_cert_renew: int = 30 + easyrsa_crl_days: int = 3649 + easyrsa_batch: bool = True + +class PKISettingUpdate(PKISettingBase): + pass + +class PKISetting(PKISettingBase): + id: int + class Config: + from_attributes = True + +# --- System Settings Schemas --- +class SystemSettingsBase(BaseModel): + protocol: Literal['tcp', 'udp'] = "udp" + port: int = 1194 + vpn_network: str = "172.20.1.0" + vpn_netmask: str = "255.255.255.0" + tunnel_type: Literal['FULL', 'SPLIT'] = "FULL" + split_routes: List[str] = Field(default_factory=list) + duplicate_cn: bool = False + crl_verify: bool = False + client_to_client: bool = False + user_defined_dns: bool = False + dns_servers: List[str] = Field(default_factory=list) + user_defined_cdscripts: bool = False + connect_script: str = "" + disconnect_script: str = "" + management_interface: bool = False + management_interface_address: str = "127.0.0.1" + management_interface_address: str = "127.0.0.1" + management_port: int = 7505 + public_ip: Optional[str] = None + tun_mtu: Optional[int] = None + mssfix: Optional[int] = None + +class SystemSettingsUpdate(SystemSettingsBase): + pass + +class SystemSettings(SystemSettingsBase): + id: int + class Config: + from_attributes = True + +class ConfigResponse(BaseModel): + server: Optional[SystemSettings] = None + pki: Optional[PKISetting] = None + +# --- User Profile Schemas --- +class UserProfileBase(BaseModel): + username: str + +class UserProfileCreate(UserProfileBase): + pass + +class UserProfile(UserProfileBase): + id: int + status: str + created_at: datetime + revoked_at: Optional[datetime] = None + expiration_date: Optional[datetime] = None + days_remaining: Optional[int] = None + is_revoked: bool = False + is_expired: bool = False + file_path: Optional[str] = None + + class Config: + from_attributes = True diff --git a/APP_PROFILER/scripts/add_mtu_mss_columns.py b/APP_PROFILER/scripts/add_mtu_mss_columns.py new file mode 100644 index 0000000..092ced6 --- /dev/null +++ b/APP_PROFILER/scripts/add_mtu_mss_columns.py @@ -0,0 +1,35 @@ +import sqlite3 +import os + +DB_FILE = "ovpn_profiler.db" + +def migrate(): + if not os.path.exists(DB_FILE): + print(f"Database {DB_FILE} not found!") + return + + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + + columns = { + "tun_mtu": "INTEGER", + "mssfix": "INTEGER" + } + + print("Checking for new columns...") + for col, dtype in columns.items(): + try: + print(f"Attempting to add {col}...") + cursor.execute(f"ALTER TABLE system_settings ADD COLUMN {col} {dtype}") + print(f"Success: Column {col} added.") + except sqlite3.OperationalError as e: + if "duplicate column name" in str(e): + print(f"Column {col} already exists.") + else: + print(f"Error adding {col}: {e}") + + conn.commit() + conn.close() + +if __name__ == "__main__": + migrate() diff --git a/APP_PROFILER/scripts/add_public_ip_column.py b/APP_PROFILER/scripts/add_public_ip_column.py new file mode 100644 index 0000000..94ab169 --- /dev/null +++ b/APP_PROFILER/scripts/add_public_ip_column.py @@ -0,0 +1,28 @@ +import sqlite3 +import os + +DB_FILE = "ovpn_profiler.db" + +def migrate(): + if not os.path.exists(DB_FILE): + print(f"Database {DB_FILE} not found!") + return + + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + + try: + print("Attempting to add public_ip column...") + cursor.execute("ALTER TABLE system_settings ADD COLUMN public_ip TEXT") + conn.commit() + print("Success: Column public_ip added.") + except sqlite3.OperationalError as e: + if "duplicate column name" in str(e): + print("Column public_ip already exists.") + else: + print(f"Error: {e}") + finally: + conn.close() + +if __name__ == "__main__": + migrate() diff --git a/APP_PROFILER/scripts/migrate_from_bash.py b/APP_PROFILER/scripts/migrate_from_bash.py new file mode 100644 index 0000000..cd8e87c --- /dev/null +++ b/APP_PROFILER/scripts/migrate_from_bash.py @@ -0,0 +1,179 @@ +import sys +import os +import re +from datetime import datetime + +# Add project root to sys.path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from database import SessionLocal, engine, Base +from models import SystemSettings, PKISetting, UserProfile +from services import config as config_service + +def parse_bash_array(content, var_name): + # Dumb parser for bash arrays: VAR=( "val1" "val2" ) + # This is fragile but fits the simple format used in confvars + pattern = float = fr'{var_name}=\((.*?)\)' + match = re.search(pattern, content, re.DOTALL) + if match: + items = re.findall(r'"([^"]*)"', match.group(1)) + return items + return [] + +def parse_bash_var(content, var_name): + pattern = fr'{var_name}="?([^"\n]*)"?' + match = re.search(pattern, content) + if match: + return match.group(1) + return None + +def migrate_confvars(db): + confvars_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "confvars") + + if not os.path.exists(confvars_path): + print(f"No confvars found at {confvars_path}") + return + + with open(confvars_path, "r") as f: + content = f.read() + + print("Migrating System Settings...") + sys_settings = config_service.get_system_settings(db) + + # Map variables + protocol = parse_bash_var(content, "TPROTO") + if protocol: sys_settings.protocol = protocol + + port = parse_bash_var(content, "TPORT") + if port: sys_settings.port = int(port) + + vpn_network = parse_bash_var(content, "TSERNET") + if vpn_network: sys_settings.vpn_network = vpn_network.strip('"') + + vpn_netmask = parse_bash_var(content, "TSERMASK") + if vpn_netmask: sys_settings.vpn_netmask = vpn_netmask.strip('"') + + tunnel_type = parse_bash_var(content, "TTUNTYPE") + if tunnel_type: sys_settings.tunnel_type = tunnel_type + + duplicate_cn = parse_bash_var(content, "TDCN") + sys_settings.duplicate_cn = (duplicate_cn == "YES") + + client_to_client = parse_bash_var(content, "TC2C") + sys_settings.client_to_client = (client_to_client == "YES") + + crl_verify = parse_bash_var(content, "TREVO") + sys_settings.crl_verify = (crl_verify == "YES") + + # Arrays + split_routes = parse_bash_array(content, "TTUNNETS") + if split_routes: sys_settings.split_routes = split_routes + + dns_servers = parse_bash_array(content, "TDNS") + if dns_servers: + sys_settings.dns_servers = dns_servers + sys_settings.user_defined_dns = True + + # Scripts + conn_scripts = parse_bash_var(content, "T_CONNSCRIPTS") + sys_settings.user_defined_cdscripts = (conn_scripts == "YES") + + conn_script = parse_bash_var(content, "T_CONNSCRIPT_STRING") + if conn_script: sys_settings.connect_script = conn_script.strip('"') + + disconn_script = parse_bash_var(content, "T_DISCONNSCRIPT_STRING") + if disconn_script: sys_settings.disconnect_script = disconn_script.strip('"') + + # Mgmt + mgmt = parse_bash_var(content, "T_MGMT") + sys_settings.management_interface = (mgmt == "YES") + + mgmt_addr = parse_bash_var(content, "T_MGMT_ADDR") + if mgmt_addr: sys_settings.management_interface_address = mgmt_addr.strip('"') + + mgmt_port = parse_bash_var(content, "T_MGMT_PORT") + if mgmt_port: sys_settings.management_port = int(mgmt_port) + + db.commit() + print("System Settings Migrated.") + + print("Migrating PKI Settings...") + pki_settings = config_service.get_pki_settings(db) + + fqdn_server = parse_bash_var(content, "FQDN_SERVER") + if fqdn_server: pki_settings.fqdn_server = fqdn_server + + fqdn_ca = parse_bash_var(content, "FQDN_CA") + if fqdn_ca: pki_settings.fqdn_ca = fqdn_ca + + # EasyRSA vars + for line in content.splitlines(): + if line.startswith("export EASYRSA_"): + parts = line.split("=") + if len(parts) == 2: + key = parts[0].replace("export ", "").strip() + val = parts[1].strip().strip('"') + + # Map to model fields (lowercase) + if hasattr(pki_settings, key.lower()): + # Simple type conversion + field_type = type(getattr(pki_settings, key.lower())) + if field_type == int: + setattr(pki_settings, key.lower(), int(val)) + elif field_type == bool: + # Handle varied boolean strings + if val.lower() in ["1", "yes", "true", "on"]: + setattr(pki_settings, key.lower(), True) + else: + setattr(pki_settings, key.lower(), False) + else: + setattr(pki_settings, key.lower(), val) + + db.commit() + print("PKI Settings Migrated.") + +def migrate_users(db): + client_config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client-config") + if not os.path.exists(client_config_dir): + print("No client-config directory found.") + return + + print("Migrating Users...") + for filename in os.listdir(client_config_dir): + if filename.endswith(".ovpn"): + username = filename[:-5] # remove .ovpn + + # Check overlap + existing = db.query(UserProfile).filter(UserProfile.username == username).first() + if not existing: + # Basic import, we don't have createdAt date easily unless we stat the file + file_path = os.path.join(client_config_dir, filename) + stat = os.stat(file_path) + created_at = datetime.fromtimestamp(stat.st_ctime) + + # Try to parse ID from filename if it matches format "ID-Name" (common in this script) + # But the bash script logic was "ID-Name" -> client_name + # The UserProfile username should probably be the CommonName + + profile = UserProfile( + username=username, + status="active", + created_at=created_at, + file_path=file_path + ) + db.add(profile) + print(f"Imported user: {username}") + + db.commit() + print("Users Migrated.") + +if __name__ == "__main__": + # Ensure tables exist + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + try: + migrate_confvars(db) + migrate_users(db) + finally: + db.close() diff --git a/APP_PROFILER/services/__init__.py b/APP_PROFILER/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/APP_PROFILER/services/config.py b/APP_PROFILER/services/config.py new file mode 100644 index 0000000..cc14a25 --- /dev/null +++ b/APP_PROFILER/services/config.py @@ -0,0 +1,37 @@ +from sqlalchemy.orm import Session +from models import SystemSettings, PKISetting +from schemas import SystemSettingsUpdate, PKISettingUpdate + +def get_system_settings(db: Session): + settings = db.query(SystemSettings).first() + if not settings: + settings = SystemSettings() + db.add(settings) + db.commit() + db.refresh(settings) + return settings + +def update_system_settings(db: Session, settings_in: SystemSettingsUpdate): + settings = get_system_settings(db) + for key, value in settings_in.model_dump(exclude_unset=True).items(): + setattr(settings, key, value) + db.commit() + db.refresh(settings) + return settings + +def get_pki_settings(db: Session): + settings = db.query(PKISetting).first() + if not settings: + settings = PKISetting() + db.add(settings) + db.commit() + db.refresh(settings) + return settings + +def update_pki_settings(db: Session, settings_in: PKISettingUpdate): + settings = get_pki_settings(db) + for key, value in settings_in.model_dump(exclude_unset=True).items(): + setattr(settings, key, value) + db.commit() + db.refresh(settings) + return settings diff --git a/APP_PROFILER/services/generator.py b/APP_PROFILER/services/generator.py new file mode 100644 index 0000000..350054d --- /dev/null +++ b/APP_PROFILER/services/generator.py @@ -0,0 +1,108 @@ +import os +import logging +from jinja2 import Environment, FileSystemLoader +from sqlalchemy.orm import Session +from .config import get_system_settings, get_pki_settings +from .pki import PKI_DIR + +logger = logging.getLogger(__name__) + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TEMPLATES_DIR = os.path.join(BASE_DIR, "templates") + +env = Environment(loader=FileSystemLoader(TEMPLATES_DIR)) + +def generate_server_config(db: Session, output_path: str = "server.conf"): + settings = get_system_settings(db) + pki_settings = get_pki_settings(db) + template = env.get_template("server.conf.j2") + + # Rendering Path + file_ca_path = os.path.join(PKI_DIR, "ca.crt") + file_srv_cert_path = os.path.join(PKI_DIR, "issued", f"{pki_settings.fqdn_server}.crt") + file_srv_key_path = os.path.join(PKI_DIR, "private", f"{pki_settings.fqdn_server}.key") + file_dh_path = os.path.join(PKI_DIR, "dh.pem") + file_ta_path = os.path.join(PKI_DIR, "ta.key") + + # Render template + config_content = template.render( + protocol=settings.protocol, + port=settings.port, + ca_path=file_ca_path, + srv_cert_path=file_srv_cert_path, + srv_key_path=file_srv_key_path, + dh_path=file_dh_path, + ta_path=file_ta_path, + vpn_network=settings.vpn_network, + vpn_netmask=settings.vpn_netmask, + tunnel_type=settings.tunnel_type, + split_routes=settings.split_routes, + user_defined_dns=settings.user_defined_dns, + dns_servers=settings.dns_servers, + client_to_client=settings.client_to_client, + duplicate_cn=settings.duplicate_cn, + crl_verify=settings.crl_verify, + user_defined_cdscripts=settings.user_defined_cdscripts, + connect_script=settings.connect_script, + disconnect_script=settings.disconnect_script, + management_interface=settings.management_interface, + management_interface_address=settings.management_interface_address, + management_port=settings.management_port, + tun_mtu=settings.tun_mtu, + mssfix=settings.mssfix + ) + + # Write to file + with open(output_path, "w") as f: + f.write(config_content) + + return config_content + +def generate_client_config(db: Session, username: str, output_path: str): + settings = get_system_settings(db) + pki = get_pki_settings(db) + + # Read Certs and Keys + # Note: filenames in easy-rsa pki structure + # ca: pki/ca.crt + # cert: pki/issued/.crt + # key: pki/private/.key + # ta: pki/ta.key + + def read_file(path): + try: + with open(path, "r") as f: + return f.read().strip() + except FileNotFoundError: + logger.error(f"File not found: {path}") + return f"Error: {path} not found" + + ca_cert = read_file(os.path.join(PKI_DIR, "ca.crt")) + client_cert = read_file(os.path.join(PKI_DIR, "issued", f"{username}.crt")) + client_key = read_file(os.path.join(PKI_DIR, "private", f"{username}.key")) + tls_auth = read_file(os.path.join(PKI_DIR, "ta.key")) + + # Determine Remote IP + if settings.public_ip: + remote_ip = settings.public_ip + else: + from .utils import get_public_ip + remote_ip = get_public_ip() + + template = env.get_template("client.ovpn.j2") + + config_content = template.render( + protocol=settings.protocol, + remote_ip=remote_ip, + port=settings.port, + ca_cert=ca_cert, + client_cert=client_cert, + client_key=client_key, + tls_auth=tls_auth, + tun_mtu=settings.tun_mtu + ) + + with open(output_path, "w") as f: + f.write(config_content) + + return config_content diff --git a/APP_PROFILER/services/pki.py b/APP_PROFILER/services/pki.py new file mode 100644 index 0000000..c69e319 --- /dev/null +++ b/APP_PROFILER/services/pki.py @@ -0,0 +1,181 @@ +import os +import subprocess +import logging +from .config import get_pki_settings +from sqlalchemy.orm import Session +from datetime import datetime + +logger = logging.getLogger(__name__) + +EASY_RSA_DIR = os.path.join(os.getcwd(), "easy-rsa") +PKI_DIR = os.path.join(EASY_RSA_DIR, "pki") +INDEX_PATH = os.path.join(PKI_DIR, "index.txt") + +def get_pki_index_data(db: Session = None): + """ + Parses easy-rsa/pki/index.txt to get certificate expiration dates. + Returns a dict: { "common_name": datetime_expiration } + """ + if not os.path.exists(INDEX_PATH): + logger.warning(f"PKI index file not found at {INDEX_PATH}") + return {} + + pki_data = {} + + try: + with open(INDEX_PATH, "r") as f: + for line in f: + parts = line.strip().split('\t') + # OpenSSL index.txt format: + # 0: Flag (V=Valid, R=Revoked, E=Expired) + # 1: Expiration Date (YYMMDDHHMMSSZ) + # 2: Revocation Date (Can be empty) + # 3: Serial + # 4: Filename (unknown, often empty) + # 5: Distinguished Name (DN) -> /CN=username + + if len(parts) < 6: + continue + + flag = parts[0] + exp_date_str = parts[1] + dn = parts[5] + + if flag != 'V': # Only interested in valid certs expiration? Or all? User wants 'expired_at'. + # Even if revoked/expired, 'expired_at' (valid_until) is still a property of the cert. + pass + + # Extract CN + # dn formats: /CN=anton or /C=RU/ST=Moscow.../CN=anton + cn = None + for segment in dn.split('/'): + if segment.startswith("CN="): + cn = segment.split("=", 1)[1] + break + + if cn: + # Parse Date: YYMMDDHHMMSSZ -> 250116102805Z + # Note: OpenSSL uses 2-digit year. stored as 20YY or 19YY. + # python strptime %y handles 2-digit years (00-68 -> 2000-2068, 69-99 -> 1969-1999) + try: + exp_dt = datetime.strptime(exp_date_str, "%y%m%d%H%M%SZ") + pki_data[cn] = exp_dt + except ValueError: + logger.error(f"Failed to parse date: {exp_date_str}") + + except Exception as e: + logger.error(f"Error reading PKI index: {e}") + + return pki_data + +def _run_easyrsa(command: list, env: dict): + env_vars = os.environ.copy() + env_vars.update(env) + # Ensure EASYRSA_PKI is set + env_vars["EASYRSA_PKI"] = PKI_DIR + + cmd = [os.path.join(EASY_RSA_DIR, "easyrsa")] + command + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + env=env_vars, + cwd=EASY_RSA_DIR + ) + if result.returncode != 0: + logger.error(f"EasyRSA command failed: {cmd}") + logger.error(f"Stderr: {result.stderr}") + raise Exception(f"EasyRSA command failed: {result.stderr}") + return result.stdout + except Exception as e: + logger.exception("Error running EasyRSA") + raise e + +def _get_easyrsa_env(db: Session): + pki = get_pki_settings(db) + env = { + "EASYRSA_DN": pki.easyrsa_dn, + "EASYRSA_REQ_COUNTRY": pki.easyrsa_req_country, + "EASYRSA_REQ_PROVINCE": pki.easyrsa_req_province, + "EASYRSA_REQ_CITY": pki.easyrsa_req_city, + "EASYRSA_REQ_ORG": pki.easyrsa_req_org, + "EASYRSA_REQ_EMAIL": pki.easyrsa_req_email, + "EASYRSA_REQ_OU": pki.easyrsa_req_ou, + "EASYRSA_KEY_SIZE": str(pki.easyrsa_key_size), + "EASYRSA_CA_EXPIRE": str(pki.easyrsa_ca_expire), + "EASYRSA_CERT_EXPIRE": str(pki.easyrsa_cert_expire), + "EASYRSA_CERT_RENEW": str(pki.easyrsa_cert_renew), + "EASYRSA_CRL_DAYS": str(pki.easyrsa_crl_days), + "EASYRSA_BATCH": "1" if pki.easyrsa_batch else "0" + } + return env + +def init_pki(db: Session): + env = _get_easyrsa_env(db) + pki_settings = get_pki_settings(db) + + if os.path.exists(os.path.join(PKI_DIR, "ca.crt")): + logger.warning("PKI already initialized") + return "PKI already initialized" + + # Init PKI + _run_easyrsa(["init-pki"], env) + + # Build CA + _run_easyrsa(["--req-cn=" + pki_settings.fqdn_ca, "build-ca", "nopass"], env) + + # Build Server Cert + _run_easyrsa(["build-server-full", pki_settings.fqdn_server, "nopass"], env) + + # Gen DH + _run_easyrsa(["gen-dh"], env) + + # Gen TLS Auth Key (requires openvpn, not easyrsa) + ta_key_path = os.path.join(PKI_DIR, "ta.key") + subprocess.run(["openvpn", "--genkey", "secret", ta_key_path], check=True) + + # Gen CRL + _run_easyrsa(["gen-crl"], env) + + return "PKI Initialized" + +def clear_pki(db: Session): + # 1. Clear Database Users + from models import UserProfile + try: + db.query(UserProfile).delete() + db.commit() + except Exception as e: + logger.error(f"Failed to clear user profiles from DB: {e}") + db.rollback() + + # 2. Clear Client Configs + client_conf_dir = os.path.join(os.getcwd(), "client-config") + if os.path.exists(client_conf_dir): + import shutil + try: + shutil.rmtree(client_conf_dir) + # Recreate empty dir + os.makedirs(client_conf_dir, exist_ok=True) + except Exception as e: + logger.error(f"Failed to clear client configs: {e}") + + # 3. Clear PKI + if os.path.exists(PKI_DIR): + import shutil + shutil.rmtree(PKI_DIR) + return "System cleared: PKI environment, User DB, and Client profiles wiped." + return "PKI directory did not exist, but User DB and Client profiles were wiped." + +def build_client(username: str, db: Session): + env = _get_easyrsa_env(db) + _run_easyrsa(["build-client-full", username, "nopass"], env) + return True + +def revoke_client(username: str, db: Session): + env = _get_easyrsa_env(db) + _run_easyrsa(["revoke", username], env) + _run_easyrsa(["gen-crl"], env) + return True diff --git a/APP_PROFILER/services/process.py b/APP_PROFILER/services/process.py new file mode 100644 index 0000000..9b85af4 --- /dev/null +++ b/APP_PROFILER/services/process.py @@ -0,0 +1,140 @@ +import os +import subprocess +import logging +import time +import psutil + +logger = logging.getLogger(__name__) + +def get_os_type(): + """ + Simple check to distinguish Alpine from others. + """ + if os.path.exists("/etc/alpine-release"): + return "alpine" + return "debian" # default fallback to systemctl + +def control_service(action: str): + """ + Action: start, stop, restart + """ + if action not in ["start", "stop", "restart"]: + raise ValueError("Invalid action") + + os_type = get_os_type() + + cmd = [] + if os_type == "alpine": + cmd = ["rc-service", "openvpn", action] + else: + cmd = ["systemctl", action, "openvpn"] + + try: + # Capture output to return it or log it + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return { + "status": "success", + "message": f"Service {action} executed successfully", + "stdout": result.stdout + } + except subprocess.CalledProcessError as e: + logger.error(f"Service control failed: {e.stderr}") + return { + "status": "error", + "message": f"Failed to {action} service", + "stderr": e.stderr + } + except FileNotFoundError: + # Happens if rc-service or systemctl is missing (e.g. dev env) + return { + "status": "error", + "message": f"Command not found found for OS type {os_type}" + } + +def get_process_stats(): + """ + Returns dict with pid, cpu_percent, memory_mb, uptime. + Uses psutil for robust telemetry. + """ + pid = None + process = None + + # Find the process + try: + # Iterate over all running processes + for proc in psutil.process_iter(['pid', 'name']): + try: + if proc.info['name'] == 'openvpn': + pid = proc.info['pid'] + process = proc + break + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + pass + except Exception as e: + logger.error(f"Failed to find process via psutil: {e}") + + if not pid or not process: + return { + "status": "stopped", + "pid": None, + "cpu_percent": 0.0, + "memory_mb": 0.0, + "uptime": None + } + + # Get Stats + try: + # CPU Percent + # Increasing interval to 1.0s to ensure we capture enough ticks for accurate reading + # especially on systems with low clock resolution (e.g. 100Hz = 10ms ticks) + cpu = process.cpu_percent(interval=1.0) + + # Memory (RSS) + mem_info = process.memory_info() + rss_mb = round(mem_info.rss / 1024 / 1024, 2) + + # Uptime + create_time = process.create_time() + uptime_seconds = time.time() - create_time + uptime_str = format_seconds(uptime_seconds) + + return { + "status": "running", + "pid": pid, + "cpu_percent": cpu, + "memory_mb": rss_mb, + "uptime": uptime_str + } + + except (psutil.NoSuchProcess, psutil.AccessDenied): + # Process might have died between discovery and stats + return { + "status": "stopped", + "pid": None, + "cpu_percent": 0.0, + "memory_mb": 0.0, + "uptime": None + } + except Exception as e: + logger.error(f"Failed to get process stats: {e}") + return { + "status": "running", + "pid": pid, + "cpu_percent": 0.0, + "memory_mb": 0.0, + "uptime": None + } + +def format_seconds(seconds: float) -> str: + seconds = int(seconds) + days, seconds = divmod(seconds, 86400) + hours, seconds = divmod(seconds, 3600) + minutes, seconds = divmod(seconds, 60) + + parts = [] + if days > 0: parts.append(f"{days}d") + if hours > 0: parts.append(f"{hours}h") + if minutes > 0: parts.append(f"{minutes}m") + parts.append(f"{seconds}s") + + return " ".join(parts) diff --git a/APP_PROFILER/services/utils.py b/APP_PROFILER/services/utils.py new file mode 100644 index 0000000..21a5c2e --- /dev/null +++ b/APP_PROFILER/services/utils.py @@ -0,0 +1,29 @@ +import requests +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + +def get_public_ip() -> str: + """ + Detects public IP using external services. + Falls back to 127.0.0.1 on failure. + """ + services = [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + ] + + for service in services: + try: + response = requests.get(service, timeout=3) + if response.status_code == 200: + ip = response.text.strip() + # Basic validation could be added here + return ip + except requests.RequestException: + continue + + logger.warning("Could not detect public IP, defaulting to 127.0.0.1") + return "127.0.0.1" diff --git a/APP_PROFILER/templates/client.ovpn.j2 b/APP_PROFILER/templates/client.ovpn.j2 new file mode 100644 index 0000000..a3cf6b4 --- /dev/null +++ b/APP_PROFILER/templates/client.ovpn.j2 @@ -0,0 +1,44 @@ +client +dev tun +windows-driver wintun +proto {{ protocol }} +remote {{ remote_ip }} {{ port }} +resolv-retry infinite +nobind + +{% if tun_mtu %} +tun-mtu {{ tun_mtu }} +{% endif %} +user nobody +group nobody +persist-key +persist-tun + +{% if protocol == 'tcp' %} +tls-client +{% else %} +#tls-client +{% endif %} + +mute-replay-warnings + +remote-cert-tls server +data-ciphers CHACHA20-POLY1305:AES-256-GCM:AES-256-CBC +data-ciphers-fallback AES-256-CBC +auth SHA256 +verb 3 + +key-direction 1 + + +{{ ca_cert }} + + +{{ client_cert }} + + +{{ client_key }} + + +{{ tls_auth }} + diff --git a/APP_PROFILER/templates/server.conf.j2 b/APP_PROFILER/templates/server.conf.j2 new file mode 100644 index 0000000..1e9bc9e --- /dev/null +++ b/APP_PROFILER/templates/server.conf.j2 @@ -0,0 +1,110 @@ +dev tun +proto {{ protocol }} +{% if protocol == 'tcp' %} +tls-server +{% else %} +# explicit-exit-notify 1 +explicit-exit-notify 1 +{% endif %} +port {{ port }} + +# Keys +ca {{ ca_path }} +cert {{ srv_cert_path }} +key {{ srv_key_path }} +dh {{ dh_path }} +tls-auth {{ ta_path }} 0 + +{% if tun_mtu %} +tun-mtu {{ tun_mtu }} +{% endif %} +{% if mssfix %} +mssfix {{ mssfix }} +{% endif %} + +# Network topology +topology subnet +server {{ vpn_network }} {{ vpn_netmask }} + +ifconfig-pool-persist /etc/openvpn/ipp.txt + +log /etc/openvpn/openvpn.log +log-append /etc/openvpn/openvpn.log + +verb 3 + +# Use Extended Status Output +status /etc/openvpn/openvpn-status.log 5 +status-version 2 + +# Tunneling Mode +{% if tunnel_type == 'FULL' %} +push "redirect-gateway def1 bypass-dhcp" +# Full tunneling mode - all routes through VPN +{% else %} +# Split tunneling mode +{% for route in split_routes %} +push "route {{ route }}" +{% endfor %} +{% endif %} + +# DNS Configuration +{% if user_defined_dns %} +{% for dns in dns_servers %} +push "dhcp-option DNS {{ dns }}" +{% endfor %} +{% endif %} + +# Client-to-client communication +{% if client_to_client %} +client-to-client +{% else %} +# client-to-client disabled +{% endif %} + +user nobody +group nogroup + +# Allow same profile on multiple devices simultaneously +{% if duplicate_cn %} +duplicate-cn +{% else %} +# duplicate-cn disabled +{% endif %} + +# data protection +data-ciphers CHACHA20-POLY1305:AES-256-GCM:AES-256-CBC +data-ciphers-fallback AES-256-CBC +auth SHA256 + +keepalive 10 120 + +persist-key +persist-tun + +# check revocation list +{% if crl_verify %} +crl-verify /etc/openvpn/crl.pem +{% else %} +# crl-verify disabled +{% endif %} + +# Script Security Level +{% if user_defined_cdscripts %} +script-security 2 + +# Client Connect Script +{% if connect_script %} +client-connect "{{ connect_script }}" +{% endif %} + +# Client Disconnect Script +{% if disconnect_script %} +client-disconnect "{{ disconnect_script }}" +{% endif %} +{% endif %} + +# Enable Management Interface +{% if management_interface %} +management {{ management_interface_address }} {{ management_port }} +{% endif %} diff --git a/APP_PROFILER/test_server_process.py b/APP_PROFILER/test_server_process.py new file mode 100644 index 0000000..47a9945 --- /dev/null +++ b/APP_PROFILER/test_server_process.py @@ -0,0 +1,33 @@ +from fastapi.testclient import TestClient +from main import app +import sys +import os + +# Add project root to sys.path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +client = TestClient(app) + +def test_get_stats(): + response = client.get("/server/process/stats") + print(f"Stats response status: {response.status_code}") + print(f"Stats response body: {response.json()}") + assert response.status_code == 200 + data = response.json() + assert "pid" in data + assert "cpu_percent" in data + assert "memory_mb" in data + +def test_control_invalid(): + response = client.post("/server/process/invalid_action") + print(f"Invalid action response: {response.status_code}") + assert response.status_code == 400 + +if __name__ == "__main__": + print("Running API tests...") + try: + test_get_stats() + test_control_invalid() + print("Tests passed!") + except Exception as e: + print(f"Tests failed: {e}") diff --git a/APP_PROFILER/utils/__init__.py b/APP_PROFILER/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/APP_PROFILER/utils/auth.py b/APP_PROFILER/utils/auth.py new file mode 100644 index 0000000..e907523 --- /dev/null +++ b/APP_PROFILER/utils/auth.py @@ -0,0 +1,94 @@ +import jwt +import configparser +import os +from fastapi import Header, HTTPException, status +from pathlib import Path + +# Load config from the main APP directory +CONFIG_FILE = Path(__file__).parent.parent.parent / 'APP' / 'config.ini' + +def get_secret_key(): + # Priority 1: Environment Variable + env_secret = os.getenv('OVPMON_SECRET_KEY') + if env_secret: + print("[AUTH] Using SECRET_KEY from environment variable") + return env_secret + + # Priority 2: Config file (multiple possible locations) + # Resolve absolute path to be sure + base_path = Path(__file__).resolve().parent.parent + + config_locations = [ + base_path.parent / 'APP' / 'config.ini', # Brother directory (Local/Gitea structure) + base_path / 'APP' / 'config.ini', # Child directory + base_path / 'config.ini', # Same directory + Path('/opt/ovpmon/APP/config.ini'), # Common production path 1 + Path('/opt/ovpmon/config.ini'), # Common production path 2 + Path('/etc/ovpmon/config.ini'), # Standard linux config path + Path('/opt/ovpn_python_profiler/APP/config.ini') # Path based on traceback + ] + + config = configparser.ConfigParser() + for loc in config_locations: + if loc.exists(): + try: + config.read(loc) + if config.has_section('api') and config.has_option('api', 'secret_key'): + key = config.get('api', 'secret_key') + if key: + print(f"[AUTH] Successfully loaded SECRET_KEY from {loc}") + return key + except Exception as e: + print(f"[AUTH] Error reading config at {loc}: {e}") + continue + + print("[AUTH] WARNING: No config found, using default fallback SECRET_KEY") + return 'ovpmon-secret-change-me' + +SECRET_KEY = get_secret_key() + +async def verify_token(authorization: str = Header(None)): + if not authorization or not authorization.startswith("Bearer "): + print(f"[AUTH] Missing or invalid Authorization header: {authorization[:20] if authorization else 'None'}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is missing or invalid", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = authorization.split(" ")[1] + + try: + # Debug: Log a few chars of the key and token (safely) + # print(f"[AUTH] Decoding token with SECRET_KEY starting with: {SECRET_KEY[:3]}...") + + payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) + return payload + except Exception as e: + error_type = type(e).__name__ + error_detail = str(e) + print(f"[AUTH] JWT Decode Failed. Type: {error_type}, Detail: {error_detail}") + + # Handling exceptions dynamically to avoid AttributeError + if error_type == "ExpiredSignatureError": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + headers={"WWW-Authenticate": "Bearer"}, + ) + elif error_type in ["InvalidTokenError", "DecodeError"]: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is invalid", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + # Check if it's a TypeError (e.g. wrong arguments for decode) + if error_type == "TypeError": + print("[AUTH] Critical: jwt.decode failed with TypeError. This likely means 'jwt' package is installed instead of 'PyJWT'.") + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Authentication error: {error_type}", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/APP_PROFILER/utils/logging.py b/APP_PROFILER/utils/logging.py new file mode 100644 index 0000000..e2a9ee2 --- /dev/null +++ b/APP_PROFILER/utils/logging.py @@ -0,0 +1,12 @@ +import logging +import sys + +def setup_logging(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler("profiler.log") + ] + ) diff --git a/UI/client/.gitignore b/APP_UI/.gitignore similarity index 100% rename from UI/client/.gitignore rename to APP_UI/.gitignore diff --git a/UI/client/.vscode/extensions.json b/APP_UI/.vscode/extensions.json similarity index 100% rename from UI/client/.vscode/extensions.json rename to APP_UI/.vscode/extensions.json diff --git a/APP_UI/README.md b/APP_UI/README.md new file mode 100644 index 0000000..c67f1f4 --- /dev/null +++ b/APP_UI/README.md @@ -0,0 +1,25 @@ +# OpenVPN Dashboard UI (`APP_UI`) + +A Single Page Application (SPA) built with **Vue 3** and **Vite**. It serves as the unified dashboard for monitoring and management. + +## Project Structure + +- `src/views/`: Page components (Dashboard, Login, Profiles, etc.). +- `src/components/`: Reusable widgets (Charts, Sidebar). +- `src/stores/`: Pinia state management (Auth, Client Data). + +## Configuration + +Runtime configuration is loaded from `/config.json` (in `public/`) to allow environment-independent builds. + +## Development + +```bash +npm install +npm run dev +# Access at http://localhost:5173 +``` + +## Documentation +See `DOCS/UI/Architecture.md` for detailed architecture notes. + diff --git a/UI/client/index.html b/APP_UI/index.html similarity index 89% rename from UI/client/index.html rename to APP_UI/index.html index f16333d..67f4601 100644 --- a/UI/client/index.html +++ b/APP_UI/index.html @@ -5,7 +5,7 @@ - OpenVPN Monitor + OpenVPN Controller diff --git a/APP_UI/jsconfig.json b/APP_UI/jsconfig.json new file mode 100644 index 0000000..5d0d5c4 --- /dev/null +++ b/APP_UI/jsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": [ + "src/*" + ] + }, + "jsx": "preserve", + "module": "esnext", + "moduleResolution": "node", + "target": "esnext", + "lib": [ + "esnext", + "dom" + ], + "checkJs": true + }, + "include": [ + "src/**/*.js", + "src/**/*.vue", + "src/**/*.css", + "index.html" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/UI/client/package-lock.json b/APP_UI/package-lock.json similarity index 99% rename from UI/client/package-lock.json rename to APP_UI/package-lock.json index 980e618..b65b590 100644 --- a/UI/client/package-lock.json +++ b/APP_UI/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.13.2", "bootstrap": "^5.3.8", "chart.js": "^4.5.1", + "qrcode.vue": "^3.6.0", "sass": "^1.97.2", "sweetalert2": "^11.26.17", "vue": "^3.5.24", @@ -1936,6 +1937,15 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/qrcode.vue": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.6.0.tgz", + "integrity": "sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", diff --git a/UI/client/package.json b/APP_UI/package.json similarity index 94% rename from UI/client/package.json rename to APP_UI/package.json index 0c1a118..6d5e61c 100644 --- a/UI/client/package.json +++ b/APP_UI/package.json @@ -13,6 +13,7 @@ "axios": "^1.13.2", "bootstrap": "^5.3.8", "chart.js": "^4.5.1", + "qrcode.vue": "^3.6.0", "sass": "^1.97.2", "sweetalert2": "^11.26.17", "vue": "^3.5.24", diff --git a/UI/client/public/.htaccess b/APP_UI/public/.htaccess similarity index 100% rename from UI/client/public/.htaccess rename to APP_UI/public/.htaccess diff --git a/APP_UI/public/config.json b/APP_UI/public/config.json new file mode 100644 index 0000000..5b5a612 --- /dev/null +++ b/APP_UI/public/config.json @@ -0,0 +1,5 @@ +{ + "api_base_url": "/api/v1", + "profiles_api_base_url": "/profiles-api", + "refresh_interval": 30000 +} \ No newline at end of file diff --git a/APP_UI/public/logo.svg b/APP_UI/public/logo.svg new file mode 100644 index 0000000..fc4d6a3 --- /dev/null +++ b/APP_UI/public/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/UI/client/public/vite.svg b/APP_UI/public/vite.svg similarity index 100% rename from UI/client/public/vite.svg rename to APP_UI/public/vite.svg diff --git a/APP_UI/src/App.vue b/APP_UI/src/App.vue new file mode 100644 index 0000000..70e73ce --- /dev/null +++ b/APP_UI/src/App.vue @@ -0,0 +1,179 @@ + + + diff --git a/APP_UI/src/assets/logo.svg b/APP_UI/src/assets/logo.svg new file mode 100644 index 0000000..fc4d6a3 --- /dev/null +++ b/APP_UI/src/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/APP_UI/src/assets/logo_dark.svg b/APP_UI/src/assets/logo_dark.svg new file mode 100644 index 0000000..b3600ae --- /dev/null +++ b/APP_UI/src/assets/logo_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/APP_UI/src/assets/main.css b/APP_UI/src/assets/main.css new file mode 100644 index 0000000..3c06218 --- /dev/null +++ b/APP_UI/src/assets/main.css @@ -0,0 +1,1819 @@ +/* --- THEME VARIABLES --- */ +:root { + /* Light Theme */ + --bg-body: #f6f8fa; + --bg-card: #ffffff; + --bg-element: #f6f8fa; + --bg-element-hover: #f1f3f5; + --bg-input: #ffffff; + + --text-heading: #24292f; + --text-main: #57606a; + --text-muted: #8c959f; + + --border-color: #d0d7de; + --border-subtle: #e9ecef; + + --badge-border-active: #92bea5; + --badge-border-disconnected: #d47e80; + + --accent-color: #0969da; + --success-bg: rgba(39, 174, 96, 0.15); + --success-text: #1a7f37; + --danger-bg: rgba(231, 76, 60, 0.15); + --danger-text: #cf222e; + --warning-bg: rgba(255, 193, 7, 0.15); + --warning-text: #9a6700; + + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + --toggle-off-bg: #e9ecef; + --toggle-off-border: #d0d7de; + --toggle-on-bg: #EC7C31; + --toggle-on-border: #EC7C31; + --toggle-knob: #ffffff; + + /* Control Button Branding Variables (Light) */ + --btn-start-bg: #1652B8; + --btn-start-border: #1652B8; + --btn-start-text: #ffffff; + + --btn-restart-bg: #EC7C31; + --btn-restart-border: #EC7C31; + --btn-restart-text: #ffffff; + + --btn-stop-bg: #FA7355; + --btn-stop-border: #FA7355; + --btn-stop-text: #ffffff; + + /* Sidebar Variables (Light) */ + --bg-sidebar: #24292f; + --text-sidebar: #ffffff; + --text-sidebar-muted: #8b949e; + --bg-sidebar-hover: rgba(255, 255, 255, 0.1); + --bg-sidebar-active: #EC7C31; + --text-sidebar-active: #ffffff; + --border-sidebar-active: transparent; + --border-sidebar: rgba(255, 255, 255, 0.1); + --bg-sidebar-element: rgba(255, 255, 255, 0.05); +} + +/* Dark Theme (Soft & Low Contrast) */ +[data-theme="dark"] { + --bg-body: #0d1117; + --bg-card: #161b22; + --bg-element: #21262d; + + /* Используем прозрачность для hover, чтобы текст не сливался */ + --bg-element-hover: rgba(255, 255, 255, 0.08); + + --bg-input: #0d1117; + + --text-heading: #f0f6fc; + /* Светлее для заголовков */ + --text-main: #c9d1d9; + /* Мягкий серый для текста */ + --text-muted: #8b949e; + + /* ОЧЕНЬ мягкие границы (6% прозрачности белого) */ + --border-color: rgba(240, 246, 252, 0.08); + --border-subtle: rgba(240, 246, 252, 0.04); + + --badge-border-active: #3e6f40; + --badge-border-disconnected: #793837; + + --accent-color: #58a6ff; + --success-bg: rgba(35, 134, 54, 0.15); + --success-text: #3fb950; + --danger-bg: rgba(218, 54, 51, 0.15); + --danger-text: #f85149; + --warning-bg: rgba(210, 153, 34, 0.15); + --warning-text: #d29922; + + --shadow: none; + + --toggle-off-bg: rgba(110, 118, 129, 0.1); + --toggle-off-border: rgba(240, 246, 252, 0.1); + --toggle-on-bg: rgba(236, 124, 49, 0.15); + --toggle-on-border: rgba(236, 124, 49, 0.3); + --toggle-knob: #EC7C31; + + /* Control Button Branding Variables (Dark - Soft Style) */ + --btn-start-bg: rgba(22, 82, 184, 0.15); + --btn-start-border: rgba(22, 82, 184, 0.3); + --btn-start-text: #58a6ff; + + --btn-restart-bg: rgba(236, 124, 49, 0.15); + --btn-restart-border: rgba(236, 124, 49, 0.3); + --btn-restart-text: #EC7C31; + + --btn-stop-bg: rgba(250, 115, 85, 0.15); + --btn-stop-border: rgba(250, 115, 85, 0.3); + --btn-stop-text: #FA7355; + + /* Sidebar Variables (Dark) - Same as card or slightly different */ + --bg-sidebar: #161b22; + --text-sidebar: #c9d1d9; + --text-sidebar-muted: #8b949e; + --bg-sidebar-hover: rgba(255, 255, 255, 0.08); + --bg-sidebar-active: rgba(236, 124, 49, 0.15); + --text-sidebar-active: #EC7C31; + --border-sidebar-active: rgba(236, 124, 49, 0.3); + --border-sidebar: rgba(240, 246, 252, 0.08); + --bg-sidebar-element: rgba(240, 246, 252, 0.05); + + /* Card background refinement for 2FA and others */ + --bg-card-inner: #1c2128; +} + +body { + background-color: var(--bg-body); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 0.9rem; + color: var(--text-main); + margin: 0; + padding: 0; + overflow-x: hidden; + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* --- NEW APP LAYOUT --- */ +.app-wrapper { + display: flex; + min-height: 100vh; +} + +.text-success { + color: #1652B8 !important; + /* OpenVPN Blue */ +} + +.text-primary { + color: #1652B8 !important; + /* OpenVPN Blue */ +} + +.text-warning { + color: #EC7C31 !important; + /* OpenVPN Orange */ +} + +.text-danger { + color: #FA7355 !important; + /* OpenVPN Pinkish Red */ +} + +/* Sidebar */ +.sidebar { + width: 260px; + background-color: var(--bg-sidebar); + border-right: none; + display: flex; + flex-direction: column; + position: fixed; + height: 100vh; + z-index: 1000; + transition: width 0.3s ease, background-color 0.3s ease, border-color 0.3s ease; + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.04); +} + +.sidebar-header { + height: 64px; + display: flex; + align-items: center; + padding: 0 20px; + border-bottom: 1px solid var(--border-sidebar); + font-weight: 700; + font-size: 0.95rem; + color: var(--text-sidebar); +} + +.sidebar-brand-icon { + /* Image logo styles */ + width: 28px; + height: auto; + margin-right: 10px; + min-width: 24px; + /* Ensure icon has space in compact mode */ + object-fit: contain; +} + +.sidebar-menu { + flex: 1; + padding: 10px; + overflow-y: auto; + overflow-x: hidden; +} + +.sidebar-footer { + padding: 10px; + border-top: 1px solid var(--border-sidebar); + display: flex; + align-items: center; + transition: all 0.3s ease; +} + +.sidebar.compact .sidebar-footer { + padding: 10px 5px; + justify-content: center; +} + +.nav-link { + display: flex; + align-items: center; + padding: 10px 15px; + color: var(--text-sidebar); + opacity: 0.8; + text-decoration: none; + border-radius: 6px; + margin-bottom: 5px; + transition: all 0.2s ease; + font-weight: 500; + white-space: nowrap; + /* Prevent wrapping in compact */ +} + +.nav-link:hover { + background-color: var(--bg-sidebar-hover); + color: var(--text-sidebar); + opacity: 1; +} + +.nav-link.active { + background-color: var(--bg-sidebar-active); + color: var(--text-sidebar-active); + border: 1px solid var(--border-sidebar-active); + opacity: 1; +} + +.nav-link i { + width: 24px; + text-align: center; + margin-right: 10px; + font-size: 1rem; + flex-shrink: 0; +} + +.nav-section-header { + padding: 15px 15px 5px 15px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05rem; + color: var(--text-sidebar-muted); + text-transform: uppercase; +} + +/* Compact Mode Styles */ +.sidebar.compact { + width: 64px; +} + +.sidebar.compact .sidebar-header { + padding: 0; + justify-content: center; +} + +.sidebar.compact .brand-text, +.sidebar.compact .nav-link span, +.sidebar.compact .nav-section-header, +.sidebar.compact .sidebar-header .brand-text { + display: none; +} + +.sidebar.compact .sidebar-brand-icon { + margin-right: 0; +} + +.sidebar.compact .nav-link { + justify-content: center; + padding: 10px 5px; + /* Tighter padding */ +} + +.sidebar.compact .nav-link i { + margin-right: 0; +} + +/* Sidebar collapse button */ +.toggle-btn { + padding: 0; + width: 100%; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + background-color: var(--bg-sidebar-element); + border: 1px solid var(--border-sidebar); + color: var(--text-sidebar-muted); + text-decoration: none; + transition: all 0.2s ease; +} + +.toggle-btn:hover { + background-color: var(--bg-sidebar-hover); + color: var(--text-sidebar); + border-color: var(--text-sidebar-muted); +} + +.sidebar.compact .toggle-btn { + width: 40px; + margin: 0 auto; +} + + +/* Main Content Area */ +.main-content { + flex: 1; + margin-left: 260px; + width: calc(100% - 260px); + display: flex; + flex-direction: column; + min-height: 100vh; + transition: margin-left 0.3s ease, width 0.3s ease; +} + +.app-wrapper.no-sidebar .main-content { + margin-left: 0; + width: 100%; +} + +.app-wrapper.sidebar-compact .main-content { + margin-left: 64px; + width: calc(100% - 64px); +} + +/* Top Navigation Bar (Glassmorphism) */ +.top-navbar { + height: 64px; + background-color: rgba(255, 255, 255, 0.6); + /* Semi-transparent Light */ + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 30px; + position: sticky; + top: 0; + z-index: 900; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +[data-theme="dark"] .top-navbar { + background-color: rgba(22, 27, 34, 0.6); + /* Semi-transparent Dark */ +} + + + +.page-title h2 { + margin: 0; + font-size: 1.25rem; + color: var(--text-heading); +} + +/* Content Container */ +.content-wrapper { + padding: 30px; + flex: 1; +} + +/* Adapt existing container */ +.container { + max-width: 100% !important; + margin: 0 !important; + padding: 0 !important; +} + +/* Layout Elements */ +.card { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: var(--shadow); + margin-bottom: 20px; + transition: border-color 0.3s ease; +} + +.card-header { + background: transparent; + border-bottom: 1px solid var(--border-color); + padding: 15px 20px; + font-weight: 600; + color: var(--text-heading) !important; +} + +.card-header:first-child { + height: 55px; +} + +.btn-account-action { + width: 250px; + height: 44px; + padding-left: 0 !important; + padding-right: 0 !important; + flex: none !important; +} + +/* Interior alignment for account cards */ +.card-interior-icon { + height: 70px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +.card-interior-title { + height: 40px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.5rem; +} + +.card-interior-description { + height: 60px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; + padding: 0 1rem; + text-align: center; +} + +/* Custom Progress Bars */ +.progress { + background-color: var(--bg-element) !important; + border-radius: 20px !important; + overflow: hidden; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] .progress { + background-color: rgba(255, 255, 255, 0.05) !important; + /* Global dark track visibility fix */ + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); +} + +/* Scoped Refinements for CPU Usage (Light Theme ONLY) */ +[data-theme="light"] .cpu-progress .progress { + background-color: #ffffff !important; + border: 1px solid var(--border-color) !important; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05); +} + +/* Dark Theme specific for CPU Progress (Preserving global track) */ +[data-theme="dark"] .cpu-progress .progress { + border: none !important; + background-color: rgba(255, 255, 255, 0.05) !important; +} + +.cpu-progress .progress-bar-brand { + background-color: #1652B8 !important; + /* OpenVPN Blue */ + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-size: 1rem 1rem; + transition: width 0.6s ease; + border-radius: 20px !important; +} + +[data-theme="dark"] .cpu-progress .progress-bar-brand { + background-color: #1a73e8 !important; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: var(--text-heading); + font-weight: 600; +} + +.text-muted { + color: var(--text-muted) !important; +} + +/* Navbar Actions */ +.navbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* Buttons & Controls */ +.btn-nav, +.btn-header, +.header-badge, +.header-timezone { + background: var(--bg-element); + border: 1px solid var(--border-color); + color: var(--text-heading); + transition: all 0.2s ease; + font-size: 0.85rem; + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + /* Fixed height for consistency */ + vertical-align: middle; + border-radius: 6px; + text-decoration: none; +} + +.btn-nav:hover, +.btn-header:hover { + background: var(--bg-element-hover); + border-color: var(--text-muted); + color: var(--text-heading); +} + +.btn-nav.active { + background: var(--accent-color); + color: #ffffff; + border-color: var(--accent-color); +} + +.btn-header { + padding: 0 12px; + min-width: 36px; +} + +.btn-nav { + padding: 0 12px; + margin-right: 0.5rem; +} + +.header-badge, +.header-timezone { + padding: 0 12px; +} + +.header-divider { + width: 1px; + height: 24px; + background-color: var(--border-color); + margin: 0 5px; +} + +.user-profile { + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; + border-radius: 6px; + transition: background-color 0.2s; +} + +.user-avatar-small { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; +} + +.user-meta { + line-height: 1; +} + +.user-meta .username { + font-weight: 600; + font-size: 0.85rem; + color: var(--text-heading); +} + +.btn-logout { + color: var(--danger-text) !important; + background: transparent !important; + border-color: transparent !important; +} + +.btn-logout:hover { + background-color: var(--danger-bg) !important; + color: var(--danger-text) !important; +} + + + +/* Stats Cards & KPI */ +.stats-info, +.kpi-grid { + display: flex; + gap: 15px; + flex-wrap: wrap; + margin-bottom: 15px; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 20px; +} + +.stat-item { + background: var(--bg-card); + padding: 15px; + border-radius: 6px; + border: 1px solid var(--border-color); + flex: 1; + min-width: 180px; + box-shadow: var(--shadow); +} + +.kpi-grid .stat-item { + padding: 20px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.stat-value { + font-size: 1.4rem; + font-weight: 600; + color: var(--text-heading); + line-height: 1.2; +} + +.kpi-grid .stat-content h3 { + font-size: 1.6rem; + font-weight: 700; + color: var(--text-heading); + margin: 0; + line-height: 1.2; +} + +.stat-label { + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 5px; +} + +/* Tables */ +.table { + --bs-table-bg: transparent; + --bs-table-color: var(--text-main); + --bs-table-border-color: var(--border-color); + margin-bottom: 0; +} + +.table th { + background-color: var(--bg-card); + color: var(--text-muted); + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 10; + cursor: pointer; + user-select: none; + padding: 12px 10px; +} + +.table th:hover { + color: var(--text-heading); +} + +.table th.active-sort { + color: var(--accent-color); + border-bottom-color: var(--accent-color); +} + +.table td { + padding: 12px 10px; + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; +} + +.table-hover tbody tr:hover { + background-color: var(--bg-element-hover); +} + +.table-hover tbody tr:hover td, +.table-hover tbody tr:hover .font-monospace { + color: var(--text-heading); +} + +.font-monospace { + color: var(--text-main); + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; +} + +/* Section Divider */ +.section-divider td { + background-color: var(--bg-element) !important; + font-weight: 600; + color: var(--text-heading); + padding: 8px 15px; + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + font-size: 0.8rem; +} + +/* Badges */ +.status-badge { + padding: 4px 8px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + display: inline-block; + line-height: normal; + border: 1px solid transparent; +} + +/* Using !important to ensure override of any bootstrap or generic styles */ +.status-valid, +.status-active { + background-color: rgba(22, 82, 184, 0.1) !important; + color: #1652B8 !important; + /* OpenVPN Blue */ + border-color: rgba(22, 82, 184, 0.2) !important; +} + +.status-expired, +.status-disconnected { + background-color: rgba(250, 115, 85, 0.1) !important; + color: #FA7355 !important; + /* OpenVPN Pinkish Red */ + border-color: rgba(250, 115, 85, 0.2) !important; +} + +.status-warning, +.status-expiring { + background-color: rgba(236, 124, 49, 0.1) !important; + color: #EC7C31 !important; + /* OpenVPN Orange */ + border-color: rgba(236, 124, 49, 0.2) !important; +} + +.status-server { + background-color: rgba(13, 202, 240, 0.1) !important; + color: #0c5460 !important; + border-color: rgba(13, 202, 240, 0.2) !important; +} + +.status-client { + background-color: rgba(108, 117, 125, 0.1) !important; + color: #373b3e !important; + border-color: rgba(108, 117, 125, 0.2) !important; +} + +.status-secondary { + background-color: rgba(108, 117, 125, 0.1) !important; + color: #373b3e !important; + border-color: rgba(108, 117, 125, 0.2) !important; +} + +.badge-soft-warning { + background: var(--warning-bg); + color: var(--warning-text); + border: 1px solid transparent; + font-weight: 600; + font-size: 0.75rem; + padding: 5px 10px; +} + +.client-link { + color: var(--text-heading); + text-decoration: none; + font-weight: 600; + cursor: pointer; + transition: color 0.2s; +} + +.client-link:hover { + color: var(--accent-color); +} + +.client-link i { + color: var(--text-muted); + transition: color 0.2s; +} + +.client-link:hover i { + color: var(--accent-color); +} + +/* Inputs */ +.form-control, +.form-select, +.search-input { + background-color: var(--bg-input); + border: 1px solid var(--border-color); + color: var(--text-heading); + border-radius: 6px; +} + +.form-control:focus, +.form-select:focus, +.search-input:focus { + background-color: var(--bg-input); + border-color: var(--accent-color); + color: var(--text-heading); + box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.15); + outline: none; +} + +.form-control::placeholder { + color: var(--text-muted); + opacity: 0.7; +} + +.form-label { + color: var(--text-heading); + font-weight: 500; +} + +.input-group-text { + background-color: var(--bg-element); + border: 1px solid var(--border-color); + color: var(--text-muted); +} + +/* Toggle Switch */ +.form-check-input { + background-color: var(--toggle-off-bg); + border-color: var(--toggle-off-border); + cursor: pointer; +} + +.form-check-input:checked { + background-color: #EC7C31; + border-color: #EC7C31; +} + +[data-theme="dark"] .form-check-input:not(:checked) { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%238b949e'/%3e%3c/svg%3e"); +} + +/* Sort Buttons */ +.sort-btn-group { + display: flex; +} + +.sort-btn { + background: var(--bg-input); + border: 1px solid var(--border-color); + padding: 6px 12px; + font-size: 0.85rem; + color: var(--text-main); + min-width: 100px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.sort-btn:first-child { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + border-right: none; +} + +.sort-btn:last-child { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +} + +.sort-btn:hover { + background-color: var(--bg-element-hover); +} + +.sort-btn.active { + background-color: var(--bg-element-hover); + color: var(--text-heading); + border-color: var(--text-muted); + font-weight: 600; + z-index: 2; +} + +/* Modals */ +.modal-content { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-main); + box-shadow: 0 15px 50px rgba(0, 0, 0, 0.5); +} + +.modal-header { + border-bottom: 1px solid var(--border-color); +} + +.modal-footer { + border-top: 1px solid var(--border-color); +} + +.btn-close { + filter: var(--btn-close-filter, none); +} + +[data-theme="dark"] .btn-close { + filter: invert(1) grayscale(100%) brightness(200%); +} + +.modal-backdrop.show { + opacity: 0.6; + background-color: #000; +} + +.chart-controls { + background: var(--bg-element); + padding: 12px; + border-radius: 6px; + border: 1px solid var(--border-color); + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + + +.bg-white-custom { + background-color: var(--bg-input) !important; + border-color: var(--border-color) !important; +} + +.chart-container { + position: relative; + height: 320px; + width: 100%; +} + +.modal-chart-container { + height: 400px; + width: 100%; + position: relative; +} + +.pie-container { + position: relative; + height: 220px; + width: 100%; + display: flex; + justify-content: center; +} + +.chart-header-controls { + display: flex; + align-items: center; + gap: 15px; +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + margin-right: 5px; +} + +/* Certificates Specifics */ +.category-header { + background: linear-gradient(135deg, var(--bg-element), var(--bg-element-hover)); + border-left: 4px solid; + padding: 12px 20px; + margin: 0; + font-weight: 600; +} + +.category-header.active { + border-left-color: var(--success-text); + color: var(--success-text); +} + +.category-header.expired { + border-left-color: var(--danger-text); + color: var(--danger-text); +} + +.certificate-file { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 2px; +} + +.empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); +} + +.empty-state i { + font-size: 3rem; + margin-bottom: 15px; + opacity: 0.5; +} + +.cert-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.cert-item:last-child { + border-bottom: none; +} + +/* Utilities */ +.refresh-indicator { + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* Responsive */ +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + width: 260px; + /* Keep fixed width when open */ + } + + .app-wrapper.mobile-nav-active .sidebar { + transform: translateX(0); + box-shadow: 0 0 50px rgba(0, 0, 0, 0.5); + } + + /* Force full-width and visibility even if sidebar.compact is present */ + .sidebar.compact { + width: 260px !important; + } + + .sidebar.compact .brand-text, + .sidebar.compact .nav-link span, + .sidebar.compact .nav-section-header, + .sidebar.compact .sidebar-header .brand-text { + display: block !important; + } + + .sidebar.compact .sidebar-header { + padding: 0 20px !important; + justify-content: flex-start !important; + } + + .sidebar.compact .sidebar-brand-icon { + margin-right: 10px !important; + } + + .sidebar.compact .nav-link { + justify-content: flex-start !important; + padding: 10px 15px !important; + } + + .sidebar.compact .nav-link i { + margin-right: 10px !important; + } + + .main-content, + .app-wrapper.sidebar-compact .main-content { + margin-left: 0 !important; + width: 100% !important; + } + + .header-controls { + flex-wrap: wrap; + gap: 8px; + } + + .stats-info { + flex-direction: column; + } + + .chart-controls { + flex-direction: column; + align-items: flex-start; + } + + .chart-controls>div { + width: 100%; + } + + .kpi-grid { + grid-template-columns: 1fr; + } + + .chart-header-controls { + flex-wrap: wrap; + margin-top: 10px; + } + + /* Overlay for mobile sidebar */ + .mobile-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + backdrop-filter: blur(2px); + } +} + +/* --- CONFIGURATION PAGES (PKI & VPN) --- */ + +/* Config Page Layout */ +.config-wrapper { + display: flex; + justify-content: center; + align-items: flex-start; + padding: 40px 0; + min-height: calc(100vh - 60px); + background-color: var(--bg-body); +} + +.config-card { + width: 640px; + max-width: 100%; + margin: 0 25px; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 24px; + background-color: var(--bg-card); + /* RESTORED: Specific shadow for config cards */ + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.04); +} + +[data-theme="dark"] .config-card { + box-shadow: none; +} + +/* Config Header */ +.config-header { + background-color: var(--bg-element); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 22px; + text-align: center; + margin-bottom: 28px; +} + +.config-header h1 { + font-size: 19px; + font-weight: 600; + color: var(--text-heading); + letter-spacing: 0.3px; + margin: 0; +} + +/* Form Grid */ +.config-row { + display: flex; + gap: 20px; + margin-bottom: 18px; +} + +.config-col { + flex: 1; + display: flex; + flex-direction: column; +} + +.config-divider { + height: 1px; + background-color: var(--border-color); + margin: 24px 0; + width: 100%; +} + +/* Inputs & Labels */ +.config-label { + font-size: 12px; + text-transform: uppercase; + color: var(--text-heading); + margin-bottom: 7px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.config-input, +.config-select { + height: 40px; + padding: 0 12px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 14px; + background-color: var(--bg-input); + color: var(--text-heading); + width: 100%; + box-sizing: border-box; + transition: all 0.2s ease; +} + +.config-input:focus, +.config-select:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.15); +} + +/* Unified Buttons */ +.btn-action-group { + display: flex; + justify-content: space-between; + gap: 16px; + margin-top: 32px; +} + +.btn-action { + flex: 1; + padding: 14px 0; + border-radius: 6px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.2s; + font-size: 15px; + letter-spacing: 0.3px; +} + +.btn-action i { + font-size: 16px; +} + +.btn-action-save { + background-color: var(--btn-restart-bg) !important; + color: var(--btn-restart-text) !important; + border-color: var(--btn-restart-border) !important; +} + +.btn-action-primary { + /* Init/Apply / Start */ + background-color: var(--btn-start-bg) !important; + color: var(--btn-start-text) !important; + border-color: var(--btn-start-border) !important; +} + +.btn-action-danger { + /* Clear / Stop */ + background-color: var(--btn-stop-bg) !important; + color: var(--btn-stop-text) !important; + border-color: var(--btn-stop-border) !important; +} + +.btn-action-secondary { + /* Cancel / Reset */ + background-color: transparent; + color: var(--text-muted); + border-color: var(--border-color); +} + +.btn-action-secondary:hover { + background-color: var(--bg-element-hover); + color: var(--text-heading); + border-color: var(--text-muted); +} + +.btn-action:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + filter: brightness(0.9); + /* More distinct darkening for light theme */ +} + +.btn-action:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +/* Small Action Button (Unified) */ +.btn-action.btn-sm { + flex: none; + padding: 0 20px; + height: 36px; + font-size: 0.85rem; + gap: 8px; +} + +.btn-action.btn-sm i { + font-size: 14px; +} + + +/* Custom Toggle Switch for Configs */ +.toggle-wrapper { + display: flex; + align-items: center; + height: 40px; + margin: 0; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 26px; + margin: 7px 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--toggle-off-bg); + transition: .3s; + border-radius: 34px; + border: 1px solid var(--border-color); +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 3px; + bottom: 2px; + background-color: var(--toggle-knob); + transition: .3s; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +input:checked+.toggle-slider { + background-color: var(--toggle-on-bg); + border-color: var(--toggle-on-border); +} + +input:checked+.toggle-slider:before { + transform: translateX(22px); +} + +/* Nested Container */ +.nested-box { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 20px; + margin-top: 10px; + margin-bottom: 10px; + background-color: var(--bg-card); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.nested-label { + color: var(--text-muted); + margin-bottom: 12px; +} + + +/* Dashed Button */ +.btn-dashed { + width: 100%; + margin-top: 10px; + padding: 10px; + background-color: var(--bg-element); + border: 1px dashed #EC7C31; + color: var(--accent-color); + border-radius: 6px; + cursor: pointer; + font-weight: 500; + transition: background 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + text-decoration: none; +} + +.btn-dashed:hover { + background-color: var(--bg-element-hover); + color: var(--accent-color); +} + +.btn-icon-sm { + width: 42px; + padding: 0; + flex-shrink: 0; + border: 1px solid var(--danger-bg); + background-color: var(--danger-bg); + color: var(--danger-text); + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + height: 40px; +} + +.btn-icon-sm:hover { + background-color: var(--bg-element-hover); + /* filter: brightness(0.95); */ +} + +/* 2FA Setup Refinements */ +.qr-setup-container { + max-width: 400px; + background-color: var(--bg-card-inner, var(--bg-element)); + padding: 30px; + border-radius: 12px; + border: 1px solid var(--border-color); +} + +.qr-code-wrapper { + background-color: #ffffff; + /* QR code needs white background for scanning */ + padding: 15px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] .qr-code-wrapper { + background-color: #f0f6fc; + /* Slightly off-white for dark mode to reduce glare */ +} + +.secret-value-box { + background-color: var(--bg-body); + color: var(--text-heading); + border: 1px solid var(--border-color) !important; + /* Enforce soft border */ + letter-spacing: 1px; + word-break: break-all; + transition: border-color 0.2s; +} + +.secret-value-box:hover { + border-color: var(--text-muted); + /* Subtle highlight on hover */ +} + +.verification-group .input-group-lg .form-control { + font-size: 1.5rem; + letter-spacing: 2px; + background-color: var(--bg-body); + border-color: var(--border-color); + height: 56px; + /* Explicit height to match button */ +} + +.verification-group .btn-action { + height: 56px; + border-radius: 0 6px 6px 0; + flex: none; + /* Prevent button from stretching unpredictably in input-group */ + min-width: 140px; +} + +[data-theme="dark"] .btn-action:hover { + filter: brightness(1.2); + /* Highlight instead of darken in dark mode */ + transform: translateY(-1px); +} + + +/* Toggle Container helper for HistoryModal */ +.chart-controls .border { + border-color: var(--border-color) !important; +} + +.config-input-group { + display: flex; + gap: 10px; + align-items: center; + width: 100%; +} + +.config-input-group .config-input { + width: auto; + flex: 1; +} + +/* Functional Number Input Spin Buttons (Wrapper Approach) */ +.number-input-container, +.select-container { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.number-input-container .config-input, +.select-container .config-select { + padding-right: 36px; +} + +.number-input-controls, +.select-arrow { + position: absolute; + right: 12px; + /* Standardized offset for all arrows */ + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + /* Perfectly center icons horizontally on the axis */ + width: 24px; + /* Fixed width ensures all icons stay on the same vertical line */ + pointer-events: none; +} + +.number-input-controls { + height: calc(100% - 2px); +} + +.select-arrow { + color: var(--text-muted); + font-size: 10px; + transition: all 0.2s ease; +} + +.number-input-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + padding: 0; + margin: 0; + cursor: pointer; + color: var(--text-muted); + transition: all 0.2s ease; + pointer-events: auto; + -webkit-user-select: none; + user-select: none; +} + +.number-input-btn:hover { + color: var(--text-heading); +} + +[data-theme="dark"] .number-input-btn, +[data-theme="dark"] .select-arrow { + color: var(--text-main); +} + +[data-theme="dark"] .number-input-btn:hover { + color: #ffffff; +} + +.number-input-btn i { + font-size: 8px; +} + +/* Ensure the input itself doesn't show text cursor on the buttons */ +.number-input-container .config-input { + cursor: text; +} + +/* Dropdown specific overrides */ +.config-select { + -webkit-appearance: none !important; + -moz-appearance: none !important; + appearance: none !important; + background: var(--bg-input); + cursor: pointer; +} + +/* Hide default spin buttons entirely */ +input[type="number"].config-input::-webkit-inner-spin-button, +input[type="number"].config-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="number"].config-input { + -moz-appearance: textfield; + appearance: textfield; +} + +/* Messages / Toast */ +.toast-message { + padding: 14px 22px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; + opacity: 0; + transform: translateY(-10px); + transition: all 0.3s ease; +} + +.toast-message.show { + opacity: 1; + transform: translateY(0); +} + +.toast-success { + background-color: #d1fae5; + color: #065f46; + border-left: 4px solid #10b981; +} + +.toast-warning { + background-color: #fef3c7; + color: #92400e; + border-left: 4px solid #f59e0b; +} + +.toast-error { + background-color: #fee2e2; + color: #991b1b; + border-left: 4px solid #ef4444; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-5px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in { + animation: fadeIn 0.3s ease-out forwards; +} + +/* --- COMPONENT-SPECIFIC STYLES (Centralized) --- */ + +/* BaseModal & Modals */ +.modal-content { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + box-shadow: 0 15px 50px rgba(0, 0, 0, 0.15); + border-radius: 12px; +} + +[data-theme="dark"] .modal-content { + box-shadow: 0 15px 50px rgba(0, 0, 0, 0.5); +} + +.modal-header .btn-close { + background-size: 0.8em; +} + +/* Login Page Stylings */ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg-body); + padding: 20px; +} + +.login-card { + width: 100%; + max-width: 400px; + background: var(--bg-card); + padding: 40px; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05); + border: 1px solid var(--border-color); +} + +.login-header { + text-align: center; + margin-bottom: 30px; +} + +.brand-logo { + width: 60px; + height: 60px; + background: var(--accent-color); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + border-radius: 12px; + margin: 0 auto 15px; +} + +.login-header h1 { + font-size: 24px; + font-weight: 700; + margin: 0; + color: var(--text-heading); +} + +.btn-primary { + background-color: #1652B8; + border-color: #1652B8; +} + +.btn-primary:hover { + background-color: #1449a5; + border-color: #1449a5; +} + +.btn-warning { + background-color: #EC7C31; + border-color: #EC7C31; + color: white; +} + +.btn-warning:hover { + background-color: #d66d2a; + border-color: #d66d2a; + color: white; +} + +/* Server Management Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; +} + +.stats-grid .stat-item { + background: var(--bg-body); + padding: 1.25rem; + border-radius: 8px; + border: 1px solid var(--border-color); + display: flex; + flex-direction: column; + justify-content: center; +} + +.stats-grid .stat-item.full-width { + grid-column: span 2; +} + +.stats-grid .stat-label { + display: block; + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 0.5rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; +} + +.stats-grid .stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-heading); +} + +/* Account & Security Helpers */ +.qr-code-wrapper { + transition: transform 0.2s ease; +} + +.qr-code-wrapper:hover { + transform: scale(1.02); +} + +/* Shared Utilities */ +.cursor-pointer { + cursor: pointer; +} + +.monospace { + font-family: 'Courier New', Courier, monospace; +} + +/* Additional Responsive Logic */ +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: 1fr; + } + + .stats-grid .stat-item.full-width { + grid-column: span 1; + } +} \ No newline at end of file diff --git a/APP_UI/src/components/BaseModal.vue b/APP_UI/src/components/BaseModal.vue new file mode 100644 index 0000000..9786114 --- /dev/null +++ b/APP_UI/src/components/BaseModal.vue @@ -0,0 +1,76 @@ + + + + diff --git a/APP_UI/src/components/ConfirmModal.vue b/APP_UI/src/components/ConfirmModal.vue new file mode 100644 index 0000000..8884239 --- /dev/null +++ b/APP_UI/src/components/ConfirmModal.vue @@ -0,0 +1,49 @@ + + + diff --git a/UI/client/src/components/HistoryModal.vue b/APP_UI/src/components/HistoryModal.vue similarity index 90% rename from UI/client/src/components/HistoryModal.vue rename to APP_UI/src/components/HistoryModal.vue index f5eec6a..c4fa565 100644 --- a/UI/client/src/components/HistoryModal.vue +++ b/APP_UI/src/components/HistoryModal.vue @@ -5,7 +5,7 @@