Compare commits

17 Commits
claude ... main

Author SHA1 Message Date
Антон
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
25 changed files with 398 additions and 365 deletions

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 FROM python:3.12-alpine
WORKDIR /app WORKDIR /app
@@ -6,11 +12,12 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy source code # Copy application
COPY . . COPY . .
# Expose the port # Ensure DB directory exists
RUN mkdir -p /app/db
EXPOSE 5001 EXPOSE 5001
# Run the API
CMD ["python", "openvpn_api_v3.py"] CMD ["python", "openvpn_api_v3.py"]

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 FROM python:3.12-alpine
WORKDIR /app WORKDIR /app
@@ -6,8 +11,10 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy source code # Copy application
COPY . . COPY . .
# Run the gatherer # Ensure DB directory exists
RUN mkdir -p /app/db
CMD ["python", "openvpn_gatherer_v3.py"] CMD ["python", "openvpn_gatherer_v3.py"]

View File

@@ -1,27 +1,17 @@
[api] [api]
host = 0.0.0.0 host = 0.0.0.0
port = 5000 port = 5001
debug = false debug = false
secret_key = ovpmon-secret-change-me secret_key = ovpmon-secret-change-me
[openvpn_monitor] [openvpn_monitor]
log_path = /etc/openvpn/openvpn-status.log log_path = /var/log/openvpn/openvpn-status.log
db_path = /opt/ovpmon/openvpn_monitor.db db_path = openvpn_monitor.db
check_interval = 10 check_interval = 10
data_retention_days = 90
cleanup_interval_hours = 24
[logging] [logging]
level = INFO level = INFO
log_file = /opt/ovpmon/openvpn_monitor.log log_file = openvpn_gatherer.log
[visualization]
refresh_interval = 5
max_display_rows = 50
[certificates]
certificates_path = /opt/ovpn/pki/issued
certificate_extensions = crt
[retention] [retention]
raw_retention_days = 7 raw_retention_days = 7
@@ -30,7 +20,3 @@ agg_15m_retention_days = 28
agg_1h_retention_days = 90 agg_1h_retention_days = 90
agg_6h_retention_days = 180 agg_6h_retention_days = 180
agg_1d_retention_days = 365 agg_1d_retention_days = 365
[pki]
pki_path = /opt/ovpn/pki
easyrsa_path = /opt/ovpn/easy-rsa

View File

@@ -13,6 +13,12 @@ class DatabaseManager:
def load_config(self): def load_config(self):
if os.path.exists(self.config_file): if os.path.exists(self.config_file):
self.config.read(self.config_file) self.config.read(self.config_file)
# 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') self.db_path = self.config.get('openvpn_monitor', 'db_path', fallback='openvpn_monitor.db')
def get_connection(self): def get_connection(self):

View File

