Compare commits

...
24 Commits
Author SHA1 Message Date
iclaoudezinandClaude Sonnet 5.5 e1146ed4fe Docs: README security defaults and links to the privilege-separation files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:43:45 +00:00
iclaoudezinandClaude Sonnet 5.5 9ffdbfa259 Publish CRL for the unprivileged OpenVPN user so crl_verify works
- ovpmon-helper: new publish-crl command (validated copy of pki/crl.pem to
  /etc/openvpn/crl.pem, root:root 644); crl-verify allowlisted only for
  that path; CRL is refreshed on service start/restart.
- Profiler: publish after gen-crl (init/revoke) and on server/configure;
  generator renders the published path when running unprivileged.
- doas rule for publish-crl; docs and helper copy updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:38:30 +00:00
iclaoudezinandClaude Sonnet 5.5 6f9e800779 Run API services unprivileged; add root helper for OpenVPN config and service control
- Profiler: when not root, render server.conf to the staging dir and let
  the root helper validate (directive allowlist) and install it; control
  the openvpn service through the helper (doas, fixed commands).
- Add ovpmon-helper and doas rules under DOCS/General/privilege-separation.
- Document the design, rollout, results and limitations.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:33:15 +00:00
iclaoudezinandClaude Sonnet 5.5 05f44b9928 Profiler: validate server/PKI settings before they reach OpenVPN config
- Schema validators on the update models (ports, subnet/mask, routes,
  DNS, public host, loopback-only management address, MTU/MSS, script
  paths, PKI DN fields, key size and lifetimes).
- Script paths must be root-owned, non-writable files directly inside
  /etc/openvpn/scripts (services/validation.py).
- Generators refuse values with newlines, quotes, backslashes or control
  characters; router maps validation errors to HTTP 400.
- Add change record and links.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:25:38 +00:00
iclaoudezinandClaude Sonnet 5.5 5de0501cbc Compose: require JWT secret, seed admin via env, stop publishing internal ports
- JWT_SECRET is mandatory (no more "supersecret" fallback).
- Pass OVPMON_INITIAL_ADMIN_USER/PASSWORD and OVPMON_CORS_ORIGINS to the
  APIs; add restart policy and drop the obsolete compose "version".
- Publish only 80/tcp and 1194/udp; expose 5001 and 8000 on ovp-net.
- CORS origins now come from OVPMON_CORS_ORIGINS instead of a hardcoded
  host (default: same-origin only).
- Update Docker/native deployment docs and README accordingly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:14:22 +00:00
iclaoudezinandClaude Sonnet 5.5 9b2882d5f4 Docs: concise README, split deployment guides, add change records
- README: short overview, quick start, config table and links.
- DOCS/General: Deployment_Docker.md and Deployment_Native.md (system
  services, HTTPS, host hardening); refresh Index.md.
- DOCS/Changes: security hardening, admin username change and egress
  via Hysteria2 with results and verification.
- Drop mentions of the built-in admin/password account.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:12:09 +00:00
iclaoudezinandClaude Sonnet 5.5 11c1b6379b Harden auth and API: username change, 2FA fixes, input validation
- Add POST /api/auth/change-username (password + OTP when 2FA is on,
  format/reserved-name checks, uniqueness) and a Change Username modal
  in Account.vue; use the real username for the 2FA provisioning URI.
- Stop creating the built-in admin/password user; the initial admin is
  seeded only from OVPMON_INITIAL_ADMIN_USER/PASSWORD.
- Reject 2FA-pending temporary tokens on all protected routes (Flask
  token_required, Profiler verify_token); only /api/auth/verify-2fa
  accepts them.
- Stop logging the OTP and TOTP secret in enable_2fa.
- Profiler: validate profile username (pattern + realpath checks in
  schema, router, pki and generator) to prevent path traversal.
- Restrict CORS to the panel origin in Profiler and Flask APIs.
- UI: header username no longer sticks to the hardcoded Admin fallback;
  it is synced from /user/me and updated after a rename.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:03:36 +00:00
