new awesome build
This commit is contained in:
26
APP_PROFILER/README.md
Normal file
26
APP_PROFILER/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# OpenVPN Profiler Module (`APP_PROFILER`)
|
||||
|
||||
The **Profiler** module is a FastAPI-based service (`port 8000`) dedicated to management tasks:
|
||||
- Public Key Infrastructure (PKI) management (EasyRSA wrapper).
|
||||
- Client Profile (`.ovpn`) generation.
|
||||
- Server Configuration management.
|
||||
- Process control (Start/Stop OpenVPN service).
|
||||
|
||||
## Documentation
|
||||
|
||||
- **API Reference**: See `DOCS/Profiler_Management/API_Reference.md`.
|
||||
- **Overview**: See `DOCS/Profiler_Management/Overview.md`.
|
||||
|
||||
## Quick Start (Dev)
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run
|
||||
python3 main.py
|
||||
# Swagger UI available at http://localhost:8000/docs
|
||||
```
|
||||
|
||||
51
APP_PROFILER/add_columns.py
Normal file
51
APP_PROFILER/add_columns.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_FILE = "ovpn_profiler.db"
|
||||
|
||||
def migrate_db():
|
||||
if not os.path.exists(DB_FILE):
|
||||
print(f"Database file {DB_FILE} not found!")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("PRAGMA table_info(user_profiles)")
|
||||
columns = [info[1] for info in cursor.fetchall()]
|
||||
|
||||
# Add is_revoked
|
||||
if "is_revoked" not in columns:
|
||||
print("Adding 'is_revoked' column...")
|
||||
cursor.execute("ALTER TABLE user_profiles ADD COLUMN is_revoked BOOLEAN DEFAULT 0")
|
||||
else:
|
||||
print("'is_revoked' column already exists.")
|
||||
|
||||
# Add is_expired
|
||||
if "is_expired" not in columns:
|
||||
print("Adding 'is_expired' column...")
|
||||
cursor.execute("ALTER TABLE user_profiles ADD COLUMN is_expired BOOLEAN DEFAULT 0")
|
||||
else:
|
||||
print("'is_expired' column already exists.")
|
||||
|
||||
# Ensure expiration_date exists
|
||||
if "expiration_date" not in columns:
|
||||
print("Adding 'expiration_date' column...")
|
||||
cursor.execute("ALTER TABLE user_profiles ADD COLUMN expiration_date DATETIME")
|
||||
else:
|
||||
print("'expiration_date' column already exists.")
|
||||
|
||||
# Note: We do NOT remove 'expired_at' via ADD COLUMN script.
|
||||
# SQLite does not support DROP COLUMN in older versions easily,
|
||||
# and keeping it harmless is safer than complex migration logic.
|
||||
print("Migration successful.")
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Migration failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_db()
|
||||
19
APP_PROFILER/database.py
Normal file
19
APP_PROFILER/database.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./ovpn_profiler.db"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
45
APP_PROFILER/main.py
Normal file
45
APP_PROFILER/main.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to sys.path explicitly to ensure absolute imports work
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from database import engine, Base
|
||||
from routers import system, server, profiles, server_process
|
||||
from utils.logging import setup_logging
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# Create Database Tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
setup_logging()
|
||||
|
||||
app = FastAPI(
|
||||
title="OpenVPN Profiler API",
|
||||
description="REST API for managing OpenVPN profiles and configuration",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Enable CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(system.router, prefix="/api", tags=["System"])
|
||||
app.include_router(server.router, prefix="/api", tags=["Server"])
|
||||
app.include_router(profiles.router, prefix="/api", tags=["Profiles"])
|
||||
app.include_router(server_process.router, prefix="/api", tags=["Process Control"])
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Welcome to OpenVPN Profiler API"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
|
||||
63
APP_PROFILER/models.py
Normal file
63
APP_PROFILER/models.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
from datetime import datetime
|
||||
|
||||
class PKISetting(Base):
|
||||
__tablename__ = "pki_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
fqdn_ca = Column(String, default="ovpn-ca")
|
||||
fqdn_server = Column(String, default="ovpn-srv")
|
||||
easyrsa_dn = Column(String, default="cn_only")
|
||||
easyrsa_req_country = Column(String, default="RU")
|
||||
easyrsa_req_province = Column(String, default="Moscow")
|
||||
easyrsa_req_city = Column(String, default="Moscow")
|
||||
easyrsa_req_org = Column(String, default="SomeORG")
|
||||
easyrsa_req_email = Column(String, default="info@someorg.local")
|
||||
easyrsa_req_ou = Column(String, default="IT")
|
||||
easyrsa_key_size = Column(Integer, default=2048)
|
||||
easyrsa_ca_expire = Column(Integer, default=3650)
|
||||
easyrsa_cert_expire = Column(Integer, default=3649)
|
||||
easyrsa_cert_renew = Column(Integer, default=30)
|
||||
easyrsa_crl_days = Column(Integer, default=3649)
|
||||
easyrsa_batch = Column(Boolean, default=True)
|
||||
|
||||
class SystemSettings(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
protocol = Column(String, default="udp")
|
||||
port = Column(Integer, default=1194)
|
||||
vpn_network = Column(String, default="172.20.1.0")
|
||||
vpn_netmask = Column(String, default="255.255.255.0")
|
||||
tunnel_type = Column(String, default="FULL") # FULL or SPLIT
|
||||
split_routes = Column(JSON, default=list)
|
||||
duplicate_cn = Column(Boolean, default=False)
|
||||
crl_verify = Column(Boolean, default=False)
|
||||
client_to_client = Column(Boolean, default=False)
|
||||
user_defined_dns = Column(Boolean, default=False)
|
||||
dns_servers = Column(JSON, default=list)
|
||||
user_defined_cdscripts = Column(Boolean, default=False)
|
||||
connect_script = Column(String, default="")
|
||||
disconnect_script = Column(String, default="")
|
||||
management_interface = Column(Boolean, default=False)
|
||||
management_interface_address = Column(String, default="127.0.0.1")
|
||||
management_port = Column(Integer, default=7505)
|
||||
public_ip = Column(String, nullable=True)
|
||||
tun_mtu = Column(Integer, nullable=True)
|
||||
mssfix = Column(Integer, nullable=True)
|
||||
|
||||
class UserProfile(Base):
|
||||
__tablename__ = "user_profiles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True)
|
||||
status = Column(String, default="active") # active, revoked
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
revoked_at = Column(DateTime, nullable=True)
|
||||
# expired_at removed as per request
|
||||
expiration_date = Column(DateTime, nullable=True)
|
||||
is_revoked = Column(Boolean, default=False)
|
||||
is_expired = Column(Boolean, default=False)
|
||||
file_path = Column(String, nullable=True)
|
||||
0
APP_PROFILER/routers/__init__.py
Normal file
0
APP_PROFILER/routers/__init__.py
Normal file
137
APP_PROFILER/routers/profiles.py
Normal file
137
APP_PROFILER/routers/profiles.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from utils.auth import verify_token
|
||||
from models import UserProfile
|
||||
from schemas import UserProfile as UserProfileSchema, UserProfileCreate
|
||||
from services import pki, generator
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(dependencies=[Depends(verify_token)])
|
||||
|
||||
@router.get("/profiles", response_model=list[UserProfileSchema])
|
||||
def list_profiles(db: Session = Depends(get_db)):
|
||||
# 1. Fetch profiles from DB
|
||||
profiles = db.query(UserProfile).all()
|
||||
|
||||
# 2. Get PKI Data (Index mapping: CN -> Expiration Date)
|
||||
pki_data = pki.get_pki_index_data(db)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
updated_profiles = []
|
||||
|
||||
for profile in profiles:
|
||||
# Sync expiration if available in PKI data
|
||||
if profile.username in pki_data:
|
||||
exp_date = pki_data[profile.username]
|
||||
# Update DB if different
|
||||
if profile.expiration_date != exp_date:
|
||||
profile.expiration_date = exp_date
|
||||
db.add(profile)
|
||||
|
||||
# Calculate derived fields
|
||||
|
||||
# 1. is_expired
|
||||
is_expired = False
|
||||
if profile.expiration_date:
|
||||
if now > profile.expiration_date:
|
||||
is_expired = True
|
||||
|
||||
# 2. is_revoked
|
||||
# (Assuming status='revoked' in DB is the source of truth)
|
||||
is_revoked = profile.status == 'revoked'
|
||||
|
||||
# 3. days_remaining (computed field)
|
||||
days_remaining = None
|
||||
if profile.expiration_date:
|
||||
delta = profile.expiration_date - now
|
||||
days_remaining = delta.days
|
||||
|
||||
# Update DB fields for persistence if they differ
|
||||
if profile.is_expired != is_expired:
|
||||
profile.is_expired = is_expired
|
||||
db.add(profile)
|
||||
|
||||
if profile.is_revoked != is_revoked:
|
||||
profile.is_revoked = is_revoked
|
||||
db.add(profile)
|
||||
|
||||
# Inject computed fields for response schema
|
||||
# Since 'days_remaining' is not a DB column, we attach it to the object instance
|
||||
setattr(profile, 'days_remaining', days_remaining)
|
||||
|
||||
updated_profiles.append(profile)
|
||||
|
||||
db.commit() # Save any updates
|
||||
|
||||
return updated_profiles
|
||||
|
||||
@router.post("/profiles", response_model=UserProfileSchema)
|
||||
def create_profile(
|
||||
profile_in: UserProfileCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Check existing
|
||||
existing = db.query(UserProfile).filter(UserProfile.username == profile_in.username).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already exists")
|
||||
|
||||
# Build PKI
|
||||
try:
|
||||
pki.build_client(profile_in.username, db)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"PKI Build failed: {str(e)}")
|
||||
|
||||
# Generate Config
|
||||
client_conf_dir = "client-config"
|
||||
os.makedirs(client_conf_dir, exist_ok=True)
|
||||
file_path = os.path.join(client_conf_dir, f"{profile_in.username}.ovpn")
|
||||
|
||||
try:
|
||||
generator.generate_client_config(db, profile_in.username, file_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Config Generation failed: {str(e)}")
|
||||
|
||||
# Create DB Entry
|
||||
new_profile = UserProfile(
|
||||
username=profile_in.username,
|
||||
status="active",
|
||||
created_at=datetime.utcnow(),
|
||||
file_path=file_path
|
||||
# expired_at would be extracted from cert in a real robust implementation
|
||||
)
|
||||
db.add(new_profile)
|
||||
db.commit()
|
||||
db.refresh(new_profile)
|
||||
return new_profile
|
||||
|
||||
@router.delete("/profiles/{profile_id}")
|
||||
def revoke_profile(profile_id: int, db: Session = Depends(get_db)):
|
||||
profile = db.query(UserProfile).filter(UserProfile.id == profile_id).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
|
||||
try:
|
||||
pki.revoke_client(profile.username, db)
|
||||
except Exception as e:
|
||||
# Log but maybe continue to update DB status?
|
||||
raise HTTPException(status_code=500, detail=f"Revocation failed: {str(e)}")
|
||||
|
||||
profile.status = "revoked"
|
||||
profile.revoked_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return {"message": f"Profile {profile.username} revoked"}
|
||||
|
||||
@router.get("/profiles/{profile_id}/download")
|
||||
def download_profile(profile_id: int, db: Session = Depends(get_db)):
|
||||
profile = db.query(UserProfile).filter(UserProfile.id == profile_id).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
|
||||
if not profile.file_path or not os.path.exists(profile.file_path):
|
||||
raise HTTPException(status_code=404, detail="Config file not found")
|
||||
|
||||
return FileResponse(profile.file_path, filename=os.path.basename(profile.file_path))
|
||||
25
APP_PROFILER/routers/server.py
Normal file
25
APP_PROFILER/routers/server.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from utils.auth import verify_token
|
||||
from services import generator
|
||||
|
||||
router = APIRouter(dependencies=[Depends(verify_token)])
|
||||
|
||||
@router.post("/server/configure")
|
||||
def configure_server(db: Session = Depends(get_db)):
|
||||
try:
|
||||
# Generate to a temporary location or standard location
|
||||
# As per plan, we behave like srvconf
|
||||
output_path = "/etc/openvpn/server.conf"
|
||||
# Since running locally for dev, maybe output to staging
|
||||
import os
|
||||
if not os.path.exists("/etc/openvpn"):
|
||||
# For local dev safety, don't try to write to /etc/openvpn if not root or not existing
|
||||
output_path = "staging/server.conf"
|
||||
os.makedirs("staging", exist_ok=True)
|
||||
|
||||
content = generator.generate_server_config(db, output_path=output_path)
|
||||
return {"message": "Server configuration generated", "path": output_path}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
44
APP_PROFILER/routers/server_process.py
Normal file
44
APP_PROFILER/routers/server_process.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import process
|
||||
from utils.auth import verify_token
|
||||
from typing import Optional
|
||||
from fastapi import Depends
|
||||
|
||||
router = APIRouter(dependencies=[Depends(verify_token)])
|
||||
|
||||
class ProcessActionResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
stdout: Optional[str] = None
|
||||
stderr: Optional[str] = None
|
||||
|
||||
class ProcessStats(BaseModel):
|
||||
status: str
|
||||
pid: Optional[int] = None
|
||||
cpu_percent: float
|
||||
memory_mb: float
|
||||
uptime: Optional[str] = None
|
||||
|
||||
@router.post("/server/process/{action}", response_model=ProcessActionResponse)
|
||||
def manage_process(action: str):
|
||||
"""
|
||||
Control the OpenVPN server process.
|
||||
Action: start, stop, restart
|
||||
"""
|
||||
if action not in ["start", "stop", "restart"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid action. Use start, stop, or restart")
|
||||
|
||||
result = process.control_service(action)
|
||||
|
||||
if result["status"] == "error":
|
||||
raise HTTPException(status_code=500, detail=result["message"])
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/server/process/stats", response_model=ProcessStats)
|
||||
def get_process_stats():
|
||||
"""
|
||||
Get current telemetry for the OpenVPN process.
|
||||
"""
|
||||
return process.get_process_stats()
|
||||
50
APP_PROFILER/routers/system.py
Normal file
50
APP_PROFILER/routers/system.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from utils.auth import verify_token
|
||||
from schemas import (
|
||||
ConfigResponse, SystemSettings, PKISetting,
|
||||
SystemSettingsUpdate, PKISettingUpdate
|
||||
)
|
||||
from services import config, pki
|
||||
|
||||
router = APIRouter(dependencies=[Depends(verify_token)])
|
||||
|
||||
@router.get("/config", response_model=ConfigResponse)
|
||||
def get_config(
|
||||
section: str = Query(None, enum=["server", "pki"]),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
response = ConfigResponse()
|
||||
if section is None or section == "server":
|
||||
response.server = config.get_system_settings(db)
|
||||
if section is None or section == "pki":
|
||||
response.pki = config.get_pki_settings(db)
|
||||
return response
|
||||
|
||||
@router.put("/config/server", response_model=SystemSettings)
|
||||
def update_server_config(
|
||||
settings: SystemSettingsUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return config.update_system_settings(db, settings)
|
||||
|
||||
@router.put("/config/pki", response_model=PKISetting)
|
||||
def update_pki_config(
|
||||
settings: PKISettingUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return config.update_pki_settings(db, settings)
|
||||
|
||||
@router.post("/system/init")
|
||||
def init_system_pki(db: Session = Depends(get_db)):
|
||||
try:
|
||||
msg = pki.init_pki(db)
|
||||
return {"message": msg}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/system/pki")
|
||||
def clear_system_pki(db: Session = Depends(get_db)):
|
||||
msg = pki.clear_pki(db)
|
||||
return {"message": msg}
|
||||
86
APP_PROFILER/schemas.py
Normal file
86
APP_PROFILER/schemas.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Literal
|
||||
from datetime import datetime
|
||||
|
||||
# --- PKI Settings Schemas ---
|
||||
class PKISettingBase(BaseModel):
|
||||
fqdn_ca: str = "ovpn-ca"
|
||||
fqdn_server: str = "ovpn-srv"
|
||||
easyrsa_dn: str = "cn_only"
|
||||
easyrsa_req_country: str = "RU"
|
||||
easyrsa_req_province: str = "Moscow"
|
||||
easyrsa_req_city: str = "Moscow"
|
||||
easyrsa_req_org: str = "SomeORG"
|
||||
easyrsa_req_email: str = "info@someorg.local"
|
||||
easyrsa_req_ou: str = "IT"
|
||||
easyrsa_key_size: int = 2048
|
||||
easyrsa_ca_expire: int = 3650
|
||||
easyrsa_cert_expire: int = 3649
|
||||
easyrsa_cert_renew: int = 30
|
||||
easyrsa_crl_days: int = 3649
|
||||
easyrsa_batch: bool = True
|
||||
|
||||
class PKISettingUpdate(PKISettingBase):
|
||||
pass
|
||||
|
||||
class PKISetting(PKISettingBase):
|
||||
id: int
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# --- System Settings Schemas ---
|
||||
class SystemSettingsBase(BaseModel):
|
||||
protocol: Literal['tcp', 'udp'] = "udp"
|
||||
port: int = 1194
|
||||
vpn_network: str = "172.20.1.0"
|
||||
vpn_netmask: str = "255.255.255.0"
|
||||
tunnel_type: Literal['FULL', 'SPLIT'] = "FULL"
|
||||
split_routes: List[str] = Field(default_factory=list)
|
||||
duplicate_cn: bool = False
|
||||
crl_verify: bool = False
|
||||
client_to_client: bool = False
|
||||
user_defined_dns: bool = False
|
||||
dns_servers: List[str] = Field(default_factory=list)
|
||||
user_defined_cdscripts: bool = False
|
||||
connect_script: str = ""
|
||||
disconnect_script: str = ""
|
||||
management_interface: bool = False
|
||||
management_interface_address: str = "127.0.0.1"
|
||||
management_interface_address: str = "127.0.0.1"
|
||||
management_port: int = 7505
|
||||
public_ip: Optional[str] = None
|
||||
tun_mtu: Optional[int] = None
|
||||
mssfix: Optional[int] = None
|
||||
|
||||
class SystemSettingsUpdate(SystemSettingsBase):
|
||||
pass
|
||||
|
||||
class SystemSettings(SystemSettingsBase):
|
||||
id: int
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ConfigResponse(BaseModel):
|
||||
server: Optional[SystemSettings] = None
|
||||
pki: Optional[PKISetting] = None
|
||||
|
||||
# --- User Profile Schemas ---
|
||||
class UserProfileBase(BaseModel):
|
||||
username: str
|
||||
|
||||
class UserProfileCreate(UserProfileBase):
|
||||
pass
|
||||
|
||||
class UserProfile(UserProfileBase):
|
||||
id: int
|
||||
status: str
|
||||
created_at: datetime
|
||||
revoked_at: Optional[datetime] = None
|
||||
expiration_date: Optional[datetime] = None
|
||||
days_remaining: Optional[int] = None
|
||||
is_revoked: bool = False
|
||||
is_expired: bool = False
|
||||
file_path: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
35
APP_PROFILER/scripts/add_mtu_mss_columns.py
Normal file
35
APP_PROFILER/scripts/add_mtu_mss_columns.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_FILE = "ovpn_profiler.db"
|
||||
|
||||
def migrate():
|
||||
if not os.path.exists(DB_FILE):
|
||||
print(f"Database {DB_FILE} not found!")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
|
||||
columns = {
|
||||
"tun_mtu": "INTEGER",
|
||||
"mssfix": "INTEGER"
|
||||
}
|
||||
|
||||
print("Checking for new columns...")
|
||||
for col, dtype in columns.items():
|
||||
try:
|
||||
print(f"Attempting to add {col}...")
|
||||
cursor.execute(f"ALTER TABLE system_settings ADD COLUMN {col} {dtype}")
|
||||
print(f"Success: Column {col} added.")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column name" in str(e):
|
||||
print(f"Column {col} already exists.")
|
||||
else:
|
||||
print(f"Error adding {col}: {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
28
APP_PROFILER/scripts/add_public_ip_column.py
Normal file
28
APP_PROFILER/scripts/add_public_ip_column.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_FILE = "ovpn_profiler.db"
|
||||
|
||||
def migrate():
|
||||
if not os.path.exists(DB_FILE):
|
||||
print(f"Database {DB_FILE} not found!")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
print("Attempting to add public_ip column...")
|
||||
cursor.execute("ALTER TABLE system_settings ADD COLUMN public_ip TEXT")
|
||||
conn.commit()
|
||||
print("Success: Column public_ip added.")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column name" in str(e):
|
||||
print("Column public_ip already exists.")
|
||||
else:
|
||||
print(f"Error: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
179
APP_PROFILER/scripts/migrate_from_bash.py
Normal file
179
APP_PROFILER/scripts/migrate_from_bash.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
# Add project root to sys.path
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database import SessionLocal, engine, Base
|
||||
from models import SystemSettings, PKISetting, UserProfile
|
||||
from services import config as config_service
|
||||
|
||||
def parse_bash_array(content, var_name):
|
||||
# Dumb parser for bash arrays: VAR=( "val1" "val2" )
|
||||
# This is fragile but fits the simple format used in confvars
|
||||
pattern = float = fr'{var_name}=\((.*?)\)'
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
items = re.findall(r'"([^"]*)"', match.group(1))
|
||||
return items
|
||||
return []
|
||||
|
||||
def parse_bash_var(content, var_name):
|
||||
pattern = fr'{var_name}="?([^"\n]*)"?'
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def migrate_confvars(db):
|
||||
confvars_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "confvars")
|
||||
|
||||
if not os.path.exists(confvars_path):
|
||||
print(f"No confvars found at {confvars_path}")
|
||||
return
|
||||
|
||||
with open(confvars_path, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
print("Migrating System Settings...")
|
||||
sys_settings = config_service.get_system_settings(db)
|
||||
|
||||
# Map variables
|
||||
protocol = parse_bash_var(content, "TPROTO")
|
||||
if protocol: sys_settings.protocol = protocol
|
||||
|
||||
port = parse_bash_var(content, "TPORT")
|
||||
if port: sys_settings.port = int(port)
|
||||
|
||||
vpn_network = parse_bash_var(content, "TSERNET")
|
||||
if vpn_network: sys_settings.vpn_network = vpn_network.strip('"')
|
||||
|
||||
vpn_netmask = parse_bash_var(content, "TSERMASK")
|
||||
if vpn_netmask: sys_settings.vpn_netmask = vpn_netmask.strip('"')
|
||||
|
||||
tunnel_type = parse_bash_var(content, "TTUNTYPE")
|
||||
if tunnel_type: sys_settings.tunnel_type = tunnel_type
|
||||
|
||||
duplicate_cn = parse_bash_var(content, "TDCN")
|
||||
sys_settings.duplicate_cn = (duplicate_cn == "YES")
|
||||
|
||||
client_to_client = parse_bash_var(content, "TC2C")
|
||||
sys_settings.client_to_client = (client_to_client == "YES")
|
||||
|
||||
crl_verify = parse_bash_var(content, "TREVO")
|
||||
sys_settings.crl_verify = (crl_verify == "YES")
|
||||
|
||||
# Arrays
|
||||
split_routes = parse_bash_array(content, "TTUNNETS")
|
||||
if split_routes: sys_settings.split_routes = split_routes
|
||||
|
||||
dns_servers = parse_bash_array(content, "TDNS")
|
||||
if dns_servers:
|
||||
sys_settings.dns_servers = dns_servers
|
||||
sys_settings.user_defined_dns = True
|
||||
|
||||
# Scripts
|
||||
conn_scripts = parse_bash_var(content, "T_CONNSCRIPTS")
|
||||
sys_settings.user_defined_cdscripts = (conn_scripts == "YES")
|
||||
|
||||
conn_script = parse_bash_var(content, "T_CONNSCRIPT_STRING")
|
||||
if conn_script: sys_settings.connect_script = conn_script.strip('"')
|
||||
|
||||
disconn_script = parse_bash_var(content, "T_DISCONNSCRIPT_STRING")
|
||||
if disconn_script: sys_settings.disconnect_script = disconn_script.strip('"')
|
||||
|
||||
# Mgmt
|
||||
mgmt = parse_bash_var(content, "T_MGMT")
|
||||
sys_settings.management_interface = (mgmt == "YES")
|
||||
|
||||
mgmt_addr = parse_bash_var(content, "T_MGMT_ADDR")
|
||||
if mgmt_addr: sys_settings.management_interface_address = mgmt_addr.strip('"')
|
||||
|
||||
mgmt_port = parse_bash_var(content, "T_MGMT_PORT")
|
||||
if mgmt_port: sys_settings.management_port = int(mgmt_port)
|
||||
|
||||
db.commit()
|
||||
print("System Settings Migrated.")
|
||||
|
||||
print("Migrating PKI Settings...")
|
||||
pki_settings = config_service.get_pki_settings(db)
|
||||
|
||||
fqdn_server = parse_bash_var(content, "FQDN_SERVER")
|
||||
if fqdn_server: pki_settings.fqdn_server = fqdn_server
|
||||
|
||||
fqdn_ca = parse_bash_var(content, "FQDN_CA")
|
||||
if fqdn_ca: pki_settings.fqdn_ca = fqdn_ca
|
||||
|
||||
# EasyRSA vars
|
||||
for line in content.splitlines():
|
||||
if line.startswith("export EASYRSA_"):
|
||||
parts = line.split("=")
|
||||
if len(parts) == 2:
|
||||
key = parts[0].replace("export ", "").strip()
|
||||
val = parts[1].strip().strip('"')
|
||||
|
||||
# Map to model fields (lowercase)
|
||||
if hasattr(pki_settings, key.lower()):
|
||||
# Simple type conversion
|
||||
field_type = type(getattr(pki_settings, key.lower()))
|
||||
if field_type == int:
|
||||
setattr(pki_settings, key.lower(), int(val))
|
||||
elif field_type == bool:
|
||||
# Handle varied boolean strings
|
||||
if val.lower() in ["1", "yes", "true", "on"]:
|
||||
setattr(pki_settings, key.lower(), True)
|
||||
else:
|
||||
setattr(pki_settings, key.lower(), False)
|
||||
else:
|
||||
setattr(pki_settings, key.lower(), val)
|
||||
|
||||
db.commit()
|
||||
print("PKI Settings Migrated.")
|
||||
|
||||
def migrate_users(db):
|
||||
client_config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client-config")
|
||||
if not os.path.exists(client_config_dir):
|
||||
print("No client-config directory found.")
|
||||
return
|
||||
|
||||
print("Migrating Users...")
|
||||
for filename in os.listdir(client_config_dir):
|
||||
if filename.endswith(".ovpn"):
|
||||
username = filename[:-5] # remove .ovpn
|
||||
|
||||
# Check overlap
|
||||
existing = db.query(UserProfile).filter(UserProfile.username == username).first()
|
||||
if not existing:
|
||||
# Basic import, we don't have createdAt date easily unless we stat the file
|
||||
file_path = os.path.join(client_config_dir, filename)
|
||||
stat = os.stat(file_path)
|
||||
created_at = datetime.fromtimestamp(stat.st_ctime)
|
||||
|
||||
# Try to parse ID from filename if it matches format "ID-Name" (common in this script)
|
||||
# But the bash script logic was "ID-Name" -> client_name
|
||||
# The UserProfile username should probably be the CommonName
|
||||
|
||||
profile = UserProfile(
|
||||
username=username,
|
||||
status="active",
|
||||
created_at=created_at,
|
||||
file_path=file_path
|
||||
)
|
||||
db.add(profile)
|
||||
print(f"Imported user: {username}")
|
||||
|
||||
db.commit()
|
||||
print("Users Migrated.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Ensure tables exist
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
migrate_confvars(db)
|
||||
migrate_users(db)
|
||||
finally:
|
||||
db.close()
|
||||
0
APP_PROFILER/services/__init__.py
Normal file
0
APP_PROFILER/services/__init__.py
Normal file
37
APP_PROFILER/services/config.py
Normal file
37
APP_PROFILER/services/config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from models import SystemSettings, PKISetting
|
||||
from schemas import SystemSettingsUpdate, PKISettingUpdate
|
||||
|
||||
def get_system_settings(db: Session):
|
||||
settings = db.query(SystemSettings).first()
|
||||
if not settings:
|
||||
settings = SystemSettings()
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
def update_system_settings(db: Session, settings_in: SystemSettingsUpdate):
|
||||
settings = get_system_settings(db)
|
||||
for key, value in settings_in.model_dump(exclude_unset=True).items():
|
||||
setattr(settings, key, value)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
def get_pki_settings(db: Session):
|
||||
settings = db.query(PKISetting).first()
|
||||
if not settings:
|
||||
settings = PKISetting()
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
def update_pki_settings(db: Session, settings_in: PKISettingUpdate):
|
||||
settings = get_pki_settings(db)
|
||||
for key, value in settings_in.model_dump(exclude_unset=True).items():
|
||||
setattr(settings, key, value)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
108
APP_PROFILER/services/generator.py
Normal file
108
APP_PROFILER/services/generator.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import os
|
||||
import logging
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from sqlalchemy.orm import Session
|
||||
from .config import get_system_settings, get_pki_settings
|
||||
from .pki import PKI_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
|
||||
|
||||
env = Environment(loader=FileSystemLoader(TEMPLATES_DIR))
|
||||
|
||||
def generate_server_config(db: Session, output_path: str = "server.conf"):
|
||||
settings = get_system_settings(db)
|
||||
pki_settings = get_pki_settings(db)
|
||||
template = env.get_template("server.conf.j2")
|
||||
|
||||
# Rendering Path
|
||||
file_ca_path = os.path.join(PKI_DIR, "ca.crt")
|
||||
file_srv_cert_path = os.path.join(PKI_DIR, "issued", f"{pki_settings.fqdn_server}.crt")
|
||||
file_srv_key_path = os.path.join(PKI_DIR, "private", f"{pki_settings.fqdn_server}.key")
|
||||
file_dh_path = os.path.join(PKI_DIR, "dh.pem")
|
||||
file_ta_path = os.path.join(PKI_DIR, "ta.key")
|
||||
|
||||
# Render template
|
||||
config_content = template.render(
|
||||
protocol=settings.protocol,
|
||||
port=settings.port,
|
||||
ca_path=file_ca_path,
|
||||
srv_cert_path=file_srv_cert_path,
|
||||
srv_key_path=file_srv_key_path,
|
||||
dh_path=file_dh_path,
|
||||
ta_path=file_ta_path,
|
||||
vpn_network=settings.vpn_network,
|
||||
vpn_netmask=settings.vpn_netmask,
|
||||
tunnel_type=settings.tunnel_type,
|
||||
split_routes=settings.split_routes,
|
||||
user_defined_dns=settings.user_defined_dns,
|
||||
dns_servers=settings.dns_servers,
|
||||
client_to_client=settings.client_to_client,
|
||||
duplicate_cn=settings.duplicate_cn,
|
||||
crl_verify=settings.crl_verify,
|
||||
user_defined_cdscripts=settings.user_defined_cdscripts,
|
||||
connect_script=settings.connect_script,
|
||||
disconnect_script=settings.disconnect_script,
|
||||
management_interface=settings.management_interface,
|
||||
management_interface_address=settings.management_interface_address,
|
||||
management_port=settings.management_port,
|
||||
tun_mtu=settings.tun_mtu,
|
||||
mssfix=settings.mssfix
|
||||
)
|
||||
|
||||
# Write to file
|
||||
with open(output_path, "w") as f:
|
||||
f.write(config_content)
|
||||
|
||||
return config_content
|
||||
|
||||
def generate_client_config(db: Session, username: str, output_path: str):
|
||||
settings = get_system_settings(db)
|
||||
pki = get_pki_settings(db)
|
||||
|
||||
# Read Certs and Keys
|
||||
# Note: filenames in easy-rsa pki structure
|
||||
# ca: pki/ca.crt
|
||||
# cert: pki/issued/<username>.crt
|
||||
# key: pki/private/<username>.key
|
||||
# ta: pki/ta.key
|
||||
|
||||
def read_file(path):
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
logger.error(f"File not found: {path}")
|
||||
return f"Error: {path} not found"
|
||||
|
||||
ca_cert = read_file(os.path.join(PKI_DIR, "ca.crt"))
|
||||
client_cert = read_file(os.path.join(PKI_DIR, "issued", f"{username}.crt"))
|
||||
client_key = read_file(os.path.join(PKI_DIR, "private", f"{username}.key"))
|
||||
tls_auth = read_file(os.path.join(PKI_DIR, "ta.key"))
|
||||
|
||||
# Determine Remote IP
|
||||
if settings.public_ip:
|
||||
remote_ip = settings.public_ip
|
||||
else:
|
||||
from .utils import get_public_ip
|
||||
remote_ip = get_public_ip()
|
||||
|
||||
template = env.get_template("client.ovpn.j2")
|
||||
|
||||
config_content = template.render(
|
||||
protocol=settings.protocol,
|
||||
remote_ip=remote_ip,
|
||||
port=settings.port,
|
||||
ca_cert=ca_cert,
|
||||
client_cert=client_cert,
|
||||
client_key=client_key,
|
||||
tls_auth=tls_auth,
|
||||
tun_mtu=settings.tun_mtu
|
||||
)
|
||||
|
||||
with open(output_path, "w") as f:
|
||||
f.write(config_content)
|
||||
|
||||
return config_content
|
||||
181
APP_PROFILER/services/pki.py
Normal file
181
APP_PROFILER/services/pki.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
from .config import get_pki_settings
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EASY_RSA_DIR = os.path.join(os.getcwd(), "easy-rsa")
|
||||
PKI_DIR = os.path.join(EASY_RSA_DIR, "pki")
|
||||
INDEX_PATH = os.path.join(PKI_DIR, "index.txt")
|
||||
|
||||
def get_pki_index_data(db: Session = None):
|
||||
"""
|
||||
Parses easy-rsa/pki/index.txt to get certificate expiration dates.
|
||||
Returns a dict: { "common_name": datetime_expiration }
|
||||
"""
|
||||
if not os.path.exists(INDEX_PATH):
|
||||
logger.warning(f"PKI index file not found at {INDEX_PATH}")
|
||||
return {}
|
||||
|
||||
pki_data = {}
|
||||
|
||||
try:
|
||||
with open(INDEX_PATH, "r") as f:
|
||||
for line in f:
|
||||
parts = line.strip().split('\t')
|
||||
# OpenSSL index.txt format:
|
||||
# 0: Flag (V=Valid, R=Revoked, E=Expired)
|
||||
# 1: Expiration Date (YYMMDDHHMMSSZ)
|
||||
# 2: Revocation Date (Can be empty)
|
||||
# 3: Serial
|
||||
# 4: Filename (unknown, often empty)
|
||||
# 5: Distinguished Name (DN) -> /CN=username
|
||||
|
||||
if len(parts) < 6:
|
||||
continue
|
||||
|
||||
flag = parts[0]
|
||||
exp_date_str = parts[1]
|
||||
dn = parts[5]
|
||||
|
||||
if flag != 'V': # Only interested in valid certs expiration? Or all? User wants 'expired_at'.
|
||||
# Even if revoked/expired, 'expired_at' (valid_until) is still a property of the cert.
|
||||
pass
|
||||
|
||||
# Extract CN
|
||||
# dn formats: /CN=anton or /C=RU/ST=Moscow.../CN=anton
|
||||
cn = None
|
||||
for segment in dn.split('/'):
|
||||
if segment.startswith("CN="):
|
||||
cn = segment.split("=", 1)[1]
|
||||
break
|
||||
|
||||
if cn:
|
||||
# Parse Date: YYMMDDHHMMSSZ -> 250116102805Z
|
||||
# Note: OpenSSL uses 2-digit year. stored as 20YY or 19YY.
|
||||
# python strptime %y handles 2-digit years (00-68 -> 2000-2068, 69-99 -> 1969-1999)
|
||||
try:
|
||||
exp_dt = datetime.strptime(exp_date_str, "%y%m%d%H%M%SZ")
|
||||
pki_data[cn] = exp_dt
|
||||
except ValueError:
|
||||
logger.error(f"Failed to parse date: {exp_date_str}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading PKI index: {e}")
|
||||
|
||||
return pki_data
|
||||
|
||||
def _run_easyrsa(command: list, env: dict):
|
||||
env_vars = os.environ.copy()
|
||||
env_vars.update(env)
|
||||
# Ensure EASYRSA_PKI is set
|
||||
env_vars["EASYRSA_PKI"] = PKI_DIR
|
||||
|
||||
cmd = [os.path.join(EASY_RSA_DIR, "easyrsa")] + command
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env_vars,
|
||||
cwd=EASY_RSA_DIR
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"EasyRSA command failed: {cmd}")
|
||||
logger.error(f"Stderr: {result.stderr}")
|
||||
raise Exception(f"EasyRSA command failed: {result.stderr}")
|
||||
return result.stdout
|
||||
except Exception as e:
|
||||
logger.exception("Error running EasyRSA")
|
||||
raise e
|
||||
|
||||
def _get_easyrsa_env(db: Session):
|
||||
pki = get_pki_settings(db)
|
||||
env = {
|
||||
"EASYRSA_DN": pki.easyrsa_dn,
|
||||
"EASYRSA_REQ_COUNTRY": pki.easyrsa_req_country,
|
||||
"EASYRSA_REQ_PROVINCE": pki.easyrsa_req_province,
|
||||
"EASYRSA_REQ_CITY": pki.easyrsa_req_city,
|
||||
"EASYRSA_REQ_ORG": pki.easyrsa_req_org,
|
||||
"EASYRSA_REQ_EMAIL": pki.easyrsa_req_email,
|
||||
"EASYRSA_REQ_OU": pki.easyrsa_req_ou,
|
||||
"EASYRSA_KEY_SIZE": str(pki.easyrsa_key_size),
|
||||
"EASYRSA_CA_EXPIRE": str(pki.easyrsa_ca_expire),
|
||||
"EASYRSA_CERT_EXPIRE": str(pki.easyrsa_cert_expire),
|
||||
"EASYRSA_CERT_RENEW": str(pki.easyrsa_cert_renew),
|
||||
"EASYRSA_CRL_DAYS": str(pki.easyrsa_crl_days),
|
||||
"EASYRSA_BATCH": "1" if pki.easyrsa_batch else "0"
|
||||
}
|
||||
return env
|
||||
|
||||
def init_pki(db: Session):
|
||||
env = _get_easyrsa_env(db)
|
||||
pki_settings = get_pki_settings(db)
|
||||
|
||||
if os.path.exists(os.path.join(PKI_DIR, "ca.crt")):
|
||||
logger.warning("PKI already initialized")
|
||||
return "PKI already initialized"
|
||||
|
||||
# Init PKI
|
||||
_run_easyrsa(["init-pki"], env)
|
||||
|
||||
# Build CA
|
||||
_run_easyrsa(["--req-cn=" + pki_settings.fqdn_ca, "build-ca", "nopass"], env)
|
||||
|
||||
# Build Server Cert
|
||||
_run_easyrsa(["build-server-full", pki_settings.fqdn_server, "nopass"], env)
|
||||
|
||||
# Gen DH
|
||||
_run_easyrsa(["gen-dh"], env)
|
||||
|
||||
# Gen TLS Auth Key (requires openvpn, not easyrsa)
|
||||
ta_key_path = os.path.join(PKI_DIR, "ta.key")
|
||||
subprocess.run(["openvpn", "--genkey", "secret", ta_key_path], check=True)
|
||||
|
||||
# Gen CRL
|
||||
_run_easyrsa(["gen-crl"], env)
|
||||
|
||||
return "PKI Initialized"
|
||||
|
||||
def clear_pki(db: Session):
|
||||
# 1. Clear Database Users
|
||||
from models import UserProfile
|
||||
try:
|
||||
db.query(UserProfile).delete()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear user profiles from DB: {e}")
|
||||
db.rollback()
|
||||
|
||||
# 2. Clear Client Configs
|
||||
client_conf_dir = os.path.join(os.getcwd(), "client-config")
|
||||
if os.path.exists(client_conf_dir):
|
||||
import shutil
|
||||
try:
|
||||
shutil.rmtree(client_conf_dir)
|
||||
# Recreate empty dir
|
||||
os.makedirs(client_conf_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear client configs: {e}")
|
||||
|
||||
# 3. Clear PKI
|
||||
if os.path.exists(PKI_DIR):
|
||||
import shutil
|
||||
shutil.rmtree(PKI_DIR)
|
||||
return "System cleared: PKI environment, User DB, and Client profiles wiped."
|
||||
return "PKI directory did not exist, but User DB and Client profiles were wiped."
|
||||
|
||||
def build_client(username: str, db: Session):
|
||||
env = _get_easyrsa_env(db)
|
||||
_run_easyrsa(["build-client-full", username, "nopass"], env)
|
||||
return True
|
||||
|
||||
def revoke_client(username: str, db: Session):
|
||||
env = _get_easyrsa_env(db)
|
||||
_run_easyrsa(["revoke", username], env)
|
||||
_run_easyrsa(["gen-crl"], env)
|
||||
return True
|
||||
140
APP_PROFILER/services/process.py
Normal file
140
APP_PROFILER/services/process.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
import time
|
||||
import psutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_os_type():
|
||||
"""
|
||||
Simple check to distinguish Alpine from others.
|
||||
"""
|
||||
if os.path.exists("/etc/alpine-release"):
|
||||
return "alpine"
|
||||
return "debian" # default fallback to systemctl
|
||||
|
||||
def control_service(action: str):
|
||||
"""
|
||||
Action: start, stop, restart
|
||||
"""
|
||||
if action not in ["start", "stop", "restart"]:
|
||||
raise ValueError("Invalid action")
|
||||
|
||||
os_type = get_os_type()
|
||||
|
||||
cmd = []
|
||||
if os_type == "alpine":
|
||||
cmd = ["rc-service", "openvpn", action]
|
||||
else:
|
||||
cmd = ["systemctl", action, "openvpn"]
|
||||
|
||||
try:
|
||||
# Capture output to return it or log it
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Service {action} executed successfully",
|
||||
"stdout": result.stdout
|
||||
}
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Service control failed: {e.stderr}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to {action} service",
|
||||
"stderr": e.stderr
|
||||
}
|
||||
except FileNotFoundError:
|
||||
# Happens if rc-service or systemctl is missing (e.g. dev env)
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Command not found found for OS type {os_type}"
|
||||
}
|
||||
|
||||
def get_process_stats():
|
||||
"""
|
||||
Returns dict with pid, cpu_percent, memory_mb, uptime.
|
||||
Uses psutil for robust telemetry.
|
||||
"""
|
||||
pid = None
|
||||
process = None
|
||||
|
||||
# Find the process
|
||||
try:
|
||||
# Iterate over all running processes
|
||||
for proc in psutil.process_iter(['pid', 'name']):
|
||||
try:
|
||||
if proc.info['name'] == 'openvpn':
|
||||
pid = proc.info['pid']
|
||||
process = proc
|
||||
break
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find process via psutil: {e}")
|
||||
|
||||
if not pid or not process:
|
||||
return {
|
||||
"status": "stopped",
|
||||
"pid": None,
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 0.0,
|
||||
"uptime": None
|
||||
}
|
||||
|
||||
# Get Stats
|
||||
try:
|
||||
# CPU Percent
|
||||
# Increasing interval to 1.0s to ensure we capture enough ticks for accurate reading
|
||||
# especially on systems with low clock resolution (e.g. 100Hz = 10ms ticks)
|
||||
cpu = process.cpu_percent(interval=1.0)
|
||||
|
||||
# Memory (RSS)
|
||||
mem_info = process.memory_info()
|
||||
rss_mb = round(mem_info.rss / 1024 / 1024, 2)
|
||||
|
||||
# Uptime
|
||||
create_time = process.create_time()
|
||||
uptime_seconds = time.time() - create_time
|
||||
uptime_str = format_seconds(uptime_seconds)
|
||||
|
||||
return {
|
||||
"status": "running",
|
||||
"pid": pid,
|
||||
"cpu_percent": cpu,
|
||||
"memory_mb": rss_mb,
|
||||
"uptime": uptime_str
|
||||
}
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
# Process might have died between discovery and stats
|
||||
return {
|
||||
"status": "stopped",
|
||||
"pid": None,
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 0.0,
|
||||
"uptime": None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get process stats: {e}")
|
||||
return {
|
||||
"status": "running",
|
||||
"pid": pid,
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 0.0,
|
||||
"uptime": None
|
||||
}
|
||||
|
||||
def format_seconds(seconds: float) -> str:
|
||||
seconds = int(seconds)
|
||||
days, seconds = divmod(seconds, 86400)
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
|
||||
parts = []
|
||||
if days > 0: parts.append(f"{days}d")
|
||||
if hours > 0: parts.append(f"{hours}h")
|
||||
if minutes > 0: parts.append(f"{minutes}m")
|
||||
parts.append(f"{seconds}s")
|
||||
|
||||
return " ".join(parts)
|
||||
29
APP_PROFILER/services/utils.py
Normal file
29
APP_PROFILER/services/utils.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_public_ip() -> str:
|
||||
"""
|
||||
Detects public IP using external services.
|
||||
Falls back to 127.0.0.1 on failure.
|
||||
"""
|
||||
services = [
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
"https://icanhazip.com",
|
||||
]
|
||||
|
||||
for service in services:
|
||||
try:
|
||||
response = requests.get(service, timeout=3)
|
||||
if response.status_code == 200:
|
||||
ip = response.text.strip()
|
||||
# Basic validation could be added here
|
||||
return ip
|
||||
except requests.RequestException:
|
||||
continue
|
||||
|
||||
logger.warning("Could not detect public IP, defaulting to 127.0.0.1")
|
||||
return "127.0.0.1"
|
||||
44
APP_PROFILER/templates/client.ovpn.j2
Normal file
44
APP_PROFILER/templates/client.ovpn.j2
Normal file
@@ -0,0 +1,44 @@
|
||||
client
|
||||
dev tun
|
||||
windows-driver wintun
|
||||
proto {{ protocol }}
|
||||
remote {{ remote_ip }} {{ port }}
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
|
||||
{% if tun_mtu %}
|
||||
tun-mtu {{ tun_mtu }}
|
||||
{% endif %}
|
||||
user nobody
|
||||
group nobody
|
||||
persist-key
|
||||
persist-tun
|
||||
|
||||
{% if protocol == 'tcp' %}
|
||||
tls-client
|
||||
{% else %}
|
||||
#tls-client
|
||||
{% endif %}
|
||||
|
||||
mute-replay-warnings
|
||||
|
||||
remote-cert-tls server
|
||||
data-ciphers CHACHA20-POLY1305:AES-256-GCM:AES-256-CBC
|
||||
data-ciphers-fallback AES-256-CBC
|
||||
auth SHA256
|
||||
verb 3
|
||||
|
||||
key-direction 1
|
||||
|
||||
<ca>
|
||||
{{ ca_cert }}
|
||||
</ca>
|
||||
<cert>
|
||||
{{ client_cert }}
|
||||
</cert>
|
||||
<key>
|
||||
{{ client_key }}
|
||||
</key>
|
||||
<tls-auth>
|
||||
{{ tls_auth }}
|
||||
</tls-auth>
|
||||
110
APP_PROFILER/templates/server.conf.j2
Normal file
110
APP_PROFILER/templates/server.conf.j2
Normal file
@@ -0,0 +1,110 @@
|
||||
dev tun
|
||||
proto {{ protocol }}
|
||||
{% if protocol == 'tcp' %}
|
||||
tls-server
|
||||
{% else %}
|
||||
# explicit-exit-notify 1
|
||||
explicit-exit-notify 1
|
||||
{% endif %}
|
||||
port {{ port }}
|
||||
|
||||
# Keys
|
||||
ca {{ ca_path }}
|
||||
cert {{ srv_cert_path }}
|
||||
key {{ srv_key_path }}
|
||||
dh {{ dh_path }}
|
||||
tls-auth {{ ta_path }} 0
|
||||
|
||||
{% if tun_mtu %}
|
||||
tun-mtu {{ tun_mtu }}
|
||||
{% endif %}
|
||||
{% if mssfix %}
|
||||
mssfix {{ mssfix }}
|
||||
{% endif %}
|
||||
|
||||
# Network topology
|
||||
topology subnet
|
||||
server {{ vpn_network }} {{ vpn_netmask }}
|
||||
|
||||
ifconfig-pool-persist /etc/openvpn/ipp.txt
|
||||
|
||||
log /etc/openvpn/openvpn.log
|
||||
log-append /etc/openvpn/openvpn.log
|
||||
|
||||
verb 3
|
||||
|
||||
# Use Extended Status Output
|
||||
status /etc/openvpn/openvpn-status.log 5
|
||||
status-version 2
|
||||
|
||||
# Tunneling Mode
|
||||
{% if tunnel_type == 'FULL' %}
|
||||
push "redirect-gateway def1 bypass-dhcp"
|
||||
# Full tunneling mode - all routes through VPN
|
||||
{% else %}
|
||||
# Split tunneling mode
|
||||
{% for route in split_routes %}
|
||||
push "route {{ route }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
# DNS Configuration
|
||||
{% if user_defined_dns %}
|
||||
{% for dns in dns_servers %}
|
||||
push "dhcp-option DNS {{ dns }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
# Client-to-client communication
|
||||
{% if client_to_client %}
|
||||
client-to-client
|
||||
{% else %}
|
||||
# client-to-client disabled
|
||||
{% endif %}
|
||||
|
||||
user nobody
|
||||
group nogroup
|
||||
|
||||
# Allow same profile on multiple devices simultaneously
|
||||
{% if duplicate_cn %}
|
||||
duplicate-cn
|
||||
{% else %}
|
||||
# duplicate-cn disabled
|
||||
{% endif %}
|
||||
|
||||
# data protection
|
||||
data-ciphers CHACHA20-POLY1305:AES-256-GCM:AES-256-CBC
|
||||
data-ciphers-fallback AES-256-CBC
|
||||
auth SHA256
|
||||
|
||||
keepalive 10 120
|
||||
|
||||
persist-key
|
||||
persist-tun
|
||||
|
||||
# check revocation list
|
||||
{% if crl_verify %}
|
||||
crl-verify /etc/openvpn/crl.pem
|
||||
{% else %}
|
||||
# crl-verify disabled
|
||||
{% endif %}
|
||||
|
||||
# Script Security Level
|
||||
{% if user_defined_cdscripts %}
|
||||
script-security 2
|
||||
|
||||
# Client Connect Script
|
||||
{% if connect_script %}
|
||||
client-connect "{{ connect_script }}"
|
||||
{% endif %}
|
||||
|
||||
# Client Disconnect Script
|
||||
{% if disconnect_script %}
|
||||
client-disconnect "{{ disconnect_script }}"
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
# Enable Management Interface
|
||||
{% if management_interface %}
|
||||
management {{ management_interface_address }} {{ management_port }}
|
||||
{% endif %}
|
||||
33
APP_PROFILER/test_server_process.py
Normal file
33
APP_PROFILER/test_server_process.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from main import app
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to sys.path
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_get_stats():
|
||||
response = client.get("/server/process/stats")
|
||||
print(f"Stats response status: {response.status_code}")
|
||||
print(f"Stats response body: {response.json()}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "pid" in data
|
||||
assert "cpu_percent" in data
|
||||
assert "memory_mb" in data
|
||||
|
||||
def test_control_invalid():
|
||||
response = client.post("/server/process/invalid_action")
|
||||
print(f"Invalid action response: {response.status_code}")
|
||||
assert response.status_code == 400
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running API tests...")
|
||||
try:
|
||||
test_get_stats()
|
||||
test_control_invalid()
|
||||
print("Tests passed!")
|
||||
except Exception as e:
|
||||
print(f"Tests failed: {e}")
|
||||
0
APP_PROFILER/utils/__init__.py
Normal file
0
APP_PROFILER/utils/__init__.py
Normal file
94
APP_PROFILER/utils/auth.py
Normal file
94
APP_PROFILER/utils/auth.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import jwt
|
||||
import configparser
|
||||
import os
|
||||
from fastapi import Header, HTTPException, status
|
||||
from pathlib import Path
|
||||
|
||||
# Load config from the main APP directory
|
||||
CONFIG_FILE = Path(__file__).parent.parent.parent / 'APP' / 'config.ini'
|
||||
|
||||
def get_secret_key():
|
||||
# Priority 1: Environment Variable
|
||||
env_secret = os.getenv('OVPMON_SECRET_KEY')
|
||||
if env_secret:
|
||||
print("[AUTH] Using SECRET_KEY from environment variable")
|
||||
return env_secret
|
||||
|
||||
# Priority 2: Config file (multiple possible locations)
|
||||
# Resolve absolute path to be sure
|
||||
base_path = Path(__file__).resolve().parent.parent
|
||||
|
||||
config_locations = [
|
||||
base_path.parent / 'APP' / 'config.ini', # Brother directory (Local/Gitea structure)
|
||||
base_path / 'APP' / 'config.ini', # Child directory
|
||||
base_path / 'config.ini', # Same directory
|
||||
Path('/opt/ovpmon/APP/config.ini'), # Common production path 1
|
||||
Path('/opt/ovpmon/config.ini'), # Common production path 2
|
||||
Path('/etc/ovpmon/config.ini'), # Standard linux config path
|
||||
Path('/opt/ovpn_python_profiler/APP/config.ini') # Path based on traceback
|
||||
]
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
for loc in config_locations:
|
||||
if loc.exists():
|
||||
try:
|
||||
config.read(loc)
|
||||
if config.has_section('api') and config.has_option('api', 'secret_key'):
|
||||
key = config.get('api', 'secret_key')
|
||||
if key:
|
||||
print(f"[AUTH] Successfully loaded SECRET_KEY from {loc}")
|
||||
return key
|
||||
except Exception as e:
|
||||
print(f"[AUTH] Error reading config at {loc}: {e}")
|
||||
continue
|
||||
|
||||
print("[AUTH] WARNING: No config found, using default fallback SECRET_KEY")
|
||||
return 'ovpmon-secret-change-me'
|
||||
|
||||
SECRET_KEY = get_secret_key()
|
||||
|
||||
async def verify_token(authorization: str = Header(None)):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
print(f"[AUTH] Missing or invalid Authorization header: {authorization[:20] if authorization else 'None'}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token is missing or invalid",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = authorization.split(" ")[1]
|
||||
|
||||
try:
|
||||
# Debug: Log a few chars of the key and token (safely)
|
||||
# print(f"[AUTH] Decoding token with SECRET_KEY starting with: {SECRET_KEY[:3]}...")
|
||||
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
return payload
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
error_detail = str(e)
|
||||
print(f"[AUTH] JWT Decode Failed. Type: {error_type}, Detail: {error_detail}")
|
||||
|
||||
# Handling exceptions dynamically to avoid AttributeError
|
||||
if error_type == "ExpiredSignatureError":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has expired",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
elif error_type in ["InvalidTokenError", "DecodeError"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token is invalid",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
else:
|
||||
# Check if it's a TypeError (e.g. wrong arguments for decode)
|
||||
if error_type == "TypeError":
|
||||
print("[AUTH] Critical: jwt.decode failed with TypeError. This likely means 'jwt' package is installed instead of 'PyJWT'.")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Authentication error: {error_type}",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
12
APP_PROFILER/utils/logging.py
Normal file
12
APP_PROFILER/utils/logging.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
def setup_logging():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler("profiler.log")
|
||||
]
|
||||
)
|
||||
Reference in New Issue
Block a user