@@ -4,10 +4,7 @@ from datetime import datetime, timedelta, timezone
from flask import Flask, jsonify, request, send_file from flask import Flask, jsonify, request, send_file
from flask_cors import CORS from flask_cors import CORS
import logging import logging
import subprocess
import os import os
from pathlib import Path
import re
import jwt import jwt
import pyotp import pyotp
import bcrypt import bcrypt
@@ -31,6 +28,17 @@ app = Flask(__name__)
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True) CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
class OpenVPNAPI: class OpenVPNAPI:
def get_config_value(self, section, key, fallback=None):
try:
# Priority: ENV > Config File > Fallback
env_key = f"OVPMON_{section.upper()}_{key.upper()}".replace('-', '_').replace(' ', '_')
env_val = os.getenv(env_key)
if env_val is not None:
return env_val
return self.config.get(section, key, fallback=fallback)
except:
return fallback
def __init__(self, config_file='config.ini'): def __init__(self, config_file='config.ini'):
self.db_manager = DatabaseManager(config_file) self.db_manager = DatabaseManager(config_file)
self.db_manager.init_database() self.db_manager.init_database()
@@ -38,21 +46,10 @@ class OpenVPNAPI:
self.config.read(config_file) self.config.read(config_file)
# Paths # Paths
self.certificates_path = self.config.get('certificates', 'certificates_path', fallback='/etc/openvpn/certs') self.public_ip = self.get_config_value('openvpn_monitor', 'public_ip', fallback='')
self.easyrsa_path = self.config.get('pki', 'easyrsa_path', fallback='/etc/openvpn/easy-rsa')
self.pki_path = self.config.get('pki', 'pki_path', fallback='/etc/openvpn/pki') # Fixed default to match Settings
self.templates_path = self.config.get('api', 'templates_path', fallback='templates')
self.server_config_dir = self.config.get('server', 'config_dir', fallback='/etc/openvpn')
self.server_config_path = self.config.get('server', 'config_path', fallback=os.path.join(self.server_config_dir, 'server.conf')) # Specific file
self.public_ip = self.config.get('openvpn_monitor', 'public_ip', fallback='')
self.cert_extensions = self.config.get('certificates', 'certificate_extensions', fallback='crt,pem,key').split(',')
self._cert_cache = {}
# Security # Security
# Priority 1: Environment Variable self.secret_key = self.get_config_value('api', 'secret_key', fallback='ovpmon-secret-change-me')
# Priority 2: Config file
self.secret_key = os.getenv('OVPMON_SECRET_KEY') or self.config.get('api', 'secret_key', fallback='ovpmon-secret-change-me')
app.config['SECRET_KEY'] = self.secret_key app.config['SECRET_KEY'] = self.secret_key
# Ensure at least one user exists # Ensure at least one user exists
@@ -130,140 +127,7 @@ class OpenVPNAPI:
conn.close() conn.close()
# --- БЛОК РАБОТЫ С СЕРТИФИКАТАМИ (Оставлен без изменений) --- # --- БЛОК РАБОТЫ С СЕРТИФИКАТАМИ (Оставлен без изменений) ---
def parse_openssl_date(self, date_str): # -----------------------------------------------------------
try:
parts = date_str.split()
if len(parts[1]) == 1:
parts[1] = f' {parts[1]}'
normalized_date = ' '.join(parts)
return datetime.strptime(normalized_date, '%b %d %H:%M:%S %Y GMT')
except ValueError:
try:
return datetime.strptime(date_str, '%b %d %H:%M:%S %Y %Z')
except ValueError:
logger.warning(f"Could not parse date: {date_str}")
return datetime.min
def calculate_days_remaining(self, not_after_str):
if not_after_str == 'N/A': return 'N/A'
try:
expiration_date = self.parse_openssl_date(not_after_str)
if expiration_date == datetime.min: return 'N/A'
days_remaining = (expiration_date - datetime.now()).days
if days_remaining < 0: return f"Expired ({abs(days_remaining)} days ago)"
else: return f"{days_remaining} days"
except Exception: return 'N/A'
def extract_cert_info(self, cert_file):
try:
result = subprocess.run(['openssl', 'x509', '-in', cert_file, '-noout', '-text'],
capture_output=True, text=True, check=True)
output = result.stdout
data = {'file': os.path.basename(cert_file), 'file_path': cert_file, 'subject': 'N/A',
'issuer': 'N/A', 'not_after': 'N/A', 'not_before': 'N/A', 'serial': 'N/A', 'type': 'Unknown'}
is_ca = False
extended_usage = ""
for line in output.split('\n'):
line = line.strip()
if line.startswith('Subject:'):
data['subject'] = line.split('Subject:', 1)[1].strip()
cn_match = re.search(r'CN\s*=\s*([^,]+)', data['subject'])
if cn_match: data['common_name'] = cn_match.group(1).strip()
elif 'Not After' in line:
data['not_after'] = line.split(':', 1)[1].strip()
elif 'Not Before' in line:
data['not_before'] = line.split(':', 1)[1].strip()
elif 'Serial Number:' in line:
data['serial'] = line.split(':', 1)[1].strip()
elif 'CA:TRUE' in line:
is_ca = True
elif 'TLS Web Server Authentication' in line:
extended_usage += "Server "
elif 'TLS Web Client Authentication' in line:
extended_usage += "Client "
# Determine Type
if is_ca:
data['type'] = 'CA'
elif 'Server' in extended_usage:
data['type'] = 'Server'
elif 'Client' in extended_usage:
data['type'] = 'Client'
elif 'server' in data.get('common_name', '').lower():
data['type'] = 'Server'
else:
data['type'] = 'Client' # Default to client if ambiguous
if data['not_after'] != 'N/A':
data['sort_date'] = self.parse_openssl_date(data['not_after']).isoformat()
else:
data['sort_date'] = datetime.min.isoformat()
# Parse dates for UI
if data['not_after'] != 'N/A':
dt = self.parse_openssl_date(data['not_after'])
data['expires_iso'] = dt.isoformat()
if data['not_before'] != 'N/A':
dt = self.parse_openssl_date(data['not_before'])
data['issued_iso'] = dt.isoformat()
data['days_remaining'] = self.calculate_days_remaining(data['not_after'])
data['is_expired'] = 'Expired' in data['days_remaining']
# State for UI
if data['is_expired']:
data['state'] = 'Expired'
else:
data['state'] = 'Valid'
return data
except Exception as e:
logger.error(f"Error processing {cert_file}: {e}")
return None
def get_certificates_info(self):
cert_path = Path(self.certificates_path)
if not cert_path.exists(): return []
cert_files = []
for ext in self.cert_extensions:
cert_files.extend(cert_path.rglob(f'*.{ext.strip()}'))
current_valid_files = set()
cert_data = []
for cert_file_path in cert_files:
cert_file = str(cert_file_path)
current_valid_files.add(cert_file)
try:
mtime = os.path.getmtime(cert_file)
# Check cache
cached = self._cert_cache.get(cert_file)
if cached and cached['mtime'] == mtime:
cert_data.append(cached['data'])
else:
# Parse and update cache
parsed_data = self.extract_cert_info(cert_file)
if parsed_data:
self._cert_cache[cert_file] = {
'mtime': mtime,
'data': parsed_data
}
cert_data.append(parsed_data)
except OSError:
continue
# Prune cache for deleted files
for cached_file in list(self._cert_cache.keys()):
if cached_file not in current_valid_files:
del self._cert_cache[cached_file]
return cert_data
# ----------------------------------------------------------- # -----------------------------------------------------------
def get_current_stats(self): def get_current_stats(self):
@@ -1109,14 +973,7 @@ def get_client_stats(common_name):
logger.error(f"API Error: {e}") logger.error(f"API Error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/certificates', methods=['GET'])
@token_required
def get_certificates():
try:
data = api.get_certificates_info()
return jsonify({'success': True, 'data': data})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/v1/clients', methods=['GET']) @app.route('/api/v1/clients', methods=['GET'])
@token_required @token_required
@@ -1180,9 +1037,9 @@ def get_sessions():
if __name__ == "__main__": if __name__ == "__main__":
host = api.config.get('api', 'host', fallback='0.0.0.0') host = api.get_config_value('api', 'host', fallback='0.0.0.0')
port = 5001 # Используем 5001, чтобы не конфликтовать, если что-то уже есть на 5000 port = int(api.get_config_value('api', 'port', fallback=5001))
debug = api.config.getboolean('api', 'debug', fallback=False) debug = api.get_config_value('api', 'debug', fallback='false').lower() == 'true'
logger.info(f"Starting API on {host}:{port}") logger.info(f"Starting API on {host}:{port}")
app.run(host=host, port=port, debug=debug) app.run(host=host, port=port, debug=debug)

View File

@@ -142,14 +142,8 @@ class OpenVPNDataGatherer:
'agg_6h_retention_days': '180', # 6 месяцев 'agg_6h_retention_days': '180', # 6 месяцев
'agg_1d_retention_days': '365' # 12 месяцев 'agg_1d_retention_days': '365' # 12 месяцев
}, },
'visualization': { 'visualization': {},
'refresh_interval': '5', 'certificates': {}
'max_display_rows': '50'
},
'certificates': {
'certificates_path': '/opt/ovpn/pki/issued',
'certificate_extensions': 'crt'
}
} }
try: try:
@@ -214,6 +208,12 @@ class OpenVPNDataGatherer:
def get_config_value(self, section, key, default=None): def get_config_value(self, section, key, default=None):
try: try:
# Priority: ENV > Config File > Fallback
# Format: OVPMON_SECTION_KEY (all uppercase, underscores for spaces/dashes)
env_key = f"OVPMON_{section.upper()}_{key.upper()}".replace('-', '_').replace(' ', '_')
env_val = os.getenv(env_key)
if env_val is not None:
return env_val
return self.config.get(section, key, fallback=default) return self.config.get(section, key, fallback=default)
except: except:
return default return default