Антон 14ffd64801 fix revocation list in server template 2026-02-08 19:43:58 +03:00
Антон e5c0e154b5 fix apply config event 2026-02-08 19:10:35 +03:00
Антон 0ccdfcf7bf fix process restart event 2026-02-08 17:53:14 +03:00
Антон 68c57c174e fix client config dir 2026-02-07 22:40:40 +03:00
Антон f7fe266571 minor fix for mangle tables in entrypoint.sh 2026-02-07 22:10:27 +03:00
Антон 8fd44fc658 minor fix for mangle tables in entrypoint.sh 2026-02-07 22:02:22 +03:00
Антон f6a81b3d7c update main README.md 2026-02-07 15:23:23 +03:00
Антон f177a89f0b analytics page fix 2026-02-07 15:11:33 +03:00
Антон 961de020fb container detection implemented 2026-02-07 14:51:15 +03:00
Антон 195d40daa2 fix entrypoint.sh stage-2 2026-02-07 14:37:57 +03:00
Антон 0961daedce fix entrypoint.sh 2026-02-07 14:30:45 +03:00
Антон 9d10bb97c7 fix missing pki path inside container 2026-02-07 14:16:49 +03:00
Антон 6131bcaba9 fix dev tun and sysctl ip_forward error 2026-02-07 14:07:47 +03:00
Антон f9df3f8d05 fix missing path to db 2026-02-07 14:01:20 +03:00
Антон 4bd4127bb5 profiler module moved from static config to environment dpendent config 2026-02-07 13:51:52 +03:00
Антон 5260e45bd8 nginx template fix 2026-02-06 21:14:52 +03:00
Антон bb1a3c9400 docker environment control improvement 2026-02-06 09:02:59 +03:00
43 changed files with 1638 additions and 394 deletions

No files matched your search

+10 -3
View File
@@ -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"]
+9 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+8 -8
View File
@@ -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
+6 -1
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
[api]
# Secret key for JWT token verification.
# MUST match the key in APP_CORE/config.ini if not overridden by ENV.
secret_key = ovpmon-secret-change-me
[profiler]
# Path to the profiler database relative to component root
db_path = ovpn_profiler.db
[logging]
level = INFO
+6 -1
View File
@@ -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}
+23 -6
View File
@@ -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
+4 -4
View File
@@ -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)
+1
View File
@@ -4,3 +4,4 @@ sqlalchemy
psutil
python-multipart
jinja2
pyjwt
+8
View File
@@ -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)
+34 -7
View File
@@ -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
View File
@@ -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
+23 -3
View File
@@ -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,
+23 -1
View File
@@ -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
+145 -10
View File
@@ -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)
+71
View File
@@ -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")
+4 -4
View File
@@ -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
View File
@@ -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)
+32
View File
@@ -0,0 +1,32 @@
import os
import configparser
from pathlib import Path
# Base directory for the component
BASE_DIR = Path(__file__).resolve().parent.parent
CONFIG_FILE = BASE_DIR / 'config.ini'
def get_config_value(section: str, key: str, fallback: str = None) -> str:
"""
Get a configuration value with priority:
1. Environment Variable (OVPMON_{SECTION}_{KEY})
2. config.ini in the component root
3. Fallback value
"""
# 1. Check Environment Variable
env_key = f"OVPMON_{section.upper()}_{key.upper()}".replace('-', '_').replace(' ', '_')
env_val = os.getenv(env_key)
if env_val is not None:
return env_val
# 2. Check config.ini
if CONFIG_FILE.exists():
try:
config = configparser.ConfigParser()
config.read(CONFIG_FILE)
if config.has_section(section) and config.has_option(section, key):
return config.get(section, key)
except Exception as e:
print(f"[CONFIG] Error reading {CONFIG_FILE}: {e}")
return fallback
+2 -1
View File
@@ -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;"]
+37
View File
@@ -0,0 +1,37 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Модуль 1: Мониторинг (Flask, порт 5001)
location /api/ {
proxy_pass http://${OVP_API_HOST}:${OVP_API_PORT};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_header Authorization;
}
# Модуль 2: Управление профилями (FastAPI, порт 8000)
# Мы проксируем /profiles-api/ на внутренний /api/ внутри FastAPI
location /profiles-api/ {
proxy_pass http://${OVP_PROFILER_HOST}:${OVP_PROFILER_PORT}/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Для корректной работы OpenAPI/Docs за заголовком
proxy_set_header X-Forwarded-Prefix /profiles-api;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
-25
View File
@@ -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
View File
@@ -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';
+2 -1
View File
@@ -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,
+104
View File
@@ -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);
+11 -28
View File
@@ -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.
+1 -1
View File
@@ -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.
+53
View File
@@ -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
```
+87
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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:]))
+51 -46
View File
@@ -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
View File
@@ -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: