Compare commits
24
Commits
claude
...
e1146ed4fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1146ed4fe | ||
|
|
9ffdbfa259 | ||
|
|
6f9e800779 | ||
|
|
05f44b9928 | ||
|
|
5de0501cbc | ||
|
|
9b2882d5f4 | ||
|
|
11c1b6379b | ||
|
|
14ffd64801 | ||
|
|
e5c0e154b5 | ||
|
|
0ccdfcf7bf | ||
|
|
68c57c174e | ||
|
|
f7fe266571 | ||
|
|
8fd44fc658 | ||
|
|
f6a81b3d7c | ||
|
|
f177a89f0b | ||
|
|
961de020fb | ||
|
|
195d40daa2 | ||
|
|
0961daedce | ||
|
|
9d10bb97c7 | ||
|
|
6131bcaba9 | ||
|
|
f9df3f8d05 | ||
|
|
4bd4127bb5 | ||
|
|
5260e45bd8 | ||
|
|
bb1a3c9400 |
No files matched your search
+10
-3
@@ -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
|
||||
|
||||
WORKDIR /app
|
||||
@@ -6,11 +12,12 @@ WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy source code
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Expose the port
|
||||
# Ensure DB directory exists
|
||||
RUN mkdir -p /app/db
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
# Run the API
|
||||
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
|
||||
|
||||
WORKDIR /app
|
||||
@@ -6,8 +11,10 @@ WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy source code
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Run the gatherer
|
||||
# Ensure DB directory exists
|
||||
RUN mkdir -p /app/db
|
||||
|
||||
CMD ["python", "openvpn_gatherer_v3.py"]
|
||||
+4
-18
@@ -1,27 +1,17 @@
|
||||
[api]
|
||||
host = 0.0.0.0
|
||||
port = 5000
|
||||
port = 5001
|
||||
debug = false
|
||||
secret_key = ovpmon-secret-change-me
|
||||
|
||||
[openvpn_monitor]
|
||||
log_path = /etc/openvpn/openvpn-status.log
|
||||
db_path = /opt/ovpmon/openvpn_monitor.db
|
||||
log_path = /var/log/openvpn/openvpn-status.log
|
||||
db_path = openvpn_monitor.db
|
||||
check_interval = 10
|
||||
data_retention_days = 90
|
||||
cleanup_interval_hours = 24
|
||||
|
||||
[logging]
|
||||
level = INFO
|
||||
log_file = /opt/ovpmon/openvpn_monitor.log
|
||||
|
||||
[visualization]
|
||||
refresh_interval = 5
|
||||
max_display_rows = 50
|
||||
|
||||
[certificates]
|
||||
certificates_path = /opt/ovpn/pki/issued
|
||||
certificate_extensions = crt
|
||||
log_file = openvpn_gatherer.log
|
||||
|
||||
[retention]
|
||||
raw_retention_days = 7
|
||||
@@ -30,7 +20,3 @@ agg_15m_retention_days = 28
|
||||
agg_1h_retention_days = 90
|
||||
agg_6h_retention_days = 180
|
||||
agg_1d_retention_days = 365
|
||||
|
||||
[pki]
|
||||
pki_path = /opt/ovpn/pki
|
||||
easyrsa_path = /opt/ovpn/easy-rsa
|
||||
+7
-1
@@ -13,7 +13,13 @@ class DatabaseManager:
|
||||
def load_config(self):
|
||||
if os.path.exists(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):
|
||||
"""Get a database connection"""
|
||||
|
||||
+97
-171
@@ -4,10 +4,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from flask import Flask, jsonify, request, send_file
|
||||
from flask_cors import CORS
|
||||
import logging
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import jwt
|
||||
import pyotp
|
||||
import bcrypt
|
||||
@@ -28,9 +25,21 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
# Enable CORS for all routes with specific headers support
|
||||
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
|
||||
# Cross-origin access is off by default (the UI is same-origin behind Nginx); allow extra origins via OVPMON_CORS_ORIGINS (comma-separated)
|
||||
CORS(app, resources={r"/api/*": {"origins": [o.strip() for o in os.getenv("OVPMON_CORS_ORIGINS", "").split(",") if o.strip()]}}, supports_credentials=True)
|
||||
|
||||
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'):
|
||||
self.db_manager = DatabaseManager(config_file)
|
||||
self.db_manager.init_database()
|
||||
@@ -38,21 +47,10 @@ class OpenVPNAPI:
|
||||
self.config.read(config_file)
|
||||
|
||||
# Paths
|
||||
self.certificates_path = self.config.get('certificates', 'certificates_path', fallback='/etc/openvpn/certs')
|
||||
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 = {}
|
||||
self.public_ip = self.get_config_value('openvpn_monitor', 'public_ip', fallback='')
|
||||
|
||||
# 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')
|
||||
self.secret_key = self.get_config_value('api', 'secret_key', fallback='ovpmon-secret-change-me')
|
||||
app.config['SECRET_KEY'] = self.secret_key
|
||||
|
||||
# Ensure at least one user exists
|
||||
@@ -70,19 +68,23 @@ class OpenVPNAPI:
|
||||
return self.db_manager.get_connection()
|
||||
|
||||
def ensure_default_admin(self):
|
||||
"""Create a default admin user if no users exist"""
|
||||
"""Create the initial admin only from OVPMON_INITIAL_ADMIN_USER/PASSWORD env (no built-in defaults)"""
|
||||
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))
|
||||
user = os.getenv('OVPMON_INITIAL_ADMIN_USER')
|
||||
pw = os.getenv('OVPMON_INITIAL_ADMIN_PASSWORD')
|
||||
if not user or not pw:
|
||||
logger.error("No users exist and OVPMON_INITIAL_ADMIN_USER/OVPMON_INITIAL_ADMIN_PASSWORD are not set: admin NOT created")
|
||||
return
|
||||
password_hash = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
cursor.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", (user, password_hash))
|
||||
conn.commit()
|
||||
logger.info("Default admin user created (admin/password)")
|
||||
logger.info("Initial admin user created from environment")
|
||||
except Exception as e:
|
||||
logger.error(f"Error ensuring default admin: {e}")
|
||||
logger.error(f"Error ensuring initial admin: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -130,140 +132,7 @@ class OpenVPNAPI:
|
||||
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):
|
||||
@@ -753,7 +622,9 @@ def token_required(f):
|
||||
|
||||
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
|
||||
# Temporary 2FA-pending tokens are valid only for /api/auth/verify-2fa
|
||||
if data.get('is_2fa_pending'):
|
||||
return jsonify({'success': False, 'error': '2FA verification required'}), 401
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'success': False, 'error': 'Token has expired'}), 401
|
||||
except Exception:
|
||||
@@ -856,7 +727,10 @@ def setup_2fa():
|
||||
|
||||
secret = pyotp.random_base32()
|
||||
totp = pyotp.TOTP(secret)
|
||||
provisioning_uri = totp.provisioning_uri(name="admin", issuer_name="OpenVPN-Monitor")
|
||||
_c = api.get_db_connection()
|
||||
_row = _c.execute("SELECT username FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
_c.close()
|
||||
provisioning_uri = totp.provisioning_uri(name=(_row[0] if _row else "user"), issuer_name="OpenVPN-Monitor")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@@ -875,7 +749,7 @@ def enable_2fa():
|
||||
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}")
|
||||
logger.info("Attempting 2FA activation")
|
||||
totp = pyotp.TOTP(secret)
|
||||
|
||||
# Adding valid_window=1 to allow ±30 seconds clock drift
|
||||
@@ -970,6 +844,65 @@ def change_password():
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
USERNAME_RE = __import__('re').compile(r'^[A-Za-z][A-Za-z0-9_.-]{2,31}$')
|
||||
RESERVED_USERNAMES = {'admin', 'administrator', 'root', 'user', 'test', 'guest'}
|
||||
|
||||
def _current_user_id():
|
||||
token = request.headers['Authorization'].split(' ')[1]
|
||||
return jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])['user_id']
|
||||
|
||||
@app.route('/api/auth/change-username', methods=['POST'])
|
||||
@token_required
|
||||
def change_username():
|
||||
data = request.get_json(silent=True) or {}
|
||||
new_username = (data.get('new_username') or '').strip()
|
||||
current_password = data.get('current_password')
|
||||
otp = data.get('otp')
|
||||
|
||||
if not new_username or not current_password:
|
||||
return jsonify({'success': False, 'error': 'Missing data'}), 400
|
||||
if not USERNAME_RE.match(new_username) or new_username.lower() in RESERVED_USERNAMES:
|
||||
return jsonify({'success': False, 'error': 'Invalid username (3-32 chars, letters/digits/._-, must start with a letter, reserved names not allowed)'}), 400
|
||||
|
||||
ip = request.remote_addr
|
||||
if not api.check_rate_limit(ip):
|
||||
return jsonify({'success': False, 'error': 'Too many attempts. Try again in 15 minutes.'}), 429
|
||||
|
||||
try:
|
||||
user_id = _current_user_id()
|
||||
except Exception:
|
||||
return jsonify({'success': False, 'error': 'Token is invalid'}), 401
|
||||
|
||||
conn = api.get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("SELECT password_hash, totp_secret, is_2fa_enabled 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')):
|
||||
api.record_login_attempt(ip, False)
|
||||
return jsonify({'success': False, 'error': 'Invalid current password'}), 401
|
||||
if user[2]:
|
||||
if not otp or not pyotp.TOTP(user[1]).verify(str(otp), valid_window=1):
|
||||
api.record_login_attempt(ip, False)
|
||||
return jsonify({'success': False, 'error': 'Invalid 2FA code'}), 401
|
||||
|
||||
cursor.execute("SELECT 1 FROM users WHERE lower(username) = lower(?) AND id != ?", (new_username, user_id))
|
||||
if cursor.fetchone():
|
||||
return jsonify({'success': False, 'error': 'Username is already taken'}), 409
|
||||
try:
|
||||
cursor.execute("UPDATE users SET username = ? WHERE id = ?", (new_username, user_id))
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
return jsonify({'success': False, 'error': 'Username is already taken'}), 409
|
||||
|
||||
logger.info(f"Username changed for user ID: {user_id}")
|
||||
return jsonify({'success': True, 'username': new_username})
|
||||
except Exception as e:
|
||||
logger.error(f"Error changing username: {e}")
|
||||
return jsonify({'success': False, 'error': 'Internal server error'}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# --- USER ROUTES ---
|
||||
|
||||
@app.route('/api/v1/user/me', methods=['GET'])
|
||||
@@ -1109,14 +1042,7 @@ def get_client_stats(common_name):
|
||||
logger.error(f"API Error: {e}")
|
||||
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'])
|
||||
@token_required
|
||||
@@ -1180,9 +1106,9 @@ def get_sessions():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = api.config.get('api', 'host', fallback='0.0.0.0')
|
||||
port = 5001 # Используем 5001, чтобы не конфликтовать, если что-то уже есть на 5000
|
||||
debug = api.config.getboolean('api', 'debug', fallback=False)
|
||||
host = api.get_config_value('api', 'host', fallback='0.0.0.0')
|
||||
port = int(api.get_config_value('api', 'port', fallback=5001))
|
||||
debug = api.get_config_value('api', 'debug', fallback='false').lower() == 'true'
|
||||
|
||||
logger.info(f"Starting API on {host}:{port}")
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
@@ -142,14 +142,8 @@ class OpenVPNDataGatherer:
|
||||
'agg_6h_retention_days': '180', # 6 месяцев
|
||||
'agg_1d_retention_days': '365' # 12 месяцев
|
||||
},
|
||||
'visualization': {
|
||||
'refresh_interval': '5',
|
||||
'max_display_rows': '50'
|
||||
},
|
||||
'certificates': {
|
||||
'certificates_path': '/opt/ovpn/pki/issued',
|
||||
'certificate_extensions': 'crt'
|
||||
}
|
||||
'visualization': {},
|
||||
'certificates': {}
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -214,6 +208,12 @@ class OpenVPNDataGatherer:
|
||||
|
||||
def get_config_value(self, section, key, default=None):
|
||||
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)
|
||||
except:
|
||||
return default
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
FROM python:3.12-alpine
|
||||
|
||||
# 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
|
||||
|
||||
@@ -9,8 +10,12 @@ WORKDIR /app
|
||||
COPY 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 . .
|
||||
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Expose API port
|
||||
|
||||
@@ -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.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(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
|
||||
@@ -7,14 +7,31 @@ if [ ! -c /dev/net/tun ]; then
|
||||
chmod 600 /dev/net/tun
|
||||
fi
|
||||
|
||||
# Enable IP forwarding
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
# Enable IP forwarding (moved to docker-compose.yml sysctls)
|
||||
# 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
|
||||
|
||||
|
||||
# We use 0.0.0.0 to be reachable from other containers
|
||||
python main.py
|
||||
@@ -26,10 +26,10 @@ app = FastAPI(
|
||||
# Enable CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=[o.strip() for o in os.getenv("OVPMON_CORS_ORIGINS", "").split(",") if o.strip()],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
app.include_router(system.router, prefix="/api", tags=["System"])
|
||||
@@ -42,4 +42,4 @@ 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)
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
@@ -4,3 +4,4 @@ sqlalchemy
|
||||
psutil
|
||||
python-multipart
|
||||
jinja2
|
||||
pyjwt
|
||||
@@ -79,6 +79,11 @@ def create_profile(
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already exists")
|
||||
|
||||
try:
|
||||
pki.validate_username(profile_in.username)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid username")
|
||||
|
||||
# Build PKI
|
||||
try:
|
||||
pki.build_client(profile_in.username, db)
|
||||
@@ -89,6 +94,9 @@ def create_profile(
|
||||
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")
|
||||
base_real = os.path.realpath(client_conf_dir)
|
||||
if not os.path.realpath(file_path).startswith(base_real + os.sep):
|
||||
raise HTTPException(status_code=400, detail="Invalid username")
|
||||
|
||||
try:
|
||||
generator.generate_client_config(db, profile_in.username, file_path)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import os
|
||||
import logging
|
||||
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
|
||||
from services import generator, process, config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(verify_token)])
|
||||
|
||||
@@ -12,14 +16,37 @@ def configure_server(db: Session = Depends(get_db)):
|
||||
# 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)
|
||||
|
||||
# Unprivileged service: render to the staging dir, the root helper validates and installs it
|
||||
if not process.is_container() and os.geteuid() != 0:
|
||||
staged = os.path.join(os.getenv("OVPMON_STAGING_DIR", "/var/lib/ovpmon/staging"), "server.conf")
|
||||
os.makedirs(os.path.dirname(staged), exist_ok=True)
|
||||
generator.generate_server_config(db, output_path=staged)
|
||||
ok, msg = process.install_config()
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"Configuration rejected: {msg}")
|
||||
ok, msg = process.publish_crl()
|
||||
if not ok:
|
||||
if config.get_system_settings(db).crl_verify:
|
||||
raise HTTPException(status_code=500, detail=f"Configuration installed, but CRL publishing failed: {msg}")
|
||||
logger.warning(f"[SERVER] CRL not published (crl_verify is off): {msg}")
|
||||
return {"message": "Server configuration generated", "path": output_path}
|
||||
|
||||
# Ensure we can write to /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 or non-root host, use staging
|
||||
output_path = "staging/server.conf"
|
||||
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)
|
||||
return {"message": "Server configuration generated", "path": output_path}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
+147
-4
@@ -1,4 +1,7 @@
|
||||
from pydantic import BaseModel, Field
|
||||
import ipaddress
|
||||
import re
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from services import validation as v
|
||||
from typing import List, Optional, Literal
|
||||
from datetime import datetime
|
||||
|
||||
@@ -21,7 +24,54 @@ class PKISettingBase(BaseModel):
|
||||
easyrsa_batch: bool = True
|
||||
|
||||
class PKISettingUpdate(PKISettingBase):
|
||||
pass
|
||||
@field_validator("fqdn_ca", "fqdn_server")
|
||||
@classmethod
|
||||
def _check_fqdn(cls, val):
|
||||
if not re.match(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$", val) or ".." in val:
|
||||
raise ValueError("Invalid name (letters, digits, . _ -; max 64)")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_dn")
|
||||
@classmethod
|
||||
def _check_dn(cls, val):
|
||||
if val not in ("cn_only", "org"):
|
||||
raise ValueError("easyrsa_dn must be 'cn_only' or 'org'")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_req_country")
|
||||
@classmethod
|
||||
def _check_country(cls, val):
|
||||
if not re.match(r"^[A-Z]{2}$", val):
|
||||
raise ValueError("Country must be a 2-letter uppercase code")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_req_province", "easyrsa_req_city", "easyrsa_req_org", "easyrsa_req_ou")
|
||||
@classmethod
|
||||
def _check_dn_text(cls, val):
|
||||
if not re.match(r"^[A-Za-z0-9 .,_-]{0,64}$", val):
|
||||
raise ValueError("Only letters, digits, space and . , _ - are allowed (max 64)")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_req_email")
|
||||
@classmethod
|
||||
def _check_email(cls, val):
|
||||
if not re.match(r"^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}$", val):
|
||||
raise ValueError("Invalid email")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_key_size")
|
||||
@classmethod
|
||||
def _check_key_size(cls, val):
|
||||
if val not in (2048, 3072, 4096):
|
||||
raise ValueError("Key size must be 2048, 3072 or 4096")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_ca_expire", "easyrsa_cert_expire", "easyrsa_cert_renew", "easyrsa_crl_days")
|
||||
@classmethod
|
||||
def _check_days(cls, val):
|
||||
if not 1 <= val <= 36500:
|
||||
raise ValueError("Days must be between 1 and 36500")
|
||||
return val
|
||||
|
||||
class PKISetting(PKISettingBase):
|
||||
id: int
|
||||
@@ -53,7 +103,100 @@ class SystemSettingsBase(BaseModel):
|
||||
mssfix: Optional[int] = None
|
||||
|
||||
class SystemSettingsUpdate(SystemSettingsBase):
|
||||
pass
|
||||
@field_validator("port", "management_port")
|
||||
@classmethod
|
||||
def _check_port(cls, val):
|
||||
if not 1 <= val <= 65535:
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
return val
|
||||
|
||||
@field_validator("vpn_network")
|
||||
@classmethod
|
||||
def _check_network(cls, val):
|
||||
try:
|
||||
ipaddress.IPv4Address(val)
|
||||
except ValueError:
|
||||
raise ValueError("Invalid IPv4 network address")
|
||||
return val
|
||||
|
||||
@field_validator("vpn_netmask")
|
||||
@classmethod
|
||||
def _check_netmask(cls, val):
|
||||
if not v.valid_netmask(val):
|
||||
raise ValueError("Invalid netmask")
|
||||
return val
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_subnet(self):
|
||||
try:
|
||||
net = ipaddress.IPv4Network(f"{self.vpn_network}/{self.vpn_netmask}", strict=True)
|
||||
except ValueError:
|
||||
raise ValueError("vpn_network is not a valid network address for vpn_netmask")
|
||||
if not 8 <= net.prefixlen <= 30:
|
||||
raise ValueError("VPN subnet prefix must be between /8 and /30")
|
||||
return self
|
||||
|
||||
@field_validator("split_routes")
|
||||
@classmethod
|
||||
def _check_routes(cls, val):
|
||||
if len(val) > 256:
|
||||
raise ValueError("Too many routes (max 256)")
|
||||
for r in val:
|
||||
if not v.valid_route(r):
|
||||
raise ValueError(f"Invalid route: {r[:40]!r} (use a.b.c.d/nn or 'a.b.c.d mask')")
|
||||
return val
|
||||
|
||||
@field_validator("dns_servers")
|
||||
@classmethod
|
||||
def _check_dns(cls, val):
|
||||
if len(val) > 8:
|
||||
raise ValueError("Too many DNS servers (max 8)")
|
||||
for d in val:
|
||||
try:
|
||||
ipaddress.ip_address(d)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid DNS server address: {d[:40]!r}")
|
||||
return val
|
||||
|
||||
@field_validator("connect_script", "disconnect_script")
|
||||
@classmethod
|
||||
def _check_script_path(cls, val):
|
||||
if val and not v.SCRIPT_RE.match(val):
|
||||
raise ValueError("Script path must be " + v.SCRIPTS_DIR + "/<name> (letters, digits, . _ -)")
|
||||
return val
|
||||
|
||||
@field_validator("management_interface_address")
|
||||
@classmethod
|
||||
def _check_mgmt_addr(cls, val):
|
||||
try:
|
||||
if not ipaddress.ip_address(val).is_loopback:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError("Management interface must listen on a loopback address")
|
||||
return val
|
||||
|
||||
@field_validator("public_ip")
|
||||
@classmethod
|
||||
def _check_public_ip(cls, val):
|
||||
if val in (None, ""):
|
||||
return val
|
||||
if not v.valid_host(val):
|
||||
raise ValueError("public_ip must be an IP address or a hostname")
|
||||
return val
|
||||
|
||||
@field_validator("tun_mtu")
|
||||
@classmethod
|
||||
def _check_mtu(cls, val):
|
||||
if val is not None and not 576 <= val <= 9000:
|
||||
raise ValueError("tun_mtu must be between 576 and 9000")
|
||||
return val
|
||||
|
||||
@field_validator("mssfix")
|
||||
@classmethod
|
||||
def _check_mss(cls, val):
|
||||
if val is not None and not 536 <= val <= 1500:
|
||||
raise ValueError("mssfix must be between 536 and 1500")
|
||||
return val
|
||||
|
||||
class SystemSettings(SystemSettingsBase):
|
||||
id: int
|
||||
@@ -66,7 +209,7 @@ class ConfigResponse(BaseModel):
|
||||
|
||||
# --- User Profile Schemas ---
|
||||
class UserProfileBase(BaseModel):
|
||||
username: str
|
||||
username: str = Field(..., pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
||||
|
||||
class UserProfileCreate(UserProfileBase):
|
||||
pass
|
||||
|
||||
@@ -4,6 +4,7 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from sqlalchemy.orm import Session
|
||||
from .config import get_system_settings, get_pki_settings
|
||||
from .pki import PKI_DIR
|
||||
from . import validation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,9 +24,14 @@ 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_dh_path = os.path.join(PKI_DIR, "dh.pem")
|
||||
file_ta_path = os.path.join(PKI_DIR, "ta.key")
|
||||
file_crl_path = os.path.join(PKI_DIR, "crl.pem")
|
||||
from .process import is_container
|
||||
if os.geteuid() != 0 and not is_container():
|
||||
# unprivileged API: OpenVPN (nobody) cannot enter the 0700 pki dir, use the copy published by the helper
|
||||
file_crl_path = "/etc/openvpn/crl.pem"
|
||||
|
||||
# Render template
|
||||
config_content = template.render(
|
||||
ctx = dict(
|
||||
protocol=settings.protocol,
|
||||
port=settings.port,
|
||||
ca_path=file_ca_path,
|
||||
@@ -33,6 +39,7 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
||||
srv_key_path=file_srv_key_path,
|
||||
dh_path=file_dh_path,
|
||||
ta_path=file_ta_path,
|
||||
crl_path=file_crl_path,
|
||||
vpn_network=settings.vpn_network,
|
||||
vpn_netmask=settings.vpn_netmask,
|
||||
tunnel_type=settings.tunnel_type,
|
||||
@@ -51,7 +58,13 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
||||
tun_mtu=settings.tun_mtu,
|
||||
mssfix=settings.mssfix
|
||||
)
|
||||
|
||||
for _name, _val in ctx.items():
|
||||
validation.assert_safe_scalar(_name, _val)
|
||||
if settings.user_defined_cdscripts:
|
||||
validation.check_script(settings.connect_script)
|
||||
validation.check_script(settings.disconnect_script)
|
||||
config_content = template.render(**ctx)
|
||||
|
||||
# Write to file
|
||||
with open(output_path, "w") as f:
|
||||
f.write(config_content)
|
||||
@@ -59,6 +72,11 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
||||
return config_content
|
||||
|
||||
def generate_client_config(db: Session, username: str, output_path: str):
|
||||
from .pki import validate_username
|
||||
validate_username(username)
|
||||
base = os.path.realpath(os.path.dirname(output_path) or ".")
|
||||
if os.path.realpath(output_path) != os.path.join(base, os.path.basename(output_path)) or os.path.basename(output_path) != f"{username}.ovpn":
|
||||
raise ValueError("Invalid output path")
|
||||
settings = get_system_settings(db)
|
||||
pki = get_pki_settings(db)
|
||||
|
||||
@@ -90,7 +108,9 @@ def generate_client_config(db: Session, username: str, output_path: str):
|
||||
remote_ip = get_public_ip()
|
||||
|
||||
template = env.get_template("client.ovpn.j2")
|
||||
|
||||
|
||||
validation.assert_safe_scalar("remote_ip", remote_ip)
|
||||
validation.assert_safe_scalar("protocol", settings.protocol)
|
||||
config_content = template.render(
|
||||
protocol=settings.protocol,
|
||||
remote_ip=remote_ip,
|
||||
|
||||
@@ -7,6 +7,14 @@ from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
import re
|
||||
USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
||||
|
||||
def validate_username(username: str) -> str:
|
||||
if not isinstance(username, str) or not USERNAME_RE.match(username) or ".." in username:
|
||||
raise ValueError("Invalid username")
|
||||
return username
|
||||
|
||||
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")
|
||||
@@ -138,7 +146,8 @@ def init_pki(db: Session):
|
||||
|
||||
# Gen CRL
|
||||
_run_easyrsa(["gen-crl"], env)
|
||||
|
||||
_publish_crl()
|
||||
|
||||
return "PKI Initialized"
|
||||
|
||||
def clear_pki(db: Session):
|
||||
@@ -170,12 +179,25 @@ def clear_pki(db: Session):
|
||||
return "PKI directory did not exist, but User DB and Client profiles were wiped."
|
||||
|
||||
def build_client(username: str, db: Session):
|
||||
validate_username(username)
|
||||
env = _get_easyrsa_env(db)
|
||||
_run_easyrsa(["build-client-full", username, "nopass"], env)
|
||||
return True
|
||||
|
||||
def _publish_crl():
|
||||
"""Make the fresh CRL readable by OpenVPN when the API runs unprivileged (see ovpmon-helper)."""
|
||||
from .process import publish_crl
|
||||
ok, msg = publish_crl()
|
||||
if not ok:
|
||||
logger.error(f"CRL was generated but could not be published: {msg}")
|
||||
return ok
|
||||
|
||||
|
||||
def revoke_client(username: str, db: Session):
|
||||
validate_username(username)
|
||||
env = _get_easyrsa_env(db)
|
||||
_run_easyrsa(["revoke", username], env)
|
||||
_run_easyrsa(["gen-crl"], env)
|
||||
if not _publish_crl():
|
||||
raise RuntimeError("Certificate revoked, but the CRL could not be published to OpenVPN")
|
||||
return True
|
||||
@@ -6,13 +6,60 @@ import psutil
|
||||
|
||||
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"):
|
||||
return "alpine"
|
||||
return "debian" # default fallback to systemctl
|
||||
if os.path.exists('/.dockerenv'):
|
||||
return True
|
||||
try:
|
||||
with open('/proc/self/cgroup', 'rt') as f:
|
||||
if 'docker' in f.read():
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
HELPER_PATH = "/usr/local/sbin/ovpmon-helper"
|
||||
|
||||
|
||||
def _run_helper(args):
|
||||
"""Call the root-side helper through doas (used when this process is unprivileged).
|
||||
Returns (returncode, parsed_json_or_None, raw_output)."""
|
||||
import json
|
||||
try:
|
||||
r = subprocess.run(["doas", "-n", HELPER_PATH] + args, capture_output=True, text=True, timeout=90)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return 1, None, str(e)
|
||||
out = (r.stdout or "").strip()
|
||||
try:
|
||||
return r.returncode, json.loads(out.splitlines()[-1]), out
|
||||
except Exception:
|
||||
return r.returncode, None, (out + " " + (r.stderr or "")).strip()
|
||||
|
||||
|
||||
def install_config():
|
||||
"""Ask the helper to validate and install the staged server.conf. Returns (ok, message)."""
|
||||
rc, data, raw = _run_helper(["install-config"])
|
||||
if rc == 0 and data and data.get("status") == "ok":
|
||||
return True, "Configuration installed"
|
||||
msg = (data or {}).get("error") or raw or "helper failed"
|
||||
logger.error(f"[PROCESS] install-config failed: {msg}")
|
||||
return False, msg
|
||||
|
||||
|
||||
def publish_crl():
|
||||
"""Publish pki/crl.pem to a root-owned location readable by the OpenVPN user (nobody).
|
||||
No-op when running as root/in a container (OpenVPN reads the PKI directly). Returns (ok, message)."""
|
||||
if is_container() or os.geteuid() == 0:
|
||||
return True, "not required"
|
||||
rc, data, raw = _run_helper(["publish-crl"])
|
||||
if rc == 0 and data and data.get("status") == "ok":
|
||||
return True, "CRL published"
|
||||
msg = (data or {}).get("error") or raw or "helper failed"
|
||||
logger.error(f"[PROCESS] publish-crl failed: {msg}")
|
||||
return False, msg
|
||||
|
||||
|
||||
def control_service(action: str):
|
||||
"""
|
||||
@@ -21,7 +68,84 @@ def control_service(action: str):
|
||||
if action not in ["start", "stop", "restart"]:
|
||||
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()
|
||||
|
||||
# Unprivileged service: delegate to the root helper (validated, fixed set of actions)
|
||||
if os.geteuid() != 0:
|
||||
rc, data, raw = _run_helper(["service", action])
|
||||
if rc == 0 and data and data.get("status") == "ok":
|
||||
return {"status": "success", "message": f"Service {action} executed successfully via helper", "stdout": data.get("output", "")}
|
||||
logger.error(f"[PROCESS] helper service {action} failed: {raw}")
|
||||
return {"status": "error", "message": f"Failed to {action} service via helper", "stderr": (data or {}).get("error") or (data or {}).get("output") or raw}
|
||||
|
||||
# On Host OS: Use system service manager
|
||||
os_type = get_os_type()
|
||||
logger.info(f"[PROCESS] Host OS detected ({os_type}), using service manager for {action}")
|
||||
|
||||
cmd = []
|
||||
if os_type == "alpine":
|
||||
@@ -30,27 +154,27 @@ def control_service(action: str):
|
||||
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",
|
||||
"message": f"Service {action} executed successfully via {cmd[0]}",
|
||||
"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",
|
||||
"message": f"Failed to {action} service via {cmd[0]}",
|
||||
"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}"
|
||||
"message": f"Command {cmd[0]} not found found for OS type {os_type}"
|
||||
}
|
||||
|
||||
|
||||
|
||||
def get_process_stats():
|
||||
"""
|
||||
Returns dict with pid, cpu_percent, memory_mb, uptime.
|
||||
@@ -125,6 +249,17 @@ def get_process_stats():
|
||||
"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:
|
||||
seconds = int(seconds)
|
||||
days, seconds = divmod(seconds, 86400)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Input validation helpers for settings that end up in generated OpenVPN configs."""
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
|
||||
SCRIPTS_DIR = "/etc/openvpn/scripts"
|
||||
SCRIPT_RE = re.compile(r"^/etc/openvpn/scripts/[A-Za-z0-9_.-]{1,64}$")
|
||||
HOSTNAME_RE = re.compile(
|
||||
r"^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$"
|
||||
)
|
||||
FORBIDDEN_CHARS = set('\n\r"\\\x00')
|
||||
|
||||
|
||||
def assert_safe_scalar(name: str, value) -> None:
|
||||
"""Reject values that could break out of a config line (newlines, quotes, backslashes, NUL)."""
|
||||
if isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
assert_safe_scalar(name, item)
|
||||
return
|
||||
if isinstance(value, str) and (FORBIDDEN_CHARS & set(value) or any(ord(c) < 32 for c in value)):
|
||||
raise ValueError(f"Unsafe characters in '{name}'")
|
||||
|
||||
|
||||
def valid_netmask(mask: str) -> bool:
|
||||
try:
|
||||
ipaddress.IPv4Network(f"0.0.0.0/{mask}")
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def valid_route(route: str) -> bool:
|
||||
"""'a.b.c.d/nn' or 'a.b.c.d m.m.m.m'."""
|
||||
parts = route.split()
|
||||
try:
|
||||
if len(parts) == 1:
|
||||
ipaddress.IPv4Network(parts[0], strict=False)
|
||||
return True
|
||||
if len(parts) == 2:
|
||||
ipaddress.IPv4Address(parts[0])
|
||||
return valid_netmask(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def valid_host(value: str) -> bool:
|
||||
try:
|
||||
ipaddress.ip_address(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return bool(HOSTNAME_RE.match(value))
|
||||
|
||||
|
||||
def check_script(path: str) -> None:
|
||||
"""A connect/disconnect script must be a root-owned, non-writable file inside SCRIPTS_DIR."""
|
||||
if not path:
|
||||
return
|
||||
if not SCRIPT_RE.match(path):
|
||||
raise ValueError(f"Script path must match {SCRIPTS_DIR}/<name>")
|
||||
real = os.path.realpath(path)
|
||||
if os.path.dirname(real) != SCRIPTS_DIR:
|
||||
raise ValueError("Script must reside directly in " + SCRIPTS_DIR)
|
||||
if not os.path.isfile(real):
|
||||
raise ValueError("Script file does not exist")
|
||||
st = os.stat(real)
|
||||
if st.st_uid != 0 or st.st_mode & 0o022:
|
||||
raise ValueError("Script must be owned by root and not writable by group/others")
|
||||
dst = os.stat(SCRIPTS_DIR)
|
||||
if dst.st_uid != 0 or dst.st_mode & 0o022:
|
||||
raise ValueError(SCRIPTS_DIR + " must be owned by root and not writable by group/others")
|
||||
@@ -28,13 +28,13 @@ server {{ vpn_network }} {{ vpn_netmask }}
|
||||
|
||||
ifconfig-pool-persist /etc/openvpn/ipp.txt
|
||||
|
||||
log /etc/openvpn/openvpn.log
|
||||
log-append /etc/openvpn/openvpn.log
|
||||
log /var/log/openvpn/openvpn.log
|
||||
log-append /var/log/openvpn/openvpn.log
|
||||
|
||||
verb 3
|
||||
|
||||
# Use Extended Status Output
|
||||
status /etc/openvpn/openvpn-status.log 5
|
||||
status /var/log/openvpn/openvpn-status.log 5
|
||||
status-version 2
|
||||
|
||||
# Tunneling Mode
|
||||
@@ -84,7 +84,7 @@ persist-tun
|
||||
|
||||
# check revocation list
|
||||
{% if crl_verify %}
|
||||
crl-verify /etc/openvpn/crl.pem
|
||||
crl-verify {{ crl_path }}
|
||||
{% else %}
|
||||
# crl-verify disabled
|
||||
{% endif %}
|
||||
|
||||
+25
-37
@@ -4,49 +4,29 @@ 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'
|
||||
from .config import get_config_value
|
||||
|
||||
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
|
||||
# Use consistent OVPMON_API_SECRET_KEY as primary source
|
||||
key = get_config_value('api', 'secret_key', fallback='ovpmon-secret-change-me')
|
||||
|
||||
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'
|
||||
if key == 'ovpmon-secret-change-me':
|
||||
print("[AUTH] WARNING: Using default fallback SECRET_KEY")
|
||||
else:
|
||||
# Check if it was from env (get_config_value prioritizes env)
|
||||
import os
|
||||
if os.getenv('OVPMON_API_SECRET_KEY'):
|
||||
print("[AUTH] Using SECRET_KEY from OVPMON_API_SECRET_KEY environment variable")
|
||||
elif os.getenv('OVPMON_SECRET_KEY'):
|
||||
print("[AUTH] Using SECRET_KEY from OVPMON_SECRET_KEY environment variable")
|
||||
else:
|
||||
print("[AUTH] SECRET_KEY loaded (config.ini or fallback)")
|
||||
|
||||
return key
|
||||
|
||||
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'}")
|
||||
@@ -63,7 +43,15 @@ async def verify_token(authorization: str = Header(None)):
|
||||
# print(f"[AUTH] Decoding token with SECRET_KEY starting with: {SECRET_KEY[:3]}...")
|
||||
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
if payload.get("is_2fa_pending"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="2FA verification required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return payload
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
error_detail = str(e)
|
||||
|
||||
@@ -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
|
||||
+2
-1
@@ -9,6 +9,7 @@ RUN npm run build
|
||||
# Stage 2: Serve
|
||||
FROM nginx:alpine
|
||||
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
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -99,7 +99,7 @@
|
||||
|
||||
<div class="user-profile ms-2">
|
||||
<div class="user-avatar-small bg-primary text-white">
|
||||
{{ username[0]?.toUpperCase() || 'A' }}
|
||||
{{ username[0]?.toUpperCase() || 'U' }}
|
||||
</div>
|
||||
<div class="user-meta d-none d-md-block">
|
||||
<span class="username">{{ username }}</span>
|
||||
@@ -123,9 +123,11 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed } from 'vue';
|
||||
import { useAppConfig } from './composables/useAppConfig';
|
||||
import { useApi } from './composables/useApi';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const { loadConfig, isLoaded } = useAppConfig();
|
||||
const { apiClient } = useApi();
|
||||
const timezoneAbbr = ref(new Date().toLocaleTimeString('en-us',{timeZoneName:'short'}).split(' ')[2] || 'UTC');
|
||||
const isDark = ref(false);
|
||||
const refreshKey = ref(0);
|
||||
@@ -135,7 +137,24 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isAuthenticated = computed(() => route.name !== 'Login');
|
||||
const username = ref(localStorage.getItem('ovpmon_user') || 'Admin');
|
||||
const username = ref(localStorage.getItem('ovpmon_user') || '');
|
||||
|
||||
// Keep the header name in sync with the server (login, rename, stale session)
|
||||
const syncUsername = async () => {
|
||||
if (!localStorage.getItem('ovpmon_token')) {
|
||||
username.value = '';
|
||||
return;
|
||||
}
|
||||
username.value = localStorage.getItem('ovpmon_user') || '';
|
||||
try {
|
||||
const res = await apiClient.get('/user/me');
|
||||
if (res.data?.username) {
|
||||
username.value = res.data.username;
|
||||
localStorage.setItem('ovpmon_user', res.data.username);
|
||||
}
|
||||
} catch (e) { /* 401 is handled by the interceptor */ }
|
||||
};
|
||||
const onUserChanged = () => { username.value = localStorage.getItem('ovpmon_user') || ''; };
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('ovpmon_token');
|
||||
@@ -162,10 +181,13 @@ const refreshPage = () => {
|
||||
// Close sidebar on route change
|
||||
watch(() => route.path, () => {
|
||||
isSidebarOpen.value = false;
|
||||
if (route.name !== 'Login') syncUsername();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConfig();
|
||||
window.addEventListener('ovpmon-user-changed', onUserChanged);
|
||||
if (route.name !== 'Login') syncUsername();
|
||||
|
||||
// Init Theme
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
|
||||
@@ -58,10 +58,11 @@ export function useApi() {
|
||||
};
|
||||
|
||||
const fetchCertificates = async () => {
|
||||
const res = await apiClient.get('/certificates');
|
||||
const res = await profilesApiClient.get('/profiles');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
apiClient,
|
||||
profilesApiClient,
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
<button class="btn btn-action btn-action-primary py-2 fw-bold btn-account-action" @click="showPwModal">
|
||||
Change Password
|
||||
</button>
|
||||
<button class="btn btn-action btn-action-secondary py-2 fw-bold btn-account-action mt-2" @click="showUserModal">
|
||||
Change Username
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,6 +167,55 @@
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<!-- Username Change Modal -->
|
||||
<BaseModal id="usernameChangeModal" title="Change Account Username" ref="userModal">
|
||||
<template #body>
|
||||
<form @submit.prevent="handleChangeUsername" id="userForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold text-muted text-uppercase">New Username</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="usernameForm.new_username"
|
||||
placeholder="3-32 chars: letters, digits, . _ -"
|
||||
pattern="[A-Za-z][A-Za-z0-9_.\-]{2,31}"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold text-muted text-uppercase">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
v-model="usernameForm.current_password"
|
||||
placeholder="Enter current password"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2" v-if="isEnabled">
|
||||
<label class="form-label small fw-bold text-muted text-uppercase">2FA Code</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="usernameForm.otp"
|
||||
placeholder="6-digit code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button type="button" class="btn-action btn-action-secondary" @click="userModal.hide()">Cancel</button>
|
||||
<button type="submit" form="userForm" class="btn-action btn-action-save" :disabled="userLoading">
|
||||
<span v-if="userLoading" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Update Username
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -179,6 +231,7 @@ const { apiClient } = useApi();
|
||||
|
||||
// UI Refs
|
||||
const pwModal = ref(null);
|
||||
const userModal = ref(null);
|
||||
const confirmModal = ref(null);
|
||||
const disable2FAModal = ref(null);
|
||||
|
||||
@@ -234,6 +287,57 @@ const handleChangePassword = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Username Logic
|
||||
const userLoading = ref(false);
|
||||
const usernameForm = reactive({ new_username: '', current_password: '', otp: '' });
|
||||
|
||||
const showUserModal = () => {
|
||||
usernameForm.new_username = '';
|
||||
usernameForm.current_password = '';
|
||||
usernameForm.otp = '';
|
||||
userModal.value.show();
|
||||
};
|
||||
|
||||
const handleChangeUsername = async () => {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_.-]{2,31}$/.test(usernameForm.new_username)) {
|
||||
Swal.fire({
|
||||
title: 'Error!',
|
||||
text: 'Username must be 3-32 chars, start with a letter, and contain only letters, digits, . _ -',
|
||||
icon: 'error',
|
||||
confirmButtonColor: '#EC7C31'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
userLoading.value = true;
|
||||
try {
|
||||
const res = await apiClient.post('../auth/change-username', {
|
||||
new_username: usernameForm.new_username,
|
||||
current_password: usernameForm.current_password,
|
||||
otp: usernameForm.otp || undefined
|
||||
});
|
||||
|
||||
try { localStorage.setItem('ovpmon_user', res.data.username); } catch (e) { /* ignore */ }
|
||||
window.dispatchEvent(new Event('ovpmon-user-changed'));
|
||||
userModal.value.hide();
|
||||
Swal.fire({
|
||||
title: 'Success!',
|
||||
text: 'Username updated. Use the new username the next time you sign in.',
|
||||
icon: 'success',
|
||||
confirmButtonColor: '#1652B8'
|
||||
});
|
||||
} catch (err) {
|
||||
Swal.fire({
|
||||
title: 'Failed',
|
||||
text: err.response?.data?.error || 'Failed to update username',
|
||||
icon: 'error',
|
||||
confirmButtonColor: '#cf222e'
|
||||
});
|
||||
} finally {
|
||||
userLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 2FA Logic
|
||||
const isEnabled = ref(false);
|
||||
const step2 = ref(false);
|
||||
|
||||
@@ -109,12 +109,12 @@
|
||||
<i class="fas fa-check-circle text-success me-2"></i>All Good
|
||||
</p>
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -202,32 +202,15 @@ const loadCerts = async () => {
|
||||
loading.certs = true;
|
||||
try {
|
||||
const res = await fetchCertificates();
|
||||
if(res.success) {
|
||||
const now = new Date();
|
||||
const warningThreshold = new Date();
|
||||
warningThreshold.setDate(now.getDate() + 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
|
||||
});
|
||||
}
|
||||
if(res.success && Array.isArray(res.data)) {
|
||||
const list = res.data.filter(cert => {
|
||||
// Only active, non-revoked, and expiring soon (within 45 days)
|
||||
return !cert.is_revoked && !cert.is_expired &&
|
||||
cert.days_remaining !== null && cert.days_remaining <= 45;
|
||||
});
|
||||
|
||||
kpi.expiringCerts = count;
|
||||
expiringCertsList.value = list.sort((a,b) => a.days_left - b.days_left);
|
||||
kpi.expiringCerts = list.length;
|
||||
expiringCertsList.value = list.sort((a,b) => (a.days_remaining || 0) - (b.days_remaining || 0));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -298,9 +281,9 @@ const renderMainChart = () => {
|
||||
mainChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
labels,
|
||||
datasets: [
|
||||
|
||||
{
|
||||
label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps',
|
||||
data: dataRx,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Admin username change (2026-09-30)
|
||||
|
||||
Goal: get rid of the well-known `admin` login and of the built-in `admin/password` account.
|
||||
|
||||
## Design
|
||||
|
||||
- The JWT carries `user_id`, not the name, and no other table references `users.username`, so a rename does not invalidate sessions.
|
||||
- Changing the username requires the current password and, when 2FA is enabled, a valid OTP. Wrong password/OTP counts toward the login rate limit.
|
||||
|
||||
## Changes
|
||||
|
||||
| Area | Change |
|
||||
|---|---|
|
||||
| API (`APP_CORE/openvpn_api_v3.py`) | `POST /api/auth/change-username`: body `new_username`, `current_password`, optional `otp`. Rules: `^[A-Za-z][A-Za-z0-9_.-]{2,31}$`, reserved names rejected (`admin`, `administrator`, `root`, `user`, `test`, `guest`), case-insensitive uniqueness (409). Helper `_current_user_id()` |
|
||||
| 2FA | `setup_2fa` puts the real username into the authenticator URI (was hardcoded `admin`) |
|
||||
| Bootstrap | `ensure_default_admin` no longer creates `admin/password`. With an empty `users` table it creates a user only from `OVPMON_INITIAL_ADMIN_USER` / `OVPMON_INITIAL_ADMIN_PASSWORD`; otherwise it logs an error |
|
||||
| UI (`Account.vue`) | "Change Username" button and modal (OTP field shown only if 2FA is on) |
|
||||
| UI (`App.vue`) | Header name synced from `/user/me` on start and on route change, and on the `ovpmon-user-changed` event; fixed the stale/hardcoded `Admin` |
|
||||
|
||||
## Existing installations
|
||||
|
||||
Rename directly in the DB (stop nothing; sessions stay valid):
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
c = sqlite3.connect("/var/lib/ovpmon/openvpn_monitor.db")
|
||||
c.execute("UPDATE users SET username=? WHERE username='admin'", ("<new-login>",)); c.commit()
|
||||
```
|
||||
|
||||
Take a DB backup first. Recovery when `users` is empty: temporarily set `OVPMON_INITIAL_ADMIN_USER/PASSWORD`, restart `ovpmon-api`, then remove the variables.
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Login with new name / with `admin` | 200 / 401 |
|
||||
| No token | 401 |
|
||||
| `admin`, `root`, `ab`, `a/b`, `1abc` | 400 |
|
||||
| Wrong current password | 401 |
|
||||
| Rename to another valid name and back | 200, login with the new name works |
|
||||
| Empty `users` without / with seed variables (DB copy) | no user / user created |
|
||||
| Manual UI test (password change, 2FA enable) | passed; header-name bug found and fixed |
|
||||
@@ -0,0 +1,48 @@
|
||||
# Egress of OpenVPN clients via a Hysteria2 tunnel (2026-09-30)
|
||||
|
||||
Goal: all traffic of VPN clients leaves the internet through an exit node reached over an existing Hysteria2 client tunnel (`hytun`) on the VPN host.
|
||||
|
||||
```
|
||||
OpenVPN client --udp/1194--> tun0 (172.20.1.1/24)
|
||||
-> ip rule 102: from 172.20.1.0/24 -> table 100
|
||||
-> table 100: default dev hytun (metric 10), blackhole default (metric 1000)
|
||||
-> iptables nat POSTROUTING -s 172.20.1.0/24 -o hytun -j MASQUERADE
|
||||
-> hytun (TUN of the Hysteria2 client) --QUIC/UDP 443--> exit node --> Internet
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running Hysteria2 client with a TUN device (`hytun`), a routing table (100) with `default dev hytun` and a `blackhole` fallback, and `rp_filter=2` (loose) on all/default/hytun (strict mode drops TUN replies).
|
||||
- Hysteria server-side `socks5`/`http` outbounds are TCP-only, so UDP needs TUN + policy routing, not a SOCKS chain.
|
||||
- The exit node needs no changes.
|
||||
|
||||
## Implementation (OpenRC service `ovpn-hytun`)
|
||||
|
||||
`depend: need hysteria-route; before openvpn`. On start:
|
||||
|
||||
```sh
|
||||
sysctl -qw net.ipv4.ip_forward=1
|
||||
ip rule add priority 102 from 172.20.1.0/24 lookup 100
|
||||
iptables -t nat -A POSTROUTING -s 172.20.1.0/24 -o hytun -j MASQUERADE
|
||||
iptables -t mangle -A FORWARD -o hytun -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu # and -i hytun
|
||||
iptables -A FORWARD -s 172.20.1.0/24 ! -o hytun -j DROP # anti-leak
|
||||
```
|
||||
|
||||
Rules are idempotent (`-C` before `-A`); `stop` removes them. Adjust `172.20.1.0/24` to the `server` network in `server.conf`. Persist `ip_forward` in `/etc/sysctl.d/`. Use POSIX `sh` in OpenRC scripts (no bash arrays).
|
||||
|
||||
## Properties
|
||||
|
||||
- Only the VPN subnet enters the tunnel; SSH, OpenVPN's own port (1194) and management traffic use the main table.
|
||||
- If the Hysteria client dies, table 100 falls to `blackhole`: clients lose internet, they do not leak through `eth0`.
|
||||
- Clients get public DNS (e.g. 1.1.1.1) that is resolved through the same tunnel.
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Node: TCP and UDP (DNS) through `hytun` | works |
|
||||
| Reference (uid routed to `hytun`): external IP | exit node address |
|
||||
| Real VPN client: `https://ifconfig.me` | exit node address (end-to-end test passed) |
|
||||
| `ip rule`, `iptables -t nat -S POSTROUTING`, `rc-status` | rules present, services started |
|
||||
|
||||
Testing tip: a network namespace test that reuses the VPN subnet conflicts with a live `tun0`; test with a real client or a different, temporary subnet added to the rules.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Privilege separation: API services no longer run as root (2026-09-30)
|
||||
|
||||
Problem: `ovpmon-api`, `ovpmon-gatherer` and `ovpmon-profiler` ran as root. Any bug in an API (or a stolen admin token combined with an input-validation gap) meant root on the host.
|
||||
|
||||
## Design
|
||||
|
||||
```
|
||||
ovpmon-api / gatherer / profiler (user ovpmon, no login shell)
|
||||
|
|
||||
| doas -n /usr/local/sbin/ovpmon-helper <fixed args> (only 6 exact commands allowed)
|
||||
v
|
||||
ovpmon-helper (root) -> install-config : validates the staged server.conf against an allowlist,
|
||||
installs /etc/openvpn/server.conf atomically
|
||||
-> publish-crl : copies pki/crl.pem to /etc/openvpn/crl.pem (root:root 644)
|
||||
-> service start|stop|restart|status : rc-service openvpn
|
||||
```
|
||||
|
||||
| Item | Detail |
|
||||
|---|---|
|
||||
| Service user | `ovpmon` (system user, nologin), owns `/var/lib/ovpmon` (DBs, `staging/`), `/var/log/ovpmon`, `APP_PROFILER/{easy-rsa,client-config,profiler.log}`, runtime logs and `__pycache__`. Code and virtualenvs stay root-owned (read-only for the service) |
|
||||
| OpenRC | `command_user="ovpmon:ovpmon"` in the three `ovpmon-*` init scripts; `/etc/ovpmon/env` stays `root:root 600` (read by the init script before the privilege drop) |
|
||||
| doas | `/etc/doas.d/ovpmon.conf`: `permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args <install-config \| service start\|stop\|restart\|status>`; nothing else is permitted |
|
||||
| Helper | `/usr/local/sbin/ovpmon-helper` (root, 755). Source: `DOCS/General/privilege-separation/ovpmon-helper` |
|
||||
| Profiler code | `services/process.py`: when not root, calls the helper (`_run_helper`, `install_config`); `routers/server.py`: renders to `/var/lib/ovpmon/staging/server.conf`, then asks the helper to install it (rejection → HTTP 400). Container and root paths are unchanged |
|
||||
| Status log | the helper sets `root:ovpmon 640` on `openvpn-status.log` before every start/restart so the gatherer can read it |
|
||||
|
||||
### What the helper allows in `server.conf`
|
||||
Only the directives the template produces, each with checked arguments: `dev tun`, `proto`, `port`, `ca/cert/key/dh/tls-auth/crl-verify` (files must resolve inside the PKI directory), `tun-mtu`, `mssfix`, `topology subnet`, `server`, fixed `ifconfig-pool-persist`, `log`, `log-append`, `status`, `verb`, `push` (only `redirect-gateway def1 bypass-dhcp`, `route <net>`, `dhcp-option DNS <ip>`), `user nobody`, `group nogroup`, ciphers/auth/keepalive, `client-to-client`, `duplicate-cn`, `persist-*`, `script-security 2`, `client-connect/disconnect` (script must be root-owned, not group/other-writable, directly in `/etc/openvpn/scripts/`), `management` (loopback only). Everything else (`up`, `down`, `plugin`, `route-up`, `tls-verify`, `setenv`, `config`, ...) is rejected. `user nobody`, `group nogroup`, `server`, `ca`, `cert`, `key` are mandatory. The file is read once (no TOCTOU between check and install), must be an `ovpmon`-owned regular file (no symlinks), ASCII only, at most 64 KiB.
|
||||
|
||||
## Rollout (what was done)
|
||||
1. Backup: `/root/backup-p2-*.tar` (`/etc/openvpn`, `/var/lib/ovpmon`, `easy-rsa`, `client-config`, init scripts, doas config, changed code) and `/root/app-bak/p2/`.
|
||||
2. Create the user/group, install the helper and the doas rules; test the helper as `ovpmon` before touching services.
|
||||
3. Patch `process.py` / `server.py` (root code path unchanged, so nothing changed while services still ran as root).
|
||||
4. `chown` runtime data, add `command_user`, restart the gatherer, then the API, then the profiler, checking each.
|
||||
|
||||
## Results
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Processes | gunicorn, uvicorn and the gatherer run as `ovpmon`; only the `supervise-daemon` supervisors are root |
|
||||
| Helper: current live config | accepted, live `server.conf` byte-identical |
|
||||
| Helper: 17 injected directives (`up`, `plugin`, `script-security 3`, `client-connect /tmp/x`, `route-up`, `tls-verify`, `setenv`, `config`, `ca /etc/shadow`, `management 0.0.0.0`, `log /etc/passwd`, `user root`, `status /etc/cron.d/x`, `push "setenv-safe"`, `dev tap`, multi-argument `push`) | all rejected, live config unchanged |
|
||||
| Helper: missing `user nobody`, cert outside PKI, CR injection, symlinked staged file | rejected |
|
||||
| doas: arbitrary command, helper with other args | denied |
|
||||
| Service user cannot | read `/etc/shadow`, `/root`, `/etc/hysteria/*.yaml`, `/etc/ovpmon/env`; write `/etc/openvpn`, `/etc/init.d`, `authorized_keys`, application code; run `iptables` |
|
||||
| API: monitoring, config, process stats, `server/configure` via helper (config identical) | 200 |
|
||||
| API: create profile (easyrsa), download `.ovpn`, revoke | 200 |
|
||||
| API: OpenVPN restart through the helper | 200; OpenVPN running, `tun0` up, status log readable by the gatherer, egress rules (`ip rule 102`, MASQUERADE to `hytun`) intact, no gatherer errors |
|
||||
|
||||
## Limitations / notes
|
||||
- The supervisors stay root by design (they only respawn the service user's process).
|
||||
- Helper checks resolve PKI paths at install time; a service-user-owned PKI directory could later swap a file for a symlink before OpenVPN (root) starts. Impact is limited to OpenVPN failing to parse or reading a key/cert-shaped file; keep the PKI directory owned by `ovpmon` only.
|
||||
- CRL checking (`crl_verify`) works with the unprivileged API, see the section below.
|
||||
- systemd deployments: same idea with `User=ovpmon`, a polkit/sudoers rule for the helper's fixed commands, and the same helper (replace `rc-service` with `systemctl`).
|
||||
- Rollback: restore `/etc/init.d/ovpmon-*` from `/root/app-bak/p2/`, `chown -R root:root` the data directories, restart the services (the helper and doas rules can stay).
|
||||
|
||||
## CRL publishing (`crl_verify`)
|
||||
|
||||
OpenVPN drops to `nobody` after start and re-reads the CRL on each new connection. `easy-rsa` creates `pki/` as `0700` owned by the service user, so `nobody` could not read `pki/crl.pem` and every client would be refused once `crl_verify` was enabled.
|
||||
|
||||
| Item | Detail |
|
||||
|---|---|
|
||||
| Published copy | `/etc/openvpn/crl.pem`, `root:root 644`, written atomically by `ovpmon-helper publish-crl` |
|
||||
| Helper checks | source must resolve inside the PKI directory, regular file (no symlink) owned by the service user, at most 1 MiB, PEM CRL markers, and `openssl crl` must parse it |
|
||||
| When it runs | after `gen-crl` in *Initialize PKI* and in *revoke* (`services/pki.py`, if publishing fails the revoke call reports an error instead of silently leaving the old CRL), on *server/configure* (an error only when `crl_verify` is on) and on every helper `service start\|restart` |
|
||||
| Config | with an unprivileged API the generator renders `crl-verify /etc/openvpn/crl.pem`; as root/in a container it still uses `pki/crl.pem`. The helper allowlist accepts only the published path for `crl-verify` |
|
||||
| doas | one more exact rule: `args publish-crl` |
|
||||
|
||||
Results (throw-away second OpenVPN instance on another port/subnet running as `nobody` with the same PKI and `crl-verify /etc/openvpn/crl.pem`; production OpenVPN untouched):
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `publish-crl` as `ovpmon` | ok; copy is `root:root 644`, readable by `nobody`; `pki/crl.pem` still unreadable by `nobody` |
|
||||
| Garbage file, fake PEM markers, symlinked source | rejected |
|
||||
| `crl_verify=true` via API + `server/configure` | 200; config contains `crl-verify /etc/openvpn/crl.pem`, accepted by the helper; setting restored afterwards, live `server.conf` byte-identical to the original |
|
||||
| Valid client with active CRL check | connects (`Initialization Sequence Completed`) |
|
||||
| Revoke through the API, then reconnect | server sends `certificate revoked`, client is refused; no CRL read errors |
|
||||
@@ -0,0 +1,28 @@
|
||||
# Security hardening (2026-09-30)
|
||||
|
||||
Scope: a native (OpenRC) deployment of this suite on an internet-facing host. Findings from a review of exposed services, the fixes and their verification.
|
||||
|
||||
## Findings and results
|
||||
|
||||
| # | Severity | Finding | Fix | Result |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Critical | SSH accepted passwords for `root` (`PasswordAuthentication yes`, cloud-init drop-in overrode the main config); the host was already being brute-forced | `/etc/ssh/sshd_config.d/00-hardening.conf`: `PasswordAuthentication no`, `KbdInteractiveAuthentication no`, `PermitRootLogin prohibit-password`, `MaxAuthTries 3`, `LoginGraceTime 30` | key login works; password login → `Permission denied (publickey)` |
|
||||
| 2 | High | Path traversal in Profiler: `username` was an unvalidated string used in `client-config/<username>.ovpn` and as an `easyrsa` argument, service runs as root | pattern `^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$` in `schemas.py`; `validate_username()` in `services/pki.py`; realpath containment checks in `routers/profiles.py` and `services/generator.py` | `../../tmp/pwn`, `a/b`, `..`, `-x` → 422, no file created; valid profile create/revoke works |
|
||||
| 3 | High | 2FA bypass: the temporary token issued after the password step was accepted by every protected route | `token_required` (Flask) and `verify_token` (Profiler) reject tokens with `is_2fa_pending`; only `/api/auth/verify-2fa` uses them | temp token → 401 on `/api/v1/user/me`, `/profiles-api/config`, `change-username`; full token → OK |
|
||||
| 4 | Medium | `enable_2fa` logged the OTP and TOTP secret | log line reduced to "Attempting 2FA activation" | no secrets in logs |
|
||||
| 5 | Medium | CORS `*` with credentials on both APIs | allowed origin restricted to the panel origin; Profiler methods/headers narrowed | foreign `Origin` gets no `Access-Control-Allow-Origin` |
|
||||
| 6 | Medium | No brute-force throttling outside the app rate limit | fail2ban jails `sshd`, `sshd-ddos`, `ovpmon-login` (401/429/503 on `POST /api/auth/login`); admin IP in `ignoreip` | jails active, bans observed for SSH scanners |
|
||||
| 7 | Medium | Panel served over plain HTTP | Nginx TLS on the panel port (TLS 1.2/1.3, HSTS, `nosniff`, `X-Frame-Options`, `no-store`, login `limit_req` 5 r/min, HTTP→HTTPS redirect via `error_page 497`) | HTTPS 200, TLS 1.1 rejected, rate limit returns 503 |
|
||||
|
||||
Checked, no issue: JWT algorithm pinned to HS256, random 32-byte secret; SQL f-strings only use internal table/column names; backends bound to `127.0.0.1`; Nginx workers unprivileged.
|
||||
|
||||
## Residual risks
|
||||
|
||||
- Self-signed certificate: verify the fingerprint on first visit; use a CA-issued certificate when a domain exists.
|
||||
- The APIs run as root; split privileged OpenVPN/PKI operations into a separate helper.
|
||||
- Enable 2FA for the admin account.
|
||||
- Changes were applied in place on the host; keep them in the repository (see the commit "Harden auth and API").
|
||||
|
||||
## Rollback
|
||||
|
||||
Backups were taken on the host before each change: `sshd_config`, `ovpmon.conf` (Nginx), application files in `/root/app-bak/`, previous UI build `/var/www/ovpmon.bak`.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Server settings validation (2026-09-30)
|
||||
|
||||
Problem: values of the server/PKI settings (`PUT /profiles-api/config/server|pki`) were free strings that were rendered into `server.conf` (written by a root process) and passed to `easyrsa`. A user with a valid token could inject extra OpenVPN directives (for example `up`/`plugin`) or shell-relevant DN characters, which is a path to code execution as root.
|
||||
|
||||
## Defence in three layers
|
||||
|
||||
| Layer | Where | What it does |
|
||||
|---|---|---|
|
||||
| A. Schema | `APP_PROFILER/schemas.py` (`SystemSettingsUpdate`, `PKISettingUpdate`) | Rejects invalid values with 422. Applies to updates only, so already stored values never break `GET /config` |
|
||||
| B. Scripts | `services/validation.py: check_script`, used in `services/generator.py` | `connect_script`/`disconnect_script` must be a file directly inside `/etc/openvpn/scripts/`, owned by root, not group/other-writable, in a root-owned non-writable directory (symlinks out of the directory are rejected) |
|
||||
| C. Renderer | `services/generator.py` | Before writing `server.conf` / client `.ovpn`, every value is checked for newline, CR, NUL, other control characters, `"` and `\`; on violation nothing is written and the API returns 400 |
|
||||
|
||||
## Rules (layer A)
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `port`, `management_port` | 1-65535 |
|
||||
| `vpn_network` + `vpn_netmask` | valid IPv4 network address for a contiguous mask, prefix /8-/30 |
|
||||
| `split_routes[]` | `a.b.c.d/nn` or `a.b.c.d mask`, at most 256 |
|
||||
| `dns_servers[]` | IPv4/IPv6 addresses, at most 8 |
|
||||
| `public_ip` | IP address or hostname |
|
||||
| `management_interface_address` | loopback only |
|
||||
| `tun_mtu`, `mssfix` | 576-9000, 536-1500 |
|
||||
| `connect_script`, `disconnect_script` | empty or `/etc/openvpn/scripts/<letters, digits, . _ ->` |
|
||||
| PKI `fqdn_ca`, `fqdn_server` | `^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`, no `..` |
|
||||
| PKI `easyrsa_dn` | `cn_only` or `org` |
|
||||
| PKI country / province, city, org, ou / email | `^[A-Z]{2}$` / `^[A-Za-z0-9 .,_-]{0,64}$` / simple email pattern |
|
||||
| PKI `key_size`, days | 2048/3072/4096; 1-36500 |
|
||||
|
||||
## Results
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Round trip of current server and PKI settings via `PUT` | 200 |
|
||||
| 17 malicious/invalid values (newline in DNS or routes, quote, `../` and `/tmp` scripts, port 0/70000, bad mask, host bits in network, non-loopback management, `a b;c` host, MTU 100, `/` in organisation, lowercase country, `../` in FQDN, key size 512) | all 422 with a clear message |
|
||||
| 4 valid changes (DNS incl. IPv6, routes in both notations, hostname, allowed script path) | 200 |
|
||||
| Script checks: root-owned 755 file / group-writable / symlink out of the directory / missing / outside the directory / traversal / empty | accepted / rejected / rejected / rejected / rejected / rejected / accepted |
|
||||
| Layer C with unsafe values injected past the schema (newline in DNS, quote in route, newline in script, script outside the directory, newline in management address) | all blocked, nothing written |
|
||||
| Regression: settings render to `server.conf` | identical to the live config |
|
||||
|
||||
Settings were restored after the tests and left unchanged.
|
||||
|
||||
## Notes
|
||||
|
||||
- The scripts directory `/etc/openvpn/scripts/` does not exist by default; create it as `root:root 755` and put root-owned `755` scripts there before enabling `user_defined_cdscripts`.
|
||||
- Next step (planned): run the API as an unprivileged user with a root helper that re-validates the config before installing it. See the project plan.
|
||||
@@ -105,6 +105,6 @@ server {
|
||||
|
||||
## 5. First Run & Initialization
|
||||
1. Access the UI via browser.
|
||||
2. Login with default credentials: `admin` / `password`.
|
||||
2. Sign in with the admin seeded via OVPMON_INITIAL_ADMIN_USER / OVPMON_INITIAL_ADMIN_PASSWORD (no built-in default user exists).
|
||||
3. **Immediately** change the password and set up 2FA in the Settings/Profile section.
|
||||
4. If using the Profiler, ensure the `easy-rsa` directory is present and initialized via the UI.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Deployment: Docker
|
||||
|
||||
Uses `docker-compose.yml` from the repository root.
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Container | Ports | Notes |
|
||||
|---|---|---|---|
|
||||
| `app-ui` | `ovp-ui` | 80 | Nginx + built UI; proxies `/api/` and `/profiles-api/` |
|
||||
| `app-api` | `ovp-api` | 5001 | Flask monitoring API |
|
||||
| `app-gatherer` | `ovp-gatherer` | - | Parses `openvpn-status.log` |
|
||||
| `app-profiler` | `ovp-profiler` | 8000, 1194/udp | FastAPI + OpenVPN; needs `NET_ADMIN` and `/dev/net/tun` |
|
||||
|
||||
Volumes: `ovp_logs`, `ovp_config`, `ovp_pki`, `ovp_client_config`, `db_data`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `.env` next to `docker-compose.yml` (`JWT_SECRET` is mandatory: compose refuses to start without it):
|
||||
```bash
|
||||
cat > .env <<EOT
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
OVPMON_INITIAL_ADMIN_USER=<login>
|
||||
OVPMON_INITIAL_ADMIN_PASSWORD=<strong password>
|
||||
EOT
|
||||
chmod 600 .env
|
||||
```
|
||||
2. `docker-compose up -d --build`
|
||||
3. Open `http://<host>`, sign in, **PKI Configuration → Initialize PKI**.
|
||||
4. Remove `OVPMON_INITIAL_ADMIN_*` from `.env` and run `docker-compose up -d app-api` (the seed is used only while the users table is empty).
|
||||
|
||||
## Compose settings
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Published ports | `80/tcp` (UI) and `1194/udp` (VPN) only; `5001` and `8000` are `expose`d on `ovp-net` and reached through Nginx in `app-ui` |
|
||||
| Secrets | `JWT_SECRET` (required) → `OVPMON_API_SECRET_KEY` for both APIs |
|
||||
| Initial admin | `OVPMON_INITIAL_ADMIN_USER` / `OVPMON_INITIAL_ADMIN_PASSWORD` (optional, first start) |
|
||||
| CORS | `OVPMON_CORS_ORIGINS` (optional, comma-separated; off by default because the UI is same-origin) |
|
||||
| Restart policy | `unless-stopped` |
|
||||
|
||||
## Hardening
|
||||
|
||||
- Terminate TLS in front of `app-ui` (reverse proxy or a certificate mounted into the UI container); see [Nginx configuration](Nginx_Configuration.md). The container serves plain HTTP on 80.
|
||||
- `ovp-profiler` is privileged (`NET_ADMIN`, TUN): keep its API off the host network.
|
||||
- Back up the `db_data` and `ovp_pki` volumes; never commit `.env`.
|
||||
|
||||
## Operations
|
||||
|
||||
```bash
|
||||
docker-compose ps
|
||||
docker-compose logs -f app-api app-profiler
|
||||
docker-compose up -d --build app-ui # after UI changes
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
# Deployment: system services (no containers)
|
||||
|
||||
Tested on **Alpine 3.23 (OpenRC)**; Debian/Ubuntu (systemd) differs only in service manager. Unit/init templates: [`systemd/`](systemd), [`openrc/`](openrc/INSTALL.md). Generic notes: [Deployment](Deployment.md), [Service management](Service_Management.md), [Nginx](Nginx_Configuration.md).
|
||||
|
||||
## 1. Packages
|
||||
|
||||
- Alpine: `apk add python3 py3-pip nginx nodejs npm openvpn easy-rsa iptables bash`
|
||||
- Debian/Ubuntu: `apt install python3-venv nginx nodejs npm openvpn easy-rsa iptables`
|
||||
|
||||
## 2. Backend
|
||||
|
||||
```bash
|
||||
cd APP_CORE && python3 -m venv venv && venv/bin/pip install -r requirements.txt gunicorn
|
||||
cd APP_PROFILER && python3 -m venv venv && venv/bin/pip install -r requirements.txt
|
||||
mkdir -p APP_PROFILER/easy-rsa && cp -r /usr/share/easy-rsa/* APP_PROFILER/easy-rsa/ # Docker entrypoint does this automatically
|
||||
```
|
||||
|
||||
## 3. Environment file
|
||||
|
||||
`/etc/ovpmon/env` (chmod 600), loaded by the services:
|
||||
|
||||
```
|
||||
OVPMON_API_SECRET_KEY=<openssl rand -hex 32>
|
||||
OVPMON_API_HOST=127.0.0.1
|
||||
OVPMON_API_PORT=5001
|
||||
OVPMON_OPENVPN_MONITOR_DB_PATH=/var/lib/ovpmon/openvpn_monitor.db
|
||||
OVPMON_OPENVPN_MONITOR_LOG_PATH=/var/log/openvpn/openvpn-status.log
|
||||
OVPMON_PROFILER_DB_PATH=/var/lib/ovpmon/ovpn_profiler.db
|
||||
OVPMON_LOGGING_LEVEL=INFO
|
||||
OVPMON_CORS_ORIGINS=https://<HOST>:8088
|
||||
```
|
||||
|
||||
Create `/var/lib/ovpmon`, `/var/log/ovpmon`, `/var/log/openvpn`. For the first start also set `OVPMON_INITIAL_ADMIN_USER` / `OVPMON_INITIAL_ADMIN_PASSWORD`, then remove them.
|
||||
|
||||
## 4. Services
|
||||
|
||||
| Service | Command | Listens |
|
||||
|---|---|---|
|
||||
| `ovpmon-api` | `APP_CORE/venv/bin/gunicorn -w 2 -b 127.0.0.1:5001 openvpn_api_v3:app` (cwd `APP_CORE`) | 127.0.0.1:5001 |
|
||||
| `ovpmon-gatherer` | `APP_CORE/venv/bin/python openvpn_gatherer_v3.py` (cwd `APP_CORE`) | - |
|
||||
| `ovpmon-profiler` | `APP_PROFILER/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000` (cwd `APP_PROFILER`) | 127.0.0.1:8000 |
|
||||
|
||||
OpenRC (Alpine): `supervisor=supervise-daemon`, `respawn_delay=3`, source `/etc/ovpmon/env` in `start_pre`, then `rc-update add <svc> default && rc-service <svc> start`. systemd: use the units from `systemd/` with `EnvironmentFile=/etc/ovpmon/env`.
|
||||
|
||||
The Profiler restarts OpenVPN through `rc-service openvpn` (Alpine) or `systemctl openvpn`; on Alpine link the config: `ln -s server.conf /etc/openvpn/openvpn.conf` and enable the `openvpn` service.
|
||||
|
||||
## 4a. Run as an unprivileged user (recommended)
|
||||
|
||||
Create the `ovpmon` user, give it the data directories, add `command_user="ovpmon:ovpmon"` to the init scripts (or `User=ovpmon` in systemd units), install the root helper and the doas rules. The API then renders `server.conf` to `/var/lib/ovpmon/staging/`; the helper validates and installs it, publishes the CRL for OpenVPN and controls the `openvpn` service. Full procedure, helper source and results: [Privilege separation](../Changes/2026-09-30_Privilege_Separation.md); files in [`privilege-separation/`](privilege-separation/).
|
||||
|
||||
## 5. UI and Nginx (HTTPS on 8088)
|
||||
|
||||
```bash
|
||||
cd APP_UI && npm install && npm run build
|
||||
mkdir -p /var/www/ovpmon && cp -r dist/. /var/www/ovpmon/
|
||||
```
|
||||
|
||||
Certificate (self-signed, replace with a real one when a domain is available):
|
||||
|
||||
```bash
|
||||
mkdir -p /etc/ovpmon/tls && cd /etc/ovpmon/tls
|
||||
openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -days 825 \
|
||||
-subj "/CN=ovpmon" -addext "subjectAltName=IP:<HOST-IP>,DNS:ovpmon" -keyout ovpmon.key -out ovpmon.crt
|
||||
chmod 600 ovpmon.key
|
||||
```
|
||||
|
||||
Nginx server (`/etc/nginx/http.d/ovpmon.conf` on Alpine): `listen 8088 ssl`, TLS 1.2/1.3, `error_page 497 =301 https://$host:8088$request_uri`, security headers, `limit_req` (5 r/min) on `/api/auth/login`, `/` → static UI, `/api/` → `127.0.0.1:5001`, `/profiles-api/` → `127.0.0.1:8000/api/`. Full listing: [Nginx configuration](Nginx_Configuration.md). Do not declare a second `ssl_session_cache shared:SSL` zone with a different size than the main `nginx.conf`.
|
||||
|
||||
## 6. First run
|
||||
|
||||
1. `https://<host>:8088/` → sign in with the seeded admin → **Account**: change username/password, enable 2FA.
|
||||
2. **PKI Configuration → Initialize PKI** → generate server config → start OpenVPN → create profiles.
|
||||
|
||||
## 7. Host hardening (recommended)
|
||||
|
||||
- SSH: key-only (`PasswordAuthentication no`, `KbdInteractiveAuthentication no`, `PermitRootLogin prohibit-password`); note that cloud-init drop-ins in `/etc/ssh/sshd_config.d/` can override the main file, so put settings in `00-*.conf`.
|
||||
- fail2ban: jails `sshd` and one for `POST /api/auth/login` (401/429/503) on the Nginx access log.
|
||||
- Backends bound to `127.0.0.1`; only 8088 (UI/API) and the VPN port are public.
|
||||
|
||||
## 8. Verify
|
||||
|
||||
```bash
|
||||
rc-status | grep -E "ovpmon|nginx|openvpn" # or: systemctl status ovpmon-*
|
||||
ss -tlnp | grep -E ":(8088|5001|8000) "
|
||||
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1:8088/ # 200
|
||||
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1:8088/profiles-api/config # 401 without token
|
||||
```
|
||||
+11
-1
@@ -3,10 +3,20 @@
|
||||
Welcome to the documentation for the OpenVPN Monitor suite.
|
||||
|
||||
## 📚 General
|
||||
- [Deployment Guide](Deployment.md): How to install and configure the application on a Linux server.
|
||||
- [Deployment: Docker](Deployment_Docker.md): containers with docker-compose.
|
||||
- [Deployment: system services](Deployment_Native.md): systemd/OpenRC, HTTPS, host hardening.
|
||||
- [Deployment Guide](Deployment.md): generic native install notes.
|
||||
- [Service Management](Service_Management.md): Setting up systemd/OpenRC services.
|
||||
- [Nginx Configuration](Nginx_Configuration.md)
|
||||
- [Security Architecture](Security_Architecture.md): Details on Authentication, 2FA, and Security features.
|
||||
|
||||
## 🛠 Changes and results
|
||||
- [Security hardening (2026-09-30)](../Changes/2026-09-30_Security_Hardening.md)
|
||||
- [Admin username change (2026-09-30)](../Changes/2026-09-30_Admin_Username_Change.md)
|
||||
- [Settings validation (2026-09-30)](../Changes/2026-09-30_Settings_Validation.md)
|
||||
- [Privilege separation (2026-09-30)](../Changes/2026-09-30_Privilege_Separation.md)
|
||||
- [Egress via Hysteria2 (2026-09-30)](../Changes/2026-09-30_Egress_via_Hysteria2.md)
|
||||
|
||||
## 🔍 Core Monitoring (`APP_CORE`)
|
||||
The core module responsible for log parsing, real-time statistics, and the primary API.
|
||||
- [API Reference](../Core_Monitoring/API_Reference.md): Endpoints for monitoring data.
|
||||
|
||||
@@ -10,7 +10,7 @@ This includes:
|
||||
|
||||
## User Review Required
|
||||
> [!IMPORTANT]
|
||||
> **Default Credentials**: We will create a default admin user (e.g., `admin` / `password`) on first run if no users exist. The user MUST change this immediately.
|
||||
> **Initial admin**: no default user is created. On first run with an empty users table the admin is seeded only from OVPMON_INITIAL_ADMIN_USER / OVPMON_INITIAL_ADMIN_PASSWORD; the username can be changed in Account (see DOCS/Changes/2026-09-30_Admin_Username_Change.md).
|
||||
|
||||
> [!WARNING]
|
||||
> **Breaking Change**: Access to the current dashboard will be blocked until the user logs in.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args install-config
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args publish-crl
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service start
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service stop
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service restart
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service status
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/python3
|
||||
"""ovpmon-helper: the only root-side entry point for the unprivileged ovpmon services.
|
||||
|
||||
Usage (via doas): ovpmon-helper install-config
|
||||
ovpmon-helper service start|stop|restart|status
|
||||
install-config reads the staged OpenVPN server config, validates it against a strict
|
||||
allowlist of directives and installs it atomically to /etc/openvpn/server.conf.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
STAGED = "/var/lib/ovpmon/staging/server.conf"
|
||||
TARGET = "/etc/openvpn/server.conf"
|
||||
PKI_DIR = "/opt/OpenVPN-Monitoring-Simple/APP_PROFILER/easy-rsa/pki"
|
||||
SCRIPTS_DIR = "/etc/openvpn/scripts"
|
||||
STATUS_LOG = "/var/log/openvpn/openvpn-status.log"
|
||||
CRL_SRC = PKI_DIR + "/crl.pem"
|
||||
CRL_PUBLISHED = "/etc/openvpn/crl.pem"
|
||||
CRL_MAX = 1024 * 1024
|
||||
SERVICE_USER = "ovpmon"
|
||||
MAX_SIZE = 64 * 1024
|
||||
CIPHERS_RE = re.compile(r"^[A-Za-z0-9:_-]{1,200}$")
|
||||
os.environ["PATH"] = "/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
|
||||
class Reject(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def under(path, base):
|
||||
real = os.path.realpath(path)
|
||||
return real == base or real.startswith(base.rstrip("/") + "/")
|
||||
|
||||
|
||||
def need(cond, msg):
|
||||
if not cond:
|
||||
raise Reject(msg)
|
||||
|
||||
|
||||
def is_int(x, lo, hi):
|
||||
return re.fullmatch(r"\d{1,6}", x) is not None and lo <= int(x) <= hi
|
||||
|
||||
|
||||
def valid_route(r):
|
||||
parts = r.split()
|
||||
try:
|
||||
if len(parts) == 1:
|
||||
ipaddress.IPv4Network(parts[0], strict=False)
|
||||
elif len(parts) == 2:
|
||||
ipaddress.IPv4Address(parts[0])
|
||||
ipaddress.IPv4Network("0.0.0.0/" + parts[1])
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def check_script(path):
|
||||
need(re.fullmatch(r"/etc/openvpn/scripts/[A-Za-z0-9_.-]{1,64}", path), "script path not allowed")
|
||||
real = os.path.realpath(path)
|
||||
need(os.path.dirname(real) == SCRIPTS_DIR and os.path.isfile(real), "script must be a file in " + SCRIPTS_DIR)
|
||||
st, dst = os.stat(real), os.stat(SCRIPTS_DIR)
|
||||
need(st.st_uid == 0 and not st.st_mode & 0o022, "script must be root-owned and not group/other-writable")
|
||||
need(dst.st_uid == 0 and not dst.st_mode & 0o022, SCRIPTS_DIR + " must be root-owned and not writable")
|
||||
|
||||
|
||||
def check_line(tokens):
|
||||
d, a = tokens[0], tokens[1:]
|
||||
if d == "dev":
|
||||
need(a == ["tun"], "dev must be tun")
|
||||
elif d == "proto":
|
||||
need(len(a) == 1 and a[0] in ("udp", "tcp", "udp4", "tcp4", "udp6", "tcp6"), "bad proto")
|
||||
elif d in ("tls-server", "client-to-client", "duplicate-cn", "persist-key", "persist-tun"):
|
||||
need(not a, d + " takes no arguments")
|
||||
elif d == "explicit-exit-notify":
|
||||
need(len(a) == 1 and is_int(a[0], 1, 10), "bad explicit-exit-notify")
|
||||
elif d in ("port", "management-port"):
|
||||
need(len(a) == 1 and is_int(a[0], 1, 65535), "bad port")
|
||||
elif d in ("ca", "cert", "key", "dh"):
|
||||
need(len(a) == 1 and under(a[0], PKI_DIR), d + " must be a file inside the PKI directory")
|
||||
elif d == "crl-verify":
|
||||
need(a == [CRL_PUBLISHED], "crl-verify must be " + CRL_PUBLISHED)
|
||||
elif d == "tls-auth":
|
||||
need(len(a) == 2 and under(a[0], PKI_DIR) and a[1] in ("0", "1"), "bad tls-auth")
|
||||
elif d == "tun-mtu":
|
||||
need(len(a) == 1 and is_int(a[0], 576, 9000), "bad tun-mtu")
|
||||
elif d == "mssfix":
|
||||
need(len(a) == 1 and is_int(a[0], 536, 1500), "bad mssfix")
|
||||
elif d == "topology":
|
||||
need(a == ["subnet"], "topology must be subnet")
|
||||
elif d == "server":
|
||||
need(len(a) == 2, "bad server")
|
||||
net = ipaddress.IPv4Network(f"{a[0]}/{a[1]}", strict=True)
|
||||
need(8 <= net.prefixlen <= 30, "bad server prefix")
|
||||
elif d == "ifconfig-pool-persist":
|
||||
need(a == ["/etc/openvpn/ipp.txt"], "ifconfig-pool-persist path not allowed")
|
||||
elif d in ("log", "log-append"):
|
||||
need(a == ["/var/log/openvpn/openvpn.log"], d + " path not allowed")
|
||||
elif d == "verb":
|
||||
need(len(a) == 1 and is_int(a[0], 0, 9), "bad verb")
|
||||
elif d == "status":
|
||||
need(len(a) == 2 and a[0] == STATUS_LOG and is_int(a[1], 1, 3600), "bad status")
|
||||
elif d == "status-version":
|
||||
need(a in (["1"], ["2"], ["3"]), "bad status-version")
|
||||
elif d == "push":
|
||||
need(len(a) == 1, "push takes one quoted argument")
|
||||
p = a[0]
|
||||
if p == "redirect-gateway def1 bypass-dhcp":
|
||||
return
|
||||
m = re.fullmatch(r"route (.+)", p)
|
||||
if m:
|
||||
need(valid_route(m.group(1)), "bad pushed route")
|
||||
return
|
||||
m = re.fullmatch(r"dhcp-option DNS (\S+)", p)
|
||||
need(m is not None, "pushed option not allowed")
|
||||
ipaddress.ip_address(m.group(1))
|
||||
elif d == "user":
|
||||
need(a == ["nobody"], "user must be nobody")
|
||||
elif d == "group":
|
||||
need(a == ["nogroup"], "group must be nogroup")
|
||||
elif d in ("data-ciphers", "data-ciphers-fallback"):
|
||||
need(len(a) == 1 and CIPHERS_RE.match(a[0]), "bad cipher list")
|
||||
elif d == "auth":
|
||||
need(len(a) == 1 and a[0] in ("SHA256", "SHA384", "SHA512"), "bad auth")
|
||||
elif d == "keepalive":
|
||||
need(len(a) == 2 and is_int(a[0], 1, 3600) and is_int(a[1], 1, 7200), "bad keepalive")
|
||||
elif d == "script-security":
|
||||
need(a == ["2"], "script-security must be 2")
|
||||
elif d in ("client-connect", "client-disconnect"):
|
||||
need(len(a) == 1, d + " takes one argument")
|
||||
check_script(a[0])
|
||||
elif d == "management":
|
||||
need(len(a) == 2 and ipaddress.ip_address(a[0]).is_loopback and is_int(a[1], 1, 65535), "management must be loopback")
|
||||
else:
|
||||
raise Reject("directive not allowed: " + d)
|
||||
|
||||
|
||||
def validate(text):
|
||||
seen = set()
|
||||
for n, raw in enumerate(text.splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or line.startswith(";"):
|
||||
continue
|
||||
need(all(32 <= ord(c) < 127 for c in line), f"line {n}: non-printable or non-ASCII character")
|
||||
try:
|
||||
tokens = shlex.split(line, comments=False)
|
||||
except ValueError as e:
|
||||
raise Reject(f"line {n}: {e}")
|
||||
try:
|
||||
check_line(tokens)
|
||||
except Reject as e:
|
||||
raise Reject(f"line {n}: {e}")
|
||||
except ValueError as e:
|
||||
raise Reject(f"line {n}: invalid value ({e})")
|
||||
seen.add(tokens[0])
|
||||
for req in ("user", "group", "server", "ca", "cert", "key"):
|
||||
need(req in seen, f"required directive missing: {req}")
|
||||
|
||||
|
||||
def install_config():
|
||||
uid = pwd.getpwnam(SERVICE_USER).pw_uid
|
||||
fd = os.open(STAGED, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
need(st.st_uid == uid and stat.S_ISREG(st.st_mode), "staged config must be a regular file owned by " + SERVICE_USER)
|
||||
need(st.st_size <= MAX_SIZE, "staged config too large")
|
||||
data = os.read(fd, MAX_SIZE + 1)
|
||||
finally:
|
||||
os.close(fd)
|
||||
text = data.decode("ascii") # one read: validate exactly what gets installed
|
||||
validate(text)
|
||||
tmp = TARGET + ".tmp"
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o644)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(text)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, TARGET)
|
||||
if not os.path.lexists("/etc/openvpn/openvpn.conf"):
|
||||
os.symlink("server.conf", "/etc/openvpn/openvpn.conf")
|
||||
|
||||
|
||||
def prepare_status_log():
|
||||
"""Let the unprivileged monitoring gatherer read the status log."""
|
||||
import grp
|
||||
gid = grp.getgrnam(SERVICE_USER).gr_gid
|
||||
if not os.path.exists(STATUS_LOG):
|
||||
open(STATUS_LOG, "a").close()
|
||||
os.chown(STATUS_LOG, 0, gid)
|
||||
os.chmod(STATUS_LOG, 0o640)
|
||||
|
||||
|
||||
def publish_crl():
|
||||
"""Copy the CRL generated by easy-rsa to a root-owned, world-readable path.
|
||||
OpenVPN reads the CRL as the unprivileged user 'nobody', who cannot enter the 0700 pki/ directory."""
|
||||
uid = pwd.getpwnam(SERVICE_USER).pw_uid
|
||||
need(under(CRL_SRC, PKI_DIR), "CRL source outside PKI directory")
|
||||
fd = os.open(CRL_SRC, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
need(stat.S_ISREG(st.st_mode) and st.st_uid in (0, uid), "CRL must be a regular file owned by " + SERVICE_USER)
|
||||
need(0 < st.st_size <= CRL_MAX, "CRL size out of range")
|
||||
data = os.read(fd, CRL_MAX + 1)
|
||||
finally:
|
||||
os.close(fd)
|
||||
need(data.startswith(b"-----BEGIN X509 CRL-----") and data.rstrip().endswith(b"-----END X509 CRL-----"), "not a PEM CRL")
|
||||
r = subprocess.run(["/usr/bin/openssl", "crl", "-noout", "-inform", "PEM"], input=data, capture_output=True, timeout=20)
|
||||
need(r.returncode == 0, "openssl rejects the CRL")
|
||||
tmp = CRL_PUBLISHED + ".tmp"
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o644)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, CRL_PUBLISHED)
|
||||
|
||||
|
||||
def service(action):
|
||||
need(action in ("start", "stop", "restart", "status"), "invalid action")
|
||||
if action in ("start", "restart"):
|
||||
need(os.path.isfile(TARGET), "server.conf is not installed")
|
||||
prepare_status_log()
|
||||
if os.path.isfile(CRL_SRC):
|
||||
try:
|
||||
publish_crl()
|
||||
except Exception:
|
||||
pass # a broken CRL must not stop the VPN; crl-verify will then keep the previously published file
|
||||
r = subprocess.run(["/sbin/rc-service", "openvpn", action], capture_output=True, text=True, timeout=60)
|
||||
return r.returncode, (r.stdout + r.stderr).strip()[-500:]
|
||||
|
||||
|
||||
def main(argv):
|
||||
try:
|
||||
if argv == ["install-config"]:
|
||||
install_config()
|
||||
print(json.dumps({"status": "ok"}))
|
||||
return 0
|
||||
if argv == ["publish-crl"]:
|
||||
publish_crl()
|
||||
print(json.dumps({"status": "ok"}))
|
||||
return 0
|
||||
if len(argv) == 2 and argv[0] == "service":
|
||||
rc, out = service(argv[1])
|
||||
print(json.dumps({"status": "ok" if rc == 0 else "error", "output": out}))
|
||||
return 0 if rc == 0 else 2
|
||||
raise Reject("usage: install-config | publish-crl | service start|stop|restart|status")
|
||||
except Reject as e:
|
||||
print(json.dumps({"status": "rejected", "error": str(e)}))
|
||||
return 3
|
||||
except Exception as e: # never leak a traceback with paths to the caller
|
||||
print(json.dumps({"status": "error", "error": type(e).__name__ + ": " + str(e)[:200]}))
|
||||
return 4
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -1,62 +1,67 @@
|
||||
# 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.
|
||||
Web suite for OpenVPN servers: real-time traffic monitoring, history/analytics, PKI and client-profile management, one UI.
|
||||
|
||||
## �️ Project Architecture
|
||||
| Component | Dir | Stack | Default port |
|
||||
|---|---|---|---|
|
||||
| UI | `APP_UI/` | Vue 3 + Vite, served by Nginx | 80 (Docker) / 8088 (native, TLS) |
|
||||
| Monitoring API | `APP_CORE/` | Flask (gunicorn) | 5001 (internal) |
|
||||
| Data gatherer | `APP_CORE/` | Python daemon | - |
|
||||
| Profiler API | `APP_PROFILER/` | FastAPI (uvicorn) | 8000 (internal) |
|
||||
|
||||
The project is modularized into three core components:
|
||||
Nginx is the only public entry point: `/` UI, `/api/` Monitoring API, `/profiles-api/` Profiler API.
|
||||
|
||||
| Component | Directory | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| **Core Monitoring** | `APP_CORE/` | Flask-based API (v3) for log parsing, real-time stats, and historical TSDB. |
|
||||
| **Profiler** | `APP_PROFILER/` | FastAPI-based module for managing PKI, Certificates, and Server Configs. |
|
||||
| **User Interface** | `APP_UI/` | Vue 3 + Vite Single Page Application (SPA) serving as the unified dashboard. |
|
||||
## Quick start
|
||||
|
||||
## 📚 Documentation
|
||||
- **Containers:** `docker-compose up -d --build`, open `http://<host>`. Details: [Deployment: Docker](DOCS/General/Deployment_Docker.md).
|
||||
- **System services** (systemd / OpenRC, no containers): [Deployment: native](DOCS/General/Deployment_Native.md).
|
||||
|
||||
Detailed documentation has been moved to the `DOCS/` directory.
|
||||
After the first start: sign in, open **PKI Configuration** → **Initialize PKI**, generate the server config, start OpenVPN, create profiles.
|
||||
|
||||
- **[Installation & Deployment](DOCS/General/Deployment.md)**: Setup guide for Linux (Alpine/Debian).
|
||||
- **[Service Management](DOCS/General/Service_Management.md)**: Configuring Systemd/OpenRC services.
|
||||
- **[Security & Auth](DOCS/General/Security_Architecture.md)**: 2FA, JWT, and Security details.
|
||||
## First login and credentials
|
||||
|
||||
### API References
|
||||
- **[Core Monitoring API](DOCS/Core_Monitoring/API_Reference.md)**: Endpoints for stats, sessions, and history.
|
||||
- **[Profiler Management API](DOCS/Profiler_Management/API_Reference.md)**: Endpoints for profiles, system config, and control.
|
||||
No default user is created. Seed the initial admin with `OVPMON_INITIAL_ADMIN_USER` / `OVPMON_INITIAL_ADMIN_PASSWORD` on first start (empty `users` table only), then remove them. Change the username and password and enable 2FA in **Account**.
|
||||
|
||||
## 🚀 Quick Start (Dev Mode)
|
||||
## Security defaults
|
||||
|
||||
### 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)
|
||||
```
|
||||
- No built-in account; the username can be changed in **Account** (API `POST /api/auth/change-username`).
|
||||
- All API routes require a JWT; 2FA-pending tokens are accepted only by `/api/auth/verify-2fa`.
|
||||
- Server/PKI settings are validated before they reach `server.conf` (ports, networks, routes, DNS, host names, script paths, DN fields); scripts run only from `/etc/openvpn/scripts/`.
|
||||
- Native deployments: APIs run as user `ovpmon`; a root helper installs the validated `server.conf`, publishes the CRL (`crl_verify`) and controls `openvpn` through `doas` (fixed commands).
|
||||
- CORS is same-origin only unless `OVPMON_CORS_ORIGINS` is set; TLS on the panel port; brute-force limits on login (Nginx + app, fail2ban jail in the deployment guide).
|
||||
|
||||
### 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)
|
||||
```
|
||||
## Configuration
|
||||
|
||||
### 3. Frontend (Vue 3)
|
||||
```bash
|
||||
cd APP_UI
|
||||
npm install
|
||||
npm run dev
|
||||
# Runs on localhost:5173
|
||||
```
|
||||
`config.ini` per component; overridden by `OVPMON_{SECTION}_{KEY}` environment variables.
|
||||
|
||||
---
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `OVPMON_API_SECRET_KEY` | JWT secret shared by both APIs (**must be random**) |
|
||||
| `OVPMON_INITIAL_ADMIN_USER` / `_PASSWORD` | One-time admin seed |
|
||||
| `OVPMON_CORS_ORIGINS` | Extra allowed CORS origins, comma-separated (empty = same-origin only) |
|
||||
| `OVPMON_OPENVPN_MONITOR_DB_PATH` | Monitoring DB |
|
||||
| `OVPMON_PROFILER_DB_PATH` | Profiler DB |
|
||||
| `OVPMON_OPENVPN_MONITOR_LOG_PATH` | `openvpn-status.log` path |
|
||||
| `OVPMON_LOGGING_LEVEL` | `INFO` / `DEBUG` |
|
||||
|
||||
## ⚠️ Important Notes
|
||||
## Documentation
|
||||
|
||||
1. **Environment**: Production deployment relies on Nginx to proxy requests to the backend services. See the [Deployment Guide](DOCS/General/Deployment.md).
|
||||
2. **Permissions**: The backend requires `sudo` or root privileges to manage OpenVPN processes and write to `/etc/openvpn`.
|
||||
- Index: [DOCS/General/Index.md](DOCS/General/Index.md)
|
||||
- Deployment: [Docker](DOCS/General/Deployment_Docker.md) · [System services](DOCS/General/Deployment_Native.md) · [Nginx](DOCS/General/Nginx_Configuration.md) · [Service management](DOCS/General/Service_Management.md)
|
||||
- Security model: [Security Architecture](DOCS/General/Security_Architecture.md) · root helper and doas rules: [`DOCS/General/privilege-separation/`](DOCS/General/privilege-separation/)
|
||||
- APIs: [Monitoring](DOCS/Core_Monitoring/API_Reference.md) · [Profiler](DOCS/Profiler_Management/API_Reference.md)
|
||||
|
||||
## Changes and results
|
||||
|
||||
| Date | Change | Document |
|
||||
|---|---|---|
|
||||
| 2026-09-30 | Security hardening: path traversal, 2FA token bypass, CORS, log leak, HTTPS, SSH, fail2ban | [Security hardening](DOCS/Changes/2026-09-30_Security_Hardening.md) |
|
||||
| 2026-09-30 | Admin username change (API + UI), no built-in default admin | [Admin username change](DOCS/Changes/2026-09-30_Admin_Username_Change.md) |
|
||||
| 2026-09-30 | Validation of server/PKI settings (config injection into the root-written OpenVPN config) | [Settings validation](DOCS/Changes/2026-09-30_Settings_Validation.md) |
|
||||
| 2026-09-30 | API services run as an unprivileged user; root helper validates and installs the OpenVPN config, publishes the CRL | [Privilege separation](DOCS/Changes/2026-09-30_Privilege_Separation.md) (includes CRL publishing for `crl_verify`) |
|
||||
| 2026-09-30 | Route OpenVPN clients through a Hysteria2 tunnel to an exit node | [Egress via Hysteria2](DOCS/Changes/2026-09-30_Egress_via_Hysteria2.md) |
|
||||
|
||||
## Notes
|
||||
|
||||
- Native deployments run the APIs as user `ovpmon`; OpenVPN config install and service control go through a root helper (`doas`, fixed commands): see [Privilege separation](DOCS/Changes/2026-09-30_Privilege_Separation.md).
|
||||
- Keep `easy-rsa/`, `client-config/`, databases and `*.env` out of git: they contain private keys and secrets.
|
||||
+47
-7
@@ -1,9 +1,16 @@
|
||||
version: '3.8'
|
||||
# OpenVPN Monitor & Profiler (containers)
|
||||
# Required: JWT_SECRET in .env (openssl rand -hex 32)
|
||||
# First start only: OVPMON_INITIAL_ADMIN_USER / OVPMON_INITIAL_ADMIN_PASSWORD in .env
|
||||
# Optional: OVPMON_CORS_ORIGINS (comma-separated origins; off by default, UI is same-origin via Nginx)
|
||||
|
||||
x-secret: &jwt-secret
|
||||
OVPMON_API_SECRET_KEY: ${JWT_SECRET:?JWT_SECRET must be set in .env (openssl rand -hex 32)}
|
||||
|
||||
services:
|
||||
app-ui:
|
||||
build: ./APP_UI
|
||||
container_name: ovp-ui
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
@@ -11,51 +18,82 @@ services:
|
||||
- app-profiler
|
||||
networks:
|
||||
- ovp-net
|
||||
environment:
|
||||
OVP_API_HOST: ovp-api
|
||||
OVP_API_PORT: 5001
|
||||
OVP_PROFILER_HOST: ovp-profiler
|
||||
OVP_PROFILER_PORT: 8000
|
||||
|
||||
app-gatherer:
|
||||
build:
|
||||
context: ./APP_CORE
|
||||
dockerfile: Dockerfile.gatherer
|
||||
container_name: ovp-gatherer
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ovp_logs:/var/log/openvpn
|
||||
- db_data:/app/db # Assuming APP_CORE looks for DB in /app/db
|
||||
- db_data:/app/db
|
||||
depends_on:
|
||||
- app-profiler
|
||||
networks:
|
||||
- ovp-net
|
||||
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
|
||||
|
||||
app-api:
|
||||
build:
|
||||
context: ./APP_CORE
|
||||
dockerfile: Dockerfile.api
|
||||
container_name: ovp-api
|
||||
ports:
|
||||
- "5001:5001"
|
||||
restart: unless-stopped
|
||||
# Not published: reached only through app-ui (Nginx) on ovp-net
|
||||
expose:
|
||||
- "5001"
|
||||
volumes:
|
||||
- db_data:/app/db
|
||||
networks:
|
||||
- ovp-net
|
||||
depends_on:
|
||||
- app-gatherer
|
||||
environment:
|
||||
- JWT_SECRET=${JWT_SECRET:-supersecret}
|
||||
<<: *jwt-secret
|
||||
OVPMON_API_PORT: 5001
|
||||
OVPMON_OPENVPN_MONITOR_DB_PATH: /app/db/openvpn_monitor.db
|
||||
OVPMON_LOGGING_LEVEL: INFO
|
||||
# Initial admin: used only while the users table is empty (remove after first start)
|
||||
OVPMON_INITIAL_ADMIN_USER: ${OVPMON_INITIAL_ADMIN_USER:-}
|
||||
OVPMON_INITIAL_ADMIN_PASSWORD: ${OVPMON_INITIAL_ADMIN_PASSWORD:-}
|
||||
OVPMON_CORS_ORIGINS: ${OVPMON_CORS_ORIGINS:-}
|
||||
|
||||
app-profiler:
|
||||
build: ./APP_PROFILER
|
||||
container_name: ovp-profiler
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
- net.ipv4.ip_forward=1
|
||||
devices:
|
||||
- "/dev/net/tun:/dev/net/tun"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# VPN port only; the profiler API (8000) is reached through app-ui
|
||||
- "1194:1194/udp"
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ovp_logs:/var/log/openvpn
|
||||
- ovp_config:/etc/openvpn
|
||||
- db_data:/app/db
|
||||
- ovp_client_config:/app/client-config
|
||||
- ovp_pki:/app/easy-rsa
|
||||
networks:
|
||||
- ovp-net
|
||||
environment:
|
||||
- JWT_SECRET=${JWT_SECRET:-supersecret}
|
||||
<<: *jwt-secret
|
||||
OVPMON_PROFILER_DB_PATH: /app/db/ovpn_profiler.db
|
||||
OVPMON_CORS_ORIGINS: ${OVPMON_CORS_ORIGINS:-}
|
||||
|
||||
networks:
|
||||
ovp-net:
|
||||
@@ -64,4 +102,6 @@ networks:
|
||||
volumes:
|
||||
ovp_logs:
|
||||
ovp_config:
|
||||
ovp_pki:
|
||||
ovp_client_config:
|
||||
db_data:
|
||||
Reference in new issue
Block a user