View File

@@ -1,7 +1,8 @@
FROM python:3.12-alpine FROM python:3.12-alpine
# Install OpenVPN, OpenRC and other system deps # Install OpenVPN, OpenRC and other system deps
RUN apk add --no-cache openvpn openrc iproute2 bash RUN apk add --no-cache openvpn openrc iproute2 bash iptables easy-rsa
WORKDIR /app WORKDIR /app
@@ -9,8 +10,12 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Ensure DB directory exists
RUN mkdir -p /app/db
# Copy source code and entrypoint # Copy source code and entrypoint
COPY . . COPY . .
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
# Expose API port # Expose API port

11
APP_PROFILER/config.ini Normal file
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

View File

@@ -2,7 +2,12 @@ from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "sqlite:///./ovpn_profiler.db" from utils.config import get_config_value
# Support override via OVPMON_PROFILER_DB_PATH or config.ini
db_path = get_config_value('profiler', 'db_path', fallback='./ovpn_profiler.db')
SQLALCHEMY_DATABASE_URL = f"sqlite:///{db_path}"
engine = create_engine( engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}

View File

@@ -7,14 +7,31 @@ if [ ! -c /dev/net/tun ]; then
chmod 600 /dev/net/tun chmod 600 /dev/net/tun
fi fi
# Enable IP forwarding # Enable IP forwarding (moved to docker-compose.yml sysctls)
sysctl -w net.ipv4.ip_forward=1 # sysctl -w net.ipv4.ip_forward=1 || true
# NAT MASQUERADE
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
# MSS Clamping (Path MTU Tuning)
iptables -t mangle -A FORWARD -o eth0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
iptables -t mangle -A FORWARD -i eth0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
# Ensure /run exists for PID files
mkdir -p /run
# Initialize Easy-RSA if not already present in /app/easy-rsa
if [ ! -f /app/easy-rsa/easyrsa ]; then
echo "[INIT] Initializing Easy-RSA workspace..."
mkdir -p /app/easy-rsa
# Alpine installs easy-rsa files to /usr/share/easy-rsa
cp -r /usr/share/easy-rsa/* /app/easy-rsa/
fi
# Start OpenRC (needed for rc-service if we use it, but better to start openvpn directly or via rc)
# Since we are in Alpine, we can try to start the service if configured,
# but Container 4 main.py might expect rc-service to work.
openrc default
# Start the APP_PROFILER API # Start the APP_PROFILER API
# We use 0.0.0.0 to be reachable from other containers # We use 0.0.0.0 to be reachable from other containers
python main.py python main.py

View File

@@ -42,4 +42,4 @@ def read_root():
return {"message": "Welcome to OpenVPN Profiler API"} return {"message": "Welcome to OpenVPN Profiler API"}
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True) uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@@ -4,3 +4,4 @@ sqlalchemy
psutil psutil
python-multipart python-multipart
jinja2 jinja2
pyjwt

View File

@@ -1,9 +1,13 @@
import os
import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from utils.auth import verify_token from utils.auth import verify_token
from services import generator from services import generator
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(verify_token)]) router = APIRouter(dependencies=[Depends(verify_token)])
@router.post("/server/configure") @router.post("/server/configure")
@@ -12,12 +16,16 @@ def configure_server(db: Session = Depends(get_db)):
# Generate to a temporary location or standard location # Generate to a temporary location or standard location
# As per plan, we behave like srvconf # As per plan, we behave like srvconf
output_path = "/etc/openvpn/server.conf" output_path = "/etc/openvpn/server.conf"
# Since running locally for dev, maybe output to staging
import os # Ensure we can write to /etc/openvpn
if not os.path.exists("/etc/openvpn"): if not os.path.exists(os.path.dirname(output_path)) or not os.access(os.path.dirname(output_path), os.W_OK):
# For local dev safety, don't try to write to /etc/openvpn if not root or not existing # For local dev or non-root host, use staging
output_path = "staging/server.conf" output_path = "staging/server.conf"
os.makedirs("staging", exist_ok=True) os.makedirs("staging", exist_ok=True)
logger.info(f"[SERVER] /etc/openvpn not writable, using staging path: {output_path}")
else:
os.makedirs(os.path.dirname(output_path), exist_ok=True)
content = generator.generate_server_config(db, output_path=output_path) content = generator.generate_server_config(db, output_path=output_path)
return {"message": "Server configuration generated", "path": output_path} return {"message": "Server configuration generated", "path": output_path}

View File

@@ -23,6 +23,7 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
file_srv_key_path = os.path.join(PKI_DIR, "private", f"{pki_settings.fqdn_server}.key") file_srv_key_path = os.path.join(PKI_DIR, "private", f"{pki_settings.fqdn_server}.key")
file_dh_path = os.path.join(PKI_DIR, "dh.pem") file_dh_path = os.path.join(PKI_DIR, "dh.pem")
file_ta_path = os.path.join(PKI_DIR, "ta.key") file_ta_path = os.path.join(PKI_DIR, "ta.key")
file_crl_path = os.path.join(PKI_DIR, "crl.pem")
# Render template # Render template
config_content = template.render( config_content = template.render(
@@ -33,6 +34,7 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
srv_key_path=file_srv_key_path, srv_key_path=file_srv_key_path,
dh_path=file_dh_path, dh_path=file_dh_path,
ta_path=file_ta_path, ta_path=file_ta_path,
crl_path=file_crl_path,
vpn_network=settings.vpn_network, vpn_network=settings.vpn_network,
vpn_netmask=settings.vpn_netmask, vpn_netmask=settings.vpn_netmask,
tunnel_type=settings.tunnel_type, tunnel_type=settings.tunnel_type,

View File

@@ -6,13 +6,19 @@ import psutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_os_type(): def is_container():
""" """
Simple check to distinguish Alpine from others. Checks if the application is running inside a Docker container.
""" """
if os.path.exists("/etc/alpine-release"): if os.path.exists('/.dockerenv'):
return "alpine" return True
return "debian" # default fallback to systemctl try:
with open('/proc/self/cgroup', 'rt') as f:
if 'docker' in f.read():
return True
except:
pass
return False
def control_service(action: str): def control_service(action: str):
""" """
@@ -21,7 +27,76 @@ def control_service(action: str):
if action not in ["start", "stop", "restart"]: if action not in ["start", "stop", "restart"]:
raise ValueError("Invalid action") raise ValueError("Invalid action")
CONFIG_PATH = "/etc/openvpn/server.conf"
PID_FILE = "/run/openvpn.pid"
# In Container: Use direct execution to avoid OpenRC/cgroups issues
if is_container():
logger.info(f"[PROCESS] Container detected, using direct execution for {action}")
def start_vpn_direct():
if not os.path.exists(CONFIG_PATH):
# Check for alternative location in dev/non-root environments
if os.path.exists("staging/server.conf"):
alt_path = os.path.abspath("staging/server.conf")
logger.info(f"[PROCESS] Using alternative config: {alt_path}")
config = alt_path
else:
return {"status": "error", "message": f"Configuration not found at {CONFIG_PATH}. Please generate it first."}
else:
config = CONFIG_PATH
# Check if already running
for proc in psutil.process_iter(['name']):
if proc.info['name'] == 'openvpn':
return {"status": "success", "message": "OpenVPN is already running"}
cmd = ["openvpn", "--config", config, "--daemon", "--writepid", PID_FILE]
try:
subprocess.run(cmd, check=True)
return {"status": "success", "message": "OpenVPN started successfully (direct)"}
except subprocess.CalledProcessError as e:
return {"status": "error", "message": f"Failed to start OpenVPN: {str(e)}"}
def stop_vpn_direct():
procs_to_stop = []
for proc in psutil.process_iter(['name']):
if proc.info['name'] == 'openvpn':
try:
proc.terminate()
procs_to_stop.append(proc)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
if procs_to_stop:
# Wait for processes to actually exit
logger.info(f"[PROCESS] Waiting for {len(procs_to_stop)} OpenVPN process(es) to terminate...")
gone, alive = psutil.wait_procs(procs_to_stop, timeout=5)
for p in alive:
try:
logger.warning(f"[PROCESS] Process {p.pid} did not terminate, killing...")
p.kill()
except:
pass
if os.path.exists(PID_FILE):
try: os.remove(PID_FILE)
except: pass
if procs_to_stop:
return {"status": "success", "message": "OpenVPN stopped successfully"}
else:
return {"status": "success", "message": "OpenVPN was not running"}
if action == "start": return start_vpn_direct()
elif action == "stop": return stop_vpn_direct()
elif action == "restart":
stop_vpn_direct()
return start_vpn_direct()
# On Host OS: Use system service manager
os_type = get_os_type() os_type = get_os_type()
logger.info(f"[PROCESS] Host OS detected ({os_type}), using service manager for {action}")
cmd = [] cmd = []
if os_type == "alpine": if os_type == "alpine":
@@ -30,27 +105,27 @@ def control_service(action: str):
cmd = ["systemctl", action, "openvpn"] cmd = ["systemctl", action, "openvpn"]
try: try:
# Capture output to return it or log it
result = subprocess.run(cmd, capture_output=True, text=True, check=True) result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return { return {
"status": "success", "status": "success",
"message": f"Service {action} executed successfully", "message": f"Service {action} executed successfully via {cmd[0]}",
"stdout": result.stdout "stdout": result.stdout
} }
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"Service control failed: {e.stderr}") logger.error(f"Service control failed: {e.stderr}")
return { return {
"status": "error", "status": "error",
"message": f"Failed to {action} service", "message": f"Failed to {action} service via {cmd[0]}",
"stderr": e.stderr "stderr": e.stderr
} }
except FileNotFoundError: except FileNotFoundError:
# Happens if rc-service or systemctl is missing (e.g. dev env)
return { return {
"status": "error", "status": "error",
"message": f"Command not found found for OS type {os_type}" "message": f"Command {cmd[0]} not found found for OS type {os_type}"
} }
def get_process_stats(): def get_process_stats():
""" """
Returns dict with pid, cpu_percent, memory_mb, uptime. Returns dict with pid, cpu_percent, memory_mb, uptime.
@@ -125,6 +200,17 @@ def get_process_stats():
"uptime": None "uptime": None
} }
def get_os_type() -> str:
"""
Detects the host OS type to determine which service manager to use.
Currently supports: 'alpine', 'debian' (fallback for systemd systems).
"""
if os.path.exists("/etc/alpine-release"):
return "alpine"
# Fallback to debian/ubuntu (systemd)
return "debian"
def format_seconds(seconds: float) -> str: def format_seconds(seconds: float) -> str:
seconds = int(seconds) seconds = int(seconds)
days, seconds = divmod(seconds, 86400) days, seconds = divmod(seconds, 86400)

View File

@@ -28,13 +28,13 @@ server {{ vpn_network }} {{ vpn_netmask }}
ifconfig-pool-persist /etc/openvpn/ipp.txt ifconfig-pool-persist /etc/openvpn/ipp.txt
log /etc/openvpn/openvpn.log log /var/log/openvpn/openvpn.log
log-append /etc/openvpn/openvpn.log log-append /var/log/openvpn/openvpn.log
verb 3 verb 3
# Use Extended Status Output # Use Extended Status Output
status /etc/openvpn/openvpn-status.log 5 status /var/log/openvpn/openvpn-status.log 5
status-version 2 status-version 2
# Tunneling Mode # Tunneling Mode
@@ -84,7 +84,7 @@ persist-tun
# check revocation list # check revocation list
{% if crl_verify %} {% if crl_verify %}
crl-verify /etc/openvpn/crl.pem crl-verify {{ crl_path }}
{% else %} {% else %}
# crl-verify disabled # crl-verify disabled
{% endif %} {% endif %}

View File

@@ -4,49 +4,29 @@ import os
from fastapi import Header, HTTPException, status from fastapi import Header, HTTPException, status
from pathlib import Path from pathlib import Path
# Load config from the main APP directory from .config import get_config_value
CONFIG_FILE = Path(__file__).parent.parent.parent / 'APP' / 'config.ini'
def get_secret_key(): def get_secret_key():
# Priority 1: Environment Variable # Use consistent OVPMON_API_SECRET_KEY as primary source
env_secret = os.getenv('OVPMON_SECRET_KEY') key = get_config_value('api', 'secret_key', fallback='ovpmon-secret-change-me')
if env_secret:
print("[AUTH] Using SECRET_KEY from environment variable")
return env_secret
# Priority 2: Config file (multiple possible locations) if key == 'ovpmon-secret-change-me':
# Resolve absolute path to be sure print("[AUTH] WARNING: Using default fallback SECRET_KEY")
base_path = Path(__file__).resolve().parent.parent 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)")
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 return key
except Exception as e:
print(f"[AUTH] Error reading config at {loc}: {e}")
continue
print("[AUTH] WARNING: No config found, using default fallback SECRET_KEY")
return 'ovpmon-secret-change-me'
SECRET_KEY = get_secret_key() SECRET_KEY = get_secret_key()
async def verify_token(authorization: str = Header(None)): async def verify_token(authorization: str = Header(None)):
if not authorization or not authorization.startswith("Bearer "): if not authorization or not authorization.startswith("Bearer "):
print(f"[AUTH] Missing or invalid Authorization header: {authorization[:20] if authorization else 'None'}") print(f"[AUTH] Missing or invalid Authorization header: {authorization[:20] if authorization else 'None'}")

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

View File

@@ -9,6 +9,7 @@ RUN npm run build
# Stage 2: Serve # Stage 2: Serve
FROM nginx:alpine FROM nginx:alpine
COPY --from=build-stage /app/dist /usr/share/nginx/html COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY default.conf.template /etc/nginx/templates/default.conf.template
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

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;
}
}

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;
}
}

View File

@@ -58,10 +58,11 @@ export function useApi() {
}; };
const fetchCertificates = async () => { const fetchCertificates = async () => {
const res = await apiClient.get('/certificates'); const res = await profilesApiClient.get('/profiles');
return res.data; return res.data;
}; };
return { return {
apiClient, apiClient,
profilesApiClient, profilesApiClient,

View File

@@ -109,12 +109,12 @@
<i class="fas fa-check-circle text-success me-2"></i>All Good <i class="fas fa-check-circle text-success me-2"></i>All Good
</p> </p>
<div v-else class="list-group list-group-flush"> <div v-else class="list-group list-group-flush">
<div v-for="cert in expiringCertsList" :key="cert.common_name" class="list-group-item px-0 py-2 d-flex justify-content-between align-items-center border-0"> <div v-for="cert in expiringCertsList" :key="cert.username" class="list-group-item px-0 py-2 d-flex justify-content-between align-items-center border-0">
<div> <div>
<div class="fw-bold small">{{ cert.common_name }}</div> <div class="fw-bold small">{{ cert.username }}</div>
<div class="text-muted" style="font-size: 0.75rem;">Expires: {{ cert.expiration_date }}</div> <div class="text-muted" style="font-size: 0.75rem;">Expires: {{ cert.expiration_date }}</div>
</div> </div>
<span class="badge status-warning text-dark">{{ cert.days_left }} days</span> <span class="badge status-warning text-dark">{{ cert.days_remaining }} days</span>
</div> </div>
</div> </div>
</div> </div>
@@ -202,32 +202,15 @@ const loadCerts = async () => {
loading.certs = true; loading.certs = true;
try { try {
const res = await fetchCertificates(); const res = await fetchCertificates();
if(res.success) { if(res.success && Array.isArray(res.data)) {
const now = new Date(); const list = res.data.filter(cert => {
const warningThreshold = new Date(); // Only active, non-revoked, and expiring soon (within 45 days)
warningThreshold.setDate(now.getDate() + 45); return !cert.is_revoked && !cert.is_expired &&
cert.days_remaining !== null && cert.days_remaining <= 45;
let count = 0;
const list = [];
res.data.forEach(cert => {
if (cert.status === 'revoked') return;
const expDate = new Date(cert.expiration_date); // Assuming API returns ISO or parsable date
if (expDate <= warningThreshold) {
count++;
const diffTime = Math.abs(expDate - now);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
list.push({
...cert,
days_left: diffDays
});
}
}); });
kpi.expiringCerts = count; kpi.expiringCerts = list.length;
expiringCertsList.value = list.sort((a,b) => a.days_left - b.days_left); expiringCertsList.value = list.sort((a,b) => (a.days_remaining || 0) - (b.days_remaining || 0));
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -298,9 +281,9 @@ const renderMainChart = () => {
mainChartInstance = new Chart(ctx, { mainChartInstance = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels,
labels, labels,
datasets: [ datasets: [
{ {
label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps', label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps',
data: dataRx, data: dataRx,

View File

@@ -1,62 +1,61 @@
# OpenVPN Monitor & Profiler # OpenVPN Monitor & Profiler
A modern, full-stack management solution for OpenVPN servers. It combines real-time traffic monitoring, historical analytics, and comprehensive user profile/PKI management into a unified web interface. A modern, full-stack management solution for OpenVPN servers. It combines real-time traffic monitoring, historical analytics, and comprehensive user profile/PKI management into a unified web interface. Perfect for both containerized (Docker) and native (Alpine/Debian/Ubuntu) deployments.
## <EFBFBD> Project Architecture ## 🏗 Project Architecture
The project is modularized into three core components: The project is modularized into four core microservices, split between **Monitoring (Core)** and **Management (Profiler)**:
| Component | Directory | Description | | Component | Directory | Service Name | Description |
| :--- | :--- | :--- | :--- |
| **User Interface** | `APP_UI/` | `ovp-ui` | Vue 3 + Vite SPA + Nginx. Communicates with both APIs. |
| **Monitoring API** | `APP_CORE/` | `ovp-api` | Flask API for real-time stats, sessions, and bandwidth data. |
| **Data Gatherer** | `APP_CORE/` | `ovp-gatherer` | Background service for traffic log aggregation & TSDB logic. |
| **Profiler API** | `APP_PROFILER/` | `ovp-profiler` | FastAPI module for PKI management, User Profiles, and VPN control. |
## 📦 Quick Start (Docker)
The recommended way to deploy is using Docker Compose:
1. **Clone the repository**
2. **Start all services**:
```bash
docker-compose up -d --build
```
3. **Access the Dashboard**: Open `http://localhost` (or your server IP) in your browser.
4. **Initialize PKI**: On the first run, navigate to the **PKI Configuration** page in the UI and click **Initialize PKI**. This sets up the CA and Easy-RSA workspace.
## ⚙️ Configuration
The system uses a unified configuration approach. Settings can be defined in `config.ini` files or overridden by environment variables following the `OVPMON_{SECTION}_{KEY}` format.
### Key Environment Variables
| Variable | Description | Default Value |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| **Core Monitoring** | `APP_CORE/` | Flask-based API (v3) for log parsing, real-time stats, and historical TSDB. | | `OVPMON_API_SECRET_KEY` | Unified JWT Secret Key (used by both APIs) | `supersecret` |
| **Profiler** | `APP_PROFILER/` | FastAPI-based module for managing PKI, Certificates, and Server Configs. | | `OVPMON_PROFILER_DB_PATH` | Path to Profiler (users/pki) SQLite DB | `/app/db/ovpn_profiler.db` |
| **User Interface** | `APP_UI/` | Vue 3 + Vite Single Page Application (SPA) serving as the unified dashboard. | | `OVPMON_OPENVPN_MONITOR_DB_PATH` | Path to Monitoring (traffic) SQLite DB | `/app/db/openvpn_monitor.db` |
| `OVPMON_OPENVPN_MONITOR_LOG_PATH`| Path to OpenVPN status log | `/var/log/openvpn/openvpn-status.log` |
| `OVPMON_LOGGING_LEVEL` | Logging level (INFO/DEBUG) | `INFO` |
## 📚 Documentation ## 🛠️ Performance & Environment Awareness
Detailed documentation has been moved to the `DOCS/` directory. - **Container Transparency**: When running in Docker, the Profiler manages OpenVPN directly to bypass cgroups restrictions.
- **Host Integration**: When running natively on Alpine or Debian/Ubuntu, it automatically switches to `rc-service` or `systemctl`.
- **Persistent Data**: Logs, Certificates (PKI), and Databases are stored in Docker volumes (`ovp_logs`, `ovp_pki`, `db_data`).
- **[Installation & Deployment](DOCS/General/Deployment.md)**: Setup guide for Linux (Alpine/Debian). ## 📚 Development
- **[Service Management](DOCS/General/Service_Management.md)**: Configuring Systemd/OpenRC services.
- **[Security & Auth](DOCS/General/Security_Architecture.md)**: 2FA, JWT, and Security details.
### API References ### Component Development
- **[Core Monitoring API](DOCS/Core_Monitoring/API_Reference.md)**: Endpoints for stats, sessions, and history. - **UI**: Uses `composables/useApi.js` to route requests to the appropriate backend service based on URL.
- **[Profiler Management API](DOCS/Profiler_Management/API_Reference.md)**: Endpoints for profiles, system config, and control. - **Profiler**: Clean Python/FastAPI code with SQLAlchemy models. Supports "staging" local mode for development without root access.
- **Core**: Lightweight Flask services focused on high-performance log parsing.
## 🚀 Quick Start (Dev Mode)
### 1. Core API (Flask)
```bash
cd APP_CORE
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python3 openvpn_api_v3.py
# Runs on :5001 (Monitoring)
```
### 2. Profiler API (FastAPI)
```bash
cd APP_PROFILER
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python3 main.py
# Runs on :8000 (Management)
```
### 3. Frontend (Vue 3)
```bash
cd APP_UI
npm install
npm run dev
# Runs on localhost:5173
```
--- ---
## ⚠️ Important Notes ### ⚠️ Important Notes
1. **Environment**: Production deployment relies on Nginx to proxy requests to the backend services. See the [Deployment Guide](DOCS/General/Deployment.md). 1. **Privileged Mode**: The `ovp-profiler` container requires `NET_ADMIN` capabilities for iptables and TUN management.
2. **Permissions**: The backend requires `sudo` or root privileges to manage OpenVPN processes and write to `/etc/openvpn`. 2. **Network Setup**: Ensure `net.ipv4.ip_forward=1` is enabled (handled automatically in the docker-compose `sysctls` section).
3. **JWT Safety**: Always change the `OVPMON_API_SECRET_KEY` in production.

View File

@@ -11,6 +11,12 @@ services:
- app-profiler - app-profiler
networks: networks:
- ovp-net - ovp-net
environment:
- OVP_API_HOST=ovp-api
- OVP_API_PORT=5001
- OVP_PROFILER_HOST=ovp-profiler
- OVP_PROFILER_PORT=8000
app-gatherer: app-gatherer:
build: build:
@@ -19,9 +25,13 @@ services:
container_name: ovp-gatherer container_name: ovp-gatherer
volumes: volumes:
- ovp_logs:/var/log/openvpn - ovp_logs:/var/log/openvpn
- db_data:/app/db # Assuming APP_CORE looks for DB in /app/db - db_data:/app/db
depends_on: depends_on:
- app-profiler - app-profiler
environment:
- OVPMON_OPENVPN_MONITOR_DB_PATH=/app/db/openvpn_monitor.db
- OVPMON_OPENVPN_MONITOR_LOG_PATH=/var/log/openvpn/openvpn-status.log
- OVPMON_LOGGING_LEVEL=INFO
networks: networks:
- ovp-net - ovp-net
@@ -37,14 +47,22 @@ services:
networks: networks:
- ovp-net - ovp-net
environment: environment:
- JWT_SECRET=${JWT_SECRET:-supersecret} - OVPMON_API_SECRET_KEY=${JWT_SECRET:-supersecret}
- OVPMON_API_PORT=5001
- OVPMON_OPENVPN_MONITOR_DB_PATH=/app/db/openvpn_monitor.db
- OVPMON_LOGGING_LEVEL=INFO
depends_on:
- app-gatherer
app-profiler: app-profiler:
build: ./APP_PROFILER build: ./APP_PROFILER
container_name: ovp-profiler container_name: ovp-profiler
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
sysctls:
- net.ipv4.ip_forward=1
devices: devices:
- "/dev/net/tun:/dev/net/tun" - "/dev/net/tun:/dev/net/tun"
ports: ports:
- "8000:8000" - "8000:8000"
@@ -52,10 +70,17 @@ services:
volumes: volumes:
- ovp_logs:/var/log/openvpn - ovp_logs:/var/log/openvpn
- ovp_config:/etc/openvpn - ovp_config:/etc/openvpn
- db_data:/app/db
- ovp_client_config:/app/client-config
- ovp_pki:/app/easy-rsa
networks: networks:
- ovp-net - ovp-net
environment: environment:
- JWT_SECRET=${JWT_SECRET:-supersecret} - OVPMON_API_SECRET_KEY=${JWT_SECRET:-supersecret}
- OVPMON_PROFILER_DB_PATH=/app/db/ovpn_profiler.db
networks: networks:
ovp-net: ovp-net:
@@ -64,4 +89,6 @@ networks:
volumes: volumes:
ovp_logs: ovp_logs:
ovp_config: ovp_config:
ovp_pki:
ovp_client_config:
db_data: db_data: