Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ffd64801 | ||
|
|
e5c0e154b5 | ||
|
|
0ccdfcf7bf | ||
|
|
68c57c174e | ||
|
|
f7fe266571 | ||
|
|
8fd44fc658 | ||
|
|
f6a81b3d7c | ||
|
|
f177a89f0b | ||
|
|
961de020fb | ||
|
|
195d40daa2 | ||
|
|
0961daedce | ||
|
|
9d10bb97c7 | ||
|
|
6131bcaba9 | ||
|
|
f9df3f8d05 | ||
|
|
4bd4127bb5 | ||
|
|
5260e45bd8 | ||
|
|
bb1a3c9400 |
@@ -1,3 +1,9 @@
|
|||||||
|
# APP_CORE API
|
||||||
|
# Supported ENV overrides (Format: OVPMON_{SECTION}_{KEY}):
|
||||||
|
# API: OVPMON_API_HOST, OVPMON_API_PORT, OVPMON_API_DEBUG, OVPMON_API_SECRET_KEY
|
||||||
|
# MONITOR: OVPMON_OPENVPN_MONITOR_DB_PATH, OVPMON_OPENVPN_MONITOR_LOG_PATH, OVPMON_OPENVPN_MONITOR_CHECK_INTERVAL
|
||||||
|
# LOGGING: OVPMON_LOGGING_LEVEL, OVPMON_LOGGING_LOG_FILE
|
||||||
|
# RETENTION: OVPMON_RETENTION_RAW_RETENTION_DAYS, OVPMON_RETENTION_AGG_5M_RETENTION_DAYS, etc.
|
||||||
FROM python:3.12-alpine
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -6,11 +12,12 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy source code
|
# Copy application
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose the port
|
# Ensure DB directory exists
|
||||||
|
RUN mkdir -p /app/db
|
||||||
|
|
||||||
EXPOSE 5001
|
EXPOSE 5001
|
||||||
|
|
||||||
# Run the API
|
|
||||||
CMD ["python", "openvpn_api_v3.py"]
|
CMD ["python", "openvpn_api_v3.py"]
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# APP_CORE Gatherer
|
||||||
|
# Supported ENV overrides (Format: OVPMON_{SECTION}_{KEY}):
|
||||||
|
# MONITOR: OVPMON_OPENVPN_MONITOR_DB_PATH, OVPMON_OPENVPN_MONITOR_LOG_PATH, OVPMON_OPENVPN_MONITOR_CHECK_INTERVAL
|
||||||
|
# LOGGING: OVPMON_LOGGING_LEVEL, OVPMON_LOGGING_LOG_FILE
|
||||||
|
# RETENTION: OVPMON_RETENTION_RAW_RETENTION_DAYS, OVPMON_RETENTION_AGG_5M_RETENTION_DAYS, etc.
|
||||||
FROM python:3.12-alpine
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -6,8 +11,10 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy source code
|
# Copy application
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run the gatherer
|
# Ensure DB directory exists
|
||||||
|
RUN mkdir -p /app/db
|
||||||
|
|
||||||
CMD ["python", "openvpn_gatherer_v3.py"]
|
CMD ["python", "openvpn_gatherer_v3.py"]
|
||||||
|
|||||||
@@ -1,27 +1,17 @@
|
|||||||
[api]
|
[api]
|
||||||
host = 0.0.0.0
|
host = 0.0.0.0
|
||||||
port = 5000
|
port = 5001
|
||||||
debug = false
|
debug = false
|
||||||
secret_key = ovpmon-secret-change-me
|
secret_key = ovpmon-secret-change-me
|
||||||
|
|
||||||
[openvpn_monitor]
|
[openvpn_monitor]
|
||||||
log_path = /etc/openvpn/openvpn-status.log
|
log_path = /var/log/openvpn/openvpn-status.log
|
||||||
db_path = /opt/ovpmon/openvpn_monitor.db
|
db_path = openvpn_monitor.db
|
||||||
check_interval = 10
|
check_interval = 10
|
||||||
data_retention_days = 90
|
|
||||||
cleanup_interval_hours = 24
|
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
level = INFO
|
level = INFO
|
||||||
log_file = /opt/ovpmon/openvpn_monitor.log
|
log_file = openvpn_gatherer.log
|
||||||
|
|
||||||
[visualization]
|
|
||||||
refresh_interval = 5
|
|
||||||
max_display_rows = 50
|
|
||||||
|
|
||||||
[certificates]
|
|
||||||
certificates_path = /opt/ovpn/pki/issued
|
|
||||||
certificate_extensions = crt
|
|
||||||
|
|
||||||
[retention]
|
[retention]
|
||||||
raw_retention_days = 7
|
raw_retention_days = 7
|
||||||
@@ -30,7 +20,3 @@ agg_15m_retention_days = 28
|
|||||||
agg_1h_retention_days = 90
|
agg_1h_retention_days = 90
|
||||||
agg_6h_retention_days = 180
|
agg_6h_retention_days = 180
|
||||||
agg_1d_retention_days = 365
|
agg_1d_retention_days = 365
|
||||||
|
|
||||||
[pki]
|
|
||||||
pki_path = /opt/ovpn/pki
|
|
||||||
easyrsa_path = /opt/ovpn/easy-rsa
|
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ class DatabaseManager:
|
|||||||
def load_config(self):
|
def load_config(self):
|
||||||
if os.path.exists(self.config_file):
|
if os.path.exists(self.config_file):
|
||||||
self.config.read(self.config_file)
|
self.config.read(self.config_file)
|
||||||
self.db_path = self.config.get('openvpn_monitor', 'db_path', fallback='openvpn_monitor.db')
|
|
||||||
|
# Priority: ENV > Config File > Fallback
|
||||||
|
env_db_path = os.getenv('OVPMON_OPENVPN_MONITOR_DB_PATH')
|
||||||
|
if env_db_path:
|
||||||
|
self.db_path = env_db_path
|
||||||
|
else:
|
||||||
|
self.db_path = self.config.get('openvpn_monitor', 'db_path', fallback='openvpn_monitor.db')
|
||||||
|
|
||||||
def get_connection(self):
|
def get_connection(self):
|
||||||
"""Get a database connection"""
|
"""Get a database connection"""
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from flask import Flask, jsonify, request, send_file
|
from flask import Flask, jsonify, request, send_file
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import jwt
|
import jwt
|
||||||
import pyotp
|
import pyotp
|
||||||
import bcrypt
|
import bcrypt
|
||||||
@@ -31,6 +28,17 @@ app = Flask(__name__)
|
|||||||
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
|
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
|
||||||
|
|
||||||
class OpenVPNAPI:
|
class OpenVPNAPI:
|
||||||
|
def get_config_value(self, section, key, fallback=None):
|
||||||
|
try:
|
||||||
|
# Priority: ENV > Config File > Fallback
|
||||||
|
env_key = f"OVPMON_{section.upper()}_{key.upper()}".replace('-', '_').replace(' ', '_')
|
||||||
|
env_val = os.getenv(env_key)
|
||||||
|
if env_val is not None:
|
||||||
|
return env_val
|
||||||
|
return self.config.get(section, key, fallback=fallback)
|
||||||
|
except:
|
||||||
|
return fallback
|
||||||
|
|
||||||
def __init__(self, config_file='config.ini'):
|
def __init__(self, config_file='config.ini'):
|
||||||
self.db_manager = DatabaseManager(config_file)
|
self.db_manager = DatabaseManager(config_file)
|
||||||
self.db_manager.init_database()
|
self.db_manager.init_database()
|
||||||
@@ -38,21 +46,10 @@ class OpenVPNAPI:
|
|||||||
self.config.read(config_file)
|
self.config.read(config_file)
|
||||||
|
|
||||||
# Paths
|
# Paths
|
||||||
self.certificates_path = self.config.get('certificates', 'certificates_path', fallback='/etc/openvpn/certs')
|
self.public_ip = self.get_config_value('openvpn_monitor', 'public_ip', fallback='')
|
||||||
self.easyrsa_path = self.config.get('pki', 'easyrsa_path', fallback='/etc/openvpn/easy-rsa')
|
|
||||||
self.pki_path = self.config.get('pki', 'pki_path', fallback='/etc/openvpn/pki') # Fixed default to match Settings
|
|
||||||
self.templates_path = self.config.get('api', 'templates_path', fallback='templates')
|
|
||||||
self.server_config_dir = self.config.get('server', 'config_dir', fallback='/etc/openvpn')
|
|
||||||
self.server_config_path = self.config.get('server', 'config_path', fallback=os.path.join(self.server_config_dir, 'server.conf')) # Specific file
|
|
||||||
self.public_ip = self.config.get('openvpn_monitor', 'public_ip', fallback='')
|
|
||||||
|
|
||||||
self.cert_extensions = self.config.get('certificates', 'certificate_extensions', fallback='crt,pem,key').split(',')
|
|
||||||
self._cert_cache = {}
|
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
# Priority 1: Environment Variable
|
self.secret_key = self.get_config_value('api', 'secret_key', fallback='ovpmon-secret-change-me')
|
||||||
# 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
|
app.config['SECRET_KEY'] = self.secret_key
|
||||||
|
|
||||||
# Ensure at least one user exists
|
# Ensure at least one user exists
|
||||||
@@ -130,140 +127,7 @@ class OpenVPNAPI:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# --- БЛОК РАБОТЫ С СЕРТИФИКАТАМИ (Оставлен без изменений) ---
|
# --- БЛОК РАБОТЫ С СЕРТИФИКАТАМИ (Оставлен без изменений) ---
|
||||||
def parse_openssl_date(self, date_str):
|
# -----------------------------------------------------------
|
||||||
try:
|
|
||||||
parts = date_str.split()
|
|
||||||
if len(parts[1]) == 1:
|
|
||||||
parts[1] = f' {parts[1]}'
|
|
||||||
normalized_date = ' '.join(parts)
|
|
||||||
return datetime.strptime(normalized_date, '%b %d %H:%M:%S %Y GMT')
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
return datetime.strptime(date_str, '%b %d %H:%M:%S %Y %Z')
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(f"Could not parse date: {date_str}")
|
|
||||||
return datetime.min
|
|
||||||
|
|
||||||
def calculate_days_remaining(self, not_after_str):
|
|
||||||
if not_after_str == 'N/A': return 'N/A'
|
|
||||||
try:
|
|
||||||
expiration_date = self.parse_openssl_date(not_after_str)
|
|
||||||
if expiration_date == datetime.min: return 'N/A'
|
|
||||||
days_remaining = (expiration_date - datetime.now()).days
|
|
||||||
if days_remaining < 0: return f"Expired ({abs(days_remaining)} days ago)"
|
|
||||||
else: return f"{days_remaining} days"
|
|
||||||
except Exception: return 'N/A'
|
|
||||||
|
|
||||||
def extract_cert_info(self, cert_file):
|
|
||||||
try:
|
|
||||||
result = subprocess.run(['openssl', 'x509', '-in', cert_file, '-noout', '-text'],
|
|
||||||
capture_output=True, text=True, check=True)
|
|
||||||
output = result.stdout
|
|
||||||
data = {'file': os.path.basename(cert_file), 'file_path': cert_file, 'subject': 'N/A',
|
|
||||||
'issuer': 'N/A', 'not_after': 'N/A', 'not_before': 'N/A', 'serial': 'N/A', 'type': 'Unknown'}
|
|
||||||
|
|
||||||
is_ca = False
|
|
||||||
extended_usage = ""
|
|
||||||
|
|
||||||
for line in output.split('\n'):
|
|
||||||
line = line.strip()
|
|
||||||
if line.startswith('Subject:'):
|
|
||||||
data['subject'] = line.split('Subject:', 1)[1].strip()
|
|
||||||
cn_match = re.search(r'CN\s*=\s*([^,]+)', data['subject'])
|
|
||||||
if cn_match: data['common_name'] = cn_match.group(1).strip()
|
|
||||||
elif 'Not After' in line:
|
|
||||||
data['not_after'] = line.split(':', 1)[1].strip()
|
|
||||||
elif 'Not Before' in line:
|
|
||||||
data['not_before'] = line.split(':', 1)[1].strip()
|
|
||||||
elif 'Serial Number:' in line:
|
|
||||||
data['serial'] = line.split(':', 1)[1].strip()
|
|
||||||
elif 'CA:TRUE' in line:
|
|
||||||
is_ca = True
|
|
||||||
elif 'TLS Web Server Authentication' in line:
|
|
||||||
extended_usage += "Server "
|
|
||||||
elif 'TLS Web Client Authentication' in line:
|
|
||||||
extended_usage += "Client "
|
|
||||||
|
|
||||||
# Determine Type
|
|
||||||
if is_ca:
|
|
||||||
data['type'] = 'CA'
|
|
||||||
elif 'Server' in extended_usage:
|
|
||||||
data['type'] = 'Server'
|
|
||||||
elif 'Client' in extended_usage:
|
|
||||||
data['type'] = 'Client'
|
|
||||||
elif 'server' in data.get('common_name', '').lower():
|
|
||||||
data['type'] = 'Server'
|
|
||||||
else:
|
|
||||||
data['type'] = 'Client' # Default to client if ambiguous
|
|
||||||
|
|
||||||
if data['not_after'] != 'N/A':
|
|
||||||
data['sort_date'] = self.parse_openssl_date(data['not_after']).isoformat()
|
|
||||||
else:
|
|
||||||
data['sort_date'] = datetime.min.isoformat()
|
|
||||||
|
|
||||||
# Parse dates for UI
|
|
||||||
if data['not_after'] != 'N/A':
|
|
||||||
dt = self.parse_openssl_date(data['not_after'])
|
|
||||||
data['expires_iso'] = dt.isoformat()
|
|
||||||
|
|
||||||
if data['not_before'] != 'N/A':
|
|
||||||
dt = self.parse_openssl_date(data['not_before'])
|
|
||||||
data['issued_iso'] = dt.isoformat()
|
|
||||||
|
|
||||||
data['days_remaining'] = self.calculate_days_remaining(data['not_after'])
|
|
||||||
data['is_expired'] = 'Expired' in data['days_remaining']
|
|
||||||
|
|
||||||
# State for UI
|
|
||||||
if data['is_expired']:
|
|
||||||
data['state'] = 'Expired'
|
|
||||||
else:
|
|
||||||
data['state'] = 'Valid'
|
|
||||||
|
|
||||||
return data
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing {cert_file}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_certificates_info(self):
|
|
||||||
cert_path = Path(self.certificates_path)
|
|
||||||
if not cert_path.exists(): return []
|
|
||||||
|
|
||||||
cert_files = []
|
|
||||||
for ext in self.cert_extensions:
|
|
||||||
cert_files.extend(cert_path.rglob(f'*.{ext.strip()}'))
|
|
||||||
|
|
||||||
current_valid_files = set()
|
|
||||||
cert_data = []
|
|
||||||
|
|
||||||
for cert_file_path in cert_files:
|
|
||||||
cert_file = str(cert_file_path)
|
|
||||||
current_valid_files.add(cert_file)
|
|
||||||
|
|
||||||
try:
|
|
||||||
mtime = os.path.getmtime(cert_file)
|
|
||||||
|
|
||||||
# Check cache
|
|
||||||
cached = self._cert_cache.get(cert_file)
|
|
||||||
if cached and cached['mtime'] == mtime:
|
|
||||||
cert_data.append(cached['data'])
|
|
||||||
else:
|
|
||||||
# Parse and update cache
|
|
||||||
parsed_data = self.extract_cert_info(cert_file)
|
|
||||||
if parsed_data:
|
|
||||||
self._cert_cache[cert_file] = {
|
|
||||||
'mtime': mtime,
|
|
||||||
'data': parsed_data
|
|
||||||
}
|
|
||||||
cert_data.append(parsed_data)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Prune cache for deleted files
|
|
||||||
for cached_file in list(self._cert_cache.keys()):
|
|
||||||
if cached_file not in current_valid_files:
|
|
||||||
del self._cert_cache[cached_file]
|
|
||||||
|
|
||||||
return cert_data
|
|
||||||
# -----------------------------------------------------------
|
# -----------------------------------------------------------
|
||||||
|
|
||||||
def get_current_stats(self):
|
def get_current_stats(self):
|
||||||
@@ -1109,14 +973,7 @@ def get_client_stats(common_name):
|
|||||||
logger.error(f"API Error: {e}")
|
logger.error(f"API Error: {e}")
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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()
|
|
||||||
return jsonify({'success': True, 'data': data})
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
|
||||||
|
|
||||||
@app.route('/api/v1/clients', methods=['GET'])
|
@app.route('/api/v1/clients', methods=['GET'])
|
||||||
@token_required
|
@token_required
|
||||||
@@ -1180,9 +1037,9 @@ def get_sessions():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
host = api.config.get('api', 'host', fallback='0.0.0.0')
|
host = api.get_config_value('api', 'host', fallback='0.0.0.0')
|
||||||
port = 5001 # Используем 5001, чтобы не конфликтовать, если что-то уже есть на 5000
|
port = int(api.get_config_value('api', 'port', fallback=5001))
|
||||||
debug = api.config.getboolean('api', 'debug', fallback=False)
|
debug = api.get_config_value('api', 'debug', fallback='false').lower() == 'true'
|
||||||
|
|
||||||
logger.info(f"Starting API on {host}:{port}")
|
logger.info(f"Starting API on {host}:{port}")
|
||||||
app.run(host=host, port=port, debug=debug)
|
app.run(host=host, port=port, debug=debug)
|
||||||
@@ -142,14 +142,8 @@ class OpenVPNDataGatherer:
|
|||||||
'agg_6h_retention_days': '180', # 6 месяцев
|
'agg_6h_retention_days': '180', # 6 месяцев
|
||||||
'agg_1d_retention_days': '365' # 12 месяцев
|
'agg_1d_retention_days': '365' # 12 месяцев
|
||||||
},
|
},
|
||||||
'visualization': {
|
'visualization': {},
|
||||||
'refresh_interval': '5',
|
'certificates': {}
|
||||||
'max_display_rows': '50'
|
|
||||||
},
|
|
||||||
'certificates': {
|
|
||||||
'certificates_path': '/opt/ovpn/pki/issued',
|
|
||||||
'certificate_extensions': 'crt'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -214,6 +208,12 @@ class OpenVPNDataGatherer:
|
|||||||
|
|
||||||
def get_config_value(self, section, key, default=None):
|
def get_config_value(self, section, key, default=None):
|
||||||
try:
|
try:
|
||||||
|
# Priority: ENV > Config File > Fallback
|
||||||
|
# Format: OVPMON_SECTION_KEY (all uppercase, underscores for spaces/dashes)
|
||||||
|
env_key = f"OVPMON_{section.upper()}_{key.upper()}".replace('-', '_').replace(' ', '_')
|
||||||
|
env_val = os.getenv(env_key)
|
||||||
|
if env_val is not None:
|
||||||
|
return env_val
|
||||||
return self.config.get(section, key, fallback=default)
|
return self.config.get(section, key, fallback=default)
|
||||||
except:
|
except:
|
||||||
return default
|
return default
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
FROM python:3.12-alpine
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
# Install OpenVPN, OpenRC and other system deps
|
# Install OpenVPN, OpenRC and other system deps
|
||||||
RUN apk add --no-cache openvpn openrc iproute2 bash
|
RUN apk add --no-cache openvpn openrc iproute2 bash iptables easy-rsa
|
||||||
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -9,8 +10,12 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Ensure DB directory exists
|
||||||
|
RUN mkdir -p /app/db
|
||||||
|
|
||||||
# Copy source code and entrypoint
|
# Copy source code and entrypoint
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
# Expose API port
|
# Expose API port
|
||||||
|
|||||||
11
APP_PROFILER/config.ini
Normal file
11
APP_PROFILER/config.ini
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[api]
|
||||||
|
# Secret key for JWT token verification.
|
||||||
|
# MUST match the key in APP_CORE/config.ini if not overridden by ENV.
|
||||||
|
secret_key = ovpmon-secret-change-me
|
||||||
|
|
||||||
|
[profiler]
|
||||||
|
# Path to the profiler database relative to component root
|
||||||
|
db_path = ovpn_profiler.db
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
level = INFO
|
||||||
@@ -2,7 +2,12 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./ovpn_profiler.db"
|
from utils.config import get_config_value
|
||||||
|
|
||||||
|
# Support override via OVPMON_PROFILER_DB_PATH or config.ini
|
||||||
|
db_path = get_config_value('profiler', 'db_path', fallback='./ovpn_profiler.db')
|
||||||
|
SQLALCHEMY_DATABASE_URL = f"sqlite:///{db_path}"
|
||||||
|
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
|||||||
@@ -7,14 +7,31 @@ if [ ! -c /dev/net/tun ]; then
|
|||||||
chmod 600 /dev/net/tun
|
chmod 600 /dev/net/tun
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Enable IP forwarding
|
# Enable IP forwarding (moved to docker-compose.yml sysctls)
|
||||||
sysctl -w net.ipv4.ip_forward=1
|
# sysctl -w net.ipv4.ip_forward=1 || true
|
||||||
|
|
||||||
|
# NAT MASQUERADE
|
||||||
|
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
||||||
|
|
||||||
|
# MSS Clamping (Path MTU Tuning)
|
||||||
|
iptables -t mangle -A FORWARD -o eth0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||||
|
iptables -t mangle -A FORWARD -i eth0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
|
||||||
|
|
||||||
|
# Ensure /run exists for PID files
|
||||||
|
mkdir -p /run
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize Easy-RSA if not already present in /app/easy-rsa
|
||||||
|
if [ ! -f /app/easy-rsa/easyrsa ]; then
|
||||||
|
echo "[INIT] Initializing Easy-RSA workspace..."
|
||||||
|
mkdir -p /app/easy-rsa
|
||||||
|
# Alpine installs easy-rsa files to /usr/share/easy-rsa
|
||||||
|
cp -r /usr/share/easy-rsa/* /app/easy-rsa/
|
||||||
|
fi
|
||||||
|
|
||||||
# Start OpenRC (needed for rc-service if we use it, but better to start openvpn directly or via rc)
|
|
||||||
# Since we are in Alpine, we can try to start the service if configured,
|
|
||||||
# but Container 4 main.py might expect rc-service to work.
|
|
||||||
openrc default
|
|
||||||
|
|
||||||
# Start the APP_PROFILER API
|
# Start the APP_PROFILER API
|
||||||
|
|
||||||
|
|
||||||
# We use 0.0.0.0 to be reachable from other containers
|
# We use 0.0.0.0 to be reachable from other containers
|
||||||
python main.py
|
python main.py
|
||||||
|
|||||||
@@ -42,4 +42,4 @@ def read_root():
|
|||||||
return {"message": "Welcome to OpenVPN Profiler API"}
|
return {"message": "Welcome to OpenVPN Profiler API"}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ sqlalchemy
|
|||||||
psutil
|
psutil
|
||||||
python-multipart
|
python-multipart
|
||||||
jinja2
|
jinja2
|
||||||
|
pyjwt
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from utils.auth import verify_token
|
from utils.auth import verify_token
|
||||||
from services import generator
|
from services import generator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(verify_token)])
|
router = APIRouter(dependencies=[Depends(verify_token)])
|
||||||
|
|
||||||
@router.post("/server/configure")
|
@router.post("/server/configure")
|
||||||
@@ -12,12 +16,16 @@ def configure_server(db: Session = Depends(get_db)):
|
|||||||
# Generate to a temporary location or standard location
|
# Generate to a temporary location or standard location
|
||||||
# As per plan, we behave like srvconf
|
# As per plan, we behave like srvconf
|
||||||
output_path = "/etc/openvpn/server.conf"
|
output_path = "/etc/openvpn/server.conf"
|
||||||
# Since running locally for dev, maybe output to staging
|
|
||||||
import os
|
# Ensure we can write to /etc/openvpn
|
||||||
if not os.path.exists("/etc/openvpn"):
|
if not os.path.exists(os.path.dirname(output_path)) or not os.access(os.path.dirname(output_path), os.W_OK):
|
||||||
# For local dev safety, don't try to write to /etc/openvpn if not root or not existing
|
# For local dev or non-root host, use staging
|
||||||
output_path = "staging/server.conf"
|
output_path = "staging/server.conf"
|
||||||
os.makedirs("staging", exist_ok=True)
|
os.makedirs("staging", exist_ok=True)
|
||||||
|
logger.info(f"[SERVER] /etc/openvpn not writable, using staging path: {output_path}")
|
||||||
|
else:
|
||||||
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
content = generator.generate_server_config(db, output_path=output_path)
|
content = generator.generate_server_config(db, output_path=output_path)
|
||||||
return {"message": "Server configuration generated", "path": output_path}
|
return {"message": "Server configuration generated", "path": output_path}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
|||||||
file_srv_key_path = os.path.join(PKI_DIR, "private", f"{pki_settings.fqdn_server}.key")
|
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_dh_path = os.path.join(PKI_DIR, "dh.pem")
|
||||||
file_ta_path = os.path.join(PKI_DIR, "ta.key")
|
file_ta_path = os.path.join(PKI_DIR, "ta.key")
|
||||||
|
file_crl_path = os.path.join(PKI_DIR, "crl.pem")
|
||||||
|
|
||||||
# Render template
|
# Render template
|
||||||
config_content = template.render(
|
config_content = template.render(
|
||||||
@@ -33,6 +34,7 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
|||||||
srv_key_path=file_srv_key_path,
|
srv_key_path=file_srv_key_path,
|
||||||
dh_path=file_dh_path,
|
dh_path=file_dh_path,
|
||||||
ta_path=file_ta_path,
|
ta_path=file_ta_path,
|
||||||
|
crl_path=file_crl_path,
|
||||||
vpn_network=settings.vpn_network,
|
vpn_network=settings.vpn_network,
|
||||||
vpn_netmask=settings.vpn_netmask,
|
vpn_netmask=settings.vpn_netmask,
|
||||||
tunnel_type=settings.tunnel_type,
|
tunnel_type=settings.tunnel_type,
|
||||||
|
|||||||
@@ -6,13 +6,19 @@ import psutil
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def get_os_type():
|
def is_container():
|
||||||
"""
|
"""
|
||||||
Simple check to distinguish Alpine from others.
|
Checks if the application is running inside a Docker container.
|
||||||
"""
|
"""
|
||||||
if os.path.exists("/etc/alpine-release"):
|
if os.path.exists('/.dockerenv'):
|
||||||
return "alpine"
|
return True
|
||||||
return "debian" # default fallback to systemctl
|
try:
|
||||||
|
with open('/proc/self/cgroup', 'rt') as f:
|
||||||
|
if 'docker' in f.read():
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
def control_service(action: str):
|
def control_service(action: str):
|
||||||
"""
|
"""
|
||||||
@@ -21,7 +27,76 @@ def control_service(action: str):
|
|||||||
if action not in ["start", "stop", "restart"]:
|
if action not in ["start", "stop", "restart"]:
|
||||||
raise ValueError("Invalid action")
|
raise ValueError("Invalid action")
|
||||||
|
|
||||||
|
CONFIG_PATH = "/etc/openvpn/server.conf"
|
||||||
|
PID_FILE = "/run/openvpn.pid"
|
||||||
|
|
||||||
|
# In Container: Use direct execution to avoid OpenRC/cgroups issues
|
||||||
|
if is_container():
|
||||||
|
logger.info(f"[PROCESS] Container detected, using direct execution for {action}")
|
||||||
|
|
||||||
|
def start_vpn_direct():
|
||||||
|
if not os.path.exists(CONFIG_PATH):
|
||||||
|
# Check for alternative location in dev/non-root environments
|
||||||
|
if os.path.exists("staging/server.conf"):
|
||||||
|
alt_path = os.path.abspath("staging/server.conf")
|
||||||
|
logger.info(f"[PROCESS] Using alternative config: {alt_path}")
|
||||||
|
config = alt_path
|
||||||
|
else:
|
||||||
|
return {"status": "error", "message": f"Configuration not found at {CONFIG_PATH}. Please generate it first."}
|
||||||
|
else:
|
||||||
|
config = CONFIG_PATH
|
||||||
|
|
||||||
|
# Check if already running
|
||||||
|
for proc in psutil.process_iter(['name']):
|
||||||
|
if proc.info['name'] == 'openvpn':
|
||||||
|
return {"status": "success", "message": "OpenVPN is already running"}
|
||||||
|
|
||||||
|
cmd = ["openvpn", "--config", config, "--daemon", "--writepid", PID_FILE]
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
return {"status": "success", "message": "OpenVPN started successfully (direct)"}
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
return {"status": "error", "message": f"Failed to start OpenVPN: {str(e)}"}
|
||||||
|
|
||||||
|
def stop_vpn_direct():
|
||||||
|
procs_to_stop = []
|
||||||
|
for proc in psutil.process_iter(['name']):
|
||||||
|
if proc.info['name'] == 'openvpn':
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
procs_to_stop.append(proc)
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if procs_to_stop:
|
||||||
|
# Wait for processes to actually exit
|
||||||
|
logger.info(f"[PROCESS] Waiting for {len(procs_to_stop)} OpenVPN process(es) to terminate...")
|
||||||
|
gone, alive = psutil.wait_procs(procs_to_stop, timeout=5)
|
||||||
|
for p in alive:
|
||||||
|
try:
|
||||||
|
logger.warning(f"[PROCESS] Process {p.pid} did not terminate, killing...")
|
||||||
|
p.kill()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if os.path.exists(PID_FILE):
|
||||||
|
try: os.remove(PID_FILE)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
if procs_to_stop:
|
||||||
|
return {"status": "success", "message": "OpenVPN stopped successfully"}
|
||||||
|
else:
|
||||||
|
return {"status": "success", "message": "OpenVPN was not running"}
|
||||||
|
|
||||||
|
if action == "start": return start_vpn_direct()
|
||||||
|
elif action == "stop": return stop_vpn_direct()
|
||||||
|
elif action == "restart":
|
||||||
|
stop_vpn_direct()
|
||||||
|
return start_vpn_direct()
|
||||||
|
|
||||||
|
# On Host OS: Use system service manager
|
||||||
os_type = get_os_type()
|
os_type = get_os_type()
|
||||||
|
logger.info(f"[PROCESS] Host OS detected ({os_type}), using service manager for {action}")
|
||||||
|
|
||||||
cmd = []
|
cmd = []
|
||||||
if os_type == "alpine":
|
if os_type == "alpine":
|
||||||
@@ -30,27 +105,27 @@ def control_service(action: str):
|
|||||||
cmd = ["systemctl", action, "openvpn"]
|
cmd = ["systemctl", action, "openvpn"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Capture output to return it or log it
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Service {action} executed successfully",
|
"message": f"Service {action} executed successfully via {cmd[0]}",
|
||||||
"stdout": result.stdout
|
"stdout": result.stdout
|
||||||
}
|
}
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
logger.error(f"Service control failed: {e.stderr}")
|
logger.error(f"Service control failed: {e.stderr}")
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Failed to {action} service",
|
"message": f"Failed to {action} service via {cmd[0]}",
|
||||||
"stderr": e.stderr
|
"stderr": e.stderr
|
||||||
}
|
}
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Happens if rc-service or systemctl is missing (e.g. dev env)
|
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Command not found found for OS type {os_type}"
|
"message": f"Command {cmd[0]} not found found for OS type {os_type}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_process_stats():
|
def get_process_stats():
|
||||||
"""
|
"""
|
||||||
Returns dict with pid, cpu_percent, memory_mb, uptime.
|
Returns dict with pid, cpu_percent, memory_mb, uptime.
|
||||||
@@ -125,6 +200,17 @@ def get_process_stats():
|
|||||||
"uptime": None
|
"uptime": None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_os_type() -> str:
|
||||||
|
"""
|
||||||
|
Detects the host OS type to determine which service manager to use.
|
||||||
|
Currently supports: 'alpine', 'debian' (fallback for systemd systems).
|
||||||
|
"""
|
||||||
|
if os.path.exists("/etc/alpine-release"):
|
||||||
|
return "alpine"
|
||||||
|
|
||||||
|
# Fallback to debian/ubuntu (systemd)
|
||||||
|
return "debian"
|
||||||
|
|
||||||
def format_seconds(seconds: float) -> str:
|
def format_seconds(seconds: float) -> str:
|
||||||
seconds = int(seconds)
|
seconds = int(seconds)
|
||||||
days, seconds = divmod(seconds, 86400)
|
days, seconds = divmod(seconds, 86400)
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ server {{ vpn_network }} {{ vpn_netmask }}
|
|||||||
|
|
||||||
ifconfig-pool-persist /etc/openvpn/ipp.txt
|
ifconfig-pool-persist /etc/openvpn/ipp.txt
|
||||||
|
|
||||||
log /etc/openvpn/openvpn.log
|
log /var/log/openvpn/openvpn.log
|
||||||
log-append /etc/openvpn/openvpn.log
|
log-append /var/log/openvpn/openvpn.log
|
||||||
|
|
||||||
verb 3
|
verb 3
|
||||||
|
|
||||||
# Use Extended Status Output
|
# Use Extended Status Output
|
||||||
status /etc/openvpn/openvpn-status.log 5
|
status /var/log/openvpn/openvpn-status.log 5
|
||||||
status-version 2
|
status-version 2
|
||||||
|
|
||||||
# Tunneling Mode
|
# Tunneling Mode
|
||||||
@@ -84,7 +84,7 @@ persist-tun
|
|||||||
|
|
||||||
# check revocation list
|
# check revocation list
|
||||||
{% if crl_verify %}
|
{% if crl_verify %}
|
||||||
crl-verify /etc/openvpn/crl.pem
|
crl-verify {{ crl_path }}
|
||||||
{% else %}
|
{% else %}
|
||||||
# crl-verify disabled
|
# crl-verify disabled
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -4,49 +4,29 @@ import os
|
|||||||
from fastapi import Header, HTTPException, status
|
from fastapi import Header, HTTPException, status
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Load config from the main APP directory
|
from .config import get_config_value
|
||||||
CONFIG_FILE = Path(__file__).parent.parent.parent / 'APP' / 'config.ini'
|
|
||||||
|
|
||||||
def get_secret_key():
|
def get_secret_key():
|
||||||
# Priority 1: Environment Variable
|
# Use consistent OVPMON_API_SECRET_KEY as primary source
|
||||||
env_secret = os.getenv('OVPMON_SECRET_KEY')
|
key = get_config_value('api', 'secret_key', fallback='ovpmon-secret-change-me')
|
||||||
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 = [
|
if key == 'ovpmon-secret-change-me':
|
||||||
base_path.parent / 'APP' / 'config.ini', # Brother directory (Local/Gitea structure)
|
print("[AUTH] WARNING: Using default fallback SECRET_KEY")
|
||||||
base_path / 'APP' / 'config.ini', # Child directory
|
else:
|
||||||
base_path / 'config.ini', # Same directory
|
# Check if it was from env (get_config_value prioritizes env)
|
||||||
Path('/opt/ovpmon/APP/config.ini'), # Common production path 1
|
import os
|
||||||
Path('/opt/ovpmon/config.ini'), # Common production path 2
|
if os.getenv('OVPMON_API_SECRET_KEY'):
|
||||||
Path('/etc/ovpmon/config.ini'), # Standard linux config path
|
print("[AUTH] Using SECRET_KEY from OVPMON_API_SECRET_KEY environment variable")
|
||||||
Path('/opt/ovpn_python_profiler/APP/config.ini') # Path based on traceback
|
elif os.getenv('OVPMON_SECRET_KEY'):
|
||||||
]
|
print("[AUTH] Using SECRET_KEY from OVPMON_SECRET_KEY environment variable")
|
||||||
|
else:
|
||||||
config = configparser.ConfigParser()
|
print("[AUTH] SECRET_KEY loaded (config.ini or fallback)")
|
||||||
for loc in config_locations:
|
|
||||||
if loc.exists():
|
return key
|
||||||
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()
|
SECRET_KEY = get_secret_key()
|
||||||
|
|
||||||
|
|
||||||
async def verify_token(authorization: str = Header(None)):
|
async def verify_token(authorization: str = Header(None)):
|
||||||
if not authorization or not authorization.startswith("Bearer "):
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
print(f"[AUTH] Missing or invalid Authorization header: {authorization[:20] if authorization else 'None'}")
|
print(f"[AUTH] Missing or invalid Authorization header: {authorization[:20] if authorization else 'None'}")
|
||||||
|
|||||||
32
APP_PROFILER/utils/config.py
Normal file
32
APP_PROFILER/utils/config.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import os
|
||||||
|
import configparser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Base directory for the component
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
CONFIG_FILE = BASE_DIR / 'config.ini'
|
||||||
|
|
||||||
|
def get_config_value(section: str, key: str, fallback: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Get a configuration value with priority:
|
||||||
|
1. Environment Variable (OVPMON_{SECTION}_{KEY})
|
||||||
|
2. config.ini in the component root
|
||||||
|
3. Fallback value
|
||||||
|
"""
|
||||||
|
# 1. Check Environment Variable
|
||||||
|
env_key = f"OVPMON_{section.upper()}_{key.upper()}".replace('-', '_').replace(' ', '_')
|
||||||
|
env_val = os.getenv(env_key)
|
||||||
|
if env_val is not None:
|
||||||
|
return env_val
|
||||||
|
|
||||||
|
# 2. Check config.ini
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
try:
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(CONFIG_FILE)
|
||||||
|
if config.has_section(section) and config.has_option(section, key):
|
||||||
|
return config.get(section, key)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[CONFIG] Error reading {CONFIG_FILE}: {e}")
|
||||||
|
|
||||||
|
return fallback
|
||||||
@@ -9,6 +9,7 @@ RUN npm run build
|
|||||||
# Stage 2: Serve
|
# Stage 2: Serve
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
|
|||||||
37
APP_UI/default.conf.template
Normal file
37
APP_UI/default.conf.template
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Модуль 1: Мониторинг (Flask, порт 5001)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://${OVP_API_HOST}:${OVP_API_PORT};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_pass_header Authorization;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Модуль 2: Управление профилями (FastAPI, порт 8000)
|
||||||
|
# Мы проксируем /profiles-api/ на внутренний /api/ внутри FastAPI
|
||||||
|
location /profiles-api/ {
|
||||||
|
proxy_pass http://${OVP_PROFILER_HOST}:${OVP_PROFILER_PORT}/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
# Для корректной работы OpenAPI/Docs за заголовком
|
||||||
|
proxy_set_header X-Forwarded-Prefix /profiles-api;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy API requests if needed or let the frontend handle URLs
|
|
||||||
# location /api/v1/ {
|
|
||||||
# proxy_pass http://app-api:5001;
|
|
||||||
# }
|
|
||||||
|
|
||||||
# location /api/ {
|
|
||||||
# proxy_pass http://app-profiler:8000;
|
|
||||||
# }
|
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
location = /50x.html {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -58,10 +58,11 @@ export function useApi() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchCertificates = async () => {
|
const fetchCertificates = async () => {
|
||||||
const res = await apiClient.get('/certificates');
|
const res = await profilesApiClient.get('/profiles');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiClient,
|
apiClient,
|
||||||
profilesApiClient,
|
profilesApiClient,
|
||||||
|
|||||||
@@ -109,12 +109,12 @@
|
|||||||
<i class="fas fa-check-circle text-success me-2"></i>All Good
|
<i class="fas fa-check-circle text-success me-2"></i>All Good
|
||||||
</p>
|
</p>
|
||||||
<div v-else class="list-group list-group-flush">
|
<div v-else class="list-group list-group-flush">
|
||||||
<div v-for="cert in expiringCertsList" :key="cert.common_name" class="list-group-item px-0 py-2 d-flex justify-content-between align-items-center border-0">
|
<div v-for="cert in expiringCertsList" :key="cert.username" class="list-group-item px-0 py-2 d-flex justify-content-between align-items-center border-0">
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-bold small">{{ cert.common_name }}</div>
|
<div class="fw-bold small">{{ cert.username }}</div>
|
||||||
<div class="text-muted" style="font-size: 0.75rem;">Expires: {{ cert.expiration_date }}</div>
|
<div class="text-muted" style="font-size: 0.75rem;">Expires: {{ cert.expiration_date }}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge status-warning text-dark">{{ cert.days_left }} days</span>
|
<span class="badge status-warning text-dark">{{ cert.days_remaining }} days</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,32 +202,15 @@ const loadCerts = async () => {
|
|||||||
loading.certs = true;
|
loading.certs = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetchCertificates();
|
const res = await fetchCertificates();
|
||||||
if(res.success) {
|
if(res.success && Array.isArray(res.data)) {
|
||||||
const now = new Date();
|
const list = res.data.filter(cert => {
|
||||||
const warningThreshold = new Date();
|
// Only active, non-revoked, and expiring soon (within 45 days)
|
||||||
warningThreshold.setDate(now.getDate() + 45);
|
return !cert.is_revoked && !cert.is_expired &&
|
||||||
|
cert.days_remaining !== null && cert.days_remaining <= 45;
|
||||||
let count = 0;
|
|
||||||
const list = [];
|
|
||||||
|
|
||||||
res.data.forEach(cert => {
|
|
||||||
if (cert.status === 'revoked') return;
|
|
||||||
const expDate = new Date(cert.expiration_date); // Assuming API returns ISO or parsable date
|
|
||||||
|
|
||||||
if (expDate <= warningThreshold) {
|
|
||||||
count++;
|
|
||||||
const diffTime = Math.abs(expDate - now);
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
list.push({
|
|
||||||
...cert,
|
|
||||||
days_left: diffDays
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
kpi.expiringCerts = count;
|
kpi.expiringCerts = list.length;
|
||||||
expiringCertsList.value = list.sort((a,b) => a.days_left - b.days_left);
|
expiringCertsList.value = list.sort((a,b) => (a.days_remaining || 0) - (b.days_remaining || 0));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -298,9 +281,9 @@ const renderMainChart = () => {
|
|||||||
mainChartInstance = new Chart(ctx, {
|
mainChartInstance = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels,
|
|
||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
|
|
||||||
{
|
{
|
||||||
label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps',
|
label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps',
|
||||||
data: dataRx,
|
data: dataRx,
|
||||||
|
|||||||
95
README.md
95
README.md
@@ -1,62 +1,61 @@
|
|||||||
# OpenVPN Monitor & Profiler
|
# OpenVPN Monitor & Profiler
|
||||||
|
|
||||||
A modern, full-stack management solution for OpenVPN servers. It combines real-time traffic monitoring, historical analytics, and comprehensive user profile/PKI management into a unified web interface.
|
A modern, full-stack management solution for OpenVPN servers. It combines real-time traffic monitoring, historical analytics, and comprehensive user profile/PKI management into a unified web interface. Perfect for both containerized (Docker) and native (Alpine/Debian/Ubuntu) deployments.
|
||||||
|
|
||||||
## <EFBFBD>️ Project Architecture
|
## 🏗️ Project Architecture
|
||||||
|
|
||||||
The project is modularized into three core components:
|
The project is modularized into four core microservices, split between **Monitoring (Core)** and **Management (Profiler)**:
|
||||||
|
|
||||||
| Component | Directory | Description |
|
| Component | Directory | Service Name | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **User Interface** | `APP_UI/` | `ovp-ui` | Vue 3 + Vite SPA + Nginx. Communicates with both APIs. |
|
||||||
|
| **Monitoring API** | `APP_CORE/` | `ovp-api` | Flask API for real-time stats, sessions, and bandwidth data. |
|
||||||
|
| **Data Gatherer** | `APP_CORE/` | `ovp-gatherer` | Background service for traffic log aggregation & TSDB logic. |
|
||||||
|
| **Profiler API** | `APP_PROFILER/` | `ovp-profiler` | FastAPI module for PKI management, User Profiles, and VPN control. |
|
||||||
|
|
||||||
|
## 📦 Quick Start (Docker)
|
||||||
|
|
||||||
|
The recommended way to deploy is using Docker Compose:
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
2. **Start all services**:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
3. **Access the Dashboard**: Open `http://localhost` (or your server IP) in your browser.
|
||||||
|
4. **Initialize PKI**: On the first run, navigate to the **PKI Configuration** page in the UI and click **Initialize PKI**. This sets up the CA and Easy-RSA workspace.
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
The system uses a unified configuration approach. Settings can be defined in `config.ini` files or overridden by environment variables following the `OVPMON_{SECTION}_{KEY}` format.
|
||||||
|
|
||||||
|
### Key Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default Value |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| **Core Monitoring** | `APP_CORE/` | Flask-based API (v3) for log parsing, real-time stats, and historical TSDB. |
|
| `OVPMON_API_SECRET_KEY` | Unified JWT Secret Key (used by both APIs) | `supersecret` |
|
||||||
| **Profiler** | `APP_PROFILER/` | FastAPI-based module for managing PKI, Certificates, and Server Configs. |
|
| `OVPMON_PROFILER_DB_PATH` | Path to Profiler (users/pki) SQLite DB | `/app/db/ovpn_profiler.db` |
|
||||||
| **User Interface** | `APP_UI/` | Vue 3 + Vite Single Page Application (SPA) serving as the unified dashboard. |
|
| `OVPMON_OPENVPN_MONITOR_DB_PATH` | Path to Monitoring (traffic) SQLite DB | `/app/db/openvpn_monitor.db` |
|
||||||
|
| `OVPMON_OPENVPN_MONITOR_LOG_PATH`| Path to OpenVPN status log | `/var/log/openvpn/openvpn-status.log` |
|
||||||
|
| `OVPMON_LOGGING_LEVEL` | Logging level (INFO/DEBUG) | `INFO` |
|
||||||
|
|
||||||
## 📚 Documentation
|
## 🛠️ Performance & Environment Awareness
|
||||||
|
|
||||||
Detailed documentation has been moved to the `DOCS/` directory.
|
- **Container Transparency**: When running in Docker, the Profiler manages OpenVPN directly to bypass cgroups restrictions.
|
||||||
|
- **Host Integration**: When running natively on Alpine or Debian/Ubuntu, it automatically switches to `rc-service` or `systemctl`.
|
||||||
|
- **Persistent Data**: Logs, Certificates (PKI), and Databases are stored in Docker volumes (`ovp_logs`, `ovp_pki`, `db_data`).
|
||||||
|
|
||||||
- **[Installation & Deployment](DOCS/General/Deployment.md)**: Setup guide for Linux (Alpine/Debian).
|
## 📚 Development
|
||||||
- **[Service Management](DOCS/General/Service_Management.md)**: Configuring Systemd/OpenRC services.
|
|
||||||
- **[Security & Auth](DOCS/General/Security_Architecture.md)**: 2FA, JWT, and Security details.
|
|
||||||
|
|
||||||
### API References
|
### Component Development
|
||||||
- **[Core Monitoring API](DOCS/Core_Monitoring/API_Reference.md)**: Endpoints for stats, sessions, and history.
|
- **UI**: Uses `composables/useApi.js` to route requests to the appropriate backend service based on URL.
|
||||||
- **[Profiler Management API](DOCS/Profiler_Management/API_Reference.md)**: Endpoints for profiles, system config, and control.
|
- **Profiler**: Clean Python/FastAPI code with SQLAlchemy models. Supports "staging" local mode for development without root access.
|
||||||
|
- **Core**: Lightweight Flask services focused on high-performance log parsing.
|
||||||
## 🚀 Quick Start (Dev Mode)
|
|
||||||
|
|
||||||
### 1. Core API (Flask)
|
|
||||||
```bash
|
|
||||||
cd APP_CORE
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
python3 openvpn_api_v3.py
|
|
||||||
# Runs on :5001 (Monitoring)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Profiler API (FastAPI)
|
|
||||||
```bash
|
|
||||||
cd APP_PROFILER
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
python3 main.py
|
|
||||||
# Runs on :8000 (Management)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Frontend (Vue 3)
|
|
||||||
```bash
|
|
||||||
cd APP_UI
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
# Runs on localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ Important Notes
|
### ⚠️ Important Notes
|
||||||
|
|
||||||
1. **Environment**: Production deployment relies on Nginx to proxy requests to the backend services. See the [Deployment Guide](DOCS/General/Deployment.md).
|
1. **Privileged Mode**: The `ovp-profiler` container requires `NET_ADMIN` capabilities for iptables and TUN management.
|
||||||
2. **Permissions**: The backend requires `sudo` or root privileges to manage OpenVPN processes and write to `/etc/openvpn`.
|
2. **Network Setup**: Ensure `net.ipv4.ip_forward=1` is enabled (handled automatically in the docker-compose `sysctls` section).
|
||||||
|
3. **JWT Safety**: Always change the `OVPMON_API_SECRET_KEY` in production.
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ services:
|
|||||||
- app-profiler
|
- app-profiler
|
||||||
networks:
|
networks:
|
||||||
- ovp-net
|
- ovp-net
|
||||||
|
environment:
|
||||||
|
- OVP_API_HOST=ovp-api
|
||||||
|
- OVP_API_PORT=5001
|
||||||
|
- OVP_PROFILER_HOST=ovp-profiler
|
||||||
|
- OVP_PROFILER_PORT=8000
|
||||||
|
|
||||||
|
|
||||||
app-gatherer:
|
app-gatherer:
|
||||||
build:
|
build:
|
||||||
@@ -19,9 +25,13 @@ services:
|
|||||||
container_name: ovp-gatherer
|
container_name: ovp-gatherer
|
||||||
volumes:
|
volumes:
|
||||||
- ovp_logs:/var/log/openvpn
|
- ovp_logs:/var/log/openvpn
|
||||||
- db_data:/app/db # Assuming APP_CORE looks for DB in /app/db
|
- db_data:/app/db
|
||||||
depends_on:
|
depends_on:
|
||||||
- app-profiler
|
- app-profiler
|
||||||
|
environment:
|
||||||
|
- OVPMON_OPENVPN_MONITOR_DB_PATH=/app/db/openvpn_monitor.db
|
||||||
|
- OVPMON_OPENVPN_MONITOR_LOG_PATH=/var/log/openvpn/openvpn-status.log
|
||||||
|
- OVPMON_LOGGING_LEVEL=INFO
|
||||||
networks:
|
networks:
|
||||||
- ovp-net
|
- ovp-net
|
||||||
|
|
||||||
@@ -37,14 +47,22 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- ovp-net
|
- ovp-net
|
||||||
environment:
|
environment:
|
||||||
- JWT_SECRET=${JWT_SECRET:-supersecret}
|
- OVPMON_API_SECRET_KEY=${JWT_SECRET:-supersecret}
|
||||||
|
- OVPMON_API_PORT=5001
|
||||||
|
- OVPMON_OPENVPN_MONITOR_DB_PATH=/app/db/openvpn_monitor.db
|
||||||
|
- OVPMON_LOGGING_LEVEL=INFO
|
||||||
|
depends_on:
|
||||||
|
- app-gatherer
|
||||||
|
|
||||||
app-profiler:
|
app-profiler:
|
||||||
build: ./APP_PROFILER
|
build: ./APP_PROFILER
|
||||||
container_name: ovp-profiler
|
container_name: ovp-profiler
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ip_forward=1
|
||||||
devices:
|
devices:
|
||||||
|
|
||||||
- "/dev/net/tun:/dev/net/tun"
|
- "/dev/net/tun:/dev/net/tun"
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -52,10 +70,17 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ovp_logs:/var/log/openvpn
|
- ovp_logs:/var/log/openvpn
|
||||||
- ovp_config:/etc/openvpn
|
- ovp_config:/etc/openvpn
|
||||||
|
- db_data:/app/db
|
||||||
|
- ovp_client_config:/app/client-config
|
||||||
|
- ovp_pki:/app/easy-rsa
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- ovp-net
|
- ovp-net
|
||||||
environment:
|
environment:
|
||||||
- JWT_SECRET=${JWT_SECRET:-supersecret}
|
- OVPMON_API_SECRET_KEY=${JWT_SECRET:-supersecret}
|
||||||
|
- OVPMON_PROFILER_DB_PATH=/app/db/ovpn_profiler.db
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
ovp-net:
|
ovp-net:
|
||||||
@@ -64,4 +89,6 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
ovp_logs:
|
ovp_logs:
|
||||||
ovp_config:
|
ovp_config:
|
||||||
|
ovp_pki:
|
||||||
|
ovp_client_config:
|
||||||
db_data:
|
db_data:
|
||||||
|
|||||||
Reference in New Issue
Block a user