diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..dab9a4e17afd2ef39d90ccb0b40ef2786fe77422 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,35 +1,35 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..fc529740dd7e44223371a8813151ed5fdbb56768 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# VPN Server with FastAPI +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Create non-root user +RUN useradd -m -u 1000 vpnuser && \ + chown -R vpnuser:vpnuser /app + +# Copy application files +COPY --chown=vpnuser:vpnuser . . + +# Set environment variables +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# Switch to non-root user +USER vpnuser + +# Expose port +EXPOSE 7860 + +# Run the application with uvicorn +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "4"] diff --git a/admin_routes.py b/admin_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..429e90f1e418f91fcd66240e86af1ccae2bab606 --- /dev/null +++ b/admin_routes.py @@ -0,0 +1,166 @@ +""" +Admin routes and functionality for Outline VPN +""" +import os +import json +import psutil +import zipfile +from datetime import datetime +from flask import jsonify, request, send_file, flash, redirect, url_for +from flask_login import login_required, current_user +from . import app +from .models import User, UserRole, SystemHealth, AuditLog, Alert +from .services import backup_service, monitoring_service + +def admin_required(f): + """Decorator to require admin role for routes""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role != UserRole.ADMIN: + flash('You need administrator privileges to access this page.') + return redirect(url_for('dashboard')) + return f(*args, **kwargs) + return decorated_function + +@app.route('/admin') +@login_required +@admin_required +def admin_dashboard(): + """Admin dashboard view""" + system_health = monitoring_service.get_system_health() + active_alerts = Alert.query.filter_by(status='active').order_by(Alert.created_at.desc()).all() + audit_logs = AuditLog.query.order_by(AuditLog.timestamp.desc()).limit(50).all() + + return render_template('admin.html', + system_health=system_health, + active_alerts=active_alerts, + audit_logs=audit_logs) + +@app.route('/api/system-health') +@login_required +@admin_required +def get_system_health(): + """Get real-time system health metrics""" + return jsonify(monitoring_service.get_system_health()) + +@app.route('/api/update-server-config', methods=['POST']) +@login_required +@admin_required +def update_server_config(): + """Update server configuration""" + try: + config = request.get_json() + backup_service.backup_config('pre_update') # Create backup before updating + + # Update configuration + current_config = ServerConfig.query.first() + for key, value in config.items(): + setattr(current_config, key, value) + + db.session.commit() + + # Log the change + AuditLog.create( + user_id=current_user.id, + action='update_config', + details='Server configuration updated' + ) + + # Restart required services + monitoring_service.restart_services() + + return jsonify({'status': 'success'}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/create-backup') +@login_required +@admin_required +def create_backup(): + """Create a backup of server configuration""" + try: + include_user_data = request.args.get('include_user_data', 'false') == 'true' + backup_path = backup_service.create_backup(include_user_data) + + # Log the backup creation + AuditLog.create( + user_id=current_user.id, + action='create_backup', + details=f'Backup created: {os.path.basename(backup_path)}' + ) + + return send_file( + backup_path, + as_attachment=True, + download_name=f'outline_backup_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip' + ) + except Exception as e: + flash(f'Error creating backup: {str(e)}', 'error') + return redirect(url_for('admin_dashboard')) + +@app.route('/api/restore-config', methods=['POST']) +@login_required +@admin_required +def restore_config(): + """Restore server configuration from backup""" + try: + if 'backup_file' not in request.files: + flash('No backup file provided', 'error') + return redirect(url_for('admin_dashboard')) + + backup_file = request.files['backup_file'] + if backup_file.filename == '': + flash('No backup file selected', 'error') + return redirect(url_for('admin_dashboard')) + + # Create backup of current configuration + backup_service.backup_config('pre_restore') + + # Restore from backup + backup_service.restore_from_backup(backup_file) + + # Log the restore + AuditLog.create( + user_id=current_user.id, + action='restore_config', + details=f'Configuration restored from {backup_file.filename}' + ) + + flash('Configuration restored successfully', 'success') + return redirect(url_for('admin_dashboard')) + except Exception as e: + flash(f'Error restoring configuration: {str(e)}', 'error') + return redirect(url_for('admin_dashboard')) + +@app.route('/api/export-audit-log') +@login_required +@admin_required +def export_audit_log(): + """Export audit log in specified format""" + format = request.args.get('format', 'csv') + logs = AuditLog.query.order_by(AuditLog.timestamp.desc()).all() + + if format == 'csv': + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(['Timestamp', 'User', 'Action', 'Details']) + for log in logs: + writer.writerow([ + log.timestamp.strftime('%Y-%m-%d %H:%M:%S'), + log.user.username, + log.action, + log.details + ]) + + return Response( + output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=audit_log.csv'} + ) + elif format == 'json': + return jsonify([{ + 'timestamp': log.timestamp.strftime('%Y-%m-%d %H:%M:%S'), + 'user': log.user.username, + 'action': log.action, + 'details': log.details + } for log in logs]) diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..ea76206db6833899273e5b323de2de518d48b8ee --- /dev/null +++ b/app.py @@ -0,0 +1,819 @@ +""" +Main application entry point +""" +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from routers import admin, users, vpn +from core.error_handlers import setup_error_handlers +from core.database import init_db +from core.logger import setup_logging +from core.middleware import RequestLoggerMiddleware, ErrorHandlerMiddleware + +import logging +import os +import requests +import socket +from starlette.responses import RedirectResponse as StarletteRedirect +from starlette.status import HTTP_302_FOUND, HTTP_303_SEE_OTHER +import logging +import json +import asyncio +import threading +import os +import json +import uuid +import bcrypt +from datetime import datetime, timedelta +import logging +from typing import Dict, Optional, List +from sqlalchemy.orm import Session +# Initialize logging +setup_logging() +logger = logging.getLogger(__name__) + +# Create FastAPI application +app = FastAPI( + title="VPN Server API", + description="API for managing VPN server and users", + version="1.0.0" +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Configure this properly in production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Add custom middleware +app.add_middleware(RequestLoggerMiddleware) +app.add_middleware(ErrorHandlerMiddleware) + +# Configure static files and templates +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + +# Include routers +app.include_router(admin.router, prefix="/api") +app.include_router(users.router, prefix="/api") +app.include_router(vpn.router, prefix="/api") + +# Setup error handlers +setup_error_handlers(app) + +@app.on_event("startup") +async def startup_event(): + """Initialize application on startup""" + try: + # Initialize database + await init_db() + logger.info("Database initialized successfully") + + except Exception as e: + logger.error(f"Failed to initialize application: {e}") + raise + +@app.get("/") +async def root(request: Request): + """Root endpoint - renders the main template""" + return templates.TemplateResponse( + "index.html", + {"request": request} + ) + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy"} +# Database dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +# OAuth2 password bearer for token auth +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) + +# Pydantic models for request/response validation +class Token(BaseModel): + access_token: str + token_type: str + +app = FastAPI() + +# Configure static files and templates +app.mount("/static", StaticFiles(directory="web/static"), name="static") +templates = Jinja2Templates(directory="web/templates") + +# Add template context processor for static URLs and other global helpers +def static_url(path: str) -> str: + return f"/static/{path}" + +@app.get("/api/user/current") +async def get_current_user(request: Request, db: Session = Depends(get_db)): + try: + user = await get_optional_user(request, db) + if user: + return { + "username": user.username, + "id": str(user.id), + "config_id": user.config_id + } + return None + except Exception: + return None + +templates.env.globals.update({ + "static_url": static_url, + "url_for": lambda name, **params: f"/{name}" if name != "static" else static_url(params.get("filename", "")), +}) + +# OAuth2 password bearer for token auth +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +# Pydantic models for request/response validation +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + username: Optional[str] = None + +class UserBase(BaseModel): + email: EmailStr + +class UserCreate(UserBase): + password: str + +class UserInDB(UserBase): + hashed_password: str + config_id: str + created_at: datetime + + class Config: + orm_mode = True + +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except JWTError: + raise credentials_exception + + user = db.query(User).filter(User.username == token_data.username).first() + if user is None: + raise credentials_exception + return user + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +# Global VPN server state +vpn_server: Optional[OutlineServer] = None +session_tracker: Optional[SessionTracker] = None +logger: Optional[LogManager] = None + +# Initialize database +init_db() + +CONFIG_DIR = 'config' +USERS_FILE = os.path.join(CONFIG_DIR, 'users.json') +os.makedirs(CONFIG_DIR, exist_ok=True) + +def load_users(): + if os.path.exists(USERS_FILE): + with open(USERS_FILE, 'r') as f: + return json.load(f) + return {} + +def save_users(users): + with open(USERS_FILE, 'w') as f: + json.dump(users, f) + +def get_server_ip(): + """Get the server's public IP address""" + try: + # First try to get public IP from external service + response = requests.get('https://api.ipify.org', timeout=5) + if response.status_code == 200: + return response.text.strip() + except: + pass + + try: + # Try another public IP service as backup + response = requests.get('https://ifconfig.me', timeout=5) + if response.status_code == 200: + return response.text.strip() + except: + pass + + # Fallback: Get local IP + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('8.8.8.8', 80)) + local_ip = s.getsockname()[0] + s.close() + return local_ip + except: + # Last resort fallback + return '127.0.0.1' + +def initialize_ikev2_server(): + """Initialize IKEv2 server""" + global ikev2_server + server_ip = get_server_ip() + ikev2_server = IKEv2Server(server_ip, logger) + logger.log(LogLevel.INFO, LogCategory.SYSTEM, "app", "IKEv2 server initialized") + +def initialize_vpn_server(): + """Initialize the VPN server components""" + global vpn_server, session_tracker, logger, ikev2_server + + # Initialize logger + logger = LogManager() + logger.log(LogLevel.INFO, LogCategory.SYSTEM, "app", "Initializing VPN server") + + # Initialize session tracker + session_tracker = SessionTracker() + + # Initialize IKEv2 server + initialize_ikev2_server() + + # Initialize VPN server + server_ip = get_server_ip() + vpn_server_config = { + "server": { + "host": server_ip, # Use automatically detected server IP + "port": 8388, # Default Shadowsocks port + "virtual_network": "10.7.0.0/24", # Virtual network for client IPs + "protocols": { + "shadowsocks": { + "enabled": True, + "port": 8388 + }, + "wireguard": { + "enabled": True, + "port": 51820 + }, + "openvpn": { + "enabled": True, + "port": 1194 + }, + "ikev2": { + "enabled": True, + "port": 500 + } + } + }, + "security": { + "cipher": "aes-256-gcm", + "auth": "sha256", + "enable_perfect_forward_secrecy": True + } + } + vpn_server = OutlineServer(vpn_server_config) + # Start the VPN server in a separate thread + def run_server(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(vpn_server.start()) + loop.run_forever() + + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + logger.log(LogLevel.INFO, LogCategory.SYSTEM, "app", f"VPN server initialized and started on {server_ip}") + +def load_users(): + if os.path.exists(USERS_FILE): + with open(USERS_FILE, 'r') as f: + return json.load(f) + return {} + +def save_users(users): + with open(USERS_FILE, 'w') as f: + json.dump(users, f) + +def login_required(func): + @wraps(func) + async def wrapper(*args, **kwargs): + token = kwargs.get('token') + if not token: + return StarletteRedirect('/login', status_code=HTTP_303_SEE_OTHER) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + return StarletteRedirect('/login', status_code=HTTP_303_SEE_OTHER) + except JWTError: + return StarletteRedirect('/login', status_code=HTTP_303_SEE_OTHER) + return await func(*args, **kwargs) + return wrapper + +async def get_optional_user( + request: Request, + db: Session = Depends(get_db) +) -> Optional[User]: + try: + auth = request.headers.get("Authorization") + if not auth: + return None + scheme, _, token = auth.partition(" ") + if scheme.lower() != "bearer": + return None + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + return None + user = db.query(User).filter(User.username == username).first() + return user + except JWTError: + return None + except Exception: + return None + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) + +@app.post("/token") +async def login( + request: Request, + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db) +): + user = db.query(User).filter(User.username == form_data.username).first() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Verify password + if not verify_password(form_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Check if account is locked + if user.status == UserStatus.LOCKED: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is locked. Please contact support." + ) + + # Create access token + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + + # Record successful login + user_service = UserService(db) + user_service.create_session( + user=user, + ip_address=request.client.host, + device_info=request.headers.get("user-agent", "") + ) + user.record_login_attempt(success=True) + db.commit() + + return {"access_token": access_token, "token_type": "bearer"} + +@app.post("/signup", response_model=Token) +async def signup(user: UserCreate, db: Session = Depends(get_db)): + # Check if user exists + db_user = db.query(User).filter(User.username == user.email).first() + if db_user: + raise HTTPException(status_code=400, detail="Email already registered") + + # Create new user + config_id = str(uuid.uuid4()) + hashed_password = get_password_hash(user.password) + db_user = User( + username=user.email, + hashed_password=hashed_password, + config_id=config_id, + created_at=datetime.utcnow() + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + + # Create VPN configuration + create_user_config(config_id) + + # Create access token + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.email}, expires_delta=access_token_expires + ) + + return {"access_token": access_token, "token_type": "bearer"} + +@app.get("/signup", response_class=HTMLResponse) +async def signup_form(request: Request): + return templates.TemplateResponse("signup.html", {"request": request}) + +@app.get("/dashboard", response_class=HTMLResponse) +async def dashboard(request: Request, current_user: User = Depends(get_current_user)): + stats = get_user_stats(current_user.config_id) + return templates.TemplateResponse("dashboard.html", { + "request": request, + "stats": stats + }) + +@app.get('/download_config') +async def download_config(current_user: User = Depends(get_current_user)): + config_path = os.path.join(CONFIG_DIR, f"{current_user.config_id}.json") + + if not os.path.exists(config_path): + raise HTTPException( + status_code=404, + detail="Configuration not found" + ) + + with open(config_path, 'r') as f: + config = json.load(f) + + return JSONResponse(content=config) + +@app.get('/api/stats') +async def get_stats(current_user: User = Depends(get_current_user)): + return JSONResponse(content=get_user_stats(current_user.config_id)) + +def get_server_ip(): + """Get the server's public IP address""" + try: + # First try to get public IP from external service + response = requests.get('https://api.ipify.org') + if response.status_code == 200: + return response.text.strip() + except: + pass + + # Fallback: Get local IP + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('8.8.8.8', 80)) + local_ip = s.getsockname()[0] + s.close() + return local_ip + except: + return '127.0.0.1' # Last resort fallback + +def initialize_ikev2_server(): + """Initialize IKEv2 server""" + global ikev2_server + server_ip = get_server_ip() + ikev2_server = IKEv2Server(server_ip, logger) + logger.log(LogLevel.INFO, LogCategory.SYSTEM, "app", "IKEv2 server initialized") + +def generate_ikev2_certificate(config_id: str) -> Dict: + """Generate IKEv2 certificates for a user""" + username = f"user_{config_id[:8]}" + password = str(uuid.uuid4()) + psk = str(uuid.uuid4()) + + try: + cert_data = ikev2_server.add_user(config_id, username, password, psk) + logger.info(LogCategory.SYSTEM, "app", f"Generated IKEv2 certificates for user {config_id}") + return cert_data + except Exception as e: + logger.error(LogCategory.SYSTEM, "app", f"Failed to generate IKEv2 certificates: {e}") + return None + +def create_user_config(config_id): + """Create Outline VPN configuration for a new user""" + if not os.path.exists(CONFIG_DIR): + os.makedirs(CONFIG_DIR) + + server_ip = get_server_ip() + access_key = str(uuid.uuid4()) + + # Outline/Shadowsocks config + ss_config = { + 'id': config_id, + 'server': { + 'host': server_ip, + 'port': 8388 # Shadowsocks port + }, + 'access_key': access_key, + 'protocol': 'shadowsocks', + 'created_at': datetime.now().isoformat() + } + + # IKEv2 config (Windows 10/11, Android 10+) + ikev2_config = { + 'id': f"{config_id}_ikev2", + 'server': { + 'host': server_ip, + 'port': 500 # IKEv2 port + }, + 'credentials': { + 'username': f"user_{config_id[:8]}", + 'password': str(uuid.uuid4()), + }, + 'psk': str(uuid.uuid4()), # Pre-shared key + 'certificate': generate_ikev2_certificate(config_id), + 'protocol': 'ikev2', + 'created_at': datetime.now().isoformat() + } + + # L2TP/IPsec config (Windows, Android) + l2tp_config = { + 'id': f"{config_id}_l2tp", + 'server': { + 'host': server_ip, + 'ports': { + 'l2tp': 1701, + 'ipsec': [500, 4500] # IPsec ports for NAT traversal + } + }, + 'credentials': { + 'username': f"user_{config_id[:8]}", + 'password': str(uuid.uuid4()) + }, + 'ipsec': { + 'psk': str(uuid.uuid4()), # Pre-shared key for IPsec + 'encryption': 'aes-256-cbc', + 'hash': 'sha256' + }, + 'protocol': 'l2tp_ipsec', + 'created_at': datetime.now().isoformat() + } + + # PPTP config (Legacy support - Windows, Android) + pptp_config = { + 'id': f"{config_id}_pptp", + 'server': { + 'host': server_ip, + 'port': 1723 # PPTP port + }, + 'credentials': { + 'username': f"user_{config_id[:8]}", + 'password': str(uuid.uuid4()) + }, + 'protocol': 'pptp', + 'encryption': 'require-mppe', # Maximum PPTP security + 'warning': 'PPTP is considered less secure, use IKEv2 or L2TP/IPsec when possible', + 'created_at': datetime.now().isoformat() + } + + # OpenVPN config (Universal support) + openvpn_config = { + 'id': f"{config_id}_openvpn", + 'server': { + 'host': server_ip, + 'port': 1194, # OpenVPN default port + 'protocol': 'udp' # UDP for better performance + }, + 'credentials': { + 'username': f"user_{config_id[:8]}", + 'password': str(uuid.uuid4()) + }, + 'certificates': generate_openvpn_certificates(config_id), + 'protocol': 'openvpn', + 'created_at': datetime.now().isoformat(), + 'config_file': generate_openvpn_config(config_id, server_ip) + } + + # WireGuard config (Built-in Windows 11, Android, iOS) + wireguard_config = { + 'id': f"{config_id}_wireguard", + 'server': { + 'host': server_ip, + 'port': 51820, # WireGuard default port + 'public_key': generate_wireguard_keys(config_id)['server_public'], + 'allowed_ips': ['0.0.0.0/0', '::/0'] # Route all traffic + }, + 'client': { + 'private_key': generate_wireguard_keys(config_id)['client_private'], + 'public_key': generate_wireguard_keys(config_id)['client_public'], + 'address': f'10.7.0.{2 + len(load_users())}', # Unique IP for each client + 'dns': ['1.1.1.1', '8.8.8.8'] + }, + 'protocol': 'wireguard', + 'created_at': datetime.now().isoformat() + } + + # L2TP/IPsec config (Built-in Windows, Android, iOS) + l2tp_config = { + 'id': f"{config_id}_l2tp", + 'server': { + 'host': server_ip, + 'port': 1701, # L2TP port + }, + 'credentials': { + 'username': f"user_{config_id[:8]}", + 'password': str(uuid.uuid4()) + }, + 'ipsec': { + 'psk': str(uuid.uuid4()) # Pre-shared key for IPsec + }, + 'protocol': 'l2tp_ipsec', + 'created_at': datetime.now().isoformat() + } + + # Combined config with all supported protocols + config = { + 'id': config_id, + 'protocols': { + 'shadowsocks': ss_config, + 'ikev2': ikev2_config, + 'l2tp': l2tp_config, + 'pptp': pptp_config + }, + 'recommended_protocol': { + 'windows': 'ikev2', + 'android': 'ikev2', + 'fallback': 'l2tp' + }, + 'created_at': datetime.now().isoformat() + } + + config_path = os.path.join(CONFIG_DIR, f"{config_id}.json") + with open(config_path, 'w') as f: + json.dump(config, f) + +def get_user_stats(config_id): + """Get real VPN usage statistics for a user from all active sessions""" + try: + if not session_tracker: + logger.error(LogCategory.SYSTEM, "app", "Session tracker not initialized") + return None + + # Get all sessions for this user + user_sessions = session_tracker.get_user_sessions(config_id) + if not user_sessions: + return { + 'bytes_sent': 0, + 'bytes_received': 0, + 'connected_since': None, + 'last_seen': None, + 'status': 'disconnected', + 'active_sessions': [], + 'protocols': [] + } + + # Aggregate stats from all active sessions + total_bytes_sent = 0 + total_bytes_received = 0 + earliest_connection = None + latest_seen = None + active_sessions = [] + used_protocols = set() + + for sess in user_sessions: + # Update totals + total_bytes_sent += sess.bytes_out + total_bytes_received += sess.bytes_in + + # Track connection times + session_start = datetime.fromtimestamp(sess.start_time) + session_last_seen = datetime.fromtimestamp(sess.last_seen) + + if not earliest_connection or session_start < earliest_connection: + earliest_connection = session_start + if not latest_seen or session_last_seen > latest_seen: + latest_seen = session_last_seen + + # Track protocols + used_protocols.add(sess.protocol) + + # Get session details + session_info = { + 'id': sess.session_id, + 'protocol': sess.protocol, + 'assigned_ip': sess.assigned_ip, + 'connected_since': session_start.isoformat(), + 'last_seen': session_last_seen.isoformat(), + 'bytes_sent': sess.bytes_out, + 'bytes_received': sess.bytes_in, + 'is_offline': sess.is_offline + } + active_sessions.append(session_info) + + # Determine overall status + current_time = datetime.now() + is_active = any( + (current_time - datetime.fromtimestamp(s.last_seen)).total_seconds() < 300 # 5 minutes + for s in user_sessions + ) + + status = 'active' if is_active else 'offline' + if not is_active and any(s.is_offline for s in user_sessions): + status = 'offline_available' + + return { + 'bytes_sent': total_bytes_sent, + 'bytes_received': total_bytes_received, + 'connected_since': earliest_connection.isoformat() if earliest_connection else None, + 'last_seen': latest_seen.isoformat() if latest_seen else None, + 'status': status, + 'active_sessions': active_sessions, + 'protocols': list(used_protocols) + } + + except Exception as e: + logger.error(LogCategory.SYSTEM, "app", f"Error getting user stats: {e}") + return None + +@app.post('/logout') +async def logout(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + try: + # Find and end the current session + current_session = ( + db.query(UserSession) + .filter(UserSession.user_id == current_user.id) + .order_by(UserSession.created_at.desc()) + .first() + ) + if current_session: + current_session.expires_at = datetime.utcnow() + db.commit() + + return StarletteRedirect('/', status_code=HTTP_303_SEE_OTHER) + except Exception as e: + raise HTTPException( + status_code=500, + detail="Error during logout" + ) + +@app.get('/forgot-password') +async def forgot_password_form(request: Request): + return templates.TemplateResponse("forgot_password.html", {"request": request}) + +@app.post('/forgot-password') +async def forgot_password(email: str, db: Session = Depends(get_db)): + try: + user = db.query(User).filter(User.username == email).first() + if user: + # Generate password reset token + user_service = UserService(db) + reset_token = user_service.generate_reset_token() + user.reset_token = reset_token + user.reset_token_expires = datetime.utcnow() + timedelta(hours=24) + db.commit() + + # TODO: Send reset email with token + # For now, just return success message + return JSONResponse( + content={"message": "Password reset link has been sent to your email address"} + ) + else: + # To prevent user enumeration, show the same message + return JSONResponse( + content={"message": "Password reset link has been sent to your email address"} + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail="Error processing password reset request" + ) + +@app.on_event("startup") +async def startup_event(): + """Initialize VPN server on startup""" + initialize_vpn_server() + +@app.on_event("shutdown") +async def shutdown_event(): + """Shutdown VPN server on application shutdown""" + global vpn_server + if vpn_server and vpn_server.is_running: + await vpn_server.stop() + logger.log(LogLevel.INFO, LogCategory.SYSTEM, "app", "VPN server shut down.") + +if __name__ == '__main__': + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=7860) + + + \ No newline at end of file diff --git a/ca/ca.crt b/ca/ca.crt new file mode 100644 index 0000000000000000000000000000000000000000..8be09f3be52f9a5c3434a921d15ab1ab2c88e3f7 --- /dev/null +++ b/ca/ca.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFEzCCAvugAwIBAgIUVVFA6g5tK6la3NhsAuNIHSgtKcIwDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOT3V0bGluZSBWUE4gQ0EwHhcNMjUwODExMjA0MzMyWhcN +MzUwODA5MjA0MzMyWjAZMRcwFQYDVQQDDA5PdXRsaW5lIFZQTiBDQTCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBANV3/kuWVymGqMMtjvKZRNgMFoQqCOMG +nAJi7bTehIFgd+uRmKnDwoHvtYjkGEmsY6D4Mq3Z6OxL7Wp3Ny2h1CouTQGdpR+I +sTvJEoVVNO8dNxJp9TrAXX3FQkSbW4+c7vfImZwoOfxMTu8AZ+VcI3+XeWKmyxdg +YgQMcTbXAMqtmIcDPxkM+/llniT4SfddG+J2MrPRuEEBcrr0vhRVPqTZXoBZ0XeM +TnLb4b7rcyo6L/MhrT07dBiDWt3J96RSM8uz/24pEFbQk3J3ppVGTPp0tpbL6DOB +3fijvOW+/Dtcq3pUiS6baM07bzD9czHatOq7Z2LbZshrrkxckl0Z83tHAvWvUOM+ +DAp+7FhxaxJQfHodKc5/QX4taEDR8iJj7uNe3tTE6gIIHlw+9oB6JN1RwSm/WQjW +QlZDrLXG9rCH6Rye2nRchIsYthDhjo1Q/FWEe14sIAxio/GKhPDK+GqWypSqbb1D +dcTQ0xduwXaP0DpA+ZJCLbZ+siX9tFyHW23YmhG14tP9IE2O9JsrFGDXDwLB4sAA +/DN+InQdW1SABFZk4TChcUs0BvaRuitzfsmiMFF1nR9Cytu3m82+FLSgFcDTNJv/ +n/nFE9/3eMOWwu786JDqJ/oAmnqRP+0qpfepMWTxbUJuhtAXpFlmjSTiiDhoyO// +w925yr1PUW8xAgMBAAGjUzBRMB0GA1UdDgQWBBRcrKe+jD6bZWSXI//HpXairOb6 +5zAfBgNVHSMEGDAWgBRcrKe+jD6bZWSXI//HpXairOb65zAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQAr7zGUZ4golbeNUlwf//Ex08/PT+wHo93C +A2yqnRE5aX4HcGSHBdXnfJIajGzqg1ha6G9KoOAeUwrz43MKQJ5MlTPsstw3F5eG +PTf8pvXtqoKCUz+n5NflVAnPi2rkwg3xek3FNnRxeFtRNQk7JdiZyUrrkls9zdXG +b51nabQulMyU+S2AgS8tZieis9zKtaxL6FIkw/Ppd3kkC+IKSDqWq73UKesUwmwz +wVxmAjCwGNkzQmIriAEMOrjrUU4TqTlcYELmKjWjQtFn3DHYx0dVtQ+EtXvpvQTh +5y5E//O5crj8w8APWZ9AbV+RnHEzzUmn+CWEmKlw6INc5GipGJiF4iOgyIBKKTg0 +gfomG2nFrmmiQMLO+SMONdb6qWbdUMO8tptATV5+NEyjgcyoD4zhiQIjnDovil5F +klX8IwKMWSu7969uzXqNbr0gNFzUbpRtaoVoVJbaZMgDBSA+eWVJYlcDgE+qR6bE +NWTJiXZfVE3rat/pp/Nn25UHdtOBt1ZLGOItI7saB9xHqfS+aL+/Kj+mp8f3Tb/q +A20AeJUECG2jjErLYdUWY8+jMcPK6H34boUBuvmcdPRfBREsfotk0bNLiY+NlcnO +qCslUskUdquWVjYZ4Vce22byIJz2TdHWqfek4HAmU5p7VdKZ9Ux82pggWSHlEmUD +7YNttdciIA== +-----END CERTIFICATE----- diff --git a/ca/ca.key b/ca/ca.key new file mode 100644 index 0000000000000000000000000000000000000000..6305cd920da8993edcc5ebee2ddcf6d220c1a23a --- /dev/null +++ b/ca/ca.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDVd/5LllcphqjD +LY7ymUTYDBaEKgjjBpwCYu203oSBYHfrkZipw8KB77WI5BhJrGOg+DKt2ejsS+1q +dzctodQqLk0BnaUfiLE7yRKFVTTvHTcSafU6wF19xUJEm1uPnO73yJmcKDn8TE7v +AGflXCN/l3lipssXYGIEDHE21wDKrZiHAz8ZDPv5ZZ4k+En3XRvidjKz0bhBAXK6 +9L4UVT6k2V6AWdF3jE5y2+G+63MqOi/zIa09O3QYg1rdyfekUjPLs/9uKRBW0JNy +d6aVRkz6dLaWy+gzgd34o7zlvvw7XKt6VIkum2jNO28w/XMx2rTqu2di22bIa65M +XJJdGfN7RwL1r1DjPgwKfuxYcWsSUHx6HSnOf0F+LWhA0fIiY+7jXt7UxOoCCB5c +PvaAeiTdUcEpv1kI1kJWQ6y1xvawh+kcntp0XISLGLYQ4Y6NUPxVhHteLCAMYqPx +ioTwyvhqlsqUqm29Q3XE0NMXbsF2j9A6QPmSQi22frIl/bRch1tt2JoRteLT/SBN +jvSbKxRg1w8CweLAAPwzfiJ0HVtUgARWZOEwoXFLNAb2kborc37JojBRdZ0fQsrb +t5vNvhS0oBXA0zSb/5/5xRPf93jDlsLu/OiQ6if6AJp6kT/tKqX3qTFk8W1CbobQ +F6RZZo0k4og4aMjv/8Pducq9T1FvMQIDAQABAoICAF4EpfcjpYsQGIcyKxn1YGlp +VYdrPhPDhvXUHY7CTIjw9JBHxX3LzwDMk19R2tKj/xNYDXYdmiVswYnZLO/HrTrQ +vrDd/mp3mVvUEPixkQlZjDZrfYsdS3AH78poxHhpraRrcSBiZTuWXlOMkbXmkWny +TI+jF6LZnAHdewWkx1/8+kdIqkM9wULUO0VcJ7OvigcBeQ5S6XyUBzSJc6hf7SHM +7P7J0GR/YtPavUAJ0mTAUPscE4F7DIR5Yg16FTyFyfNHeVJK+rvJzI8nXLK1TlUn +D342G7SH17xZXWqw5cW9aHcOAYeKAiwWJ8BjeJd2FKWn2X6kVE4kgxV11i70LZSJ +eRtp3oEsUXOyTBu1XnVqkyokhvT4SW+UzCSbTVpCaDVbN223Ep2sw2ntjfL9OKwc +DmZaHhwycDtHABlERBpom+IZofVHH6NBX7CMsTNrD4y/qOSwdkbWrGhzGfNHBgrq +WPb9nSok+6m3LQT+8i7bpGj0LImYei5tp2rmQJt1psQOFTIksd7blOhy9dNeknj7 +zrfLXfT6GkeZ2CPStg7yUDTdQ7mVYa5FeTqpi09bfGZ9Y1ACxSdvdfMlrkLqTRQH +KkXKlwg607mK5t/QSt7tlUiE9AFCrciXviheQd2hfij2R5Kfoprp4PoGiWz/o478 +gsezT4CzugpEyZ/ICVqTAoIBAQD2jMVbqr5ivRsXdMhdru4MaZ5t/K+fHtL2V5c5 +SkDzrOIdOpy25zNbIpbNdSyVIv2zm69VTZHV3lDGT3RSFjr7T3jK1h9HrCBk9duV +IC1NDYNoIajJWGzF3VjAdRz5gsGNu74+04uNkkYBJzY4m6KYNsCYSPQw3Q01HdOh +hVcIbxyzbUFfUb2Nck+bnTYJAACnkCMxAZNE9g6THnTnjNMMwhuBvt19W/L4Qurl +F+Ix1kaBPe+3BFI4IyMhe3W3LvEraESDri5dTS0BlgM/lpGh9cme1JQc34xV/eUA +E31JG60prQCTWkkZuZxOTjgiiE7Z7/9L0hZqfcIVWHpF5gb3AoIBAQDdpp59OAaY +3Wud+lGc3KTkoiPHZYzAcVshUhg2FxkAKrwUJA7GGjcFWETh+E9LYYy0IfQcRKiN +ZmV8aXYnL26O/N6Dr2mHOt5WHvlzkEIZCp0QMJImh0If25fZXuMRl9eRVOQZn+Gh +eU8mhzNKS+5apXuofY04hch8VXCaGkwPVeD0EKXx9xuKcatGUCjQ1zkp7I9hXI69 +uX3pxZWHN2zCisFB9yEomq9G8xueUc86t0inFEsecESGLNOQmgxXscQTDt4tWG9h +7QQA1KA+TQeLg+i5Mr9xv++kTJNmPI63Jw0rWYvxk8mJRY5KUtSR+iSyYF960IJP +UlssOAFLMOkXAoIBAQDE6vpWlLErO87/lQ7ThHws/c7EGiZK+NuWVa862su11Edl +AQNaMp8aEy5PO184XpIzeg04HJR2NPJe8eb+CTNitb7MgujI3fmhqZyQJvsHp9tk +uD2PU0jNYFUaom9Z+c2N3n28wEmd8U5obWEpJWVgHZsGBn7C6Es8OW5me5Ff8x8B +UCn+b9LtvndG2vHljlL3gnAZHCD722sYpiLJLfkDH6XIoyFUlrQhBZGHGORY2cPG +RinIC3N/0tCkVW9Xt+53tPfEFMKDUri3o5FEoIYAzccTTMZfqUz1Aax9uxM96RUN +TFhBWMM6AL2O7Xp4WlZgSwelD09IDtmNIvXGDktRAoIBAQCt2h6+AM/L3wCmLM0O +yFHdsv91SsWXvFHKVOYApyVI6DwVYCLmZ3F4k7+TrnwjmCQQtgEOmxvJrOM1LlMq +cR26scSmbVPMafQygKEQb7ooghanuDEqXzUSX98+9BoOlpbSu08eejUzvj7C7ZDh +WaVfHCVeBvxZtTWHsExd0vqNnMKRLO28WCIV+QpqYD1jcSy5IX9k0oBzd6a3Ue7y +3BpGjScAYqJzgsCwWcbz6x8r4s7tnhE9krlstIRNC0dbEWfFuwexcYgLuyhEroHx +2+FrIM/NU2yt/+oraJTEwAMAzXSa5+XIWi7dqNzulwF8bkOSVd0OK7XKGcLBcDwz +ie2JAoIBAAEES8SwOYxbKxfle5zfEIJ33F9xvJZaoIHKK39oUsv6akG95QLuYi/O +EzNbvFs2DulZNijro9HavZzVTq8yevIsdJY3fUyVk/Rw408c8NiLPMqQs7CbuB43 +e8mexW0rECPM0BWJaBAbVbYfUyNPWFgAucmiPj8EKTiA64P/xHvwfeRF7cDsnBYl +ecAePGTGa0Kkrmbg1VbTxNu9Y5POdw/yYYfYw2D0dSX2g/wPovvcjdBemmdHBMr4 +UlL6YR/tc6/TuECC9FCR5Y/l9isd7v3lIanQKgQBG1+NkpIZ2rW/zDeBscAUGlYw +Xt2St+RJucRsevZirIfm7k2Dwyn22NY= +-----END PRIVATE KEY----- diff --git a/config/outline_config.json b/config/outline_config.json new file mode 100644 index 0000000000000000000000000000000000000000..7c0926ec5f78b0ea9f7186db3749f309182ac9a7 --- /dev/null +++ b/config/outline_config.json @@ -0,0 +1,11 @@ +{ + "server": { + "port": 443, + "cipher": "chacha20-ietf-poly1305", + "timeout": 600, + "workers": 4, + "bind_address": "0.0.0.0", + "access_key_salt": "974edfe0ae852e69d07515fa724334668cea0b34b83c5e4e5f6c83c0fe4fd3ed" + }, + "users": [] +} \ No newline at end of file diff --git a/config/server.json b/config/server.json new file mode 100644 index 0000000000000000000000000000000000000000..c7ca95799179a0c813c708e5e130ef39ef695ef8 --- /dev/null +++ b/config/server.json @@ -0,0 +1,33 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 8388, + "interface": "eth0", + "virtual_network": "10.0.0.0/16" + }, + "protocols": { + "shadowsocks": true, + "l2tp": true, + "pptp": true + }, + "offline_access": { + "enabled": true, + "timeout": 604800, + "max_offline_sessions": 5 + }, + "internet_sharing": { + "enabled": true, + "rate_limit": "10mbps", + "allowed_ports": ["80", "443", "53", "22"] + }, + "security": { + "encryption": "aes-256-gcm", + "ipsec_psk": "your_pre_shared_key_here", + "certificate_path": "/path/to/server.crt", + "key_path": "/path/to/server.key" + }, + "logging": { + "level": "info", + "file": "/var/log/vpn-server.log" + } +} diff --git a/config/server_config.json b/config/server_config.json new file mode 100644 index 0000000000000000000000000000000000000000..dbd1f54137349cca99967963380025e741ef79f3 --- /dev/null +++ b/config/server_config.json @@ -0,0 +1,11 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 8443, + "virtual_network": "10.0.0.0/24", + "dns_servers": [ + "8.8.8.8", + "8.8.4.4" + ] + } +} \ No newline at end of file diff --git a/config/users.json b/config/users.json new file mode 100644 index 0000000000000000000000000000000000000000..8d292253e8c0a7ef1a82fa07910d8dcc5334c626 --- /dev/null +++ b/config/users.json @@ -0,0 +1 @@ +{"test@example.com": {"password": "$2b$12$r7y/a..c8zrgBL/nlN1yQOCDYxNkJu/I8iQzBxBpTzxndZ16TIiuC", "created_at": "2025-08-18T16:26:15.307068", "config_id": "e58fa5bc-30b0-4c16-9533-3761727a56fa"}} \ No newline at end of file diff --git a/core/__pycache__/database.cpython-311.pyc b/core/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..08ea8ed6b38ddd31b1ad91dd3bdf71b219144c80 Binary files /dev/null and b/core/__pycache__/database.cpython-311.pyc differ diff --git a/core/__pycache__/database_init.cpython-311.pyc b/core/__pycache__/database_init.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3233134c2c74b006b4054ede2618f3ae05123e33 Binary files /dev/null and b/core/__pycache__/database_init.cpython-311.pyc differ diff --git a/core/__pycache__/ikev2_server.cpython-311.pyc b/core/__pycache__/ikev2_server.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2ddb748e3ab5f6b3ac8c708982de618ee3f633af Binary files /dev/null and b/core/__pycache__/ikev2_server.cpython-311.pyc differ diff --git a/core/__pycache__/ip_parser.cpython-311.pyc b/core/__pycache__/ip_parser.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5399e23bd0cb313aa56900f6e186808f42e32016 Binary files /dev/null and b/core/__pycache__/ip_parser.cpython-311.pyc differ diff --git a/core/__pycache__/logger.cpython-311.pyc b/core/__pycache__/logger.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..81be317f90a04bb94260aa8c4aebeeded78a15ca Binary files /dev/null and b/core/__pycache__/logger.cpython-311.pyc differ diff --git a/core/__pycache__/nat_engine.cpython-311.pyc b/core/__pycache__/nat_engine.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..46ef44cf8f4496144be3e2968c72da88673e593c Binary files /dev/null and b/core/__pycache__/nat_engine.cpython-311.pyc differ diff --git a/core/__pycache__/outline_config.cpython-311.pyc b/core/__pycache__/outline_config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c7e258b54b6fab508fcf40b7d000bea0dab50aec Binary files /dev/null and b/core/__pycache__/outline_config.cpython-311.pyc differ diff --git a/core/__pycache__/outline_server.cpython-311.pyc b/core/__pycache__/outline_server.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0203b64f7097abf54533f90e7c66be03df118807 Binary files /dev/null and b/core/__pycache__/outline_server.cpython-311.pyc differ diff --git a/core/__pycache__/port_manager.cpython-311.pyc b/core/__pycache__/port_manager.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a963dd051178ed24bcae44a9ee3ee83dd173949b Binary files /dev/null and b/core/__pycache__/port_manager.cpython-311.pyc differ diff --git a/core/__pycache__/session_tracker.cpython-311.pyc b/core/__pycache__/session_tracker.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4cee1e3f921ed1b0807d8a86c6a9177fe58c36bf Binary files /dev/null and b/core/__pycache__/session_tracker.cpython-311.pyc differ diff --git a/core/__pycache__/shadowsocks_protocol.cpython-311.pyc b/core/__pycache__/shadowsocks_protocol.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..20a7fb3ab33f64d401ad0f8dbc29a15a202b4fa8 Binary files /dev/null and b/core/__pycache__/shadowsocks_protocol.cpython-311.pyc differ diff --git a/core/__pycache__/tcp_engine.cpython-311.pyc b/core/__pycache__/tcp_engine.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..389053e81f78739f0237e9121deb5c1a6ffa519a Binary files /dev/null and b/core/__pycache__/tcp_engine.cpython-311.pyc differ diff --git a/core/__pycache__/tcp_forward.cpython-311.pyc b/core/__pycache__/tcp_forward.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f5a342d80184fa6fae6d6609c10fbabdfcc60818 Binary files /dev/null and b/core/__pycache__/tcp_forward.cpython-311.pyc differ diff --git a/core/__pycache__/traffic_router.cpython-311.pyc b/core/__pycache__/traffic_router.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d5d6200369f3792d022882b45f95f20ab7391e17 Binary files /dev/null and b/core/__pycache__/traffic_router.cpython-311.pyc differ diff --git a/core/__pycache__/vpn_auth.cpython-311.pyc b/core/__pycache__/vpn_auth.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b82098d02bc603fe735907a6ec857c56ca8e154 Binary files /dev/null and b/core/__pycache__/vpn_auth.cpython-311.pyc differ diff --git a/core/auth.py b/core/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..51131137138f1ccea879803662f869a138af7abd --- /dev/null +++ b/core/auth.py @@ -0,0 +1,74 @@ +""" +Authentication and authorization utilities +""" +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +from typing import Optional + +from core.database import get_db +from models.user import User, UserRole +from schemas.auth import TokenData + +# JWT settings +SECRET_KEY = "your-secret-key" # Change this to a secure secret key in production +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """Create JWT token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: Session = Depends(get_db) +) -> User: + """Get current user from JWT token""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except JWTError: + raise credentials_exception + + user = db.query(User).filter(User.username == token_data.username).first() + if user is None: + raise credentials_exception + return user + +async def get_current_active_user( + current_user: User = Depends(get_current_user) +) -> User: + """Get current active user""" + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + +async def get_current_admin_user( + current_user: User = Depends(get_current_active_user) +) -> User: + """Get current admin user""" + if current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough privileges" + ) + return current_user diff --git a/core/database.py b/core/database.py new file mode 100644 index 0000000000000000000000000000000000000000..83684ff6bbecce1d4b1048485af457646d45eb0b --- /dev/null +++ b/core/database.py @@ -0,0 +1,31 @@ +""" +Database configuration and base models +""" +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + +# Get the database file path +DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'vpn.db') +os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + +# Create database engine +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}" +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) + +# Create SessionLocal class +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Create Base class +Base = declarative_base() + +def get_db(): + """Dependency to get database session""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/core/database_init.py b/core/database_init.py new file mode 100644 index 0000000000000000000000000000000000000000..a32507ef0897efe51ae879655137e4905a127306 --- /dev/null +++ b/core/database_init.py @@ -0,0 +1,52 @@ +""" +Database initialization and migration +""" +from sqlalchemy import event +from .database import Base, engine, SessionLocal +from .models.user import User, UserRole, UserStatus +import logging + +logger = logging.getLogger(__name__) + +def init_db(): + """Initialize database and create default admin user""" + try: + # Create all tables + Base.metadata.create_all(bind=engine) + + # Create a session + db = SessionLocal() + + # Check if admin user exists + admin = db.query(User).filter(User.role == UserRole.ADMIN).first() + if not admin: + # Create default admin user with password 'admin' + # Note: This should be changed immediately after first login + admin = User( + username="admin", + password_hash=User.hash_password("admin"), + role=UserRole.ADMIN, + status=UserStatus.ACTIVE + ) + db.add(admin) + db.commit() + logger.info("Created default admin user") + + db.close() + logger.info("Database initialized successfully") + return True + + except Exception as e: + logger.error(f"Error initializing database: {e}") + return False + +@event.listens_for(User, 'after_insert') +def user_created(mapper, connection, target): + """Log user creation""" + logger.info(f"New user created: {target.username}") + +@event.listens_for(User.status, 'set') +def user_status_changed(target, value, oldvalue, initiator): + """Log user status changes""" + if oldvalue and value != oldvalue: + logger.info(f"User {target.username} status changed from {oldvalue} to {value}") diff --git a/core/error_handlers.py b/core/error_handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..c8c00c1562d3a96d2ad497edddb6c575685616f7 --- /dev/null +++ b/core/error_handlers.py @@ -0,0 +1,51 @@ +""" +Global error handlers and exceptions +""" +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse +from sqlalchemy.exc import SQLAlchemyError +from typing import Union, Dict, Any + +class AppException(Exception): + """Base application exception""" + def __init__( + self, + status_code: int, + detail: Union[str, Dict[str, Any]], + headers: Dict[str, str] = None + ): + self.status_code = status_code + self.detail = detail + self.headers = headers + +def setup_error_handlers(app: FastAPI): + """Setup global error handlers""" + + @app.exception_handler(AppException) + async def app_exception_handler(request: Request, exc: AppException): + headers = exc.headers if exc.headers else {} + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + headers=headers + ) + + @app.exception_handler(SQLAlchemyError) + async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError): + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "detail": "Database error occurred", + "message": str(exc) + } + ) + + @app.exception_handler(Exception) + async def general_exception_handler(request: Request, exc: Exception): + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "detail": "An unexpected error occurred", + "message": str(exc) + } + ) diff --git a/core/ikev2_server.py b/core/ikev2_server.py new file mode 100644 index 0000000000000000000000000000000000000000..d4f1e3de0b854b8fba1311d73b282b12f972aded --- /dev/null +++ b/core/ikev2_server.py @@ -0,0 +1,207 @@ +""" +IKEv2 Server Implementation for Outline VPN +""" + +import os +import subprocess +import tempfile +from typing import Dict, Optional +import uuid +from datetime import datetime, timedelta + +class IKEv2Server: + def __init__(self, server_ip: str, logger): + self.server_ip = server_ip + self.logger = logger + self.ca_dir = "ca" + self.cert_dir = "certs" + self.config_dir = "config" + self._setup_directories() + self._initialize_ca() + + def _setup_directories(self): + """Create necessary directories""" + for directory in [self.ca_dir, self.cert_dir, self.config_dir]: + os.makedirs(directory, exist_ok=True) + + def _initialize_ca(self): + """Initialize Certificate Authority if not already done""" + ca_key = os.path.join(self.ca_dir, "ca.key") + ca_cert = os.path.join(self.ca_dir, "ca.crt") + + if not os.path.exists(ca_key) or not os.path.exists(ca_cert): + # Generate CA private key + subprocess.run([ + "openssl", "genrsa", + "-out", ca_key, + "4096" + ], check=True) + + # Generate CA certificate + subprocess.run([ + "openssl", "req", + "-x509", + "-new", + "-nodes", + "-key", ca_key, + "-sha256", + "-days", "3650", + "-out", ca_cert, + "-subj", f"/CN=Outline VPN CA" + ], check=True) + + def generate_certificate(self, user_id: str) -> Dict[str, str]: + """Generate client certificate for IKEv2""" + cert_name = f"client_{user_id}" + key_path = os.path.join(self.cert_dir, f"{cert_name}.key") + csr_path = os.path.join(self.cert_dir, f"{cert_name}.csr") + cert_path = os.path.join(self.cert_dir, f"{cert_name}.crt") + p12_path = os.path.join(self.cert_dir, f"{cert_name}.p12") + + try: + # Generate client private key + subprocess.run([ + "openssl", "genrsa", + "-out", key_path, + "2048" + ], check=True) + + # Generate CSR + subprocess.run([ + "openssl", "req", + "-new", + "-key", key_path, + "-out", csr_path, + "-subj", f"/CN=client_{user_id}" + ], check=True) + + # Sign client certificate with CA + subprocess.run([ + "openssl", "x509", + "-req", + "-in", csr_path, + "-CA", os.path.join(self.ca_dir, "ca.crt"), + "-CAkey", os.path.join(self.ca_dir, "ca.key"), + "-CAcreateserial", + "-out", cert_path, + "-days", "365", + "-sha256" + ], check=True) + + # Create PKCS12 bundle + export_password = str(uuid.uuid4()) + subprocess.run([ + "openssl", "pkcs12", + "-export", + "-in", cert_path, + "-inkey", key_path, + "-out", p12_path, + "-password", f"pass:{export_password}" + ], check=True) + + # Read certificate files + with open(cert_path, 'r') as f: + cert_data = f.read() + with open(key_path, 'r') as f: + key_data = f.read() + with open(os.path.join(self.ca_dir, "ca.crt"), 'r') as f: + ca_data = f.read() + + return { + 'certificate': cert_data, + 'private_key': key_data, + 'ca_certificate': ca_data, + 'p12_bundle': p12_path, + 'p12_password': export_password + } + + except Exception as e: + self.logger.error("Error generating certificate: " + str(e)) + raise + + def generate_strongswan_config(self, user_id: str, psk: str) -> str: + """Generate strongSwan configuration for a user""" + config = f""" +conn outline-{user_id} + auto=add + compress=no + type=tunnel + keyexchange=ikev2 + fragmentation=yes + forceencaps=yes + + # Local/Server configuration + left=%any + leftsubnet=0.0.0.0/0 + leftcert=/etc/ipsec.d/certs/server.crt + leftsendcert=always + leftid=@outline.vpn + + # Remote/Client configuration + right=%any + rightid=%any + rightauth=eap-mschapv2 + rightsourceip=10.10.10.0/24 + rightdns=8.8.8.8,8.8.4.4 + + # Security parameters + ike=aes256-sha256-modp2048,aes128-sha1-modp2048 + esp=aes256-sha256,aes128-sha1 + dpdaction=clear + dpddelay=300s + rekey=no +""" + config_path = os.path.join(self.config_dir, f"outline-{user_id}.conf") + with open(config_path, 'w') as f: + f.write(config) + + return config_path + + def add_user(self, user_id: str, username: str, password: str, psk: str): + """Add a new VPN user""" + # Generate certificates + cert_data = self.generate_certificate(user_id) + + # Generate strongSwan config + config_path = self.generate_strongswan_config(user_id, psk) + + # Add user credentials to strongSwan secrets + secrets_path = os.path.join(self.config_dir, "ipsec.secrets") + with open(secrets_path, 'a') as f: + f.write(f'{username} : EAP "{password}"\n') + f.write(f'{self.server_ip} %any : PSK "{psk}"\n') + + return cert_data + + def remove_user(self, user_id: str): + """Remove a VPN user""" + # Remove certificates + cert_name = f"client_{user_id}" + for ext in ['.key', '.csr', '.crt', '.p12']: + path = os.path.join(self.cert_dir, f"{cert_name}{ext}") + if os.path.exists(path): + os.remove(path) + + # Remove config + config_path = os.path.join(self.config_dir, f"outline-{user_id}.conf") + if os.path.exists(config_path): + os.remove(config_path) + + # Remove from secrets (would need to rewrite the file) + # This is a bit more complex and would require parsing and rewriting ipsec.secrets + + + async def start(self): + """Start the IKEv2 service""" + self.logger.info("Starting IKEv2 service...") + # Placeholder for actual IKEv2 service startup logic + # This might involve starting strongSwan or similar + pass + + async def stop(self): + """Stop the IKEv2 service""" + self.logger.info("Stopping IKEv2 service...") + # Placeholder for actual IKEv2 service shutdown logic + pass + + diff --git a/core/ip_parser.py b/core/ip_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..079bae80f3a64e81771f1cd905a29059bbfeee66 --- /dev/null +++ b/core/ip_parser.py @@ -0,0 +1,230 @@ +""" +IP Parser/Assembler Module + +Handles IPv4 packet parsing and construction: +- Parse IPv4, UDP, and TCP headers +- Calculate and verify checksums +- Handle packet fragmentation and reassembly +- Support various IP options +""" + +import struct +import socket +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass +from enum import Enum + + +class IPProtocol(Enum): + ICMP = 1 + TCP = 6 + UDP = 17 + + +@dataclass +class IPv4Header: + """IPv4 header structure""" + version: int = 4 + ihl: int = 5 # Internet Header Length (in 32-bit words) + tos: int = 0 # Type of Service + total_length: int = 0 + identification: int = 0 + flags: int = 0 # 3 bits: Reserved, Don't Fragment, More Fragments + fragment_offset: int = 0 # 13 bits + ttl: int = 64 # Time to Live + protocol: int = 0 + header_checksum: int = 0 + source_ip: str = '0.0.0.0' + dest_ip: str = '0.0.0.0' + options: bytes = b'' + + @property + def header_length(self) -> int: + """Get header length in bytes""" + return self.ihl * 4 + + @property + def dont_fragment(self) -> bool: + """Check if Don't Fragment flag is set""" + return bool(self.flags & 0x2) + + @property + def more_fragments(self) -> bool: + """Check if More Fragments flag is set""" + return bool(self.flags & 0x1) + + @property + def is_fragment(self) -> bool: + """Check if this is a fragment""" + return self.more_fragments or self.fragment_offset > 0 + + +@dataclass +class TCPHeader: + """TCP header structure""" + source_port: int = 0 + dest_port: int = 0 + seq_num: int = 0 + ack_num: int = 0 + data_offset: int = 5 # Header length in 32-bit words + reserved: int = 0 + flags: int = 0 # 9 bits: NS, CWR, ECE, URG, ACK, PSH, RST, SYN, FIN + window_size: int = 65535 + checksum: int = 0 + urgent_pointer: int = 0 + options: bytes = b'' + + @property + def header_length(self) -> int: + """Get header length in bytes""" + return self.data_offset * 4 + + # TCP Flag properties + @property + def fin(self) -> bool: + return bool(self.flags & 0x01) + + @property + def syn(self) -> bool: + return bool(self.flags & 0x02) + + @property + def rst(self) -> bool: + return bool(self.flags & 0x04) + + @property + def psh(self) -> bool: + return bool(self.flags & 0x08) + + @property + def ack(self) -> bool: + return bool(self.flags & 0x10) + + @property + def urg(self) -> bool: + return bool(self.flags & 0x20) + + @property + def ece(self) -> bool: + return bool(self.flags & 0x40) + + @property + def cwr(self) -> bool: + return bool(self.flags & 0x80) + + @property + def ns(self) -> bool: + return bool(self.flags & 0x100) + + +@dataclass +class UDPHeader: + """UDP header structure""" + source_port: int = 0 + dest_port: int = 0 + length: int = 8 # Header length (8) + data length + checksum: int = 0 + + +class IPParser: + """IP packet parser and assembler""" + + @staticmethod + def parse_ipv4_header(packet: bytes) -> Tuple[IPv4Header, bytes]: + """Parse IPv4 header and return header object and remaining data""" + # Basic header (20 bytes) + if len(packet) < 20: + raise ValueError("Packet too short for IPv4 header") + + ver_ihl, tos, total_len, ident, flags_frag, ttl, proto, checksum, src, dst = \ + struct.unpack('!BBHHHBBH4s4s', packet[:20]) + + version = ver_ihl >> 4 + ihl = ver_ihl & 0x0F + flags = flags_frag >> 13 + frag_offset = flags_frag & 0x1FFF + + header = IPv4Header( + version=version, + ihl=ihl, + tos=tos, + total_length=total_len, + identification=ident, + flags=flags, + fragment_offset=frag_offset, + ttl=ttl, + protocol=proto, + header_checksum=checksum, + source_ip=socket.inet_ntoa(src), + dest_ip=socket.inet_ntoa(dst) + ) + + # Extract options if present + header_len = header.header_length + if header_len > 20: + header.options = packet[20:header_len] + + return header, packet[header_len:] + + @staticmethod + def parse_tcp_header(packet: bytes) -> Tuple[TCPHeader, bytes]: + """Parse TCP header and return header object and remaining data""" + if len(packet) < 20: + raise ValueError("Packet too short for TCP header") + + src_port, dst_port, seq, ack, offset_flags, window, checksum, urgent = \ + struct.unpack('!HHIIHHH', packet[:20]) + + data_offset = offset_flags >> 12 + flags = offset_flags & 0x1FF + + header = TCPHeader( + source_port=src_port, + dest_port=dst_port, + seq_num=seq, + ack_num=ack, + data_offset=data_offset, + flags=flags, + window_size=window, + checksum=checksum, + urgent_pointer=urgent + ) + + # Extract options if present + header_len = header.header_length + if header_len > 20: + header.options = packet[20:header_len] + + return header, packet[header_len:] + + @staticmethod + def parse_udp_header(packet: bytes) -> Tuple[UDPHeader, bytes]: + """Parse UDP header and return header object and remaining data""" + if len(packet) < 8: + raise ValueError("Packet too short for UDP header") + + src_port, dst_port, length, checksum = struct.unpack('!HHHH', packet[:8]) + + header = UDPHeader( + source_port=src_port, + dest_port=dst_port, + length=length, + checksum=checksum + ) + + return header, packet[8:] + + @staticmethod + def calculate_checksum(data: bytes) -> int: + """Calculate IP/TCP/UDP checksum""" + if len(data) % 2 == 1: + data += b'\0' + + words = struct.unpack('!%dH' % (len(data) // 2), data) + checksum = sum(words) + + # Fold 32-bit sum into 16 bits + while checksum >> 16: + checksum = (checksum & 0xFFFF) + (checksum >> 16) + + return ~checksum & 0xFFFF diff --git a/core/l2tp_server.py b/core/l2tp_server.py new file mode 100644 index 0000000000000000000000000000000000000000..d2183889664ca2427e2f6b42e5786d3ef8feb8e3 --- /dev/null +++ b/core/l2tp_server.py @@ -0,0 +1,201 @@ +""" +L2TP/IPsec Server Implementation +Handles L2TP tunneling with IPsec encryption +""" + +import asyncio +import socket +import struct +from typing import Dict, Optional, Tuple +from dataclasses import dataclass +import os +import hmac +import hashlib +from .ip_parser import IPv4Header, IPParser +from .logger import Logger, LogCategory + +@dataclass +class L2TPSession: + tunnel_id: int + session_id: int + client_ip: str + assigned_ip: str + created_at: float + last_seen: float + bytes_in: int = 0 + bytes_out: int = 0 + +class L2TPServer: + """L2TP/IPsec server implementation""" + + def __init__(self, logger: Logger, ip_pool_start: str = "10.10.0.2"): + self.logger = logger + self.sessions: Dict[Tuple[int, int], L2TPSession] = {} # (tunnel_id, session_id) -> session + self.next_tunnel_id = 1 + self.next_session_id = 1 + self.next_ip = ip_pool_start + self._running = False + self._transport = None + self._ipsec = IPSecHandler(logger) + + async def start(self, host: str = "0.0.0.0", port: int = 1701): + """Start L2TP server""" + loop = asyncio.get_running_loop() + self._transport, _ = await loop.create_datagram_endpoint( + lambda: L2TPProtocol(self), + local_addr=(host, port) + ) + self._running = True + self.logger.info(LogCategory.SYSTEM, "l2tp_server", f"L2TP server started on {host}:{port}") + + async def stop(self): + """Stop L2TP server""" + if self._transport: + self._transport.close() + self._running = False + self.logger.info(LogCategory.SYSTEM, "l2tp_server", "L2TP server stopped") + + def allocate_ip(self) -> str: + """Allocate next available IP from pool""" + allocated = self.next_ip + # Increment last octet, handling rollover + last_octet = int(self.next_ip.split('.')[-1]) + if last_octet >= 254: + raise ValueError("IP pool exhausted") + self.next_ip = f"10.10.0.{last_octet + 1}" + return allocated + + async def handle_packet(self, data: bytes, addr: Tuple[str, int]): + """Handle incoming L2TP packet""" + try: + # Decrypt IPsec if present + if self._ipsec.is_ipsec_packet(data): + data = self._ipsec.decrypt_packet(data) + if not data: + return + + # Parse L2TP header + if len(data) < 6: + return + + flags = struct.unpack('!H', data[0:2])[0] + tunnel_id = struct.unpack('!H', data[2:4])[0] + session_id = struct.unpack('!H', data[4:6])[0] + + # Handle control messages + if flags & 0x8000: # Control message + await self._handle_control(data[6:], tunnel_id, session_id, addr) + else: # Data message + await self._handle_data(data[6:], tunnel_id, session_id) + + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "l2tp_server", f"Error handling packet: {e}") + + async def _handle_control(self, data: bytes, tunnel_id: int, session_id: int, addr: Tuple[str, int]): + """Handle L2TP control message""" + msg_type = struct.unpack('!H', data[0:2])[0] + + if msg_type == 1: # SCCRQ - Start-Control-Connection-Request + # Send SCCRP - Start-Control-Connection-Reply + reply = self._build_sccrp(tunnel_id) + await self._send_control(reply, addr) + + elif msg_type == 3: # ICRQ - Incoming-Call-Request + # Create new session + session = L2TPSession( + tunnel_id=tunnel_id, + session_id=self.next_session_id, + client_ip=addr[0], + assigned_ip=self.allocate_ip(), + created_at=asyncio.get_running_loop().time(), + last_seen=asyncio.get_running_loop().time() + ) + self.sessions[(tunnel_id, session_id)] = session + self.next_session_id += 1 + + # Send ICRP - Incoming-Call-Reply + reply = self._build_icrp(tunnel_id, session_id) + await self._send_control(reply, addr) + + async def _handle_data(self, data: bytes, tunnel_id: int, session_id: int): + """Handle L2TP data message""" + session_key = (tunnel_id, session_id) + if session_key not in self.sessions: + return + + session = self.sessions[session_key] + session.last_seen = asyncio.get_running_loop().time() + + # Parse PPP frame + if len(data) < 4: + return + + ppp_protocol = struct.unpack('!H', data[2:4])[0] + if ppp_protocol == 0x0021: # IP + ip_packet = data[4:] + await self._handle_ip_packet(ip_packet, session) + + async def _handle_ip_packet(self, data: bytes, session: L2TPSession): + """Handle IP packet inside PPP frame""" + try: + # Parse IP header + ip_header = IPParser.parse_ipv4_header(data) + + # Update statistics + session.bytes_in += len(data) + + # Forward packet to destination + if ip_header.protocol == socket.IPPROTO_TCP: + await self._forward_tcp(data, session) + elif ip_header.protocol == socket.IPPROTO_UDP: + await self._forward_udp(data, session) + + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "l2tp_server", f"Error handling IP packet: {e}") + + async def _forward_tcp(self, data: bytes, session: L2TPSession): + """Forward TCP packet""" + # This will be handled by the TCP forwarding engine + # Just a placeholder for now + pass + + async def _forward_udp(self, data: bytes, session: L2TPSession): + """Forward UDP packet""" + # This will be handled by the UDP forwarding engine + # Just a placeholder for now + pass + +class IPSecHandler: + """Handles IPsec encryption/decryption""" + + def __init__(self, logger: Logger): + self.logger = logger + self.security_associations: Dict[str, Dict] = {} + + def is_ipsec_packet(self, data: bytes) -> bool: + """Check if packet is IPsec encrypted""" + if len(data) < 8: + return False + # Check for ESP or AH protocol + return data[0] == 50 or data[0] == 51 + + def decrypt_packet(self, data: bytes) -> Optional[bytes]: + """Decrypt IPsec packet""" + try: + # Implementation depends on the encryption method + # This is a placeholder for actual decryption + return data[8:] # Skip ESP header for now + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "ipsec_handler", f"Decryption error: {e}") + return None + + def encrypt_packet(self, data: bytes, sa_id: str) -> bytes: + """Encrypt packet using IPsec""" + try: + # Implementation depends on the encryption method + # This is a placeholder for actual encryption + esp_header = struct.pack('!II', 0, 0) # SPI and Sequence Number + return esp_header + data + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "ipsec_handler", f"Encryption error: {e}") + return data diff --git a/core/logger.py b/core/logger.py new file mode 100644 index 0000000000000000000000000000000000000000..26919427fc6c36cba2ec039ce1820dd3e1dd0d47 --- /dev/null +++ b/core/logger.py @@ -0,0 +1,282 @@ +""" +Logger Module + +Centralized logging system for the virtual ISP stack: +- Structured logging with multiple levels +- Log aggregation and filtering +- Real-time log streaming +- Log persistence and rotation +""" + +import logging +import logging.handlers +import time +import threading +import json +import os +from typing import Dict, List, Optional, Any, Callable +from dataclasses import dataclass, asdict +from enum import Enum +from collections import deque +import queue + + +class LogLevel(Enum): + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +class LogCategory(Enum): + SYSTEM = "SYSTEM" + OUTLINE = "OUTLINE" + NAT = "NAT" + TCP = "TCP" + ROUTER = "ROUTER" + BRIDGE = "BRIDGE" + SOCKET = "SOCKET" + SESSION = "SESSION" + SECURITY = "SECURITY" + PERFORMANCE = "PERFORMANCE" + + +@dataclass +class LogEntry: + """Structured log entry""" + timestamp: float + level: str + category: str + module: str + message: str + session_id: Optional[str] = None + client_id: Optional[str] = None + source_ip: Optional[str] = None + dest_ip: Optional[str] = None + protocol: Optional[str] = None + metadata: Dict[str, Any] = None + + def __post_init__(self): + if self.timestamp == 0: + self.timestamp = time.time() + if self.metadata is None: + self.metadata = {} + + def to_dict(self) -> Dict: + """Convert to dictionary""" + return asdict(self) + + def to_json(self) -> str: + """Convert to JSON string""" + return json.dumps(self.to_dict(), default=str) + + +class LogFilter: + """Log filtering class""" + + def __init__(self): + self.level_filter: Optional[LogLevel] = None + self.category_filter: Optional[LogCategory] = None + self.module_filter: Optional[str] = None + self.session_filter: Optional[str] = None + self.client_filter: Optional[str] = None + self.ip_filter: Optional[str] = None + self.text_filter: Optional[str] = None + self.time_range: Optional[tuple] = None + + def matches(self, entry: LogEntry) -> bool: + """Check if log entry matches filter criteria""" + # Level filter + if self.level_filter: + entry_level_value = getattr(logging, entry.level) + filter_level_value = getattr(logging, self.level_filter.value) + if entry_level_value < filter_level_value: + return False + + # Category filter + if self.category_filter and entry.category != self.category_filter.value: + return False + + # Module filter + if self.module_filter and entry.module != self.module_filter: + return False + + # Session filter + if self.session_filter and entry.session_id != self.session_filter: + return False + + # Client filter + if self.client_filter and entry.client_id != self.client_filter: + return False + + # IP filter + if self.ip_filter: + if not (entry.source_ip == self.ip_filter or + entry.dest_ip == self.ip_filter): + return False + + # Text filter + if self.text_filter: + if self.text_filter.lower() not in entry.message.lower(): + return False + + # Time range filter + if self.time_range: + start_time, end_time = self.time_range + if not (start_time <= entry.timestamp <= end_time): + return False + + return True + + +class LogAggregator: + """Aggregates and manages logs""" + + def __init__(self, max_entries: int = 10000): + self.entries: deque = deque(maxlen=max_entries) + self.subscribers: List[Callable[[LogEntry], None]] = [] + self.lock = threading.Lock() + + def add_entry(self, entry: LogEntry): + """Add a new log entry""" + with self.lock: + self.entries.append(entry) + # Notify subscribers + for subscriber in self.subscribers: + try: + subscriber(entry) + except Exception as e: + logging.error(f"Error notifying subscriber: {e}") + + def get_entries(self, log_filter: Optional[LogFilter] = None) -> List[LogEntry]: + """Get filtered log entries""" + with self.lock: + if log_filter is None: + return list(self.entries) + return [entry for entry in self.entries if log_filter.matches(entry)] + + def subscribe(self, callback: Callable[[LogEntry], None]): + """Subscribe to new log entries""" + with self.lock: + self.subscribers.append(callback) + + def unsubscribe(self, callback: Callable[[LogEntry], None]): + """Unsubscribe from log entries""" + with self.lock: + try: + self.subscribers.remove(callback) + except ValueError: + pass + + +class LogManager: + """Central logging manager""" + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self.aggregator = LogAggregator() + self.log_queue = queue.Queue() + self.file_handler = None + self.console_handler = None + + # Configure logging + self._configure_logging() + + # Start processing thread + self.running = True + self.process_thread = threading.Thread(target=self._process_queue) + self.process_thread.daemon = True + self.process_thread.start() + + self._initialized = True + + def _configure_logging(self): + """Configure logging handlers""" + log_dir = os.path.join(os.path.dirname(__file__), '../logs') + os.makedirs(log_dir, exist_ok=True) + + # File handler with rotation + log_file = os.path.join(log_dir, 'outline.log') + self.file_handler = logging.handlers.RotatingFileHandler( + log_file, maxBytes=10*1024*1024, backupCount=5 + ) + self.file_handler.setFormatter( + logging.Formatter('%(asctime)s [%(levelname)s] %(message)s') + ) + + # Console handler + self.console_handler = logging.StreamHandler() + self.console_handler.setFormatter( + logging.Formatter('[%(levelname)s] %(message)s') + ) + + # Configure root logger + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + root_logger.addHandler(self.file_handler) + root_logger.addHandler(self.console_handler) + + def _process_queue(self): + """Process log queue""" + while self.running: + try: + entry = self.log_queue.get(timeout=1) + self.aggregator.add_entry(entry) + except queue.Empty: + continue + except Exception as e: + logging.error(f"Error processing log entry: {e}") + + def log(self, level: LogLevel, category: LogCategory, module: str, + message: str, **kwargs): + """Add a log entry""" + entry = LogEntry( + timestamp=time.time(), + level=level.value, + category=category.value, + module=module, + message=message, + **kwargs + ) + self.log_queue.put(entry) + + # Also log to Python's logging system + log_func = getattr(logging, level.value.lower()) + log_func(f"[{category.value}] {message}") + + def get_logs(self, log_filter: Optional[LogFilter] = None) -> List[LogEntry]: + """Get filtered logs""" + return self.aggregator.get_entries(log_filter) + + def subscribe(self, callback: Callable[[LogEntry], None]): + """Subscribe to log entries""" + self.aggregator.subscribe(callback) + + def unsubscribe(self, callback: Callable[[LogEntry], None]): + """Unsubscribe from log entries""" + self.aggregator.unsubscribe(callback) + + def shutdown(self): + """Shutdown the log manager""" + self.running = False + if self.process_thread.is_alive(): + self.process_thread.join() + + # Close handlers + if self.file_handler: + self.file_handler.close() + if self.console_handler: + self.console_handler.close() diff --git a/core/middleware.py b/core/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..2f75e159dca63d3d06b8cbc61ac1c13839463798 --- /dev/null +++ b/core/middleware.py @@ -0,0 +1,58 @@ +""" +Custom middleware for the application +""" +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +import logging +import time +from typing import Callable + +logger = logging.getLogger(__name__) + +class RequestLoggerMiddleware(BaseHTTPMiddleware): + """Middleware for logging requests""" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + start_time = time.time() + + # Get request details + method = request.method + url = str(request.url) + + try: + # Process the request + response = await call_next(request) + + # Calculate processing time + process_time = time.time() - start_time + + # Log the request details + logger.info( + f"Request: {method} {url} - Status: {response.status_code} - " + f"Processing Time: {process_time:.3f}s" + ) + + return response + + except Exception as e: + logger.error( + f"Request failed: {method} {url} - Error: {str(e)}" + ) + raise + +class ErrorHandlerMiddleware(BaseHTTPMiddleware): + """Middleware for handling errors""" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + try: + return await call_next(request) + except Exception as e: + # Log the error + logger.error(f"Unhandled error: {str(e)}", exc_info=True) + + # Return error response + return Response( + content={"detail": "Internal server error"}, + status_code=500 + ) diff --git a/core/models/__pycache__/user.cpython-311.pyc b/core/models/__pycache__/user.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6affde43b3c7b3a8bcaf7b99176563d508ceb3d Binary files /dev/null and b/core/models/__pycache__/user.cpython-311.pyc differ diff --git a/core/models/user.py b/core/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..66ffaabf2c81f07a6e77bc69b4e44454a81e7499 --- /dev/null +++ b/core/models/user.py @@ -0,0 +1,98 @@ +""" +User models and authentication +""" +from datetime import datetime, timedelta +from typing import Optional, List +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Enum as SQLEnum +from sqlalchemy.sql import func +import enum +from passlib.context import CryptContext +from core.database import Base + +# Password hashing context +pwd_context = CryptContext( + schemes=["argon2"], + deprecated="auto", + argon2__memory_cost=65536, + argon2__parallelism=4, + argon2__time_cost=3 +) + +class UserRole(enum.Enum): + ADMIN = "admin" + POWER_USER = "power_user" + USER = "user" + GUEST = "guest" + +class UserStatus(enum.Enum): + ACTIVE = "active" + SUSPENDED = "suspended" + LOCKED = "locked" + PENDING = "pending" + EXPIRED = "expired" + +class User(Base): + """User model for authentication and authorization""" + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, unique=True, index=True, nullable=False) + password_hash = Column(String, nullable=False) + role = Column(SQLEnum(UserRole), nullable=False, default=UserRole.USER) + status = Column(SQLEnum(UserStatus), nullable=False, default=UserStatus.PENDING) + + # Account tracking + created_at = Column(DateTime(timezone=True), server_default=func.now()) + last_login = Column(DateTime(timezone=True), nullable=True) + failed_attempts = Column(Integer, default=0) + lockout_until = Column(DateTime(timezone=True), nullable=True) + + def verify_password(self, password: str) -> bool: + """Verify password against hash""" + return pwd_context.verify(password, self.password_hash) + + @staticmethod + def hash_password(password: str) -> str: + """Hash password using Argon2""" + return pwd_context.hash(password) + + def is_locked(self) -> bool: + """Check if account is locked""" + if self.lockout_until and self.lockout_until > datetime.utcnow(): + return True + return False + + def record_login_attempt(self, success: bool): + """Record login attempt and handle lockout""" + if success: + self.failed_attempts = 0 + self.last_login = datetime.utcnow() + self.lockout_until = None + else: + self.failed_attempts += 1 + if self.failed_attempts >= 5: # Lock after 5 failed attempts + self.lockout_until = datetime.utcnow() + timedelta(minutes=15) + +class UserSession(Base): + """User session tracking""" + __tablename__ = "user_sessions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, nullable=False) + token = Column(String, unique=True, index=True, nullable=False) + ip_address = Column(String) + device_info = Column(String) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + expires_at = Column(DateTime(timezone=True), nullable=False) + last_active = Column(DateTime(timezone=True), server_default=func.now()) + +class UserPermission(Base): + """User specific permissions""" + __tablename__ = "user_permissions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, nullable=False) + permission_type = Column(String, nullable=False) + resource_id = Column(String, nullable=True) # Optional resource identifier + granted_at = Column(DateTime(timezone=True), server_default=func.now()) + granted_by = Column(Integer, nullable=True) # User ID who granted the permission diff --git a/core/nat_engine.py b/core/nat_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..543c88ea19239a2a75e7473c7d6503ec83e51e30 --- /dev/null +++ b/core/nat_engine.py @@ -0,0 +1,135 @@ +""" +NAT Engine Module for VPN +Implements Network Address Translation +""" + +import asyncio +import time +from typing import Dict, Optional, Tuple +from dataclasses import dataclass +import logging +import subprocess + +logger = logging.getLogger(__name__) + +@dataclass +class NATSession: + virtual_ip: str + virtual_port: int + real_ip: str + real_port: int + host_port: int + created_time: float + last_activity: float + bytes_in: int = 0 + bytes_out: int = 0 + +class NATEngine: + def __init__(self, logger_instance=None): + self.sessions: Dict[str, NATSession] = {} + self.port_mappings: Dict[int, Tuple[str, int]] = {} + self.next_port = 10000 + self.cleanup_interval = 300 # 5 minutes + self._cleanup_task = None + self.logger = logger_instance if logger_instance else logging.getLogger(__name__) + + async def start(self): + """Start the NAT engine""" + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + self.logger.info("NAT engine started") + + async def stop(self): + """Stop the NAT engine""" + if self._cleanup_task: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + self.sessions.clear() + self.port_mappings.clear() + self.logger.info("NAT engine stopped") + + def create_session(self, virtual_ip: str, virtual_port: int, + real_ip: str, real_port: int) -> NATSession: + """Create a new NAT session""" + host_port = self._allocate_port() + session = NATSession( + virtual_ip=virtual_ip, + virtual_port=virtual_port, + real_ip=real_ip, + real_port=real_port, + host_port=host_port, + created_time=time.time(), + last_activity=time.time() + ) + + session_key = f"{virtual_ip}:{virtual_port}" + self.sessions[session_key] = session + self.port_mappings[host_port] = (virtual_ip, virtual_port) + + self.logger.debug(f"Created NAT session: {session_key} -> {real_ip}:{real_port}") + return session + + def lookup_session(self, ip: str, port: int) -> Optional[NATSession]: + """Look up a NAT session by real IP and port""" + for session in self.sessions.values(): + if session.real_ip == ip and session.real_port == port: + return session + return None + + def get_session_by_virtual(self, ip: str, port: int) -> Optional[NATSession]: + """Get session by virtual IP and port""" + return self.sessions.get(f"{ip}:{port}") + + def remove_session(self, session: NATSession): + """Remove a NAT session""" + session_key = f"{session.virtual_ip}:{session.virtual_port}" + if session_key in self.sessions: + del self.sessions[session_key] + if session.host_port in self.port_mappings: + del self.port_mappings[session.host_port] + + def _allocate_port(self) -> int: + """Allocate a new port for NAT""" + while self.next_port in self.port_mappings: + self.next_port += 1 + if self.next_port > 65535: + self.next_port = 10000 + return self.next_port + + async def _cleanup_loop(self): + """Periodically clean up expired sessions""" + while True: + try: + await asyncio.sleep(self.cleanup_interval) + current_time = time.time() + + for session_key, session in list(self.sessions.items()): + if current_time - session.last_activity > self.cleanup_interval: + self.remove_session(session) + self.logger.debug(f"Removed expired session: {session_key}") + + except asyncio.CancelledError: + break + except Exception as e: + self.logger.error(f"Error in cleanup loop: {e}") + + def get_stats(self) -> Dict: + """Get NAT engine statistics""" + return { + "active_sessions": len(self.sessions), + "allocated_ports": len(self.port_mappings), + "total_bytes_in": sum(s.bytes_in for s in self.sessions.values()), + "total_bytes_out": sum(s.bytes_out for s in self.sessions.values()) + } + + + + + async def setup_nat(self, interface: str): + """Setup NAT configuration""" + # In this Windows implementation, NAT is handled at the application level + # through the traffic forwarding engine and doesn't require system-level configuration + logger.info(f"NAT configuration ready for interface {interface}") + pass diff --git a/core/outline_config.py b/core/outline_config.py new file mode 100644 index 0000000000000000000000000000000000000000..7555713689c954c104739007ebf77e6f4720a683 --- /dev/null +++ b/core/outline_config.py @@ -0,0 +1,126 @@ +""" +Outline VPN Configuration Manager +""" + +import os +import json +from dataclasses import dataclass +from typing import List, Optional, Dict + +@dataclass +class OutlineConfig: + port: int = 443 + cipher: str = "chacha20-ietf-poly1305" + timeout: int = 600 + workers: int = 4 + bind_address: str = "0.0.0.0" + access_key_salt: str = os.urandom(32).hex() + +@dataclass +class UserConfig: + user_id: str + access_key: str + data_limit: Optional[int] = None + expiry_date: Optional[str] = None + is_active: bool = True + last_connection: Optional[str] = None + bandwidth_usage: int = 0 + +class OutlineManager: + def __init__(self, config_path: str = "config/outline_config.json"): + self.config_path = config_path + self.config = OutlineConfig() + self.users: List[UserConfig] = [] + self._load_config() + + def _load_config(self): + """Load configuration from file or create default""" + os.makedirs(os.path.dirname(self.config_path), exist_ok=True) + + if os.path.exists(self.config_path): + with open(self.config_path, 'r') as f: + data = json.load(f) + self.config = OutlineConfig(**data.get('server', {})) + self.users = [UserConfig(**u) for u in data.get('users', [])] + else: + self.save_config() + + def save_config(self): + """Save current configuration to file""" + data = { + 'server': self.config.__dict__, + 'users': [u.__dict__ for u in self.users] + } + with open(self.config_path, 'w') as f: + json.dump(data, f, indent=4) + + def add_user(self, user_id: str, data_limit: Optional[int] = None) -> UserConfig: + """Add a new user and generate their access key""" + # Check if user already exists + if any(u.user_id == user_id for u in self.users): + raise ValueError(f"User {user_id} already exists") + + access_key = self._generate_access_key(user_id) + user = UserConfig( + user_id=user_id, + access_key=access_key, + data_limit=data_limit + ) + self.users.append(user) + self.save_config() + return user + + def remove_user(self, user_id: str) -> bool: + """Remove a user by their ID""" + initial_length = len(self.users) + self.users = [u for u in self.users if u.user_id != user_id] + if len(self.users) < initial_length: + self.save_config() + return True + return False + + def get_user_by_key(self, access_key: str) -> Optional[UserConfig]: + """Find user by their access key""" + for user in self.users: + if user.access_key == access_key and user.is_active: + return user + return None + + def update_user_bandwidth(self, user_id: str, bytes_used: int): + """Update user's bandwidth usage""" + for user in self.users: + if user.user_id == user_id: + user.bandwidth_usage += bytes_used + if user.data_limit and user.bandwidth_usage >= user.data_limit: + user.is_active = False + self.save_config() + break + + def _generate_access_key(self, user_id: str) -> str: + """Generate a unique access key for a user""" + import hashlib + key = hashlib.sha256(f"{user_id}{self.config.access_key_salt}".encode()).hexdigest() + return key[:32] # Return first 32 chars as access key + + def get_server_stats(self) -> Dict: + """Get server statistics""" + return { + "total_users": len(self.users), + "active_users": sum(1 for u in self.users if u.is_active), + "total_bandwidth": sum(u.bandwidth_usage for u in self.users) + } + + +def generate_openvpn_certificates(config_id: str) -> Dict: + """Placeholder for OpenVPN certificate generation""" + return {"cert": "placeholder_cert", "key": "placeholder_key"} + +def generate_openvpn_config(config_id: str, server_ip: str) -> str: + """Placeholder for OpenVPN config generation""" + return f"client\ndev tun\nproto udp\nremote {server_ip} 1194\nresolv-retry infinite\nnobind\npersist-key\npersist-tun\nremote-cert-tls server\ncipher AES-256-CBC\nverb 3" + +def generate_wireguard_keys(config_id: str) -> Dict: + """Placeholder for WireGuard key generation""" + return {"server_public": "placeholder_server_public", "client_private": "placeholder_client_private", "client_public": "placeholder_client_public"} + + diff --git a/core/outline_server.py b/core/outline_server.py new file mode 100644 index 0000000000000000000000000000000000000000..78a8e90e921443dc986fc83a032d4b3016172eb8 --- /dev/null +++ b/core/outline_server.py @@ -0,0 +1,181 @@ +""" +Enhanced Outline VPN Server Implementation +Core server component with offline access and internet sharing support +""" + +import asyncio +import logging +import json +import os +import psutil +from typing import Dict, Optional, List, Tuple +from .shadowsocks_protocol import ShadowsocksProtocol +from .nat_engine import NATEngine +from .traffic_router import TrafficRouter +from .outline_config import OutlineManager, OutlineConfig +from .ikev2_server import IKEv2Server as IPsecManager +from .session_tracker import SessionTracker +from .port_manager import PortManager + +logger = logging.getLogger(__name__) + +class OutlineServer: + def __init__(self, config: Dict): + self.config = config + self.outline_manager = OutlineManager() + self.ipsec_manager = IPsecManager(config["server"]["host"], logger) + + # Initialize authentication + from .vpn_auth import VPNAuthManager + from .database_init import init_db + + # Initialize database + if not init_db(): + raise RuntimeError("Failed to initialize database") + + self.auth_manager = VPNAuthManager() + + # Initialize port manager + self.port_manager = PortManager() + + # Initialize components + self.nat_engine = NATEngine(logger) + self.traffic_router = TrafficRouter({ + "vpn_host": "0.0.0.0", # Listen on all interfaces + "vpn_port": config["server"]["port"], + "virtual_network": config["server"]["virtual_network"] + }, logger) + + # Initialize session tracker for offline support + self.session_tracker = SessionTracker() + + self.sessions = {} + self.is_running = False + self.bound_ports: List[Tuple[int, str]] = [] # List of (port, service) tuples + + async def setup_internet_sharing(self): + """Configure internet sharing for VPN clients""" + try: + interface = self.config["server"].get("interface", "eth0") + await self.nat_engine.setup_nat(interface) + logger.info(f"Internet sharing enabled on interface {interface}") + except Exception as e: + logger.error(f"Failed to setup internet sharing: {e}") + raise + async def start(self): + """Start the VPN server""" + if self.is_running: + logger.warning("Outline VPN server is already running") + return + + try: + # Clean up any existing connections first + await self.cleanup_existing_connections() + + # Setup network + await self.setup_internet_sharing() + + # Start components in order with proper error handling + try: + await self.nat_engine.start() + except Exception as e: + logger.error(f"Failed to start NAT engine: {e}") + raise + + try: + await self.traffic_router.start() + except Exception as e: + await self.nat_engine.stop() + logger.error(f"Failed to start traffic router: {e}") + raise + + try: + await self.ipsec_manager.start() + except Exception as e: + await self.nat_engine.stop() + await self.traffic_router.stop() + logger.error(f"Failed to start IKEv2 service: {e}") + raise + + self.is_running = True + logger.info(f"Outline VPN server started successfully on port {self.config['server']['port']}") + + except Exception as e: + logger.error(f"Failed to start server: {e}") + await self.stop() # Ensure cleanup on failure + raise + + async def cleanup_existing_connections(self): + """Clean up any existing connections before starting""" + import psutil + current_pid = os.getpid() + + # Find and clean up any existing VPN processes + for proc in psutil.process_iter(['pid', 'name', 'connections']): + try: + if proc.pid != current_pid: + for conn in proc.connections(): + if conn.laddr.port == self.config['server']['port']: + logger.warning(f"Found existing process using port {self.config['server']['port']}, terminating...") + proc.terminate() + proc.wait(timeout=5) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.TimeoutExpired): + continue + + async def stop(self): + """Stop the VPN server""" + self.is_running = False + await self.traffic_router.stop() + await self.nat_engine.stop() + await self.ipsec_manager.stop() + + # Close all active sessions + for session in self.sessions.values(): + await session.close() + self.sessions.clear() + + logger.info("VPN server stopped") + + async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + """Handle new client connections""" + peer = writer.get_extra_info('peername') + logger.info(f"New connection from {peer}") + + try: + # First packet contains access key + first_packet = await reader.read(64) + access_key = self._extract_access_key(first_packet) + + # Validate access key + user = self.outline_manager.get_user_by_key(access_key) + if not user: + logger.warning(f"Invalid access key from {peer}") + writer.close() + return + + # Create protocol handler + protocol = ShadowsocksProtocol(access_key) + self.sessions[user.user_id] = protocol + + # Handle the connection + await protocol.handle_connection(reader, writer) + + except Exception as e: + logger.error(f"Error handling client {peer}: {e}") + finally: + if not writer.is_closing(): + writer.close() + await writer.wait_closed() + + def _extract_access_key(self, packet: bytes) -> str: + """Extract access key from initial packet""" + return packet[:32].hex() + + def get_stats(self) -> Dict: + """Get server statistics""" + return { + "is_running": self.is_running, + "active_sessions": len(self.sessions), + "traffic_stats": self.traffic_router.get_stats(), + "nat_stats": self.nat_engine.get_stats() + } diff --git a/core/port_manager.py b/core/port_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..38ba668b51c26834dea078ec76a46f044cc4b66c --- /dev/null +++ b/core/port_manager.py @@ -0,0 +1,113 @@ +""" +Port Manager for VPN Server +Handles port allocation, testing, and management +""" + +import socket +import asyncio +import logging +from typing import List, Optional, Tuple +from contextlib import closing + +logger = logging.getLogger(__name__) + +class PortManager: + # Common VPN ports to try + DEFAULT_VPN_PORTS = [ + 443, # HTTPS + 8388, # Shadowsocks default + 8443, # Alternative HTTPS + 1194, # OpenVPN default + 1984, # Outline default + 8000, # Alternative + 8080 # Alternative HTTP + ] + + def __init__(self): + self.bound_ports: List[int] = [] + self.reserved_ports: List[int] = [] + + async def find_available_port(self, preferred_port: int, + fallback_ports: List[int] = None, + bind_address: str = '0.0.0.0') -> Tuple[int, bool]: + """ + Find an available port, starting with preferred_port. + Returns: (port_number, is_preferred_port) + """ + # Try preferred port first + if await self._test_port(preferred_port, bind_address): + return preferred_port, True + + # Try fallback ports + ports_to_try = (fallback_ports or []) + self.DEFAULT_VPN_PORTS + for port in ports_to_try: + if port != preferred_port and await self._test_port(port, bind_address): + return port, False + + # If no predefined ports work, scan for any available port + port = await self._scan_for_available_port(bind_address) + return port, False + + async def _test_port(self, port: int, bind_address: str) -> bool: + """Test if a port is available for both TCP and UDP""" + if port in self.bound_ports or port in self.reserved_ports: + return False + + try: + # Test TCP + tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + tcp_sock.bind((bind_address, port)) + tcp_sock.close() + + # Test UDP + udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + udp_sock.bind((bind_address, port)) + udp_sock.close() + + return True + + except OSError: + return False + + async def _scan_for_available_port(self, bind_address: str, + start_port: int = 10000, + end_port: int = 65535) -> int: + """Scan for any available port in the given range""" + for port in range(start_port, end_port): + if await self._test_port(port, bind_address): + return port + raise RuntimeError("No available ports found") + + async def reserve_port(self, port: int): + """Reserve a port for future use""" + if port not in self.bound_ports and port not in self.reserved_ports: + self.reserved_ports.append(port) + + async def release_port(self, port: int): + """Release a reserved or bound port""" + if port in self.bound_ports: + self.bound_ports.remove(port) + if port in self.reserved_ports: + self.reserved_ports.remove(port) + + async def bind_port(self, port: int, bind_address: str = '0.0.0.0') -> bool: + """ + Attempt to bind to a port and mark it as bound if successful + Returns True if binding was successful + """ + if await self._test_port(port, bind_address): + self.bound_ports.append(port) + if port in self.reserved_ports: + self.reserved_ports.remove(port) + return True + return False + + def get_bound_ports(self) -> List[int]: + """Get list of currently bound ports""" + return self.bound_ports.copy() + + def get_reserved_ports(self) -> List[int]: + """Get list of currently reserved ports""" + return self.reserved_ports.copy() diff --git a/core/pptp_server.py b/core/pptp_server.py new file mode 100644 index 0000000000000000000000000000000000000000..c258946282ad1f78ffa48f983fad2fc2c58eb480 --- /dev/null +++ b/core/pptp_server.py @@ -0,0 +1,179 @@ +""" +PPTP Server Implementation +Handles PPTP tunneling and packet forwarding +""" + +import asyncio +import socket +import struct +from typing import Dict, Optional, Tuple +from dataclasses import dataclass +import os +import hmac +import hashlib +from .ip_parser import IPv4Header, IPParser +from .logger import Logger, LogCategory + +@dataclass +class PPTPSession: + call_id: int + peer_call_id: int + client_ip: str + assigned_ip: str + created_at: float + last_seen: float + bytes_in: int = 0 + bytes_out: int = 0 + +class PPTPServer: + """PPTP server implementation""" + + def __init__(self, logger: Logger, ip_pool_start: str = "10.20.0.2"): + self.logger = logger + self.sessions: Dict[int, PPTPSession] = {} # call_id -> session + self.next_call_id = 1 + self.next_ip = ip_pool_start + self._running = False + self._transport = None + + async def start(self, host: str = "0.0.0.0", port: int = 1723): + """Start PPTP server""" + loop = asyncio.get_running_loop() + self._transport, _ = await loop.create_server( + lambda: PPTPProtocol(self), + host, port + ) + self._running = True + self.logger.info(LogCategory.SYSTEM, "pptp_server", f"PPTP server started on {host}:{port}") + + async def stop(self): + """Stop PPTP server""" + if self._transport: + self._transport.close() + self._running = False + self.logger.info(LogCategory.SYSTEM, "pptp_server", "PPTP server stopped") + + def allocate_ip(self) -> str: + """Allocate next available IP from pool""" + allocated = self.next_ip + # Increment last octet, handling rollover + last_octet = int(self.next_ip.split('.')[-1]) + if last_octet >= 254: + raise ValueError("IP pool exhausted") + self.next_ip = f"10.20.0.{last_octet + 1}" + return allocated + + async def handle_packet(self, data: bytes, addr: Tuple[str, int]): + """Handle incoming PPTP packet""" + try: + if len(data) < 8: + return + + # Parse PPTP header + message_type = struct.unpack('!H', data[2:4])[0] + + if message_type == 1: # Control Message + await self._handle_control(data, addr) + else: # Data Message (GRE) + await self._handle_gre(data, addr) + + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "pptp_server", f"Error handling packet: {e}") + + async def _handle_control(self, data: bytes, addr: Tuple[str, int]): + """Handle PPTP control message""" + control_type = struct.unpack('!H', data[8:10])[0] + + if control_type == 1: # Start-Control-Connection-Request + # Send Start-Control-Connection-Reply + reply = self._build_start_control_reply() + await self._send_control(reply, addr) + + elif control_type == 7: # Outgoing-Call-Request + call_id = struct.unpack('!H', data[12:14])[0] + + # Create new session + session = PPTPSession( + call_id=call_id, + peer_call_id=self.next_call_id, + client_ip=addr[0], + assigned_ip=self.allocate_ip(), + created_at=asyncio.get_running_loop().time(), + last_seen=asyncio.get_running_loop().time() + ) + self.sessions[call_id] = session + self.next_call_id += 1 + + # Send Outgoing-Call-Reply + reply = self._build_outgoing_call_reply(session) + await self._send_control(reply, addr) + + async def _handle_gre(self, data: bytes, addr: Tuple[str, int]): + """Handle GRE encapsulated data""" + try: + if len(data) < 12: # GRE v1 header size + return + + # Parse GRE header + flags = struct.unpack('!H', data[0:2])[0] + protocol = struct.unpack('!H', data[2:4])[0] + payload_len = struct.unpack('!H', data[4:6])[0] + call_id = struct.unpack('!H', data[6:8])[0] + + if call_id not in self.sessions: + return + + session = self.sessions[call_id] + session.last_seen = asyncio.get_running_loop().time() + + # Handle PPP payload + if protocol == 0x880B: # PPP + await self._handle_ppp(data[8:], session) + + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "pptp_server", f"Error handling GRE packet: {e}") + + async def _handle_ppp(self, data: bytes, session: PPTPSession): + """Handle PPP payload""" + try: + if len(data) < 4: + return + + protocol = struct.unpack('!H', data[2:4])[0] + + if protocol == 0x0021: # IP + ip_packet = data[4:] + await self._handle_ip_packet(ip_packet, session) + + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "pptp_server", f"Error handling PPP packet: {e}") + + async def _handle_ip_packet(self, data: bytes, session: PPTPSession): + """Handle IP packet inside PPP frame""" + try: + # Parse IP header + ip_header = IPParser.parse_ipv4_header(data) + + # Update statistics + session.bytes_in += len(data) + + # Forward packet to destination + if ip_header.protocol == socket.IPPROTO_TCP: + await self._forward_tcp(data, session) + elif ip_header.protocol == socket.IPPROTO_UDP: + await self._forward_udp(data, session) + + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "pptp_server", f"Error handling IP packet: {e}") + + async def _forward_tcp(self, data: bytes, session: PPTPSession): + """Forward TCP packet""" + # This will be handled by the TCP forwarding engine + # Just a placeholder for now + pass + + async def _forward_udp(self, data: bytes, session: PPTPSession): + """Forward UDP packet""" + # This will be handled by the UDP forwarding engine + # Just a placeholder for now + pass diff --git a/core/process_lock.py b/core/process_lock.py new file mode 100644 index 0000000000000000000000000000000000000000..0eb65c3e089748e4d22329398fc4461994b44615 --- /dev/null +++ b/core/process_lock.py @@ -0,0 +1,51 @@ +""" +Process Lock Handler for VPN Server +Ensures only one instance of the server is running +""" + +import os +import fcntl +import errno +import atexit +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + +class ProcessLock: + def __init__(self, lock_file: str = "/tmp/outline_vpn.lock"): + self.lock_file = lock_file + self.lock_fd: Optional[int] = None + atexit.register(self.release) + + def acquire(self) -> bool: + """Acquire process lock. Returns True if successful, False if already locked.""" + try: + # Create or open lock file + self.lock_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR) + fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + + # Write PID to lock file + os.truncate(self.lock_fd, 0) + os.write(self.lock_fd, str(os.getpid()).encode()) + + return True + + except (IOError, OSError) as e: + if e.errno == errno.EWOULDBLOCK: + # Another instance is running + logger.warning("Another instance of the VPN server is already running") + else: + logger.error(f"Failed to acquire process lock: {e}") + return False + + def release(self): + """Release the process lock""" + if self.lock_fd is not None: + try: + fcntl.flock(self.lock_fd, fcntl.LOCK_UN) + os.close(self.lock_fd) + os.unlink(self.lock_file) + self.lock_fd = None + except (IOError, OSError) as e: + logger.error(f"Failed to release process lock: {e}") diff --git a/core/services/__pycache__/user_service.cpython-311.pyc b/core/services/__pycache__/user_service.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fda1b5284fe295de88458bfca1842f00ac4f5d64 Binary files /dev/null and b/core/services/__pycache__/user_service.cpython-311.pyc differ diff --git a/core/services/user_service.py b/core/services/user_service.py new file mode 100644 index 0000000000000000000000000000000000000000..d67967b9fb365d3b0536fd52055e1112e2c6f811 --- /dev/null +++ b/core/services/user_service.py @@ -0,0 +1,99 @@ +""" +User management and authentication services +""" +from datetime import datetime, timedelta +from typing import Optional, List, Tuple +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from core.models.user import User, UserRole, UserStatus, UserSession, UserPermission + +class UserService: + def __init__(self, db: Session): + self.db = db + + def create_user(self, username: str, password: str, role: UserRole = UserRole.USER) -> Tuple[bool, str, Optional[User]]: + """ + Create a new user + Returns: (success, message, user) + """ + try: + user = User( + username=username, + password_hash=User.hash_password(password), + role=role, + status=UserStatus.ACTIVE + ) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return True, "User created successfully", user + except IntegrityError: + self.db.rollback() + return False, "Username already exists", None + except Exception as e: + self.db.rollback() + return False, f"Error creating user: {str(e)}", None + + def authenticate_user(self, username: str, password: str) -> Tuple[bool, str, Optional[User]]: + """ + Authenticate user credentials + Returns: (success, message, user) + """ + user = self.db.query(User).filter(User.username == username).first() + + if not user: + return False, "Invalid username or password", None + + if user.is_locked(): + return False, f"Account is locked until {user.lockout_until}", None + + if user.status != UserStatus.ACTIVE: + return False, f"Account is {user.status.value}", None + + if not user.verify_password(password): + user.record_login_attempt(success=False) + self.db.commit() + return False, "Invalid username or password", None + + user.record_login_attempt(success=True) + self.db.commit() + return True, "Authentication successful", user + + def create_session(self, user: User, ip_address: str, device_info: str = None) -> UserSession: + """Create a new session for user""" + session = UserSession( + user_id=user.id, + token=self._generate_session_token(), + ip_address=ip_address, + device_info=device_info, + expires_at=datetime.utcnow() + timedelta(days=1) + ) + self.db.add(session) + self.db.commit() + self.db.refresh(session) + return session + + def validate_session(self, token: str) -> Tuple[bool, str, Optional[UserSession]]: + """Validate session token""" + session = self.db.query(UserSession).filter(UserSession.token == token).first() + + if not session: + return False, "Invalid session", None + + if session.expires_at < datetime.utcnow(): + return False, "Session expired", None + + # Update last active + session.last_active = datetime.utcnow() + self.db.commit() + + return True, "Session valid", session + + def get_user_permissions(self, user_id: int) -> List[UserPermission]: + """Get user permissions""" + return self.db.query(UserPermission).filter(UserPermission.user_id == user_id).all() + + def _generate_session_token(self) -> str: + """Generate a unique session token""" + import secrets + return secrets.token_urlsafe(32) diff --git a/core/session_tracker.py b/core/session_tracker.py new file mode 100644 index 0000000000000000000000000000000000000000..4572bf9342b8055ba2a071a70449ba78798d7c56 --- /dev/null +++ b/core/session_tracker.py @@ -0,0 +1,287 @@ +""" +Session Tracker Module + +Manages and tracks all network sessions across the virtual ISP stack: +- Unified session management across all modules +- Session lifecycle tracking +- Performance metrics and analytics +- Session correlation and debugging +""" + +import time +import threading +import uuid +from typing import Dict, List, Optional, Set, Any, Tuple +from dataclasses import dataclass, field +from enum import Enum +import json + +from .tcp_engine import TCPConnection +from .nat_engine import NATSession + + +class SessionType(Enum): + NAT_SESSION = "NAT_SESSION" + TCP_CONNECTION = "TCP_CONNECTION" + SOCKET_CONNECTION = "SOCKET_CONNECTION" + + +class SessionState(Enum): + INITIALIZING = "INITIALIZING" + ACTIVE = "ACTIVE" + IDLE = "IDLE" + CLOSING = "CLOSING" + CLOSED = "CLOSED" + ERROR = "ERROR" + + +@dataclass +class SessionMetrics: + """Session performance metrics""" + bytes_in: int = 0 + bytes_out: int = 0 + packets_in: int = 0 + packets_out: int = 0 + errors: int = 0 + retransmits: int = 0 + rtt_samples: List[float] = field(default_factory=list) + + @property + def total_bytes(self) -> int: + return self.bytes_in + self.bytes_out + + @property + def total_packets(self) -> int: + return self.packets_in + self.packets_out + + @property + def average_rtt(self) -> float: + return sum(self.rtt_samples) / len(self.rtt_samples) if self.rtt_samples else 0.0 + + def update_bytes(self, bytes_in: int = 0, bytes_out: int = 0): + """Update byte counters""" + self.bytes_in += bytes_in + self.bytes_out += bytes_out + + def update_packets(self, packets_in: int = 0, packets_out: int = 0): + """Update packet counters""" + self.packets_in += packets_in + self.packets_out += packets_out + + def add_rtt_sample(self, rtt: float): + """Add RTT sample""" + self.rtt_samples.append(rtt) + # Keep only last 100 samples + if len(self.rtt_samples) > 100: + self.rtt_samples = self.rtt_samples[-100:] + + def to_dict(self) -> Dict: + """Convert to dictionary""" + return { + 'bytes_in': self.bytes_in, + 'bytes_out': self.bytes_out, + 'packets_in': self.packets_in, + 'packets_out': self.packets_out, + 'total_bytes': self.total_bytes, + 'total_packets': self.total_packets, + 'errors': self.errors, + 'retransmits': self.retransmits, + 'average_rtt': self.average_rtt, + 'rtt_samples_count': len(self.rtt_samples) + } + + +@dataclass +class UnifiedSession: + """Unified session representation""" + id: str + type: SessionType + state: SessionState + start_time: float + last_active: float + source_ip: str + source_port: int + dest_ip: str + dest_port: int + metrics: SessionMetrics = field(default_factory=SessionMetrics) + metadata: Dict[str, Any] = field(default_factory=dict) + + @property + def duration(self) -> float: + """Get session duration""" + return time.time() - self.start_time + + @property + def idle_time(self) -> float: + """Get idle time""" + return time.time() - self.last_active + + def update_activity(self): + """Update last activity timestamp""" + self.last_active = time.time() + + def to_dict(self) -> Dict: + """Convert to dictionary""" + return { + 'id': self.id, + 'type': self.type.value, + 'state': self.state.value, + 'start_time': self.start_time, + 'last_active': self.last_active, + 'duration': self.duration, + 'idle_time': self.idle_time, + 'source_ip': self.source_ip, + 'source_port': self.source_port, + 'dest_ip': self.dest_ip, + 'dest_port': self.dest_port, + 'metrics': self.metrics.to_dict(), + 'metadata': self.metadata + } + + +class SessionTracker: + """Tracks all active network sessions""" + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self.sessions: Dict[str, UnifiedSession] = {} + self.lock = threading.Lock() + self.cleanup_thread = threading.Thread(target=self._cleanup_loop) + self.cleanup_thread.daemon = True + self.running = True + self.cleanup_thread.start() + + self._initialized = True + + def create_session(self, session_type: SessionType, source_ip: str, + source_port: int, dest_ip: str, dest_port: int, + **kwargs) -> UnifiedSession: + """Create a new session""" + session = UnifiedSession( + id=str(uuid.uuid4()), + type=session_type, + state=SessionState.INITIALIZING, + start_time=time.time(), + last_active=time.time(), + source_ip=source_ip, + source_port=source_port, + dest_ip=dest_ip, + dest_port=dest_port, + metadata=kwargs + ) + + with self.lock: + self.sessions[session.id] = session + + return session + + def get_session(self, session_id: str) -> Optional[UnifiedSession]: + """Get session by ID""" + return self.sessions.get(session_id) + + def update_session(self, session_id: str, state: Optional[SessionState] = None, + metrics_update: Optional[Dict] = None, + metadata_update: Optional[Dict] = None) -> bool: + """Update session state and metrics""" + session = self.get_session(session_id) + if not session: + return False + + with self.lock: + if state: + session.state = state + + if metrics_update: + session.metrics.update_bytes( + metrics_update.get('bytes_in', 0), + metrics_update.get('bytes_out', 0) + ) + session.metrics.update_packets( + metrics_update.get('packets_in', 0), + metrics_update.get('packets_out', 0) + ) + if 'rtt' in metrics_update: + session.metrics.add_rtt_sample(metrics_update['rtt']) + + if metadata_update: + session.metadata.update(metadata_update) + + session.update_activity() + + return True + + def close_session(self, session_id: str): + """Close a session""" + session = self.get_session(session_id) + if session: + with self.lock: + session.state = SessionState.CLOSED + + def get_all_sessions(self) -> List[UnifiedSession]: + """Get all active sessions""" + with self.lock: + return [s for s in self.sessions.values() + if s.state != SessionState.CLOSED] + + def get_sessions_by_type(self, session_type: SessionType) -> List[UnifiedSession]: + """Get sessions by type""" + with self.lock: + return [s for s in self.sessions.values() + if s.type == session_type and s.state != SessionState.CLOSED] + + def get_sessions_by_ip(self, ip_address: str) -> List[UnifiedSession]: + """Get sessions by IP address""" + with self.lock: + return [s for s in self.sessions.values() + if (s.source_ip == ip_address or s.dest_ip == ip_address) + and s.state != SessionState.CLOSED] + + def _cleanup_loop(self): + """Background cleanup loop""" + while self.running: + time.sleep(60) # Run every minute + try: + self._cleanup_sessions() + except Exception as e: + print(f"Error in cleanup loop: {e}") + + def _cleanup_sessions(self): + """Clean up old sessions""" + current_time = time.time() + to_remove = [] + + with self.lock: + for session_id, session in self.sessions.items(): + # Remove closed sessions after 5 minutes + if (session.state == SessionState.CLOSED and + current_time - session.last_active > 300): + to_remove.append(session_id) + # Remove idle sessions after 30 minutes + elif (session.state != SessionState.CLOSED and + current_time - session.last_active > 1800): + session.state = SessionState.CLOSED + to_remove.append(session_id) + + for session_id in to_remove: + del self.sessions[session_id] + + def shutdown(self): + """Shutdown the tracker""" + self.running = False + if self.cleanup_thread.is_alive(): + self.cleanup_thread.join() + + with self.lock: + self.sessions.clear() diff --git a/core/shadowsocks_protocol.py b/core/shadowsocks_protocol.py new file mode 100644 index 0000000000000000000000000000000000000000..631ce1fa4a6ec59bd4b00333bfefddd83cabfee1 --- /dev/null +++ b/core/shadowsocks_protocol.py @@ -0,0 +1,121 @@ +""" +Shadowsocks Protocol Implementation +""" + +import os +import asyncio +import hashlib +from typing import Optional, Tuple +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +import logging + +logger = logging.getLogger(__name__) + +class ShadowsocksProtocol: + CHUNK_SIZE = 8192 + + def __init__(self, access_key: str): + self.access_key = access_key + self.cipher = self._create_cipher() + self.buffer = bytearray() + + def _create_cipher(self) -> ChaCha20Poly1305: + """Create ChaCha20-Poly1305 cipher""" + key = hashlib.sha256(self.access_key.encode()).digest() + return ChaCha20Poly1305(key) + + async def handle_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + """Handle client connection""" + try: + # Read and decrypt initial packet + data = await reader.read(self.CHUNK_SIZE) + if not data: + return + + # Extract target address + decrypted = self._decrypt_packet(data) + target_addr = self._extract_address(decrypted) + if not target_addr: + logger.error("Invalid target address") + return + + # Connect to target + target_reader, target_writer = await asyncio.open_connection( + target_addr[0], target_addr[1] + ) + + # Start bidirectional forwarding + await self._proxy_data(reader, writer, target_reader, target_writer) + + except Exception as e: + logger.error(f"Connection error: {e}") + finally: + writer.close() + await writer.wait_closed() + + async def _proxy_data(self, + client_reader: asyncio.StreamReader, + client_writer: asyncio.StreamWriter, + target_reader: asyncio.StreamReader, + target_writer: asyncio.StreamWriter): + """Handle bidirectional data forwarding""" + async def forward(reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + encrypt: bool = False): + try: + while True: + data = await reader.read(self.CHUNK_SIZE) + if not data: + break + if encrypt: + data = self._encrypt_packet(data) + writer.write(data) + await writer.drain() + except Exception as e: + logger.error(f"Forward error: {e}") + finally: + writer.close() + await writer.wait_closed() + + await asyncio.gather( + forward(client_reader, target_writer, encrypt=False), + forward(target_reader, client_writer, encrypt=True) + ) + + def _encrypt_packet(self, data: bytes) -> bytes: + """Encrypt a packet""" + nonce = os.urandom(12) + encrypted = self.cipher.encrypt(nonce, data, None) + return nonce + encrypted + + def _decrypt_packet(self, data: bytes) -> bytes: + """Decrypt a packet""" + nonce, ciphertext = data[:12], data[12:] + return self.cipher.decrypt(nonce, ciphertext, None) + + def _extract_address(self, data: bytes) -> Optional[Tuple[str, int]]: + """Extract address from Shadowsocks address header""" + try: + atyp = data[0] # Address type + + if atyp == 1: # IPv4 + addr = '.'.join(str(b) for b in data[1:5]) + port = int.from_bytes(data[5:7], 'big') + payload_start = 7 + elif atyp == 3: # Domain name + length = data[1] + addr = data[2:2+length].decode() + port = int.from_bytes(data[2+length:4+length], 'big') + payload_start = 4 + length + elif atyp == 4: # IPv6 + addr = ':'.join(format(b, '02x') for b in data[1:17]) + port = int.from_bytes(data[17:19], 'big') + payload_start = 19 + else: + return None + + return addr, port + + except Exception as e: + logger.error(f"Error extracting address: {e}") + return None diff --git a/core/socket_translator.py b/core/socket_translator.py new file mode 100644 index 0000000000000000000000000000000000000000..845a0520aec396ada3a76f2e8efce6360111fac6 --- /dev/null +++ b/core/socket_translator.py @@ -0,0 +1,339 @@ +""" +Socket Translator Module + +Bridges virtual connections to real host sockets: +- Map virtual connections to host sockets/HTTP clients +- Bidirectional data streaming +- Connection lifecycle management +- Protocol translation (TCP/UDP to host sockets) +""" + +import socket +import threading +import time +import asyncio +import aiohttp +import ssl +from typing import Dict, Optional, Callable, Tuple, Any +from dataclasses import dataclass +from enum import Enum +import urllib.parse +import json + +from .tcp_engine import TCPConnection + + +class ConnectionType(Enum): + TCP_SOCKET = "TCP_SOCKET" + UDP_SOCKET = "UDP_SOCKET" + HTTP_CLIENT = "HTTP_CLIENT" + HTTPS_CLIENT = "HTTPS_CLIENT" + + +@dataclass +class SocketConnection: + """Represents a socket connection""" + connection_id: str + connection_type: ConnectionType + virtual_connection: Optional[TCPConnection] + host_socket: Optional[socket.socket] + remote_host: str + remote_port: int + created_time: float + last_activity: float + bytes_sent: int = 0 + bytes_received: int = 0 + is_connected: bool = False + error_count: int = 0 + + def update_activity(self, bytes_transferred: int = 0, direction: str = 'sent'): + """Update connection activity""" + self.last_activity = time.time() + if direction == 'sent': + self.bytes_sent += bytes_transferred + else: + self.bytes_received += bytes_transferred + + def to_dict(self) -> Dict: + """Convert to dictionary""" + return { + 'connection_id': self.connection_id, + 'connection_type': self.connection_type.value, + 'remote_host': self.remote_host, + 'remote_port': self.remote_port, + 'created_time': self.created_time, + 'last_activity': self.last_activity, + 'bytes_sent': self.bytes_sent, + 'bytes_received': self.bytes_received, + 'is_connected': self.is_connected, + 'error_count': self.error_count, + 'duration': time.time() - self.created_time + } + + +class SocketTranslator: + """Manages socket translations and connections""" + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self.connections: Dict[str, SocketConnection] = {} + self.lock = threading.Lock() + self.cleanup_thread = threading.Thread(target=self._cleanup_loop) + self.cleanup_thread.daemon = True + self.running = True + self.cleanup_thread.start() + + # SSL context for HTTPS + self.ssl_context = ssl.create_default_context() + self.ssl_context.check_hostname = False + self.ssl_context.verify_mode = ssl.CERT_NONE + + # Async event loop + self.loop = asyncio.new_event_loop() + self.async_thread = threading.Thread(target=self._run_async_loop) + self.async_thread.daemon = True + self.async_thread.start() + + self._initialized = True + + def create_connection(self, connection_type: ConnectionType, + remote_host: str, remote_port: int, + virtual_connection: Optional[TCPConnection] = None) -> str: + """Create a new connection""" + connection = SocketConnection( + connection_id=str(time.time_ns()), + connection_type=connection_type, + virtual_connection=virtual_connection, + host_socket=None, + remote_host=remote_host, + remote_port=remote_port, + created_time=time.time(), + last_activity=time.time() + ) + + with self.lock: + self.connections[connection.connection_id] = connection + + return connection.connection_id + + async def connect(self, connection_id: str) -> bool: + """Establish connection""" + connection = self.connections.get(connection_id) + if not connection: + return False + + try: + if connection.connection_type in (ConnectionType.TCP_SOCKET, ConnectionType.UDP_SOCKET): + # Create socket + sock_type = socket.SOCK_STREAM if connection.connection_type == ConnectionType.TCP_SOCKET else socket.SOCK_DGRAM + sock = socket.socket(socket.AF_INET, sock_type) + sock.setblocking(False) + + # Connect + try: + sock.connect((connection.remote_host, connection.remote_port)) + except BlockingIOError: + pass # Expected for non-blocking socket + + connection.host_socket = sock + connection.is_connected = True + + # Start monitoring + asyncio.create_task(self._monitor_socket(connection)) + + elif connection.connection_type in (ConnectionType.HTTP_CLIENT, ConnectionType.HTTPS_CLIENT): + # HTTP(S) connection will be made per request + connection.is_connected = True + + connection.update_activity() + return True + + except Exception as e: + print(f"Connection error: {e}") + connection.error_count += 1 + return False + + async def send_data(self, connection_id: str, data: bytes) -> bool: + """Send data through connection""" + connection = self.connections.get(connection_id) + if not connection or not connection.is_connected: + return False + + try: + if connection.connection_type in (ConnectionType.TCP_SOCKET, ConnectionType.UDP_SOCKET): + await self.loop.sock_sendall(connection.host_socket, data) + connection.update_activity(len(data), 'sent') + + elif connection.connection_type in (ConnectionType.HTTP_CLIENT, ConnectionType.HTTPS_CLIENT): + # Parse HTTP request + request = self._parse_http_request(data) + if not request: + return False + + # Make HTTP request + async with aiohttp.ClientSession() as session: + url = f"{'https' if connection.connection_type == ConnectionType.HTTPS_CLIENT else 'http'}://{connection.remote_host}:{connection.remote_port}{request['path']}" + + async with session.request( + method=request['method'], + url=url, + headers=request['headers'], + data=request.get('body', b''), + ssl=self.ssl_context if connection.connection_type == ConnectionType.HTTPS_CLIENT else None + ) as response: + # Forward response to virtual connection + response_data = await response.read() + if connection.virtual_connection: + connection.virtual_connection.send_data(response_data) + + connection.update_activity(len(data), 'sent') + connection.update_activity(len(response_data), 'received') + + return True + + except Exception as e: + print(f"Send error: {e}") + connection.error_count += 1 + return False + + def close_connection(self, connection_id: str): + """Close a connection""" + connection = self.connections.get(connection_id) + if connection: + if connection.host_socket: + try: + connection.host_socket.close() + except: + pass + connection.is_connected = False + connection.update_activity() + + def get_connection(self, connection_id: str) -> Optional[SocketConnection]: + """Get connection by ID""" + return self.connections.get(connection_id) + + def _run_async_loop(self): + """Run async event loop""" + asyncio.set_event_loop(self.loop) + self.loop.run_forever() + + async def _monitor_socket(self, connection: SocketConnection): + """Monitor socket for incoming data""" + while connection.is_connected: + try: + data = await self.loop.sock_recv(connection.host_socket, 8192) + if not data: + break + + connection.update_activity(len(data), 'received') + + # Forward data to virtual connection + if connection.virtual_connection: + connection.virtual_connection.send_data(data) + + except Exception as e: + print(f"Monitor error: {e}") + connection.error_count += 1 + break + + connection.is_connected = False + + def _cleanup_loop(self): + """Background cleanup loop""" + while self.running: + time.sleep(60) # Run every minute + try: + self._cleanup_connections() + except Exception as e: + print(f"Cleanup error: {e}") + + def _cleanup_connections(self): + """Clean up inactive connections""" + current_time = time.time() + to_remove = [] + + with self.lock: + for connection_id, connection in self.connections.items(): + # Remove if: + # 1. Not connected and inactive for 5 minutes + # 2. Connected but inactive for 30 minutes + # 3. Too many errors + if ((not connection.is_connected and current_time - connection.last_activity > 300) or + (connection.is_connected and current_time - connection.last_activity > 1800) or + connection.error_count > 5): + self.close_connection(connection_id) + to_remove.append(connection_id) + + for connection_id in to_remove: + del self.connections[connection_id] + + def _parse_http_request(self, data: bytes) -> Optional[Dict]: + """Parse HTTP request from raw data""" + try: + # Split into lines + lines = data.decode('utf-8', errors='ignore').split('\r\n') + if not lines: + return None + + # Parse request line + request_line = lines[0].split(' ') + if len(request_line) < 3: + return None + + method, path, version = request_line[0], request_line[1], request_line[2] + + # Parse headers + headers = {} + i = 1 + while i < len(lines): + line = lines[i].strip() + if not line: + break + if ':' in line: + key, value = line.split(':', 1) + headers[key.strip()] = value.strip() + i += 1 + + # Get body + body = '\r\n'.join(lines[i+1:]).encode('utf-8') if i+1 < len(lines) else b'' + + return { + 'method': method, + 'path': path, + 'version': version, + 'headers': headers, + 'body': body + } + + except Exception as e: + print(f"HTTP parse error: {e}") + return None + + def shutdown(self): + """Shutdown the translator""" + self.running = False + if self.cleanup_thread.is_alive(): + self.cleanup_thread.join() + + # Close all connections + with self.lock: + for connection_id in list(self.connections.keys()): + self.close_connection(connection_id) + self.connections.clear() + + # Stop async loop + self.loop.stop() + if self.async_thread.is_alive(): + self.async_thread.join() diff --git a/core/tcp_engine.py b/core/tcp_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..ea2faf34dd8656e4cf77a531aac110fd4814a412 --- /dev/null +++ b/core/tcp_engine.py @@ -0,0 +1,356 @@ +""" +TCP Engine Module + +Implements a complete TCP state machine in user-space: +- Full TCP state machine (SYN, SYN-ACK, ESTABLISHED, FIN, RST) +- Sequence and acknowledgment number tracking +- Sliding window implementation +- Retransmission and timeout handling +- Congestion control +""" + +import time +import threading +import random +from typing import Dict, List, Optional, Tuple, Callable +from dataclasses import dataclass, field +from enum import Enum +from collections import deque + +from .ip_parser import TCPHeader, IPv4Header, IPParser + + +class TCPState(Enum): + CLOSED = "CLOSED" + LISTEN = "LISTEN" + SYN_SENT = "SYN_SENT" + SYN_RECEIVED = "SYN_RECEIVED" + ESTABLISHED = "ESTABLISHED" + FIN_WAIT_1 = "FIN_WAIT_1" + FIN_WAIT_2 = "FIN_WAIT_2" + CLOSE_WAIT = "CLOSE_WAIT" + CLOSING = "CLOSING" + LAST_ACK = "LAST_ACK" + TIME_WAIT = "TIME_WAIT" + + +@dataclass +class TCPSegment: + """Represents a TCP segment""" + seq_num: int + ack_num: int + flags: int + window: int + data: bytes + timestamp: float = field(default_factory=time.time) + retransmit_count: int = 0 + + @property + def data_length(self) -> int: + """Get data length""" + return len(self.data) + + @property + def seq_end(self) -> int: + """Get sequence number after this segment""" + length = self.data_length + # SYN and FIN consume one sequence number + if self.flags & 0x02: # SYN + length += 1 + if self.flags & 0x01: # FIN + length += 1 + return self.seq_num + length + + +@dataclass +class TCPConnection: + """Represents a TCP connection state""" + # Connection identification + local_ip: str + local_port: int + remote_ip: str + remote_port: int + + # State + state: TCPState = TCPState.CLOSED + + # Sequence numbers + local_seq: int = field(default_factory=lambda: random.randint(0, 0xFFFFFFFF)) + local_ack: int = 0 + remote_seq: int = 0 + remote_ack: int = 0 + initial_seq: int = 0 + + # Window management + local_window: int = 65535 + remote_window: int = 65535 + window_scale: int = 0 + + # Buffers + send_buffer: deque = field(default_factory=deque) + recv_buffer: deque = field(default_factory=deque) + out_of_order_buffer: Dict[int, bytes] = field(default_factory=dict) + + # Retransmission + unacked_segments: Dict[int, TCPSegment] = field(default_factory=dict) + retransmit_timer: Optional[float] = None + rto: float = 1.0 # Retransmission timeout + srtt: float = 0.0 # Smoothed round-trip time + rttvar: float = 0.0 # Round-trip time variation + + # Congestion control + cwnd: int = 1 # Congestion window (in MSS) + ssthresh: int = 65535 # Slow start threshold + dupacks: int = 0 # Duplicate ACK count + mss: int = 1460 # Maximum segment size + + # Callbacks + on_data_received: Optional[Callable[[bytes], None]] = None + on_state_change: Optional[Callable[[TCPState], None]] = None + + def __post_init__(self): + self.initial_seq = self.local_seq + + def handle_packet(self, packet: bytes): + """Process incoming TCP packet""" + try: + # Parse headers + ip_header, payload = IPParser.parse_ipv4_header(packet) + tcp_header, data = IPParser.parse_tcp_header(payload) + + # Process based on current state + if self.state == TCPState.LISTEN: + self._handle_listen(tcp_header, data) + elif self.state == TCPState.SYN_SENT: + self._handle_syn_sent(tcp_header, data) + elif self.state == TCPState.SYN_RECEIVED: + self._handle_syn_received(tcp_header, data) + elif self.state == TCPState.ESTABLISHED: + self._handle_established(tcp_header, data) + elif self.state in (TCPState.FIN_WAIT_1, TCPState.FIN_WAIT_2): + self._handle_fin_wait(tcp_header, data) + elif self.state == TCPState.CLOSE_WAIT: + self._handle_close_wait(tcp_header, data) + elif self.state == TCPState.LAST_ACK: + self._handle_last_ack(tcp_header, data) + + # Update RTT if this is an ACK for a sent packet + if tcp_header.ack and tcp_header.ack_num > self.local_seq: + self._update_rtt(tcp_header.ack_num) + + # Handle retransmission timer + self._manage_retransmission_timer() + + except Exception as e: + print(f"Error handling packet: {e}") + + def send_data(self, data: bytes): + """Send data over the connection""" + if self.state != TCPState.ESTABLISHED: + return False + + # Add to send buffer + self.send_buffer.extend(data) + + # Try to send what we can + self._send_from_buffer() + + return True + + def close(self): + """Initiate connection close""" + if self.state == TCPState.ESTABLISHED: + self._send_fin() + self._set_state(TCPState.FIN_WAIT_1) + elif self.state == TCPState.CLOSE_WAIT: + self._send_fin() + self._set_state(TCPState.LAST_ACK) + + def _set_state(self, new_state: TCPState): + """Change connection state""" + if new_state != self.state: + self.state = new_state + if self.on_state_change: + self.on_state_change(new_state) + + def _send_packet(self, flags: int, data: bytes = b''): + """Send TCP packet""" + segment = TCPSegment( + seq_num=self.local_seq, + ack_num=self.local_ack, + flags=flags, + window=self.local_window, + data=data + ) + + # Add to unacked segments if not pure ACK + if data or flags != 0x10: # Not pure ACK + self.unacked_segments[self.local_seq] = segment + + # Update sequence number + self.local_seq = (self.local_seq + len(data)) % 0x100000000 + if flags & 0x02: # SYN + self.local_seq = (self.local_seq + 1) % 0x100000000 + if flags & 0x01: # FIN + self.local_seq = (self.local_seq + 1) % 0x100000000 + + # TODO: Actually send the packet + + def _handle_listen(self, header: TCPHeader, data: bytes): + """Handle LISTEN state""" + if header.syn: + self.remote_seq = header.seq_num + self.local_ack = (header.seq_num + 1) % 0x100000000 + self._send_packet(0x12) # SYN-ACK + self._set_state(TCPState.SYN_RECEIVED) + + def _handle_syn_sent(self, header: TCPHeader, data: bytes): + """Handle SYN_SENT state""" + if header.syn and header.ack: + if header.ack_num == (self.initial_seq + 1) % 0x100000000: + self.remote_seq = header.seq_num + self.local_ack = (header.seq_num + 1) % 0x100000000 + self._send_packet(0x10) # ACK + self._set_state(TCPState.ESTABLISHED) + + def _handle_established(self, header: TCPHeader, data: bytes): + """Handle ESTABLISHED state""" + if data: + if header.seq_num == self.local_ack: + # In-order segment + if self.on_data_received: + self.on_data_received(data) + self.local_ack = (self.local_ack + len(data)) % 0x100000000 + self._send_packet(0x10) # ACK + elif header.seq_num > self.local_ack: + # Out-of-order segment + self.out_of_order_buffer[header.seq_num] = data + self._send_packet(0x10) # ACK + else: + # Duplicate segment + self._send_packet(0x10) # ACK + + if header.ack: + # Process acknowledgments + self._handle_ack(header.ack_num) + + if header.fin: + self.local_ack = (self.local_ack + 1) % 0x100000000 + self._send_packet(0x10) # ACK + self._set_state(TCPState.CLOSE_WAIT) + + def _handle_ack(self, ack_num: int): + """Handle incoming acknowledgment""" + # Remove acknowledged segments + acknowledged = [seq for seq in self.unacked_segments.keys() + if seq < ack_num] + for seq in acknowledged: + del self.unacked_segments[seq] + + # Update congestion window + if self.cwnd < self.ssthresh: + # Slow start + self.cwnd += 1 + else: + # Congestion avoidance + self.cwnd += 1 / self.cwnd + + # Try to send more data + self._send_from_buffer() + + def _send_from_buffer(self): + """Send data from send buffer""" + while self.send_buffer: + # Calculate how much we can send + window = min(self.remote_window, self.cwnd * self.mss) + if not window: + break + + # Get data to send + data = bytes(list(self.send_buffer)[:window]) + if not data: + break + + # Remove from buffer and send + for _ in range(len(data)): + self.send_buffer.popleft() + self._send_packet(0x18, data) # PSH-ACK + + def _update_rtt(self, ack_num: int): + """Update RTT estimation""" + for seq, segment in self.unacked_segments.items(): + if seq == ack_num - 1: + rtt = time.time() - segment.timestamp + if self.srtt == 0: + self.srtt = rtt + self.rttvar = rtt / 2 + else: + self.rttvar = (0.75 * self.rttvar + + 0.25 * abs(self.srtt - rtt)) + self.srtt = 0.875 * self.srtt + 0.125 * rtt + self.rto = self.srtt + max(4 * self.rttvar, 0.5) + break + + def _manage_retransmission_timer(self): + """Manage retransmission timer""" + if not self.unacked_segments: + self.retransmit_timer = None + return + + current_time = time.time() + if self.retransmit_timer is None: + self.retransmit_timer = current_time + self.rto + elif current_time >= self.retransmit_timer: + # Timeout occurred + self._handle_timeout() + + def _handle_timeout(self): + """Handle retransmission timeout""" + # Exponential backoff + self.rto *= 2 + + # Reset congestion window + self.ssthresh = max(2, self.cwnd // 2) + self.cwnd = 1 + + # Retransmit oldest unacked segment + if self.unacked_segments: + oldest_seq = min(self.unacked_segments.keys()) + segment = self.unacked_segments[oldest_seq] + if segment.retransmit_count < 5: + segment.retransmit_count += 1 + self._send_packet(segment.flags, segment.data) + else: + # Too many retransmissions, close connection + self._set_state(TCPState.CLOSED) + + # Reset timer + self.retransmit_timer = time.time() + self.rto + + def _send_fin(self): + """Send FIN packet""" + self._send_packet(0x11) # FIN-ACK + + def _handle_fin_wait(self, header: TCPHeader, data: bytes): + """Handle FIN_WAIT states""" + if self.state == TCPState.FIN_WAIT_1: + if header.ack and header.ack_num == self.local_seq: + self._set_state(TCPState.FIN_WAIT_2) + + if header.fin: + self.local_ack = (header.seq_num + 1) % 0x100000000 + self._send_packet(0x10) # ACK + if self.state == TCPState.FIN_WAIT_1: + self._set_state(TCPState.CLOSING) + else: # FIN_WAIT_2 + self._set_state(TCPState.TIME_WAIT) + + def _handle_close_wait(self, header: TCPHeader, data: bytes): + """Handle CLOSE_WAIT state""" + if header.ack: + self._handle_ack(header.ack_num) + + def _handle_last_ack(self, header: TCPHeader, data: bytes): + """Handle LAST_ACK state""" + if header.ack and header.ack_num == self.local_seq: + self._set_state(TCPState.CLOSED) diff --git a/core/tcp_forward.py b/core/tcp_forward.py new file mode 100644 index 0000000000000000000000000000000000000000..3cbc5d2599d2b97d14f3887bd19af36fd0622c14 --- /dev/null +++ b/core/tcp_forward.py @@ -0,0 +1,159 @@ +""" +TCP Forwarding Engine Module for Outline VPN + +Handles TCP traffic forwarding and connection tracking with Shadowsocks protocol support +""" + +import asyncio +import logging +import socket +from typing import Dict, Set, Optional, Tuple +from dataclasses import dataclass +from datetime import datetime +from .shadowsocks_protocol import ShadowsocksProtocol + +logger = logging.getLogger(__name__) + +@dataclass +class OutlineTCPConnection: + client_addr: Tuple[str, int] + target_addr: Tuple[str, int] + client_writer: asyncio.StreamWriter + target_writer: asyncio.StreamWriter + shadowsocks: ShadowsocksProtocol + bytes_in: int = 0 + bytes_out: int = 0 + created_at: datetime = datetime.now() + last_activity: datetime = datetime.now() + +class OutlineTCPForwardingEngine: + def __init__(self, access_key: str): + self.connections: Dict[str, OutlineTCPConnection] = {} + self.active_ports: Set[int] = set() + self._lock = asyncio.Lock() + self.buffer_size = 8192 + self.shadowsocks = ShadowsocksProtocol(access_key) + + async def create_connection(self, + client_reader: asyncio.StreamReader, + client_writer: asyncio.StreamWriter, + target_host: str, + target_port: int) -> Optional[OutlineTCPConnection]: + """Create a new TCP connection with Shadowsocks encryption""" + try: + # Get client information + client_addr = client_writer.get_extra_info('peername') + + # Connect to target + target_reader, target_writer = await asyncio.open_connection( + target_host, target_port + ) + + # Create connection object + conn = OutlineTCPConnection( + client_addr=client_addr, + target_addr=(target_host, target_port), + client_writer=client_writer, + target_writer=target_writer, + shadowsocks=self.shadowsocks + ) + + # Store connection + conn_id = f"{client_addr[0]}:{client_addr[1]}-{target_host}:{target_port}" + async with self._lock: + self.connections[conn_id] = conn + + # Start bidirectional forwarding with encryption + asyncio.create_task(self._forward_stream( + client_reader, target_writer, conn, 'in')) + asyncio.create_task(self._forward_stream( + target_reader, client_writer, conn, 'out')) + + logger.info(f"Created Outline TCP connection: {conn_id}") + return conn + + except Exception as e: + logger.error(f"Error creating Outline TCP connection: {e}") + if client_writer: + client_writer.close() + await client_writer.wait_closed() + return None + + async def _forward_stream(self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + conn: OutlineTCPConnection, + direction: str): + """Forward data with Shadowsocks encryption/decryption""" + try: + while True: + data = await reader.read(self.buffer_size) + if not data: + break + + # Handle encryption/decryption + if direction == 'in': + # Decrypt incoming data from client + data = conn.shadowsocks._decrypt_packet(data) + conn.bytes_in += len(data) + else: + # Encrypt outgoing data to client + data = conn.shadowsocks._encrypt_packet(data) + conn.bytes_out += len(data) + + writer.write(data) + await writer.drain() + conn.last_activity = datetime.now() + + except Exception as e: + logger.error(f"Error forwarding Outline data: {e}") + + finally: + writer.close() + await writer.wait_closed() + # Clean up connection + conn_id = (f"{conn.client_addr[0]}:{conn.client_addr[1]}-" + f"{conn.target_addr[0]}:{conn.target_addr[1]}") + async with self._lock: + if conn_id in self.connections: + del self.connections[conn_id] + + async def cleanup_inactive(self, timeout: int = 300): + """Clean up inactive connections""" + while True: + try: + now = datetime.now() + to_remove = [] + + async with self._lock: + for conn_id, conn in self.connections.items(): + if (now - conn.last_activity).seconds > timeout: + to_remove.append(conn_id) + + for conn_id in to_remove: + if conn_id in self.connections: + conn = self.connections[conn_id] + conn.client_writer.close() + conn.target_writer.close() + del self.connections[conn_id] + logger.info(f"Cleaned up inactive connection: {conn_id}") + + await asyncio.sleep(60) # Check every minute + + except Exception as e: + logger.error(f"Error in connection cleanup: {e}") + await asyncio.sleep(60) # Retry after error + + def get_connection_stats(self) -> Dict[str, Dict]: + """Get statistics for all active connections""" + stats = {} + for conn_id, conn in self.connections.items(): + stats[conn_id] = { + 'bytes_in': conn.bytes_in, + 'bytes_out': conn.bytes_out, + 'created_at': conn.created_at.isoformat(), + 'last_activity': conn.last_activity.isoformat(), + 'client_addr': f"{conn.client_addr[0]}:{conn.client_addr[1]}", + 'target_addr': f"{conn.target_addr[0]}:{conn.target_addr[1]}" + } + return stats diff --git a/core/traffic_forwarder.py b/core/traffic_forwarder.py new file mode 100644 index 0000000000000000000000000000000000000000..9ee4f183afc160f52b5758dac2de073f3b91d60e --- /dev/null +++ b/core/traffic_forwarder.py @@ -0,0 +1,185 @@ +""" +Traffic Forwarding Engine +Handles IP packet forwarding and NAT for VPN tunnels +""" + +import asyncio +import socket +import struct +from typing import Dict, Optional, Tuple, Union +from dataclasses import dataclass +import os +from .ip_parser import IPv4Header, IPParser +from .logger import Logger, LogCategory +from .nat_engine import NATEngine + +@dataclass +class ForwardSession: + src_ip: str + dst_ip: str + src_port: int + dst_port: int + protocol: int + created_at: float + last_seen: float + bytes_in: int = 0 + bytes_out: int = 0 + +class TrafficForwarder: + """Handles packet forwarding and NAT""" + + def __init__(self, logger: Logger, nat_engine: NATEngine): + self.logger = logger + self.nat_engine = nat_engine + self.sessions: Dict[Tuple[str, str, int, int, int], ForwardSession] = {} + self.tcp_connections = {} + self.udp_endpoints = {} + + async def forward_packet(self, data: bytes, client_ip: str) -> Optional[bytes]: + """Forward an IP packet""" + try: + # Parse IP header + ip_header = IPParser.parse_ipv4_header(data) + + # Apply NAT + translated_packet = self.nat_engine.translate_outbound(data) + if not translated_packet: + return None + + # Track session + session_key = ( + ip_header.src_ip, + ip_header.dst_ip, + ip_header.protocol, + self._get_src_port(data[ip_header.ihl*4:], ip_header.protocol), + self._get_dst_port(data[ip_header.ihl*4:], ip_header.protocol) + ) + + if session_key not in self.sessions: + self.sessions[session_key] = ForwardSession( + src_ip=ip_header.src_ip, + dst_ip=ip_header.dst_ip, + src_port=session_key[3], + dst_port=session_key[4], + protocol=ip_header.protocol, + created_at=asyncio.get_running_loop().time(), + last_seen=asyncio.get_running_loop().time() + ) + + session = self.sessions[session_key] + session.last_seen = asyncio.get_running_loop().time() + session.bytes_out += len(data) + + # Forward based on protocol + if ip_header.protocol == socket.IPPROTO_TCP: + return await self._forward_tcp(translated_packet, session) + elif ip_header.protocol == socket.IPPROTO_UDP: + return await self._forward_udp(translated_packet, session) + else: + # Forward other IP protocols directly + return translated_packet + + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "traffic_forwarder", f"Error forwarding packet: {e}") + return None + + async def _forward_tcp(self, data: bytes, session: ForwardSession) -> Optional[bytes]: + """Forward TCP packet""" + try: + ip_header = IPParser.parse_ipv4_header(data) + tcp_header_offset = ip_header.ihl * 4 + + if len(data) < tcp_header_offset + 20: # TCP header is at least 20 bytes + return None + + # Parse TCP header + tcp_header = data[tcp_header_offset:tcp_header_offset + 20] + flags = tcp_header[13] + seq_num = struct.unpack('!I', tcp_header[4:8])[0] + ack_num = struct.unpack('!I', tcp_header[8:12])[0] + + conn_key = (session.src_ip, session.src_port, session.dst_ip, session.dst_port) + + # Handle TCP state + if flags & 0x02: # SYN + if conn_key not in self.tcp_connections: + self.tcp_connections[conn_key] = { + 'state': 'SYN_SENT', + 'seq': seq_num, + 'ack': 0 + } + elif flags & 0x01: # FIN + if conn_key in self.tcp_connections: + self.tcp_connections[conn_key]['state'] = 'FIN_WAIT' + elif flags & 0x04: # RST + if conn_key in self.tcp_connections: + del self.tcp_connections[conn_key] + + # Forward the packet + return await self._send_packet(data) + + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "traffic_forwarder", f"Error forwarding TCP: {e}") + return None + + async def _forward_udp(self, data: bytes, session: ForwardSession) -> Optional[bytes]: + """Forward UDP packet""" + try: + ip_header = IPParser.parse_ipv4_header(data) + udp_header_offset = ip_header.ihl * 4 + + if len(data) < udp_header_offset + 8: # UDP header is 8 bytes + return None + + # Track UDP endpoint + endpoint_key = (session.src_ip, session.src_port, session.dst_ip, session.dst_port) + self.udp_endpoints[endpoint_key] = asyncio.get_running_loop().time() + + # Forward the packet + return await self._send_packet(data) + + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "traffic_forwarder", f"Error forwarding UDP: {e}") + return None + + async def _send_packet(self, data: bytes) -> Optional[bytes]: + """Send packet to destination""" + try: + # This is where you'd actually send the packet + # For now, we'll just return it for the VPN server to handle + return data + + except Exception as e: + self.logger.error(LogCategory.SYSTEM, "traffic_forwarder", f"Error sending packet: {e}") + return None + + def _get_src_port(self, transport_header: bytes, protocol: int) -> int: + """Extract source port from transport header""" + if len(transport_header) >= 2: + return struct.unpack('!H', transport_header[0:2])[0] + return 0 + + def _get_dst_port(self, transport_header: bytes, protocol: int) -> int: + """Extract destination port from transport header""" + if len(transport_header) >= 4: + return struct.unpack('!H', transport_header[2:4])[0] + return 0 + + async def cleanup(self): + """Clean up expired sessions""" + current_time = asyncio.get_running_loop().time() + + # Clean TCP connections + for key, conn in list(self.tcp_connections.items()): + if current_time - conn.get('last_seen', 0) > 300: # 5 minutes timeout + del self.tcp_connections[key] + + # Clean UDP endpoints + for key, last_seen in list(self.udp_endpoints.items()): + if current_time - last_seen > 60: # 1 minute timeout + del self.udp_endpoints[key] + + # Clean sessions + for key, session in list(self.sessions.items()): + if current_time - session.last_seen > 300: # 5 minutes timeout + del self.sessions[key] diff --git a/core/traffic_router.py b/core/traffic_router.py new file mode 100644 index 0000000000000000000000000000000000000000..e3877442d7bd67e32e40adcd0a8b1b7a3f740887 --- /dev/null +++ b/core/traffic_router.py @@ -0,0 +1,221 @@ +""" +Traffic Router Module +Handles routing of all client traffic with bandwidth monitoring +""" + +import asyncio +import socket +import logging +import ipaddress +from typing import Dict, Any, Optional, Tuple +from datetime import datetime + +from .tcp_forward import OutlineTCPForwardingEngine as TCPForwardingEngine +from .nat_engine import NATEngine + +logger = logging.getLogger(__name__) + +class BandwidthMonitor: + def __init__(self): + self.total_bytes_in = 0 + self.total_bytes_out = 0 + self.user_bandwidth: Dict[str, Dict[str, int]] = {} + self.start_time = datetime.now() + + def update(self, user_id: str, bytes_in: int = 0, bytes_out: int = 0): + """Update bandwidth usage for a user""" + if user_id not in self.user_bandwidth: + self.user_bandwidth[user_id] = { + "bytes_in": 0, + "bytes_out": 0, + "last_update": datetime.now() + } + + self.user_bandwidth[user_id]["bytes_in"] += bytes_in + self.user_bandwidth[user_id]["bytes_out"] += bytes_out + self.user_bandwidth[user_id]["last_update"] = datetime.now() + + self.total_bytes_in += bytes_in + self.total_bytes_out += bytes_out + + def get_stats(self) -> Dict: + """Get bandwidth statistics""" + current_time = datetime.now() + uptime = (current_time - self.start_time).total_seconds() + + return { + "total_bytes_in": self.total_bytes_in, + "total_bytes_out": self.total_bytes_out, + "avg_speed_in": self.total_bytes_in / uptime if uptime > 0 else 0, + "avg_speed_out": self.total_bytes_out / uptime if uptime > 0 else 0, + "user_stats": self.user_bandwidth + } + +class TrafficRouter: + """Manages traffic routing for VPN clients with bandwidth monitoring""" + + def __init__(self, config: Dict[str, Any], logger_instance=None): + self.config = config + self.is_running = False + + # VPN server configuration + self.vpn_host = self.config.get("vpn_host", "0.0.0.0") + self.vpn_port = self.config.get("vpn_port", 9000) + + # Virtual network configuration + self.virtual_network = ipaddress.ip_network( + self.config.get("virtual_network", "10.0.0.0/24") + ) + self.virtual_gateway = str(next(self.virtual_network.hosts())) + + # Initialize engines + self.nat_engine = NATEngine() + self.tcp_engine = TCPForwardingEngine(access_key="") + self.bandwidth_monitor = BandwidthMonitor() + self.logger = logger_instance if logger_instance else logging.getLogger(__name__) + + # Server instances + self.loop = None + self.vpn_server = None + + # Statistics + self.stats = { + "total_connections": 0, + "active_connections": 0, + "bytes_forwarded": 0, + "nat_sessions": 0, + "errors": 0 + } + + async def start(self): + """Start the traffic router""" + if self.is_running: + logger.warning("Traffic Router is already running") + return True + + self.is_running = True + self.loop = asyncio.get_event_loop() + + try: + # Start VPN server + self.vpn_server = await asyncio.start_server( + self._handle_client_connection, + self.vpn_host, + self.vpn_port + ) + + self.logger.info(f"Traffic Router started on {self.vpn_host}:{self.vpn_port}") + self.logger.info(f"Virtual network: {self.virtual_network}") + self.logger.info(f"Virtual gateway: {self.virtual_gateway}") + + # Start NAT engine + await self.nat_engine.start() + + return True + + except Exception as e: + logger.error(f"Failed to start Traffic Router: {e}") + self.is_running = False + return False + + async def stop(self): + """Stop the traffic router""" + if not self.is_running: + return + + self.is_running = False + + # Stop NAT engine + await self.nat_engine.stop() + + # Close VPN server + if self.vpn_server: + self.vpn_server.close() + await self.vpn_server.wait_closed() + + logger.info("Traffic Router stopped") + + async def _handle_client_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + """Handle incoming client connection""" + peer = writer.get_extra_info('peername') + logger.info(f"New client connection from {peer}") + + try: + buffer_size = self.config.get("buffer_size", 8192) + user_id = self.config.get("user_id", "unknown") + + while self.is_running: + data = await reader.read(buffer_size) + if not data: + break + + # Forward data and track bandwidth + bytes_forwarded = await self._forward_data(data, "client", reader, writer) + self.bandwidth_monitor.update(user_id, bytes_in=bytes_forwarded) + self.stats["bytes_forwarded"] += bytes_forwarded + + except Exception as e: + logger.error(f"Error handling client {peer}: {e}") + self.stats["errors"] += 1 + finally: + writer.close() + await writer.wait_closed() + + async def _forward_data(self, data: bytes, source: str, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> int: + """Forward data and return bytes forwarded""" + try: + if source == "client": + target_addr = self._extract_target_address(data) + if not target_addr: + return 0 + + # Create NAT session + session = self.nat_engine.create_session( + virtual_ip=writer.get_extra_info('peername')[0], + virtual_port=writer.get_extra_info('peername')[1], + real_ip=target_addr[0], + real_port=target_addr[1] + ) + + # Forward through TCP engine + conn = await self.tcp_engine.create_connection( + reader, writer, + target_addr[0], target_addr[1] + ) + + if conn: + return len(data) + return 0 + + except Exception as e: + logger.error(f"Error forwarding data: {e}") + return 0 + + def _extract_target_address(self, data: bytes) -> Optional[Tuple[str, int]]: + """Extract target address from packet""" + try: + if len(data) < 7: + return None + + addr_type = data[0] + if addr_type == 1: # IPv4 + ip = socket.inet_ntoa(data[1:5]) + port = int.from_bytes(data[5:7], 'big') + return (ip, port) + elif addr_type == 3: # Domain + domain_len = data[1] + domain = data[2:2+domain_len].decode() + port = int.from_bytes(data[2+domain_len:4+domain_len], 'big') + return (domain, port) + return None + except Exception as e: + logger.error(f"Error extracting target address: {e}") + return None + + def get_stats(self) -> Dict: + """Get traffic router statistics""" + return { + "router_stats": self.stats, + "bandwidth_stats": self.bandwidth_monitor.get_stats(), + "nat_stats": self.nat_engine.get_stats() + } diff --git a/core/vpn_auth.py b/core/vpn_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..65f54f5ac4da46868ab78cd78cfd6a595d897750 --- /dev/null +++ b/core/vpn_auth.py @@ -0,0 +1,50 @@ +""" +VPN Authentication Manager +Handles authentication across different VPN protocols +""" +from typing import Optional, Tuple +from sqlalchemy.orm import Session +from .database import SessionLocal +from .services.user_service import UserService +from .models.user import User, UserStatus + +class VPNAuthManager: + def __init__(self): + self._db = SessionLocal() + self._user_service = UserService(self._db) + + async def authenticate(self, username: str, password: str, protocol: str, + ip_address: str, device_info: str = None) -> Tuple[bool, str, Optional[str]]: + """ + Authenticate user for VPN access + Returns: (success, message, session_token) + """ + # Authenticate user + success, message, user = self._user_service.authenticate_user(username, password) + if not success: + return False, message, None + + # Create session if authentication successful + session = self._user_service.create_session(user, ip_address, device_info) + + return True, "Authentication successful", session.token + + async def validate_session(self, token: str) -> Tuple[bool, str, Optional[User]]: + """ + Validate a session token + Returns: (success, message, user) + """ + success, message, session = self._user_service.validate_session(token) + if not success: + return False, message, None + + # Get user + user = self._db.query(User).filter(User.id == session.user_id).first() + if not user or user.status != UserStatus.ACTIVE: + return False, "User inactive or not found", None + + return True, "Session valid", user + + def close(self): + """Close database connection""" + self._db.close() diff --git a/data/vpn.db b/data/vpn.db new file mode 100644 index 0000000000000000000000000000000000000000..04a7f7c011065994fc5dc478fcbc4097755cfcf6 Binary files /dev/null and b/data/vpn.db differ diff --git a/logs/outline.log b/logs/outline.log new file mode 100644 index 0000000000000000000000000000000000000000..f3ea55158f146da4bdacae0e45284f8d11b335e1 --- /dev/null +++ b/logs/outline.log @@ -0,0 +1,134 @@ +2025-08-18 16:27:11,670 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:27:18,846 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:28:04,348 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:28:04,455 [INFO] [SYSTEM] IKEv2 server initialized +2025-08-18 16:28:19,222 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:28:19,313 [INFO] [SYSTEM] IKEv2 server initialized +2025-08-18 16:28:19,421 [INFO] Database initialized successfully +2025-08-18 16:28:32,273 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:28:32,361 [INFO] [SYSTEM] IKEv2 server initialized +2025-08-18 16:28:32,457 [INFO] Database initialized successfully +2025-08-18 16:28:32,459 [INFO] [SYSTEM] VPN server initialized and started on 44.200.228.79 +2025-08-18 16:28:32,462 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:28:32,554 [INFO] NAT rules set up for interface eth0 +2025-08-18 16:28:32,554 [INFO] Internet sharing enabled on interface eth0 +2025-08-18 16:28:32,554 [INFO] NAT engine started +2025-08-18 16:28:32,554 [ERROR] Failed to start Traffic Router: [Errno 99] error while attempting to bind on address ('44.200.228.79', 8388): cannot assign requested address +2025-08-18 16:28:32,554 [INFO] Starting IKEv2 service... +2025-08-18 16:28:32,554 [INFO] Outline VPN server started successfully on port 8388 +2025-08-18 16:30:03,889 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:30:03,976 [INFO] [SYSTEM] IKEv2 server initialized +2025-08-18 16:30:04,085 [INFO] Database initialized successfully +2025-08-18 16:30:04,088 [INFO] [SYSTEM] VPN server initialized and started on 44.200.228.79 +2025-08-18 16:30:04,092 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:30:04,174 [INFO] NAT rules set up for interface eth0 +2025-08-18 16:30:04,174 [INFO] Internet sharing enabled on interface eth0 +2025-08-18 16:30:04,174 [INFO] NAT engine started +2025-08-18 16:30:04,174 [ERROR] Failed to start Traffic Router: [Errno 99] error while attempting to bind on address ('44.200.228.79', 8388): cannot assign requested address +2025-08-18 16:30:04,174 [INFO] Starting IKEv2 service... +2025-08-18 16:30:04,174 [INFO] Outline VPN server started successfully on port 8388 +2025-08-18 16:30:04,459 [INFO] [SYSTEM] IKEv2 server initialized +2025-08-18 16:30:04,576 [INFO] Database initialized successfully +2025-08-18 16:30:04,577 [INFO] [SYSTEM] VPN server initialized and started on 44.200.228.79 +2025-08-18 16:30:04,608 [INFO] WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on http://127.0.0.1:5000 +2025-08-18 16:30:04,609 [INFO] Press CTRL+C to quit +2025-08-18 16:30:04,610 [INFO] * Restarting with stat +2025-08-18 16:30:04,692 [INFO] NAT rules set up for interface eth0 +2025-08-18 16:30:04,692 [INFO] Internet sharing enabled on interface eth0 +2025-08-18 16:30:04,692 [INFO] NAT engine started +2025-08-18 16:30:04,693 [ERROR] Failed to start Traffic Router: [Errno 99] error while attempting to bind on address ('44.200.228.79', 8388): cannot assign requested address +2025-08-18 16:30:04,693 [INFO] Starting IKEv2 service... +2025-08-18 16:30:04,693 [INFO] Outline VPN server started successfully on port 8388 +2025-08-18 16:30:05,093 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:30:05,188 [INFO] [SYSTEM] IKEv2 server initialized +2025-08-18 16:30:05,273 [INFO] Database initialized successfully +2025-08-18 16:30:05,275 [INFO] [SYSTEM] VPN server initialized and started on 44.200.228.79 +2025-08-18 16:30:05,278 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:30:05,379 [INFO] NAT rules set up for interface eth0 +2025-08-18 16:30:05,379 [INFO] Internet sharing enabled on interface eth0 +2025-08-18 16:30:05,380 [INFO] NAT engine started +2025-08-18 16:30:05,380 [ERROR] Failed to start Traffic Router: [Errno 99] error while attempting to bind on address ('44.200.228.79', 8388): cannot assign requested address +2025-08-18 16:30:05,380 [INFO] Starting IKEv2 service... +2025-08-18 16:30:05,380 [INFO] Outline VPN server started successfully on port 8388 +2025-08-18 16:30:05,387 [INFO] [SYSTEM] IKEv2 server initialized +2025-08-18 16:30:05,487 [INFO] Database initialized successfully +2025-08-18 16:30:05,488 [INFO] [SYSTEM] VPN server initialized and started on 44.200.228.79 +2025-08-18 16:30:05,507 [WARNING] * Debugger is active! +2025-08-18 16:30:05,508 [INFO] * Debugger PIN: 847-303-629 +2025-08-18 16:30:05,613 [INFO] NAT rules set up for interface eth0 +2025-08-18 16:30:05,613 [INFO] Internet sharing enabled on interface eth0 +2025-08-18 16:30:05,613 [INFO] NAT engine started +2025-08-18 16:30:05,614 [ERROR] Failed to start Traffic Router: [Errno 99] error while attempting to bind on address ('44.200.228.79', 8388): cannot assign requested address +2025-08-18 16:30:05,614 [INFO] Starting IKEv2 service... +2025-08-18 16:30:05,614 [INFO] Outline VPN server started successfully on port 8388 +2025-08-18 16:30:37,472 [INFO] 127.0.0.1 - - [18/Aug/2025 16:30:37] "GET / HTTP/1.1" 500 - +2025-08-18 16:30:37,497 [INFO] 127.0.0.1 - - [18/Aug/2025 16:30:37] "GET /?__debugger__=yes&cmd=resource&f=style.css HTTP/1.1" 200 - +2025-08-18 16:30:37,499 [INFO] 127.0.0.1 - - [18/Aug/2025 16:30:37] "GET /?__debugger__=yes&cmd=resource&f=debugger.js HTTP/1.1" 200 - +2025-08-18 16:30:37,526 [INFO] 127.0.0.1 - - [18/Aug/2025 16:30:37] "GET /?__debugger__=yes&cmd=resource&f=console.png&s=HoHl7s1rvD5oO3DVfnnY HTTP/1.1" 200 - +2025-08-18 16:30:37,553 [INFO] 127.0.0.1 - - [18/Aug/2025 16:30:37] "GET /?__debugger__=yes&cmd=resource&f=console.png HTTP/1.1" 200 - +2025-08-18 16:30:45,618 [INFO] * Detected change in '/home/ubuntu/outline-vpn/outline-vpn/outline-vpn/web/app.py', reloading +2025-08-18 16:30:45,840 [INFO] * Restarting with stat +2025-08-18 16:30:46,398 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:30:46,512 [INFO] [SYSTEM] IKEv2 server initialized +2025-08-18 16:30:46,644 [INFO] Database initialized successfully +2025-08-18 16:30:46,647 [INFO] [SYSTEM] VPN server initialized and started on 44.200.228.79 +2025-08-18 16:30:46,650 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:30:46,739 [INFO] NAT rules set up for interface eth0 +2025-08-18 16:30:46,739 [INFO] Internet sharing enabled on interface eth0 +2025-08-18 16:30:46,739 [INFO] NAT engine started +2025-08-18 16:30:46,739 [ERROR] Failed to start Traffic Router: [Errno 99] error while attempting to bind on address ('44.200.228.79', 8388): cannot assign requested address +2025-08-18 16:30:46,739 [INFO] Starting IKEv2 service... +2025-08-18 16:30:46,740 [INFO] Outline VPN server started successfully on port 8388 +2025-08-18 16:30:46,764 [INFO] [SYSTEM] IKEv2 server initialized +2025-08-18 16:30:46,856 [INFO] Database initialized successfully +2025-08-18 16:30:46,857 [INFO] [SYSTEM] VPN server initialized and started on 44.200.228.79 +2025-08-18 16:30:46,881 [WARNING] * Debugger is active! +2025-08-18 16:30:46,882 [INFO] * Debugger PIN: 847-303-629 +2025-08-18 16:30:46,976 [INFO] NAT rules set up for interface eth0 +2025-08-18 16:30:46,976 [INFO] Internet sharing enabled on interface eth0 +2025-08-18 16:30:46,976 [INFO] NAT engine started +2025-08-18 16:30:46,976 [ERROR] Failed to start Traffic Router: [Errno 99] error while attempting to bind on address ('44.200.228.79', 8388): cannot assign requested address +2025-08-18 16:30:46,977 [INFO] Starting IKEv2 service... +2025-08-18 16:30:46,977 [INFO] Outline VPN server started successfully on port 8388 +2025-08-18 16:31:04,045 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:31:04,144 [INFO] [SYSTEM] IKEv2 server initialized +2025-08-18 16:31:04,234 [INFO] Database initialized successfully +2025-08-18 16:31:04,237 [INFO] [SYSTEM] VPN server initialized and started on 44.200.228.79 +2025-08-18 16:31:04,256 [INFO] WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on http://127.0.0.1:5000 +2025-08-18 16:31:04,256 [INFO] Press CTRL+C to quit +2025-08-18 16:31:04,257 [INFO] * Restarting with stat +2025-08-18 16:31:04,335 [INFO] NAT rules set up for interface eth0 +2025-08-18 16:31:04,336 [INFO] Internet sharing enabled on interface eth0 +2025-08-18 16:31:04,336 [INFO] NAT engine started +2025-08-18 16:31:04,336 [ERROR] Failed to start Traffic Router: [Errno 99] error while attempting to bind on address ('44.200.228.79', 8388): cannot assign requested address +2025-08-18 16:31:04,336 [INFO] Starting IKEv2 service... +2025-08-18 16:31:04,336 [INFO] Outline VPN server started successfully on port 8388 +2025-08-18 16:31:04,730 [INFO] [SYSTEM] Initializing VPN server +2025-08-18 16:31:04,832 [INFO] [SYSTEM] IKEv2 server initialized +2025-08-18 16:31:04,924 [INFO] Database initialized successfully +2025-08-18 16:31:04,926 [INFO] [SYSTEM] VPN server initialized and started on 44.200.228.79 +2025-08-18 16:31:04,943 [WARNING] * Debugger is active! +2025-08-18 16:31:04,943 [INFO] * Debugger PIN: 250-455-276 +2025-08-18 16:31:05,032 [INFO] NAT rules set up for interface eth0 +2025-08-18 16:31:05,032 [INFO] Internet sharing enabled on interface eth0 +2025-08-18 16:31:05,032 [INFO] NAT engine started +2025-08-18 16:31:05,032 [ERROR] Failed to start Traffic Router: [Errno 99] error while attempting to bind on address ('44.200.228.79', 8388): cannot assign requested address +2025-08-18 16:31:05,032 [INFO] Starting IKEv2 service... +2025-08-18 16:31:05,033 [INFO] Outline VPN server started successfully on port 8388 +2025-08-18 16:31:37,704 [INFO] 127.0.0.1 - - [18/Aug/2025 16:31:37] "GET / HTTP/1.1" 200 - +2025-08-18 16:31:37,737 [INFO] 127.0.0.1 - - [18/Aug/2025 16:31:37] "GET /static/css/style.css HTTP/1.1" 304 - +2025-08-18 16:31:37,738 [INFO] 127.0.0.1 - - [18/Aug/2025 16:31:37] "GET /static/js/main.js HTTP/1.1" 304 - +2025-08-18 16:31:45,663 [INFO] 127.0.0.1 - - [18/Aug/2025 16:31:45] "GET /signup HTTP/1.1" 200 - +2025-08-18 16:31:45,791 [INFO] 127.0.0.1 - - [18/Aug/2025 16:31:45] "GET /static/css/style.css HTTP/1.1" 304 - +2025-08-18 16:31:45,807 [INFO] 127.0.0.1 - - [18/Aug/2025 16:31:45] "GET /static/js/main.js HTTP/1.1" 304 - +2025-08-18 16:31:46,002 [INFO] 127.0.0.1 - - [18/Aug/2025 16:31:46] "GET /static/css/style.css HTTP/1.1" 304 - +2025-08-18 16:32:17,741 [INFO] 127.0.0.1 - - [18/Aug/2025 16:32:17] "POST /signup HTTP/1.1" 500 - +2025-08-18 16:32:17,763 [INFO] 127.0.0.1 - - [18/Aug/2025 16:32:17] "GET /signup?__debugger__=yes&cmd=resource&f=style.css HTTP/1.1" 304 - +2025-08-18 16:32:17,765 [INFO] 127.0.0.1 - - [18/Aug/2025 16:32:17] "GET /signup?__debugger__=yes&cmd=resource&f=debugger.js HTTP/1.1" 304 - +2025-08-18 16:32:17,781 [INFO] 127.0.0.1 - - [18/Aug/2025 16:32:17] "GET /signup?__debugger__=yes&cmd=resource&f=console.png&s=SWpKuG4jXHoO71qMEOPo HTTP/1.1" 200 - +2025-08-18 16:32:17,814 [INFO] 127.0.0.1 - - [18/Aug/2025 16:32:17] "GET /signup?__debugger__=yes&cmd=resource&f=console.png HTTP/1.1" 304 - +2025-08-18 16:32:17,822 [INFO] 127.0.0.1 - - [18/Aug/2025 16:32:17] "GET /signup?__debugger__=yes&cmd=resource&f=style.css HTTP/1.1" 304 - +2025-08-18 16:32:32,155 [INFO] * Detected change in '/home/ubuntu/outline-vpn/outline-vpn/outline-vpn/web/app.py', reloading +2025-08-18 16:32:32,293 [INFO] * Restarting with stat diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..5aa5e1ec289dc24b7728dcf70e4d5917375d4ca4 --- /dev/null +++ b/main.py @@ -0,0 +1,110 @@ +""" +Main entry point for Outline/IKEv2 VPN server +""" + +import os +import sys +import asyncio +import logging +import json +import socket +from pathlib import Path +from contextlib import closing + +# Add project root to path +project_root = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, project_root) + +from core.outline_server import OutlineServer +from core.ikev2_server import IKEv2Server as IPsecManager +from core.process_lock import ProcessLock + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('vpn_server.log') + ] +) + +logger = logging.getLogger(__name__) + +def find_available_port(start_port: int, end_port: int = 65535) -> int: + """Find an available port in the given range""" + for port in range(start_port, end_port + 1): + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: + try: + sock.bind(('', port)) + return port + except socket.error: + continue + raise RuntimeError(f"No available ports in range {start_port}-{end_port}") + +async def main(): + # Initialize process lock + process_lock = ProcessLock() + if not process_lock.acquire(): + logger.error("Another instance of the VPN server is already running") + sys.exit(1) + + try: + # Load configuration + config_path = os.path.join(project_root, 'config', 'server_config.json') + if not os.path.exists(config_path): + default_config = { + "server": { + "host": "0.0.0.0", + "port": 8388, # Default Shadowsocks port + "virtual_network": "10.0.0.0/24", + "dns_servers": ["8.8.8.8", "8.8.4.4"], + "fallback_ports": [8389, 8390, 8391, 8392] + } + } + os.makedirs(os.path.dirname(config_path), exist_ok=True) + with open(config_path, 'w') as f: + json.dump(default_config, f, indent=4) + logger.info("Created default configuration") + + # Load configuration + with open(config_path, 'r') as f: + config = json.load(f) + + # Find available port + try: + port = config["server"]["port"] + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: + sock.bind(('', port)) + except socket.error: + logger.warning(f"Default port {port} is in use, finding alternative...") + port = find_available_port(8388) + config["server"]["port"] = port + logger.info(f"Using alternative port: {port}") + + # Start Outline server with elevated privileges check + if os.name != 'nt' and os.geteuid() != 0: # Not running as root on Unix + logger.error("VPN server must be run with administrator/root privileges") + sys.exit(1) + + server = OutlineServer(config) + await server.start() + + # Keep running until interrupted + while True: + await asyncio.sleep(1) + + except KeyboardInterrupt: + logger.info("Shutting down VPN server...") + if 'server' in locals(): + await server.stop() + + except Exception as e: + logger.error(f"Server error: {e}") + sys.exit(1) + + finally: + process_lock.release() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/models/database.py b/models/database.py new file mode 100644 index 0000000000000000000000000000000000000000..0b6aeca9e03a74706d032db926e5893da09502f3 --- /dev/null +++ b/models/database.py @@ -0,0 +1,103 @@ +""" +SQLAlchemy models for VPN server +""" +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Enum, Boolean +from sqlalchemy.orm import relationship +from datetime import datetime +import enum + +from core.database import Base + +class UserRole(str, enum.Enum): + ADMIN = "admin" + USER = "user" + +class UserStatus(str, enum.Enum): + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, unique=True, index=True) + email = Column(String, unique=True, index=True) + password = Column(String) + role = Column(String, default=UserRole.USER) + status = Column(String, default=UserStatus.ACTIVE) + vpn_protocol = Column(String, default="outline") + created_at = Column(DateTime, default=datetime.utcnow) + last_login = Column(DateTime, nullable=True) + + # Relationships + sessions = relationship("VPNSession", back_populates="user") + config = relationship("UserVPNConfig", back_populates="user", uselist=False) + bandwidth_usages = relationship("BandwidthUsage", back_populates="user") + +class VPNSession(Base): + __tablename__ = "vpn_sessions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id")) + protocol = Column(String) + client_ip = Column(String) + server_ip = Column(String) + start_time = Column(DateTime, default=datetime.utcnow) + end_time = Column(DateTime, nullable=True) + bytes_sent = Column(Integer, default=0) + bytes_received = Column(Integer, default=0) + status = Column(String, default="active") + + # Relationships + user = relationship("User", back_populates="sessions") + +class UserVPNConfig(Base): + __tablename__ = "user_vpn_configs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), unique=True) + protocol = Column(String) + config_data = Column(String) # JSON string of configuration + created_at = Column(DateTime, default=datetime.utcnow) + last_updated = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="config") + +class BandwidthUsage(Base): + __tablename__ = "bandwidth_usage" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id")) + timestamp = Column(DateTime, default=datetime.utcnow) + bytes_up = Column(Integer, default=0) + bytes_down = Column(Integer, default=0) + protocol = Column(String) + + # Relationships + user = relationship("User", back_populates="bandwidth_usages") + +class ServerConfig(Base): + __tablename__ = "server_config" + + id = Column(Integer, primary_key=True, index=True) + server_name = Column(String, default="VPN Server") + max_clients = Column(Integer, default=100) + bandwidth_limit = Column(Integer, nullable=True) # In bytes per second + maintenance_mode = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True) + timestamp = Column(DateTime, default=datetime.utcnow) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + action = Column(String) + details = Column(String) + ip_address = Column(String, nullable=True) + + # Relationships + user = relationship("User") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..2a27f8fb8954363faeff1eef412396c1fb52457f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,63 @@ +# Core dependencies +fastapi>=0.68.0 +uvicorn[standard]>=0.15.0 +aiohttp>=3.8.1 +cryptography>=35.0.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +python-multipart>=0.0.5 +jinja2>=3.0.1 +email-validator>=1.1.3 +SQLAlchemy>=1.4.23 +psutil>=5.9.5 +bcrypt>=3.2.0 + +# Authentication and Security +pyotp>=2.8.0 +qrcode>=7.4.2 +python-jose[cryptography]>=3.3.0 + +# Monitoring and Analytics +APScheduler>=3.10.1 +pytz>=2023.3 +prometheus-client>=0.17.1 + +# Networking +aiohappyeyeballs==2.6.1 +aiosignal==1.4.0 +websockets==15.0.1 +requests==2.31.0 + +# Crypto and security +pyOpenSSL==23.3.0 +shadowsocks-fixed + +# Data Export +pandas==2.0.3 +openpyxl==3.1.2 +XlsxWriter==3.1.2 + +# Database Backup +alembic==1.11.1 +python-dotenv==1.0.0 + +# Testing +pytest==7.4.0 +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 +faker==19.3.0 + +# Frontend +Werkzeug>=3.1.0 +itsdangerous>=2.2.0 +Jinja2==3.1.2 +flask-assets==2.0 +webassets==2.0 +cssmin==0.2.0 +jsmin==3.0.1 + +# Utilities +click==8.2.1 +typing_extensions==4.14.0 +python-dateutil==2.8.2 +humanize==4.7.0 diff --git a/routers/admin.py b/routers/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..0af78a92746192b6c4fd6184870857ca76c73adc --- /dev/null +++ b/routers/admin.py @@ -0,0 +1,144 @@ +""" +Admin routes and functionality for VPN Server +""" +from fastapi import APIRouter, Depends, HTTPException, status, Request, File, UploadFile +from fastapi.responses import JSONResponse, FileResponse, StreamingResponse +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import datetime +import os + +from models.user import User, UserRole +from models.system import SystemHealth, AuditLog, Alert, ServerConfig +from services import backup_service, monitoring_service +from core.auth import get_current_active_user, get_current_admin_user +from core.database import get_db +from schemas.admin import ( + SystemHealthResponse, + ServerConfigUpdate, + AlertResponse, + AuditLogResponse +) + +router = APIRouter( + prefix="/admin", + tags=["admin"], + responses={404: {"description": "Not found"}}, +) + +@router.get("/dashboard", response_model=dict) +async def admin_dashboard( + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Admin dashboard data""" + system_health = await monitoring_service.get_system_health() + active_alerts = db.query(Alert).filter(Alert.status == 'active').order_by(Alert.created_at.desc()).all() + audit_logs = db.query(AuditLog).order_by(AuditLog.timestamp.desc()).limit(50).all() + + return { + "system_health": system_health, + "active_alerts": active_alerts, + "audit_logs": audit_logs + } + +@router.get("/system-health", response_model=SystemHealthResponse) +async def get_system_health( + current_user: User = Depends(get_current_admin_user) +): + """Get real-time system health metrics""" + return await monitoring_service.get_system_health() + +@router.post("/server-config", response_model=dict) +async def update_server_config( + config: ServerConfigUpdate, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Update server configuration""" + try: + # Create backup before updating + await backup_service.backup_config('pre_update') + + # Update configuration + current_config = db.query(ServerConfig).first() + for key, value in config.dict(exclude_unset=True).items(): + setattr(current_config, key, value) + + db.commit() + + # Log the change + audit_log = AuditLog( + user_id=current_user.id, + action='update_config', + details='Server configuration updated' + ) + db.add(audit_log) + db.commit() + + # Restart required services + await monitoring_service.restart_services() + + return {"status": "success"} + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + +@router.post("/backup", response_model=dict) +async def create_backup( + include_user_data: bool = False, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Create a backup of server configuration""" + try: + backup_path = await backup_service.create_backup(include_user_data) + + # Log the backup creation + audit_log = AuditLog( + user_id=current_user.id, + action='create_backup', + details=f'Backup created: {os.path.basename(backup_path)}' + ) + db.add(audit_log) + db.commit() + + filename = f'outline_backup_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip' + return FileResponse( + path=backup_path, + filename=filename, + media_type='application/zip' + ) + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) + +@router.get("/audit-logs", response_model=List[AuditLogResponse]) +async def get_audit_logs( + limit: int = 50, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Get audit logs""" + logs = db.query(AuditLog).order_by(AuditLog.timestamp.desc()).limit(limit).all() + return logs + +@router.get("/alerts", response_model=List[AlertResponse]) +async def get_alerts( + status: Optional[str] = None, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Get system alerts""" + query = db.query(Alert) + if status: + query = query.filter(Alert.status == status) + alerts = query.order_by(Alert.created_at.desc()).all() + return alerts diff --git a/routers/users.py b/routers/users.py new file mode 100644 index 0000000000000000000000000000000000000000..76de502bdebad80c690f242c0eee9d856a3fe50c --- /dev/null +++ b/routers/users.py @@ -0,0 +1,92 @@ +""" +User management routes +""" +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from typing import List + +from core.auth import ( + get_current_active_user, + create_access_token, + verify_password, + get_password_hash +) +from core.database import get_db +from models.user import User, UserRole +from schemas.user import ( + UserCreate, + UserUpdate, + UserResponse, + TokenResponse +) + +router = APIRouter( + prefix="/users", + tags=["users"], + responses={404: {"description": "Not found"}} +) + +@router.post("/token", response_model=TokenResponse) +async def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db) +): + """Login user and create access token""" + user = db.query(User).filter(User.username == form_data.username).first() + if not user or not verify_password(form_data.password, user.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token = create_access_token(data={"sub": user.username}) + return {"access_token": access_token, "token_type": "bearer"} + +@router.get("/me", response_model=UserResponse) +async def read_user_me(current_user: User = Depends(get_current_active_user)): + """Get current user information""" + return current_user + +@router.put("/me", response_model=UserResponse) +async def update_user_me( + user_update: UserUpdate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """Update current user information""" + if user_update.password: + current_user.password = get_password_hash(user_update.password) + if user_update.email: + current_user.email = user_update.email + if user_update.vpn_protocol: + current_user.vpn_protocol = user_update.vpn_protocol + + db.commit() + return current_user + +@router.post("/register", response_model=UserResponse) +async def register_user(user: UserCreate, db: Session = Depends(get_db)): + """Register a new user""" + # Check if username exists + if db.query(User).filter(User.username == user.username).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already registered" + ) + + # Create new user + db_user = User( + username=user.username, + email=user.email, + password=get_password_hash(user.password), + role=UserRole.USER, + vpn_protocol=user.vpn_protocol + ) + + db.add(db_user) + db.commit() + db.refresh(db_user) + + return db_user diff --git a/routers/vpn.py b/routers/vpn.py new file mode 100644 index 0000000000000000000000000000000000000000..12ccadcf3f2b576d546c8cc36c6bd85148bf3fc8 --- /dev/null +++ b/routers/vpn.py @@ -0,0 +1,67 @@ +""" +VPN server management routes +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from core.auth import get_current_active_user +from core.database import get_db +from models.user import User +from schemas.vpn import ( + VPNConfigResponse, + VPNSessionResponse, + VPNServerStats +) +from services.vpn_service import VPNService + +router = APIRouter( + prefix="/vpn", + tags=["vpn"], + responses={404: {"description": "Not found"}} +) + +@router.get("/config", response_model=VPNConfigResponse) +async def get_vpn_config( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """Get VPN configuration for current user""" + vpn_service = VPNService(db) + return await vpn_service.get_user_config(current_user.id) + +@router.get("/sessions", response_model=List[VPNSessionResponse]) +async def get_vpn_sessions( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """Get active VPN sessions for current user""" + vpn_service = VPNService(db) + return await vpn_service.get_user_sessions(current_user.id) + +@router.get("/stats", response_model=VPNServerStats) +async def get_vpn_stats( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """Get VPN usage statistics for current user""" + vpn_service = VPNService(db) + return await vpn_service.get_user_stats(current_user.id) + +@router.post("/disconnect/{session_id}") +async def disconnect_vpn_session( + session_id: str, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """Disconnect a specific VPN session""" + vpn_service = VPNService(db) + success = await vpn_service.disconnect_session(session_id, current_user.id) + + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found or already disconnected" + ) + + return {"status": "success", "message": "Session disconnected"} diff --git a/schemas/admin.py b/schemas/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..43a1b75d81ed29633736070a3ba313531d50dabf --- /dev/null +++ b/schemas/admin.py @@ -0,0 +1,48 @@ +""" +Admin route schemas and models +""" +from pydantic import BaseModel, Field +from typing import List, Optional, Dict +from datetime import datetime + +class SystemHealthResponse(BaseModel): + cpu_usage: float + memory_usage: float + disk_usage: float + network_stats: Dict + active_connections: int + uptime: float + + class Config: + orm_mode = True + +class ServerConfigUpdate(BaseModel): + server_name: Optional[str] = None + max_clients: Optional[int] = Field(None, gt=0) + bandwidth_limit: Optional[int] = Field(None, gt=0) + logging_level: Optional[str] = None + maintenance_mode: Optional[bool] = None + + class Config: + orm_mode = True + +class AlertResponse(BaseModel): + id: int + type: str + message: str + status: str + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + +class AuditLogResponse(BaseModel): + id: int + user_id: int + action: str + details: str + timestamp: datetime + + class Config: + orm_mode = True diff --git a/schemas/user.py b/schemas/user.py new file mode 100644 index 0000000000000000000000000000000000000000..48fda558e15700cbc2453a910d58c9c595774be6 --- /dev/null +++ b/schemas/user.py @@ -0,0 +1,33 @@ +""" +User-related schemas +""" +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime + +class UserBase(BaseModel): + username: str + email: EmailStr + vpn_protocol: str = "outline" + +class UserCreate(UserBase): + password: str + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + password: Optional[str] = None + vpn_protocol: Optional[str] = None + +class UserResponse(UserBase): + id: str + role: str + is_active: bool + created_at: datetime + last_login: Optional[datetime] + + class Config: + orm_mode = True + +class TokenResponse(BaseModel): + access_token: str + token_type: str diff --git a/schemas/vpn.py b/schemas/vpn.py new file mode 100644 index 0000000000000000000000000000000000000000..53c43f36df7e7d436f599ee3ea6f255b1510d4ed --- /dev/null +++ b/schemas/vpn.py @@ -0,0 +1,42 @@ +""" +VPN-related schemas +""" +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, Dict, List + +class VPNConfigResponse(BaseModel): + protocol: str + server_address: str + port: int + encryption: str + certificate: Optional[str] = None + private_key: Optional[str] = None + shared_secret: Optional[str] = None + additional_params: Dict[str, str] = {} + + class Config: + orm_mode = True + +class VPNSessionResponse(BaseModel): + session_id: str + start_time: datetime + last_active: datetime + protocol: str + client_ip: str + bytes_sent: int + bytes_received: int + status: str + + class Config: + orm_mode = True + +class VPNServerStats(BaseModel): + total_data_transferred: int + active_sessions: int + total_session_time: int + last_connection: Optional[datetime] + bandwidth_usage: Dict[str, float] + + class Config: + orm_mode = True diff --git a/services/monitoring_service.py b/services/monitoring_service.py new file mode 100644 index 0000000000000000000000000000000000000000..213b210f6ff918859897e4cce720d880acfd3bda --- /dev/null +++ b/services/monitoring_service.py @@ -0,0 +1,130 @@ +""" +System monitoring and health check service +""" +import psutil +import platform +from datetime import datetime, timedelta +from typing import Dict, List +from .models import SystemHealth, Alert, db + +class MonitoringService: + def __init__(self): + self.alert_thresholds = { + 'cpu_usage': 90, + 'memory_usage': 90, + 'disk_usage': 90, + 'failed_login_attempts': 10 + } + + def get_system_health(self) -> Dict: + """Get current system health metrics""" + cpu_usage = psutil.cpu_percent(interval=1) + memory = psutil.virtual_memory() + disk = psutil.disk_usage('/') + + health = SystemHealth( + cpu_usage=cpu_usage, + memory_usage=memory.percent, + disk_usage=disk.percent, + timestamp=datetime.utcnow() + ) + db.session.add(health) + db.session.commit() + + # Check for alerts + self._check_alerts(health) + + return { + 'cpu_usage': cpu_usage, + 'memory_usage': memory.percent, + 'disk_usage': disk.percent, + 'memory_total': memory.total, + 'memory_available': memory.available, + 'disk_total': disk.total, + 'disk_free': disk.free, + 'system_time': datetime.utcnow().isoformat(), + 'system_uptime': self.get_system_uptime() + } + + def get_system_uptime(self) -> str: + """Get system uptime in human readable format""" + uptime = datetime.now() - datetime.fromtimestamp(psutil.boot_time()) + days = uptime.days + hours, remainder = divmod(uptime.seconds, 3600) + minutes, seconds = divmod(remainder, 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") + if seconds > 0 or not parts: + parts.append(f"{seconds}s") + + return " ".join(parts) + + def _check_alerts(self, health: SystemHealth) -> None: + """Check system health metrics and create alerts if needed""" + # CPU Usage Alert + if health.cpu_usage >= self.alert_thresholds['cpu_usage']: + self._create_alert( + 'High CPU Usage', + f'CPU usage is at {health.cpu_usage}%', + 'warning' + ) + + # Memory Usage Alert + if health.memory_usage >= self.alert_thresholds['memory_usage']: + self._create_alert( + 'High Memory Usage', + f'Memory usage is at {health.memory_usage}%', + 'warning' + ) + + # Disk Usage Alert + if health.disk_usage >= self.alert_thresholds['disk_usage']: + self._create_alert( + 'High Disk Usage', + f'Disk usage is at {health.disk_usage}%', + 'warning' + ) + + def _create_alert(self, title: str, message: str, severity: str) -> None: + """Create a new alert if one doesn't already exist""" + # Check if a similar active alert exists + existing_alert = Alert.query.filter_by( + title=title, + status='active' + ).first() + + if not existing_alert: + alert = Alert( + title=title, + message=message, + severity=severity, + status='active', + created_at=datetime.utcnow() + ) + db.session.add(alert) + db.session.commit() + + def get_active_alerts(self) -> List[Alert]: + """Get list of active alerts""" + return Alert.query.filter_by(status='active').all() + + def resolve_alert(self, alert_id: int) -> None: + """Mark an alert as resolved""" + alert = Alert.query.get(alert_id) + if alert: + alert.status = 'resolved' + alert.resolved_at = datetime.utcnow() + db.session.commit() + + def restart_services(self) -> None: + """Restart VPN services after configuration change""" + # TODO: Implement service restart logic + pass + +monitoring_service = MonitoringService() diff --git a/services/vpn_service.py b/services/vpn_service.py new file mode 100644 index 0000000000000000000000000000000000000000..393dd3a3464c6cf628bcfe47de2a92d00cb0feea --- /dev/null +++ b/services/vpn_service.py @@ -0,0 +1,157 @@ +""" +VPN service implementation with database integration +""" +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import datetime +import json + +from models.database import ( + User, + VPNSession, + UserVPNConfig, + BandwidthUsage, + ServerConfig +) +from core.nat_engine import NATEngine +from core.outline_server import OutlineServer +from core.ikev2_server import IKEv2Server +from schemas.vpn import VPNConfigResponse, VPNSessionResponse, VPNServerStats + +class VPNService: + def __init__(self, db: Session): + self.db = db + self.nat_engine = NATEngine() + self.outline_server = OutlineServer() + self.ikev2_server = IKEv2Server() + + async def get_user_config(self, user_id: int) -> VPNConfigResponse: + """Get VPN configuration for a user""" + user = self.db.query(User).filter(User.id == user_id).first() + if not user: + raise ValueError("User not found") + + # Get or create user's VPN config + config = self.db.query(UserVPNConfig).filter( + UserVPNConfig.user_id == user_id + ).first() + + if not config: + # Generate new configuration based on protocol + if user.vpn_protocol == "outline": + config_data = await self.outline_server.generate_user_config(user_id) + elif user.vpn_protocol == "ikev2": + config_data = await self.ikev2_server.generate_user_config(user_id) + else: + raise ValueError(f"Unsupported VPN protocol: {user.vpn_protocol}") + + # Save configuration to database + config = UserVPNConfig( + user_id=user_id, + protocol=user.vpn_protocol, + config_data=json.dumps(config_data) + ) + self.db.add(config) + self.db.commit() + + config_data = json.loads(config.config_data) + return VPNConfigResponse(**config_data) + + async def get_user_sessions(self, user_id: int) -> List[VPNSessionResponse]: + """Get active VPN sessions for a user""" + sessions = self.db.query(VPNSession).filter( + VPNSession.user_id == user_id, + VPNSession.status == "active" + ).all() + + return [ + VPNSessionResponse( + session_id=str(session.id), + start_time=session.start_time, + last_active=session.end_time or datetime.utcnow(), + protocol=session.protocol, + client_ip=session.client_ip, + bytes_sent=session.bytes_sent, + bytes_received=session.bytes_received, + status=session.status + ) + for session in sessions + ] + + async def get_user_stats(self, user_id: int) -> VPNServerStats: + """Get VPN usage statistics for a user""" + # Get active sessions + active_sessions = self.db.query(VPNSession).filter( + VPNSession.user_id == user_id, + VPNSession.status == "active" + ).all() + + # Get total bytes transferred + bandwidth_usage = self.db.query(BandwidthUsage).filter( + BandwidthUsage.user_id == user_id + ).all() + + total_bytes_up = sum(usage.bytes_up for usage in bandwidth_usage) + total_bytes_down = sum(usage.bytes_down for usage in bandwidth_usage) + + # Get last connection + last_session = self.db.query(VPNSession).filter( + VPNSession.user_id == user_id + ).order_by(VPNSession.start_time.desc()).first() + + # Calculate bandwidth usage by protocol + protocol_usage = {} + for usage in bandwidth_usage: + if usage.protocol not in protocol_usage: + protocol_usage[usage.protocol] = { + 'up': 0, + 'down': 0 + } + protocol_usage[usage.protocol]['up'] += usage.bytes_up + protocol_usage[usage.protocol]['down'] += usage.bytes_down + + return VPNServerStats( + total_data_transferred=total_bytes_up + total_bytes_down, + active_sessions=len(active_sessions), + total_session_time=sum( + int((s.end_time or datetime.utcnow() - s.start_time).total_seconds()) + for s in active_sessions + ), + last_connection=last_session.start_time if last_session else None, + bandwidth_usage=protocol_usage + ) + + async def disconnect_session(self, session_id: str, user_id: int) -> bool: + """Disconnect a specific VPN session""" + session = self.db.query(VPNSession).filter( + VPNSession.id == session_id, + VPNSession.user_id == user_id, + VPNSession.status == "active" + ).first() + + if not session: + return False + + # Disconnect from VPN server + if session.protocol == "outline": + await self.outline_server.disconnect_session(session_id) + elif session.protocol == "ikev2": + await self.ikev2_server.disconnect_session(session_id) + + # Update session status in database + session.status = "disconnected" + session.end_time = datetime.utcnow() + self.db.commit() + + return True + + async def update_bandwidth_usage(self, user_id: int, protocol: str, bytes_up: int, bytes_down: int): + """Update bandwidth usage statistics""" + usage = BandwidthUsage( + user_id=user_id, + protocol=protocol, + bytes_up=bytes_up, + bytes_down=bytes_down + ) + self.db.add(usage) + self.db.commit() diff --git a/static/css/dashboard.css b/static/css/dashboard.css new file mode 100644 index 0000000000000000000000000000000000000000..276d277e8ebda2fa38c202f10ae9ae12129811eb --- /dev/null +++ b/static/css/dashboard.css @@ -0,0 +1,248 @@ +/* Modern Dashboard Styles */ +:root { + --primary: #4F46E5; + --primary-dark: #4338CA; + --secondary: #10B981; + --secondary-dark: #059669; + --background: #F9FAFB; + --surface: #FFFFFF; + --text-primary: #111827; + --text-secondary: #6B7280; + --danger: #EF4444; + --warning: #F59E0B; + --success: #10B981; +} + +body { + background-color: var(--background); + font-family: 'Inter', sans-serif; +} + +/* Animated Cards */ +.dashboard-card { + background: var(--surface); + border-radius: 1rem; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + transition: all 0.3s ease; + transform: translateY(0); + border: 1px solid rgba(0,0,0,0.05); +} + +.dashboard-card:hover { + transform: translateY(-4px); + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); +} + +/* Stat Cards */ +.stat-card { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stat-card .stat-icon { + width: 2.5rem; + height: 2.5rem; + border-radius: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.5rem; +} + +.stat-card .stat-value { + font-size: 1.875rem; + font-weight: 600; + color: var(--text-primary); + line-height: 1.2; +} + +.stat-card .stat-label { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.stat-card .stat-change { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; +} + +/* Status Indicators */ +.status-indicator { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 500; +} + +.status-indicator.online { + background-color: rgba(16, 185, 129, 0.1); + color: var(--success); +} + +.status-indicator.offline { + background-color: rgba(239, 68, 68, 0.1); + color: var(--danger); +} + +/* Charts and Graphs */ +.chart-container { + padding: 1.5rem; + height: 300px; +} + +/* Session Cards */ +.session-card { + padding: 1.25rem; + display: flex; + align-items: center; + gap: 1rem; + border-bottom: 1px solid rgba(0,0,0,0.05); +} + +.session-card:last-child { + border-bottom: none; +} + +.session-info { + flex: 1; +} + +/* Quick Actions */ +.quick-action { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + background-color: var(--background); + color: var(--text-primary); + transition: all 0.2s ease; +} + +.quick-action:hover { + background-color: var(--primary); + color: white; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in { + animation: fadeIn 0.5s ease forwards; +} + +/* Protocol Badges */ +.protocol-badge { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.75rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + background-color: rgba(79, 70, 229, 0.1); + color: var(--primary); + margin: 0.25rem; +} + +/* Traffic Charts */ +.traffic-chart { + position: relative; + height: 400px; + margin-top: 1rem; +} + +/* Data Cards Grid */ +.data-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +/* Responsive Sidebar */ +.dashboard-sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: 250px; + background: var(--surface); + border-right: 1px solid rgba(0,0,0,0.05); + padding: 1.5rem; + transform: translateX(0); + transition: transform 0.3s ease; + z-index: 1000; +} + +@media (max-width: 768px) { + .dashboard-sidebar { + transform: translateX(-100%); + } + + .dashboard-sidebar.active { + transform: translateX(0); + } + + .dashboard-main { + margin-left: 0 !important; + } +} + +/* Loading Animations */ +.loading-skeleton { + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: loading 1.5s infinite; +} + +@keyframes loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +/* Tooltip */ +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip .tooltip-text { + visibility: hidden; + background-color: rgba(0,0,0,0.8); + color: white; + text-align: center; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + position: absolute; + z-index: 1; + bottom: 125%; + left: 50%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.2s ease; +} + +.tooltip:hover .tooltip-text { + visibility: visible; + opacity: 1; +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000000000000000000000000000000000000..444fe09b12dca0f855c80512f0300c5573a76e3b --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,132 @@ +/* Custom styles for Outline VPN */ + +/* Global styles */ +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +/* Card styles */ +.card { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: transform 0.2s ease-in-out; +} + +.card:hover { + transform: translateY(-2px); +} + +/* Dashboard stats cards */ +.stats-card { + border-radius: 10px; + border: none; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); +} + +.stats-card .card-title { + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Navigation */ +.navbar { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.navbar-brand { + font-weight: bold; + letter-spacing: 0.5px; +} + +/* Forms */ +.form-control:focus { + border-color: #0d6efd; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +/* Buttons */ +.btn { + border-radius: 5px; + padding: 0.5rem 1.5rem; + transition: all 0.2s ease-in-out; +} + +.btn-primary { + background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%); + border: none; +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(13, 110, 253, 0.2); +} + +/* Footer */ +.footer { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + margin-top: auto; +} + +/* Dashboard chart container */ +.chart-container { + position: relative; + height: 300px; + width: 100%; +} + +/* Status badges */ +.badge { + padding: 0.5em 1em; + font-weight: 500; + letter-spacing: 0.5px; +} + +/* Quick setup guide */ +.list-group-numbered { + counter-reset: section; +} + +.list-group-numbered > .list-group-item { + display: flex; + align-items: center; +} + +.list-group-numbered > .list-group-item::before { + content: counter(section); + counter-increment: section; + background-color: #e9ecef; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 1rem; + font-size: 0.875rem; + font-weight: 500; +} + +/* Loading spinner */ +.spinner-border-sm { + width: 1rem; + height: 1rem; + border-width: 0.2em; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .card { + margin-bottom: 1rem; + } + + .btn { + width: 100%; + margin-bottom: 0.5rem; + } +} diff --git a/static/js/auth.js b/static/js/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..4790a596ba931ae8f1e2d1f11e0b09923ecab6fd --- /dev/null +++ b/static/js/auth.js @@ -0,0 +1,193 @@ +/** + * Client-side form validation and authentication handling + */ + +// Token management functions +function getToken() { + return localStorage.getItem('auth_token'); +} + +function setToken(token) { + localStorage.setItem('auth_token', token); +} + +function removeToken() { + localStorage.removeItem('auth_token'); +} + +// Check authentication state and update UI accordingly +async function checkAuthState() { + try { + const token = getToken(); + const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; + + const response = await fetch('/api/user/current', { headers }); + if (!response.ok) { + throw new Error('Auth check failed'); + } + const user = await response.json(); + + const loggedIn = document.querySelector('.auth-logged-in'); + const loggedOut = document.querySelector('.auth-logged-out'); + const userProfile = document.querySelector('.user-profile'); + + if (user && user.username) { + loggedIn.style.display = 'block'; + loggedOut.style.display = 'none'; + userProfile.textContent = user.username; + } else { + loggedIn.style.display = 'none'; + loggedOut.style.display = 'block'; + } + } catch (error) { + console.error('Auth check failed:', error); + document.querySelector('.auth-logged-in').style.display = 'none'; + document.querySelector('.auth-logged-out').style.display = 'block'; + removeToken(); + } +} + +document.addEventListener('DOMContentLoaded', function() { + // Check auth state when page loads + checkAuthState(); + + // Set up form validation listeners + const loginForm = document.getElementById('login-form'); + if (loginForm) { + loginForm.addEventListener('submit', handleLoginSubmit); + } + + const signupForm = document.getElementById('signup-form'); + if (signupForm) { + signupForm.addEventListener('submit', handleSignupSubmit); + } + + // Handle logout + const logoutLink = document.querySelector('a[href="/logout"]'); + if (logoutLink) { + logoutLink.addEventListener('click', function(e) { + e.preventDefault(); + removeToken(); + window.location.href = '/'; + }); + } +}); + +async function handleLoginSubmit(e) { + e.preventDefault(); + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + + if (!validateLoginForm(email, password)) { + return; + } + + try { + const response = await fetch('/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}` + }); + + if (!response.ok) { + throw new Error('Login failed'); + } + + const data = await response.json(); + setToken(data.access_token); + window.location.href = '/dashboard'; + } catch (error) { + console.error('Login error:', error); + showError('Login failed. Please check your credentials and try again.'); + } +} + +async function handleSignupSubmit(e) { + e.preventDefault(); + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + const confirmPassword = document.getElementById('confirm_password').value; + + if (!validateSignupForm(email, password, confirmPassword)) { + return; + } + + try { + const response = await fetch('/signup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: email, + password: password + }) + }); + + if (!response.ok) { + throw new Error('Signup failed'); + } + + const data = await response.json(); + setToken(data.access_token); + window.location.href = '/dashboard'; + } catch (error) { + console.error('Signup error:', error); + showError('Signup failed. Please try again.'); + } +} + +function validateLoginForm(email, password) { + if (!email || !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { + showError('Please enter a valid email address'); + return false; + } + + if (!password || password.length < 8) { + showError('Password must be at least 8 characters long'); + return false; + } + + return true; +} + +function validateSignupForm(email, password, confirmPassword) { + if (!email || !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { + showError('Please enter a valid email address'); + return false; + } + + if (!password || password.length < 8) { + showError('Password must be at least 8 characters long'); + return false; + } + + if (password !== confirmPassword) { + showError('Passwords do not match'); + return false; + } + + return true; +} + +function showError(message) { + // Remove any existing error alerts + const existingAlert = document.querySelector('.alert-danger'); + if (existingAlert) { + existingAlert.remove(); + } + + // Create and show new error alert + const alert = document.createElement('div'); + alert.className = 'alert alert-danger alert-dismissible fade show'; + alert.role = 'alert'; + alert.innerHTML = ` + ${message} + + `; + + const form = document.querySelector('form'); + form.parentNode.insertBefore(alert, form); +} diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000000000000000000000000000000000000..0740f8116de1b46ed5ae112c4bad7512292bfbc4 --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,254 @@ +/** + * Dashboard animations and interactivity + */ + +// Initialize animations when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + initializeAnimations(); + initializeCharts(); + initializeEventListeners(); +}); + +// Animation utilities +const animate = { + fadeIn: (element, delay = 0) => { + element.style.opacity = '0'; + element.style.transform = 'translateY(20px)'; + + setTimeout(() => { + element.style.transition = 'all 0.5s ease'; + element.style.opacity = '1'; + element.style.transform = 'translateY(0)'; + }, delay); + }, + + slideIn: (element, direction = 'left', delay = 0) => { + const transforms = { + left: 'translateX(-50px)', + right: 'translateX(50px)', + up: 'translateY(50px)', + down: 'translateY(-50px)' + }; + + element.style.opacity = '0'; + element.style.transform = transforms[direction]; + + setTimeout(() => { + element.style.transition = 'all 0.5s ease'; + element.style.opacity = '1'; + element.style.transform = 'translate(0)'; + }, delay); + }, + + pulse: (element) => { + element.style.transform = 'scale(1)'; + element.style.transition = 'transform 0.2s ease'; + + element.style.transform = 'scale(1.05)'; + setTimeout(() => { + element.style.transform = 'scale(1)'; + }, 200); + } +}; + +// Initialize animations +function initializeAnimations() { + // Animate stat cards + const statCards = document.querySelectorAll('.stat-card'); + statCards.forEach((card, index) => { + animate.fadeIn(card, index * 100); + }); + + // Animate charts + const charts = document.querySelectorAll('.chart-container'); + charts.forEach((chart, index) => { + animate.slideIn(chart, 'up', 300 + index * 100); + }); + + // Animate session cards + const sessionCards = document.querySelectorAll('.session-card'); + sessionCards.forEach((card, index) => { + animate.slideIn(card, 'right', 500 + index * 100); + }); +} + +// Initialize Charts +function initializeCharts() { + // Traffic Chart + const trafficChart = document.getElementById('traffic-chart'); + if (trafficChart) { + new Chart(trafficChart, { + type: 'line', + data: { + labels: getLastNHours(24), + datasets: [{ + label: 'Upload', + data: generateRandomData(24), + borderColor: '#4F46E5', + tension: 0.4 + }, { + label: 'Download', + data: generateRandomData(24), + borderColor: '#10B981', + tension: 0.4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top' + } + }, + scales: { + y: { + beginAtZero: true + } + } + } + }); + } + + // Protocol Distribution Chart + const protocolChart = document.getElementById('protocol-chart'); + if (protocolChart) { + new Chart(protocolChart, { + type: 'doughnut', + data: { + labels: ['Shadowsocks', 'IKEv2', 'L2TP', 'PPTP'], + datasets: [{ + data: [40, 30, 20, 10], + backgroundColor: [ + '#4F46E5', + '#10B981', + '#F59E0B', + '#EF4444' + ] + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom' + } + } + } + }); + } +} + +// Initialize Event Listeners +function initializeEventListeners() { + // Quick Action Buttons + const quickActions = document.querySelectorAll('.quick-action'); + quickActions.forEach(action => { + action.addEventListener('mouseenter', () => { + animate.pulse(action); + }); + }); + + // Responsive Sidebar Toggle + const sidebarToggle = document.getElementById('sidebar-toggle'); + const sidebar = document.querySelector('.dashboard-sidebar'); + if (sidebarToggle && sidebar) { + sidebarToggle.addEventListener('click', () => { + sidebar.classList.toggle('active'); + }); + } + + // Live Data Updates + setInterval(updateLiveStats, 5000); +} + +// Utility Functions +function getLastNHours(n) { + const hours = []; + for (let i = n - 1; i >= 0; i--) { + const d = new Date(); + d.setHours(d.getHours() - i); + hours.push(d.getHours() + ':00'); + } + return hours; +} + +function generateRandomData(n) { + return Array.from({length: n}, () => Math.floor(Math.random() * 100)); +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +// Live Data Updates +function updateLiveStats() { + fetch('/api/stats') + .then(response => response.json()) + .then(data => { + // Update connection status + const statusIndicator = document.querySelector('.status-indicator'); + if (statusIndicator) { + statusIndicator.className = `status-indicator ${data.isConnected ? 'online' : 'offline'}`; + statusIndicator.textContent = data.isConnected ? 'Connected' : 'Disconnected'; + } + + // Update traffic stats + const uploadStat = document.getElementById('upload-stat'); + const downloadStat = document.getElementById('download-stat'); + if (uploadStat) uploadStat.textContent = formatBytes(data.uploadTotal); + if (downloadStat) downloadStat.textContent = formatBytes(data.downloadTotal); + + // Update active sessions + updateSessionsList(data.sessions); + }) + .catch(console.error); +} + +function updateSessionsList(sessions) { + const sessionsList = document.getElementById('active-sessions'); + if (!sessionsList) return; + + sessions.forEach(session => { + const existingSession = document.getElementById(`session-${session.id}`); + if (!existingSession) { + // Create new session card + const sessionCard = createSessionCard(session); + animate.slideIn(sessionCard, 'right'); + sessionsList.appendChild(sessionCard); + } else { + // Update existing session + updateSessionCard(existingSession, session); + } + }); +} + +function createSessionCard(session) { + const card = document.createElement('div'); + card.id = `session-${session.id}`; + card.className = 'session-card dashboard-card'; + card.innerHTML = ` +
+
${session.protocol}
+ ${session.ip} +
+
+
↑ ${formatBytes(session.upload)}
+
↓ ${formatBytes(session.download)}
+
+ `; + return card; +} + +function updateSessionCard(card, session) { + const uploadEl = card.querySelector('.session-stats div:first-child'); + const downloadEl = card.querySelector('.session-stats div:last-child'); + + uploadEl.textContent = `↑ ${formatBytes(session.upload)}`; + downloadEl.textContent = `↓ ${formatBytes(session.download)}`; +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000000000000000000000000000000000000..0935d090055b52ce6c75f778b3b6afa1d92e7138 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,149 @@ +// Main JavaScript file for Outline VPN + +// Helper function to format bytes to human-readable format +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +// Helper function to format duration +function formatDuration(milliseconds) { + const seconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ${hours % 24}h`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +} + +// Show toast notification +function showToast(message, type = 'info') { + const toast = document.createElement('div'); + toast.className = `toast align-items-center text-white bg-${type} border-0`; + toast.setAttribute('role', 'alert'); + toast.setAttribute('aria-live', 'assertive'); + toast.setAttribute('aria-atomic', 'true'); + + toast.innerHTML = ` +
+
${message}
+ +
+ `; + + const container = document.createElement('div'); + container.className = 'toast-container position-fixed bottom-0 end-0 p-3'; + container.appendChild(toast); + document.body.appendChild(container); + + const bsToast = new bootstrap.Toast(toast); + bsToast.show(); + + toast.addEventListener('hidden.bs.toast', () => { + container.remove(); + }); +} + +// Copy text to clipboard +function copyToClipboard(text, successMessage = 'Copied to clipboard!') { + navigator.clipboard.writeText(text) + .then(() => showToast(successMessage, 'success')) + .catch(() => showToast('Failed to copy text', 'danger')); +} + +// Download file helper +function downloadFile(content, filename, type = 'application/json') { + const blob = new Blob([content], { type }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); +} + +// Form validation +function validateForm(formElement) { + const requiredFields = formElement.querySelectorAll('[required]'); + let isValid = true; + + requiredFields.forEach(field => { + if (!field.value.trim()) { + isValid = false; + field.classList.add('is-invalid'); + } else { + field.classList.remove('is-invalid'); + } + }); + + return isValid; +} + +// Password strength checker +function checkPasswordStrength(password) { + let strength = 0; + const messages = []; + + if (password.length >= 8) strength++; + else messages.push('Password should be at least 8 characters long'); + + if (password.match(/[a-z]/)) strength++; + if (password.match(/[A-Z]/)) strength++; + else messages.push('Include at least one uppercase letter'); + + if (password.match(/[0-9]/)) strength++; + else messages.push('Include at least one number'); + + if (password.match(/[^a-zA-Z0-9]/)) strength++; + else messages.push('Include at least one special character'); + + return { + score: strength, + messages: messages, + label: ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'][strength - 1] || 'Very Weak' + }; +} + +// Initialize password strength meter if it exists +document.addEventListener('DOMContentLoaded', () => { + const passwordInput = document.querySelector('input[type="password"]'); + if (passwordInput) { + const feedbackDiv = document.createElement('div'); + feedbackDiv.className = 'password-strength-meter mt-2'; + passwordInput.parentNode.appendChild(feedbackDiv); + + passwordInput.addEventListener('input', (e) => { + const strength = checkPasswordStrength(e.target.value); + const color = ['danger', 'warning', 'info', 'primary', 'success'][strength.score - 1] || 'danger'; + + feedbackDiv.innerHTML = ` +
+
+
+
+ ${strength.label} + `; + }); + } +}); + +// Initialize tooltips +document.addEventListener('DOMContentLoaded', () => { + const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl); + }); +}); diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000000000000000000000000000000000000..977d00981753b5a0a0983a22e734fbff2030e84f --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,247 @@ +{% extends "base.html" %} + +{% block title %}Server Administration - Outline VPN{% endblock %} + +{% block content %} +
+ {% include 'sidebar.html' %} + +
+
+
+

Server Administration

+
+ + +
+
+ +
+ +
+
+

Server Configuration

+
+ +
+

Network Settings

+
+
+ + +
+
+ + +
+
+
+ + +
+

Protocol Settings

+
+
+ + +
+
+ + +
+
+
+ + +
+

Security Settings

+
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+
+ + +
+
+

Audit Log

+
+ + +
+
+
+ + + + + + + + + + + {% for log in audit_logs %} + + + + + + + {% endfor %} + +
TimestampUserActionDetails
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ log.username }}{{ log.action }}{{ log.details }}
+
+
+
+ + + +
+

Active Alerts

+
+ {% for alert in active_alerts %} +
+ +
+ {{ alert.title }} +

{{ alert.message }}

+ {{ alert.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} +
+
+ {% endfor %} + {% if not active_alerts %} +

No active alerts

+ {% endif %} +
+
+
+
+
+ + + + + + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000000000000000000000000000000000000..5d55788bc3dae237c5d157c0fd098ab419da26fa --- /dev/null +++ b/templates/base.html @@ -0,0 +1,85 @@ + + + + + + {% block title %}Outline VPN{% endblock %} + + + + + + {% block extra_css %}{% endblock %} + + + + + +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + + {% block content %}{% endblock %} +
+ + + + + + + + + + {% block extra_js %}{% endblock %} + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000000000000000000000000000000000000..8611fedf15b802379753296c408867337d222616 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,662 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Outline VPN{% endblock %} + +{% block extra_css %} + + + +{% endblock %} + +{% block content %} +
+ + + + +
+
+

Welcome back, {{ current_user.username }}

+ +
+ + +
+
+

Connection Status

+
+ + Connected +
+
+ + Uptime: 12h 30m +
+
+ +
+

Data Usage

+
+ 450.5 GB +
+
+ + +2.3% from last week +
+
+ +
+

Active Sessions

+
+ 3 +
+
+ + Across all devices +
+
+ +
+

Network Speed

+
+ 125 Mbps +
+
+ + Average speed +
+
+
+ + +
+

Traffic Overview

+ +
+ + +
+
+

Active Sessions

+ +
+
+ + + + + + + + + + + + + {% for session in active_sessions %} + + + + + + + + + {% endfor %} + +
DeviceIP AddressLocationConnected SinceStatusActions
+
+ + {{ session.device_name }} +
+
{{ session.ip_address }}{{ session.location }}{{ session.connected_since }} + + {{ session.status|title }} + + +
+
+
+ + +
+
+
+

Quick Actions

+
+ + + +
+
+
+
+
+

System Status

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + + + + +{% endblock %} +
+
+
+
Data Upload
+

0 B

+
+
+
+
+
+
+
+
+
+
Data Download
+

0 B

+
+
+
+
+
+
+
+
+
+
Connected Since
+

-

+ +
+
+
+ + + +
+
+
Active Sessions
+
+
+
+ +
+
+
+ + +
+ +
+
+
Quick Actions
+
+
+
+ + +
+
+
+ + +
+
+
Active Protocols
+
+
+
+ +
+
+
+
+ + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} +
+
+
+
Usage History
+ +
+
+
+ + +
+
+
+
+
VPN Configuration
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+
+
+
+
+
+
Quick Setup Guide
+
    +
  1. Download the Outline Client for your device
  2. +
  3. Copy your access key from this dashboard
  4. +
  5. Open the Outline Client and paste your access key
  6. +
  7. Click "Connect" to start using the VPN
  8. +
+ +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/forgot_password.html b/templates/forgot_password.html new file mode 100644 index 0000000000000000000000000000000000000000..e7e78584ff22ddef914617e1c6874c886c5950a3 --- /dev/null +++ b/templates/forgot_password.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block title %}Forgot Password - Outline VPN{% endblock %} + +{% block content %} +
+
+
+
+

Reset Password

+

Enter your email address and we'll send you instructions to reset your password.

+
+
+ + +
+
+ + Back to Login +
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..ebc624c3b361443d01ee6ae6d45c8aeadeab05b1 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}Welcome to Outline VPN{% endblock %} + +{% block content %} +
+

Welcome to Outline VPN

+
+

+ Secure, fast, and reliable VPN service for all your needs. + Access the internet freely and privately with our advanced VPN solution. +

+
+ {% if user and user is not none %} + Go to Dashboard + {% else %} + Get Started + Sign In + {% endif %} +
+
+
+ +
+
+
+
+
+
Secure Connection
+

+ Military-grade encryption keeps your data safe and private. +

+
+
+
+
+
+
+
High Speed
+

+ Optimized servers ensure fast and reliable connections. +

+
+
+
+
+
+
+
Easy to Use
+

+ Simple setup and user-friendly interface for all devices. +

+
+
+
+
+
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000000000000000000000000000000000000..ecb06641444d4c1f3048f5816545a86904c5cdb3 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} + +{% block title %}Login - Outline VPN{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+
+

Login

+
+
+ + +
+
+ + + +
+
+ + +
+
+ + Forgot Password? +
+
+
+

Don't have an account? Sign up

+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000000000000000000000000000000000000..1e6d483837ead666f6411d426c9e0778d91936c7 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,235 @@ +{% extends "base.html" %} + +{% block title %}User Profile - Outline VPN{% endblock %} + +{% block content %} +
+ {% include 'sidebar.html' %} + +
+
+

User Profile

+ +
+ +
+
+
+

Profile Information

+ +
+ +
+
+

Username

+

{{ current_user.username }}

+
+
+

Role

+

{{ current_user.role.value|title }}

+
+
+

Account Status

+

+ + {{ current_user.status.value|title }} +

+
+
+

Member Since

+

{{ current_user.created_at.strftime('%B %d, %Y') }}

+
+
+
+ + +
+

Security Settings

+ + +
+
+
+

Password

+

Last changed {{ current_user.password_changed_at.strftime('%B %d, %Y') if current_user.password_changed_at else 'Never' }}

+
+ +
+
+ + +
+
+
+

Two-Factor Authentication

+

Add an extra layer of security to your account

+
+ +
+
+
+
+ + +
+
+

Active Sessions

+
+ {% for session in user_sessions %} +
+
+
+
{{ session.device_info }}
+ {{ session.ip_address }} +
+ {% if session.is_current %} + Current + {% endif %} +
+
+ Last active: {{ session.last_active.strftime('%B %d, %Y %H:%M') }} +
+ {% if not session.is_current %} + + {% endif %} +
+ {% endfor %} +
+
+
+
+
+
+
+ + + + + + + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/signup.html b/templates/signup.html new file mode 100644 index 0000000000000000000000000000000000000000..0f050db23be39b039fdd3bcff21379ad02422a0e --- /dev/null +++ b/templates/signup.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block title %}Sign Up - Outline VPN{% endblock %} + +{% block content %} +
+
+
+
+

Create Account

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+

Already have an account? Sign in

+
+
+
+
+
+ +{% block extra_js %} + +{% endblock %} +{% endblock %} diff --git a/tests/test_outline.py b/tests/test_outline.py new file mode 100644 index 0000000000000000000000000000000000000000..312f9e8e95f3e3f4cfd61705a4d1b2d46a0032a9 --- /dev/null +++ b/tests/test_outline.py @@ -0,0 +1,157 @@ +""" +Test suite for Outline VPN implementation +""" + +import os +import sys +import asyncio +import unittest +import tempfile +from unittest.mock import Mock, patch +from pathlib import Path + +# Add project root to path +project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, project_root) + +from core.outline_config import OutlineManager, UserConfig +from core.outline_server import OutlineServer +from core.shadowsocks_protocol import ShadowsocksProtocol +from core.ipsec_manager import IPsecManager +from core.nat_engine import NATEngine +from core.traffic_router import TrafficRouter + +class TestOutlineVPN(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.config_path = os.path.join(self.temp_dir, 'test_config.json') + self.manager = OutlineManager(self.config_path) + + def tearDown(self): + import shutil + shutil.rmtree(self.temp_dir) + + def test_user_management(self): + """Test user creation and key management""" + # Add user + user = self.manager.add_user('testuser') + self.assertIsNotNone(user.access_key) + self.assertEqual(user.user_id, 'testuser') + self.assertIsNone(user.data_limit) + + # Test user retrieval + found_user = self.manager.get_user_by_key(user.access_key) + self.assertEqual(found_user.user_id, 'testuser') + + # Test user removal + self.assertTrue(self.manager.remove_user('testuser')) + self.assertIsNone(self.manager.get_user_by_key(user.access_key)) + + def test_bandwidth_tracking(self): + """Test bandwidth usage tracking""" + user = self.manager.add_user('bandwidthuser', data_limit=1000) + self.manager.update_user_bandwidth('bandwidthuser', 500) + self.assertEqual(user.bandwidth_usage, 500) + self.assertTrue(user.is_active) + + # Test data limit enforcement + self.manager.update_user_bandwidth('bandwidthuser', 600) + self.assertFalse(user.is_active) + +class TestShadowsocksProtocol(unittest.TestCase): + def setUp(self): + self.protocol = ShadowsocksProtocol("test_key") + + def test_encryption(self): + """Test packet encryption and decryption""" + test_data = b"Hello, World!" + encrypted = self.protocol._encrypt_packet(test_data) + decrypted = self.protocol._decrypt_packet(encrypted) + self.assertEqual(test_data, decrypted) + +class TestOutlineServer(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + config = { + "server": { + "host": "127.0.0.1", + "port": 8388, + "virtual_network": "10.0.0.0/24" + } + } + self.server = OutlineServer(config) + + async def test_server_start_stop(self): + """Test server startup and shutdown""" + # Mock the actual server binding + with patch('asyncio.start_server'): + startup_task = asyncio.create_task(self.server.start()) + await asyncio.sleep(0.1) # Give time for server to "start" + self.assertTrue(self.server.is_running) + + await self.server.stop() + self.assertFalse(self.server.is_running) + await startup_task + + async def test_client_handling(self): + """Test client connection handling""" + # Add a test user + self.server.outline_manager.add_user('testuser') + + # Mock reader/writer + reader = Mock() + writer = Mock() + writer.get_extra_info.return_value = ('127.0.0.1', 12345) + + # Test connection handling + await self.server._handle_client(reader, writer) + writer.close.assert_called_once() + +class TestNATEngine(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.nat = NATEngine() + + async def test_nat_session(self): + """Test NAT session creation and tracking""" + session = self.nat.create_session( + virtual_ip="10.0.0.2", + virtual_port=1234, + real_ip="192.168.1.1", + real_port=80 + ) + self.assertIsNotNone(session) + self.assertEqual(session.virtual_ip, "10.0.0.2") + + # Test session lookup + found = self.nat.lookup_session("192.168.1.1", 80) + self.assertEqual(found.virtual_ip, "10.0.0.2") + +class TestTrafficRouter(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + config = { + "vpn_host": "127.0.0.1", + "vpn_port": 8388, + "virtual_network": "10.0.0.0/24" + } + self.router = TrafficRouter(config) + + async def test_traffic_routing(self): + """Test traffic routing functionality""" + # Mock connection + reader = Mock() + writer = Mock() + writer.get_extra_info.return_value = ('10.0.0.2', 1234) + + # Test connection handling + with patch('asyncio.open_connection'): + await self.router._handle_client_connection(reader, writer) + writer.close.assert_called_once() + +def run_tests(): + """Run all tests""" + loader = unittest.TestLoader() + suite = loader.loadTestsFromModule(sys.modules[__name__]) + runner = unittest.TextTestRunner(verbosity=2) + return runner.run(suite) + +if __name__ == '__main__': + run_tests() diff --git a/vpn_server.log b/vpn_server.log new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/web/admin_routes.py b/web/admin_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..429e90f1e418f91fcd66240e86af1ccae2bab606 --- /dev/null +++ b/web/admin_routes.py @@ -0,0 +1,166 @@ +""" +Admin routes and functionality for Outline VPN +""" +import os +import json +import psutil +import zipfile +from datetime import datetime +from flask import jsonify, request, send_file, flash, redirect, url_for +from flask_login import login_required, current_user +from . import app +from .models import User, UserRole, SystemHealth, AuditLog, Alert +from .services import backup_service, monitoring_service + +def admin_required(f): + """Decorator to require admin role for routes""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role != UserRole.ADMIN: + flash('You need administrator privileges to access this page.') + return redirect(url_for('dashboard')) + return f(*args, **kwargs) + return decorated_function + +@app.route('/admin') +@login_required +@admin_required +def admin_dashboard(): + """Admin dashboard view""" + system_health = monitoring_service.get_system_health() + active_alerts = Alert.query.filter_by(status='active').order_by(Alert.created_at.desc()).all() + audit_logs = AuditLog.query.order_by(AuditLog.timestamp.desc()).limit(50).all() + + return render_template('admin.html', + system_health=system_health, + active_alerts=active_alerts, + audit_logs=audit_logs) + +@app.route('/api/system-health') +@login_required +@admin_required +def get_system_health(): + """Get real-time system health metrics""" + return jsonify(monitoring_service.get_system_health()) + +@app.route('/api/update-server-config', methods=['POST']) +@login_required +@admin_required +def update_server_config(): + """Update server configuration""" + try: + config = request.get_json() + backup_service.backup_config('pre_update') # Create backup before updating + + # Update configuration + current_config = ServerConfig.query.first() + for key, value in config.items(): + setattr(current_config, key, value) + + db.session.commit() + + # Log the change + AuditLog.create( + user_id=current_user.id, + action='update_config', + details='Server configuration updated' + ) + + # Restart required services + monitoring_service.restart_services() + + return jsonify({'status': 'success'}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/create-backup') +@login_required +@admin_required +def create_backup(): + """Create a backup of server configuration""" + try: + include_user_data = request.args.get('include_user_data', 'false') == 'true' + backup_path = backup_service.create_backup(include_user_data) + + # Log the backup creation + AuditLog.create( + user_id=current_user.id, + action='create_backup', + details=f'Backup created: {os.path.basename(backup_path)}' + ) + + return send_file( + backup_path, + as_attachment=True, + download_name=f'outline_backup_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip' + ) + except Exception as e: + flash(f'Error creating backup: {str(e)}', 'error') + return redirect(url_for('admin_dashboard')) + +@app.route('/api/restore-config', methods=['POST']) +@login_required +@admin_required +def restore_config(): + """Restore server configuration from backup""" + try: + if 'backup_file' not in request.files: + flash('No backup file provided', 'error') + return redirect(url_for('admin_dashboard')) + + backup_file = request.files['backup_file'] + if backup_file.filename == '': + flash('No backup file selected', 'error') + return redirect(url_for('admin_dashboard')) + + # Create backup of current configuration + backup_service.backup_config('pre_restore') + + # Restore from backup + backup_service.restore_from_backup(backup_file) + + # Log the restore + AuditLog.create( + user_id=current_user.id, + action='restore_config', + details=f'Configuration restored from {backup_file.filename}' + ) + + flash('Configuration restored successfully', 'success') + return redirect(url_for('admin_dashboard')) + except Exception as e: + flash(f'Error restoring configuration: {str(e)}', 'error') + return redirect(url_for('admin_dashboard')) + +@app.route('/api/export-audit-log') +@login_required +@admin_required +def export_audit_log(): + """Export audit log in specified format""" + format = request.args.get('format', 'csv') + logs = AuditLog.query.order_by(AuditLog.timestamp.desc()).all() + + if format == 'csv': + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(['Timestamp', 'User', 'Action', 'Details']) + for log in logs: + writer.writerow([ + log.timestamp.strftime('%Y-%m-%d %H:%M:%S'), + log.user.username, + log.action, + log.details + ]) + + return Response( + output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=audit_log.csv'} + ) + elif format == 'json': + return jsonify([{ + 'timestamp': log.timestamp.strftime('%Y-%m-%d %H:%M:%S'), + 'user': log.user.username, + 'action': log.action, + 'details': log.details + } for log in logs]) diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000000000000000000000000000000000000..369b6be864d85fb7e5351e14de4b357d25ce0cf0 --- /dev/null +++ b/web/app.py @@ -0,0 +1,648 @@ +from flask import Flask, render_template, jsonify, request, redirect, url_for, flash, session +from flask_login import LoginManager, login_user, logout_user, login_required, current_user +from functools import wraps +import asyncio +import threading +import os +import json +import uuid +import bcrypt +from datetime import datetime, timedelta +import logging +from typing import Dict, Optional + + +import sys +from pathlib import Path +sys.path.append(str(Path(__file__).parent.parent)) + +# Import core VPN components +from core.shadowsocks_protocol import ShadowsocksProtocol +from core.tcp_forward import OutlineTCPForwardingEngine +from core.session_tracker import SessionTracker, UnifiedSession, SessionType, SessionState +from core.logger import LogManager, LogCategory, LogLevel +from core.outline_server import OutlineServer +from core.ikev2_server import IKEv2Server +from core.models.user import User, UserSession, UserRole, UserStatus +from core.database import SessionLocal, engine +from core.database_init import init_db +from core.services.user_service import UserService +from core.outline_config import generate_openvpn_certificates, generate_openvpn_config, generate_wireguard_keys + +# For IP detection +import socket +import requests + +app = Flask(__name__) +app.secret_key = os.urandom(24) +app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) + +# Initialize Flask-Login +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = "login" +login_manager.login_message = "Please log in to access this page." + +@login_manager.user_loader +def load_user(user_id): + db = SessionLocal() + try: + return db.query(User).get(int(user_id)) + finally: + db.close() + +# Global VPN server state +vpn_server: Optional[OutlineServer] = None +session_tracker: Optional[SessionTracker] = None +logger: Optional[LogManager] = None + +# Initialize database +init_db() + +CONFIG_DIR = 'config' +USERS_FILE = os.path.join(CONFIG_DIR, 'users.json') +os.makedirs(CONFIG_DIR, exist_ok=True) + +def load_users(): + if os.path.exists(USERS_FILE): + with open(USERS_FILE, 'r') as f: + return json.load(f) + return {} + +def save_users(users): + with open(USERS_FILE, 'w') as f: + json.dump(users, f) + +def get_server_ip(): + """Get the server's public IP address""" + try: + # First try to get public IP from external service + response = requests.get('https://api.ipify.org', timeout=5) + if response.status_code == 200: + return response.text.strip() + except: + pass + + try: + # Try another public IP service as backup + response = requests.get('https://ifconfig.me', timeout=5) + if response.status_code == 200: + return response.text.strip() + except: + pass + + # Fallback: Get local IP + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('8.8.8.8', 80)) + local_ip = s.getsockname()[0] + s.close() + return local_ip + except: + # Last resort fallback + return '127.0.0.1' + +def initialize_ikev2_server(): + """Initialize IKEv2 server""" + global ikev2_server + server_ip = get_server_ip() + ikev2_server = IKEv2Server(server_ip, logger) + logger.log(LogLevel.INFO, LogCategory.SYSTEM, "app", "IKEv2 server initialized") + +def initialize_vpn_server(): + """Initialize the VPN server components""" + global vpn_server, session_tracker, logger, ikev2_server + + # Initialize logger + logger = LogManager() + logger.log(LogLevel.INFO, LogCategory.SYSTEM, "app", "Initializing VPN server") + + # Initialize session tracker + session_tracker = SessionTracker() + + # Initialize IKEv2 server + initialize_ikev2_server() + + # Initialize VPN server + server_ip = get_server_ip() + vpn_server_config = { + "server": { + "host": server_ip, # Use automatically detected server IP + "port": 8388, # Default Shadowsocks port + "virtual_network": "10.7.0.0/24", # Virtual network for client IPs + "protocols": { + "shadowsocks": { + "enabled": True, + "port": 8388 + }, + "wireguard": { + "enabled": True, + "port": 51820 + }, + "openvpn": { + "enabled": True, + "port": 1194 + }, + "ikev2": { + "enabled": True, + "port": 500 + } + } + }, + "security": { + "cipher": "aes-256-gcm", + "auth": "sha256", + "enable_perfect_forward_secrecy": True + } + } + vpn_server = OutlineServer(vpn_server_config) + # Start the VPN server in a separate thread + def run_server(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(vpn_server.start()) + loop.run_forever() + + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + logger.log(LogLevel.INFO, LogCategory.SYSTEM, "app", f"VPN server initialized and started on {server_ip}") + +def load_users(): + if os.path.exists(USERS_FILE): + with open(USERS_FILE, 'r') as f: + return json.load(f) + return {} + +def save_users(users): + with open(USERS_FILE, 'w') as f: + json.dump(users, f) + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated_function + +@app.route('/') +def index(): + if 'user_id' in session: + return redirect(url_for('dashboard')) + return render_template('index.html') + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("dashboard")) + if request.method == "POST": + email = request.form.get("email") + password = request.form.get("password") + remember_me = request.form.get("remember_me") == "on" + db = SessionLocal() + try: + user_service = UserService(db) + success, message, user = user_service.authenticate_user(email, password) + + if success: + if user.status == UserStatus.LOCKED: + flash('Your account is locked. Please try again later or contact support.') + return redirect(url_for('login')) + + # Create a new session + ip_address = request.remote_addr + device_info = request.user_agent.string + user_service.create_session(user, ip_address, device_info) + + # Log in the user + login_user(user, remember=remember_me) + + # Record successful login + user.record_login_attempt(success=True) + db.commit() + + next_page = request.args.get('next') + if not next_page or not next_page.startswith('/'): + next_page = url_for('dashboard') + return redirect(next_page) + else: + if user: + # Record failed attempt + user.record_login_attempt(success=False) + db.commit() + flash(message) + return redirect(url_for('login')) + finally: + db.close() + + return render_template('login.html') + +@app.route('/signup', methods=['GET', 'POST']) +def signup(): + if request.method == 'POST': + users = load_users() + email = request.form.get('email') + password = request.form.get('password') + + if email in users: + flash('Email already registered') + return redirect(url_for('signup')) + + # Hash password and create user + hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + users[email] = { + 'password': hashed, + 'created_at': datetime.now().isoformat(), + 'config_id': str(uuid.uuid4()) + } + save_users(users) + + # Create user's VPN configuration + create_user_config(users[email]['config_id']) + + session['user_id'] = email + return redirect(url_for('dashboard')) + + return render_template('signup.html') + +@app.route('/dashboard') +@login_required +def dashboard(): + users = load_users() + user = users[session['user_id']] + stats = get_user_stats(user['config_id']) + return render_template('dashboard.html', user=user, stats=stats) + +@app.route('/download_config') +@login_required +def download_config(): + users = load_users() + user = users[session['user_id']] + config_path = os.path.join(CONFIG_DIR, f"{user['config_id']}.json") + + if not os.path.exists(config_path): + flash('Configuration not found') + return redirect(url_for('dashboard')) + + with open(config_path, 'r') as f: + config = json.load(f) + + return jsonify(config) + +@app.route('/api/stats') +@login_required +def get_stats(): + users = load_users() + user = users[session['user_id']] + return jsonify(get_user_stats(user['config_id'])) + +def get_server_ip(): + """Get the server's public IP address""" + try: + # First try to get public IP from external service + response = requests.get('https://api.ipify.org') + if response.status_code == 200: + return response.text.strip() + except: + pass + + # Fallback: Get local IP + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('8.8.8.8', 80)) + local_ip = s.getsockname()[0] + s.close() + return local_ip + except: + return '127.0.0.1' # Last resort fallback + +def initialize_ikev2_server(): + """Initialize IKEv2 server""" + global ikev2_server + server_ip = get_server_ip() + ikev2_server = IKEv2Server(server_ip, logger) + logger.log(LogLevel.INFO, LogCategory.SYSTEM, "app", "IKEv2 server initialized") + +def generate_ikev2_certificate(config_id: str) -> Dict: + """Generate IKEv2 certificates for a user""" + username = f"user_{config_id[:8]}" + password = str(uuid.uuid4()) + psk = str(uuid.uuid4()) + + try: + cert_data = ikev2_server.add_user(config_id, username, password, psk) + logger.info(LogCategory.SYSTEM, "app", f"Generated IKEv2 certificates for user {config_id}") + return cert_data + except Exception as e: + logger.error(LogCategory.SYSTEM, "app", f"Failed to generate IKEv2 certificates: {e}") + return None + +def create_user_config(config_id): + """Create Outline VPN configuration for a new user""" + if not os.path.exists(CONFIG_DIR): + os.makedirs(CONFIG_DIR) + + server_ip = get_server_ip() + access_key = str(uuid.uuid4()) + + # Outline/Shadowsocks config + ss_config = { + 'id': config_id, + 'server': { + 'host': server_ip, + 'port': 8388 # Shadowsocks port + }, + 'access_key': access_key, + 'protocol': 'shadowsocks', + 'created_at': datetime.now().isoformat() + } + + # IKEv2 config (Windows 10/11, Android 10+) + ikev2_config = { + 'id': f"{config_id}_ikev2", + 'server': { + 'host': server_ip, + 'port': 500 # IKEv2 port + }, + 'credentials': { + 'username': f"user_{config_id[:8]}", + 'password': str(uuid.uuid4()), + }, + 'psk': str(uuid.uuid4()), # Pre-shared key + 'certificate': generate_ikev2_certificate(config_id), + 'protocol': 'ikev2', + 'created_at': datetime.now().isoformat() + } + + # L2TP/IPsec config (Windows, Android) + l2tp_config = { + 'id': f"{config_id}_l2tp", + 'server': { + 'host': server_ip, + 'ports': { + 'l2tp': 1701, + 'ipsec': [500, 4500] # IPsec ports for NAT traversal + } + }, + 'credentials': { + 'username': f"user_{config_id[:8]}", + 'password': str(uuid.uuid4()) + }, + 'ipsec': { + 'psk': str(uuid.uuid4()), # Pre-shared key for IPsec + 'encryption': 'aes-256-cbc', + 'hash': 'sha256' + }, + 'protocol': 'l2tp_ipsec', + 'created_at': datetime.now().isoformat() + } + + # PPTP config (Legacy support - Windows, Android) + pptp_config = { + 'id': f"{config_id}_pptp", + 'server': { + 'host': server_ip, + 'port': 1723 # PPTP port + }, + 'credentials': { + 'username': f"user_{config_id[:8]}", + 'password': str(uuid.uuid4()) + }, + 'protocol': 'pptp', + 'encryption': 'require-mppe', # Maximum PPTP security + 'warning': 'PPTP is considered less secure, use IKEv2 or L2TP/IPsec when possible', + 'created_at': datetime.now().isoformat() + } + + # OpenVPN config (Universal support) + openvpn_config = { + 'id': f"{config_id}_openvpn", + 'server': { + 'host': server_ip, + 'port': 1194, # OpenVPN default port + 'protocol': 'udp' # UDP for better performance + }, + 'credentials': { + 'username': f"user_{config_id[:8]}", + 'password': str(uuid.uuid4()) + }, + 'certificates': generate_openvpn_certificates(config_id), + 'protocol': 'openvpn', + 'created_at': datetime.now().isoformat(), + 'config_file': generate_openvpn_config(config_id, server_ip) + } + + # WireGuard config (Built-in Windows 11, Android, iOS) + wireguard_config = { + 'id': f"{config_id}_wireguard", + 'server': { + 'host': server_ip, + 'port': 51820, # WireGuard default port + 'public_key': generate_wireguard_keys(config_id)['server_public'], + 'allowed_ips': ['0.0.0.0/0', '::/0'] # Route all traffic + }, + 'client': { + 'private_key': generate_wireguard_keys(config_id)['client_private'], + 'public_key': generate_wireguard_keys(config_id)['client_public'], + 'address': f'10.7.0.{2 + len(load_users())}', # Unique IP for each client + 'dns': ['1.1.1.1', '8.8.8.8'] + }, + 'protocol': 'wireguard', + 'created_at': datetime.now().isoformat() + } + + # L2TP/IPsec config (Built-in Windows, Android, iOS) + l2tp_config = { + 'id': f"{config_id}_l2tp", + 'server': { + 'host': server_ip, + 'port': 1701, # L2TP port + }, + 'credentials': { + 'username': f"user_{config_id[:8]}", + 'password': str(uuid.uuid4()) + }, + 'ipsec': { + 'psk': str(uuid.uuid4()) # Pre-shared key for IPsec + }, + 'protocol': 'l2tp_ipsec', + 'created_at': datetime.now().isoformat() + } + + # Combined config with all supported protocols + config = { + 'id': config_id, + 'protocols': { + 'shadowsocks': ss_config, + 'ikev2': ikev2_config, + 'l2tp': l2tp_config, + 'pptp': pptp_config + }, + 'recommended_protocol': { + 'windows': 'ikev2', + 'android': 'ikev2', + 'fallback': 'l2tp' + }, + 'created_at': datetime.now().isoformat() + } + + config_path = os.path.join(CONFIG_DIR, f"{config_id}.json") + with open(config_path, 'w') as f: + json.dump(config, f) + +def get_user_stats(config_id): + """Get real VPN usage statistics for a user from all active sessions""" + try: + if not session_tracker: + logger.error(LogCategory.SYSTEM, "app", "Session tracker not initialized") + return None + + # Get all sessions for this user + user_sessions = session_tracker.get_user_sessions(config_id) + if not user_sessions: + return { + 'bytes_sent': 0, + 'bytes_received': 0, + 'connected_since': None, + 'last_seen': None, + 'status': 'disconnected', + 'active_sessions': [], + 'protocols': [] + } + + # Aggregate stats from all active sessions + total_bytes_sent = 0 + total_bytes_received = 0 + earliest_connection = None + latest_seen = None + active_sessions = [] + used_protocols = set() + + for sess in user_sessions: + # Update totals + total_bytes_sent += sess.bytes_out + total_bytes_received += sess.bytes_in + + # Track connection times + session_start = datetime.fromtimestamp(sess.start_time) + session_last_seen = datetime.fromtimestamp(sess.last_seen) + + if not earliest_connection or session_start < earliest_connection: + earliest_connection = session_start + if not latest_seen or session_last_seen > latest_seen: + latest_seen = session_last_seen + + # Track protocols + used_protocols.add(sess.protocol) + + # Get session details + session_info = { + 'id': sess.session_id, + 'protocol': sess.protocol, + 'assigned_ip': sess.assigned_ip, + 'connected_since': session_start.isoformat(), + 'last_seen': session_last_seen.isoformat(), + 'bytes_sent': sess.bytes_out, + 'bytes_received': sess.bytes_in, + 'is_offline': sess.is_offline + } + active_sessions.append(session_info) + + # Determine overall status + current_time = datetime.now() + is_active = any( + (current_time - datetime.fromtimestamp(s.last_seen)).total_seconds() < 300 # 5 minutes + for s in user_sessions + ) + + status = 'active' if is_active else 'offline' + if not is_active and any(s.is_offline for s in user_sessions): + status = 'offline_available' + + return { + 'bytes_sent': total_bytes_sent, + 'bytes_received': total_bytes_received, + 'connected_since': earliest_connection.isoformat() if earliest_connection else None, + 'last_seen': latest_seen.isoformat() if latest_seen else None, + 'status': status, + 'active_sessions': active_sessions, + 'protocols': list(used_protocols) + } + + except Exception as e: + logger.error(LogCategory.SYSTEM, "app", f"Error getting user stats: {e}") + return None + +@app.route('/logout') +def logout(): + if current_user.is_authenticated: + db = SessionLocal() + try: + # Find and end the current session + current_session = ( + db.query(UserSession) + .filter(UserSession.user_id == current_user.id) + .order_by(UserSession.created_at.desc()) + .first() + ) + if current_session: + current_session.expires_at = datetime.utcnow() + db.commit() + finally: + db.close() + + logout_user() + flash('You have been logged out.') + return redirect(url_for('index')) + +@app.route('/forgot-password', methods=['GET', 'POST']) +def forgot_password(): + if request.method == 'POST': + email = request.form.get('email') + + db = SessionLocal() + try: + user = db.query(User).filter(User.username == email).first() + if user: + # Generate password reset token + user_service = UserService(db) + reset_token = user_service.generate_reset_token() + user.reset_token = reset_token + user.reset_token_expires = datetime.utcnow() + timedelta(hours=24) + db.commit() + + # TODO: Send reset email with token + # For now, just show the token (in production, you'd send this via email) + flash(f'Password reset link has been sent to your email address.') + else: + # To prevent user enumeration, show the same message + flash(f'Password reset link has been sent to your email address.') + + return redirect(url_for('login')) + finally: + db.close() + + return render_template('forgot_password.html') + +if __name__ == '__main__': + # Initialize the VPN server first + initialize_vpn_server() + + # Run Flask development server + app.run(host="0.0.0.0", port=7860, debug=True) + + # If you want to use Uvicorn (production), uncomment these lines and comment out app.run(): + # from asgiref.wsgi import WsgiToAsgi + # asgi_app = WsgiToAsgi(app) + # import uvicorn + # uvicorn.run(asgi_app, host="0.0.0.0", port=7860) + + + +@app.teardown_appcontext +def shutdown_vpn_server(exception=None): + global vpn_server + if vpn_server and vpn_server.is_running: + logger.log(LogLevel.INFO, LogCategory.SYSTEM, "app", "Shutting down VPN server...") + asyncio.run(vpn_server.stop()) + logger.log(LogLevel.INFO, LogCategory.SYSTEM, "app", "VPN server shut down.") diff --git a/web/services/monitoring_service.py b/web/services/monitoring_service.py new file mode 100644 index 0000000000000000000000000000000000000000..213b210f6ff918859897e4cce720d880acfd3bda --- /dev/null +++ b/web/services/monitoring_service.py @@ -0,0 +1,130 @@ +""" +System monitoring and health check service +""" +import psutil +import platform +from datetime import datetime, timedelta +from typing import Dict, List +from .models import SystemHealth, Alert, db + +class MonitoringService: + def __init__(self): + self.alert_thresholds = { + 'cpu_usage': 90, + 'memory_usage': 90, + 'disk_usage': 90, + 'failed_login_attempts': 10 + } + + def get_system_health(self) -> Dict: + """Get current system health metrics""" + cpu_usage = psutil.cpu_percent(interval=1) + memory = psutil.virtual_memory() + disk = psutil.disk_usage('/') + + health = SystemHealth( + cpu_usage=cpu_usage, + memory_usage=memory.percent, + disk_usage=disk.percent, + timestamp=datetime.utcnow() + ) + db.session.add(health) + db.session.commit() + + # Check for alerts + self._check_alerts(health) + + return { + 'cpu_usage': cpu_usage, + 'memory_usage': memory.percent, + 'disk_usage': disk.percent, + 'memory_total': memory.total, + 'memory_available': memory.available, + 'disk_total': disk.total, + 'disk_free': disk.free, + 'system_time': datetime.utcnow().isoformat(), + 'system_uptime': self.get_system_uptime() + } + + def get_system_uptime(self) -> str: + """Get system uptime in human readable format""" + uptime = datetime.now() - datetime.fromtimestamp(psutil.boot_time()) + days = uptime.days + hours, remainder = divmod(uptime.seconds, 3600) + minutes, seconds = divmod(remainder, 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") + if seconds > 0 or not parts: + parts.append(f"{seconds}s") + + return " ".join(parts) + + def _check_alerts(self, health: SystemHealth) -> None: + """Check system health metrics and create alerts if needed""" + # CPU Usage Alert + if health.cpu_usage >= self.alert_thresholds['cpu_usage']: + self._create_alert( + 'High CPU Usage', + f'CPU usage is at {health.cpu_usage}%', + 'warning' + ) + + # Memory Usage Alert + if health.memory_usage >= self.alert_thresholds['memory_usage']: + self._create_alert( + 'High Memory Usage', + f'Memory usage is at {health.memory_usage}%', + 'warning' + ) + + # Disk Usage Alert + if health.disk_usage >= self.alert_thresholds['disk_usage']: + self._create_alert( + 'High Disk Usage', + f'Disk usage is at {health.disk_usage}%', + 'warning' + ) + + def _create_alert(self, title: str, message: str, severity: str) -> None: + """Create a new alert if one doesn't already exist""" + # Check if a similar active alert exists + existing_alert = Alert.query.filter_by( + title=title, + status='active' + ).first() + + if not existing_alert: + alert = Alert( + title=title, + message=message, + severity=severity, + status='active', + created_at=datetime.utcnow() + ) + db.session.add(alert) + db.session.commit() + + def get_active_alerts(self) -> List[Alert]: + """Get list of active alerts""" + return Alert.query.filter_by(status='active').all() + + def resolve_alert(self, alert_id: int) -> None: + """Mark an alert as resolved""" + alert = Alert.query.get(alert_id) + if alert: + alert.status = 'resolved' + alert.resolved_at = datetime.utcnow() + db.session.commit() + + def restart_services(self) -> None: + """Restart VPN services after configuration change""" + # TODO: Implement service restart logic + pass + +monitoring_service = MonitoringService() diff --git a/web/static/css/dashboard.css b/web/static/css/dashboard.css new file mode 100644 index 0000000000000000000000000000000000000000..276d277e8ebda2fa38c202f10ae9ae12129811eb --- /dev/null +++ b/web/static/css/dashboard.css @@ -0,0 +1,248 @@ +/* Modern Dashboard Styles */ +:root { + --primary: #4F46E5; + --primary-dark: #4338CA; + --secondary: #10B981; + --secondary-dark: #059669; + --background: #F9FAFB; + --surface: #FFFFFF; + --text-primary: #111827; + --text-secondary: #6B7280; + --danger: #EF4444; + --warning: #F59E0B; + --success: #10B981; +} + +body { + background-color: var(--background); + font-family: 'Inter', sans-serif; +} + +/* Animated Cards */ +.dashboard-card { + background: var(--surface); + border-radius: 1rem; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + transition: all 0.3s ease; + transform: translateY(0); + border: 1px solid rgba(0,0,0,0.05); +} + +.dashboard-card:hover { + transform: translateY(-4px); + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); +} + +/* Stat Cards */ +.stat-card { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stat-card .stat-icon { + width: 2.5rem; + height: 2.5rem; + border-radius: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.5rem; +} + +.stat-card .stat-value { + font-size: 1.875rem; + font-weight: 600; + color: var(--text-primary); + line-height: 1.2; +} + +.stat-card .stat-label { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.stat-card .stat-change { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; +} + +/* Status Indicators */ +.status-indicator { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 500; +} + +.status-indicator.online { + background-color: rgba(16, 185, 129, 0.1); + color: var(--success); +} + +.status-indicator.offline { + background-color: rgba(239, 68, 68, 0.1); + color: var(--danger); +} + +/* Charts and Graphs */ +.chart-container { + padding: 1.5rem; + height: 300px; +} + +/* Session Cards */ +.session-card { + padding: 1.25rem; + display: flex; + align-items: center; + gap: 1rem; + border-bottom: 1px solid rgba(0,0,0,0.05); +} + +.session-card:last-child { + border-bottom: none; +} + +.session-info { + flex: 1; +} + +/* Quick Actions */ +.quick-action { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + background-color: var(--background); + color: var(--text-primary); + transition: all 0.2s ease; +} + +.quick-action:hover { + background-color: var(--primary); + color: white; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in { + animation: fadeIn 0.5s ease forwards; +} + +/* Protocol Badges */ +.protocol-badge { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.75rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + background-color: rgba(79, 70, 229, 0.1); + color: var(--primary); + margin: 0.25rem; +} + +/* Traffic Charts */ +.traffic-chart { + position: relative; + height: 400px; + margin-top: 1rem; +} + +/* Data Cards Grid */ +.data-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +/* Responsive Sidebar */ +.dashboard-sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: 250px; + background: var(--surface); + border-right: 1px solid rgba(0,0,0,0.05); + padding: 1.5rem; + transform: translateX(0); + transition: transform 0.3s ease; + z-index: 1000; +} + +@media (max-width: 768px) { + .dashboard-sidebar { + transform: translateX(-100%); + } + + .dashboard-sidebar.active { + transform: translateX(0); + } + + .dashboard-main { + margin-left: 0 !important; + } +} + +/* Loading Animations */ +.loading-skeleton { + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: loading 1.5s infinite; +} + +@keyframes loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +/* Tooltip */ +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip .tooltip-text { + visibility: hidden; + background-color: rgba(0,0,0,0.8); + color: white; + text-align: center; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + position: absolute; + z-index: 1; + bottom: 125%; + left: 50%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.2s ease; +} + +.tooltip:hover .tooltip-text { + visibility: visible; + opacity: 1; +} diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000000000000000000000000000000000000..444fe09b12dca0f855c80512f0300c5573a76e3b --- /dev/null +++ b/web/static/css/style.css @@ -0,0 +1,132 @@ +/* Custom styles for Outline VPN */ + +/* Global styles */ +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +/* Card styles */ +.card { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: transform 0.2s ease-in-out; +} + +.card:hover { + transform: translateY(-2px); +} + +/* Dashboard stats cards */ +.stats-card { + border-radius: 10px; + border: none; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); +} + +.stats-card .card-title { + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Navigation */ +.navbar { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.navbar-brand { + font-weight: bold; + letter-spacing: 0.5px; +} + +/* Forms */ +.form-control:focus { + border-color: #0d6efd; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +/* Buttons */ +.btn { + border-radius: 5px; + padding: 0.5rem 1.5rem; + transition: all 0.2s ease-in-out; +} + +.btn-primary { + background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%); + border: none; +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(13, 110, 253, 0.2); +} + +/* Footer */ +.footer { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + margin-top: auto; +} + +/* Dashboard chart container */ +.chart-container { + position: relative; + height: 300px; + width: 100%; +} + +/* Status badges */ +.badge { + padding: 0.5em 1em; + font-weight: 500; + letter-spacing: 0.5px; +} + +/* Quick setup guide */ +.list-group-numbered { + counter-reset: section; +} + +.list-group-numbered > .list-group-item { + display: flex; + align-items: center; +} + +.list-group-numbered > .list-group-item::before { + content: counter(section); + counter-increment: section; + background-color: #e9ecef; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 1rem; + font-size: 0.875rem; + font-weight: 500; +} + +/* Loading spinner */ +.spinner-border-sm { + width: 1rem; + height: 1rem; + border-width: 0.2em; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .card { + margin-bottom: 1rem; + } + + .btn { + width: 100%; + margin-bottom: 0.5rem; + } +} diff --git a/web/static/js/auth.js b/web/static/js/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..47b9e18d4aaf77773b54a09dabf74a13a8a5803c --- /dev/null +++ b/web/static/js/auth.js @@ -0,0 +1,102 @@ +/** + * Client-side form validation and authentication handling + */ + +document.addEventListener('DOMContentLoaded', function() { + const loginForm = document.getElementById('login-form'); + if (loginForm) { + loginForm.addEventListener('submit', validateLoginForm); + } + + const signupForm = document.getElementById('signup-form'); + if (signupForm) { + signupForm.addEventListener('submit', validateSignupForm); + } +}); + +function validateLoginForm(e) { + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + const rememberMe = document.getElementById('remember-me')?.checked; + + let isValid = true; + let errorMessage = ''; + + // Email validation + if (!email || !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { + errorMessage = 'Please enter a valid email address'; + isValid = false; + } + + // Password validation + if (!password || password.length < 8) { + errorMessage = 'Password must be at least 8 characters long'; + isValid = false; + } + + if (!isValid) { + e.preventDefault(); + showError(errorMessage); + } +} + +function validateSignupForm(e) { + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + const confirmPassword = document.getElementById('confirm_password').value; + + let isValid = true; + let errorMessage = ''; + + // Email validation + if (!email || !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { + errorMessage = 'Please enter a valid email address'; + isValid = false; + } + + // Password validation + if (!password || password.length < 8) { + errorMessage = 'Password must be at least 8 characters long'; + isValid = false; + } else if (!/[A-Z]/.test(password)) { + errorMessage = 'Password must contain at least one uppercase letter'; + isValid = false; + } else if (!/[a-z]/.test(password)) { + errorMessage = 'Password must contain at least one lowercase letter'; + isValid = false; + } else if (!/[0-9]/.test(password)) { + errorMessage = 'Password must contain at least one number'; + isValid = false; + } + + // Confirm password + if (password !== confirmPassword) { + errorMessage = 'Passwords do not match'; + isValid = false; + } + + if (!isValid) { + e.preventDefault(); + showError(errorMessage); + } +} + +function showError(message) { + // Remove any existing error alerts + const existingAlert = document.querySelector('.alert-danger'); + if (existingAlert) { + existingAlert.remove(); + } + + // Create and show new error alert + const alert = document.createElement('div'); + alert.className = 'alert alert-danger alert-dismissible fade show'; + alert.role = 'alert'; + alert.innerHTML = ` + ${message} + + `; + + const form = document.querySelector('form'); + form.parentNode.insertBefore(alert, form); +} diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js new file mode 100644 index 0000000000000000000000000000000000000000..0740f8116de1b46ed5ae112c4bad7512292bfbc4 --- /dev/null +++ b/web/static/js/dashboard.js @@ -0,0 +1,254 @@ +/** + * Dashboard animations and interactivity + */ + +// Initialize animations when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + initializeAnimations(); + initializeCharts(); + initializeEventListeners(); +}); + +// Animation utilities +const animate = { + fadeIn: (element, delay = 0) => { + element.style.opacity = '0'; + element.style.transform = 'translateY(20px)'; + + setTimeout(() => { + element.style.transition = 'all 0.5s ease'; + element.style.opacity = '1'; + element.style.transform = 'translateY(0)'; + }, delay); + }, + + slideIn: (element, direction = 'left', delay = 0) => { + const transforms = { + left: 'translateX(-50px)', + right: 'translateX(50px)', + up: 'translateY(50px)', + down: 'translateY(-50px)' + }; + + element.style.opacity = '0'; + element.style.transform = transforms[direction]; + + setTimeout(() => { + element.style.transition = 'all 0.5s ease'; + element.style.opacity = '1'; + element.style.transform = 'translate(0)'; + }, delay); + }, + + pulse: (element) => { + element.style.transform = 'scale(1)'; + element.style.transition = 'transform 0.2s ease'; + + element.style.transform = 'scale(1.05)'; + setTimeout(() => { + element.style.transform = 'scale(1)'; + }, 200); + } +}; + +// Initialize animations +function initializeAnimations() { + // Animate stat cards + const statCards = document.querySelectorAll('.stat-card'); + statCards.forEach((card, index) => { + animate.fadeIn(card, index * 100); + }); + + // Animate charts + const charts = document.querySelectorAll('.chart-container'); + charts.forEach((chart, index) => { + animate.slideIn(chart, 'up', 300 + index * 100); + }); + + // Animate session cards + const sessionCards = document.querySelectorAll('.session-card'); + sessionCards.forEach((card, index) => { + animate.slideIn(card, 'right', 500 + index * 100); + }); +} + +// Initialize Charts +function initializeCharts() { + // Traffic Chart + const trafficChart = document.getElementById('traffic-chart'); + if (trafficChart) { + new Chart(trafficChart, { + type: 'line', + data: { + labels: getLastNHours(24), + datasets: [{ + label: 'Upload', + data: generateRandomData(24), + borderColor: '#4F46E5', + tension: 0.4 + }, { + label: 'Download', + data: generateRandomData(24), + borderColor: '#10B981', + tension: 0.4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top' + } + }, + scales: { + y: { + beginAtZero: true + } + } + } + }); + } + + // Protocol Distribution Chart + const protocolChart = document.getElementById('protocol-chart'); + if (protocolChart) { + new Chart(protocolChart, { + type: 'doughnut', + data: { + labels: ['Shadowsocks', 'IKEv2', 'L2TP', 'PPTP'], + datasets: [{ + data: [40, 30, 20, 10], + backgroundColor: [ + '#4F46E5', + '#10B981', + '#F59E0B', + '#EF4444' + ] + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom' + } + } + } + }); + } +} + +// Initialize Event Listeners +function initializeEventListeners() { + // Quick Action Buttons + const quickActions = document.querySelectorAll('.quick-action'); + quickActions.forEach(action => { + action.addEventListener('mouseenter', () => { + animate.pulse(action); + }); + }); + + // Responsive Sidebar Toggle + const sidebarToggle = document.getElementById('sidebar-toggle'); + const sidebar = document.querySelector('.dashboard-sidebar'); + if (sidebarToggle && sidebar) { + sidebarToggle.addEventListener('click', () => { + sidebar.classList.toggle('active'); + }); + } + + // Live Data Updates + setInterval(updateLiveStats, 5000); +} + +// Utility Functions +function getLastNHours(n) { + const hours = []; + for (let i = n - 1; i >= 0; i--) { + const d = new Date(); + d.setHours(d.getHours() - i); + hours.push(d.getHours() + ':00'); + } + return hours; +} + +function generateRandomData(n) { + return Array.from({length: n}, () => Math.floor(Math.random() * 100)); +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +// Live Data Updates +function updateLiveStats() { + fetch('/api/stats') + .then(response => response.json()) + .then(data => { + // Update connection status + const statusIndicator = document.querySelector('.status-indicator'); + if (statusIndicator) { + statusIndicator.className = `status-indicator ${data.isConnected ? 'online' : 'offline'}`; + statusIndicator.textContent = data.isConnected ? 'Connected' : 'Disconnected'; + } + + // Update traffic stats + const uploadStat = document.getElementById('upload-stat'); + const downloadStat = document.getElementById('download-stat'); + if (uploadStat) uploadStat.textContent = formatBytes(data.uploadTotal); + if (downloadStat) downloadStat.textContent = formatBytes(data.downloadTotal); + + // Update active sessions + updateSessionsList(data.sessions); + }) + .catch(console.error); +} + +function updateSessionsList(sessions) { + const sessionsList = document.getElementById('active-sessions'); + if (!sessionsList) return; + + sessions.forEach(session => { + const existingSession = document.getElementById(`session-${session.id}`); + if (!existingSession) { + // Create new session card + const sessionCard = createSessionCard(session); + animate.slideIn(sessionCard, 'right'); + sessionsList.appendChild(sessionCard); + } else { + // Update existing session + updateSessionCard(existingSession, session); + } + }); +} + +function createSessionCard(session) { + const card = document.createElement('div'); + card.id = `session-${session.id}`; + card.className = 'session-card dashboard-card'; + card.innerHTML = ` +
+
${session.protocol}
+ ${session.ip} +
+
+
↑ ${formatBytes(session.upload)}
+
↓ ${formatBytes(session.download)}
+
+ `; + return card; +} + +function updateSessionCard(card, session) { + const uploadEl = card.querySelector('.session-stats div:first-child'); + const downloadEl = card.querySelector('.session-stats div:last-child'); + + uploadEl.textContent = `↑ ${formatBytes(session.upload)}`; + downloadEl.textContent = `↓ ${formatBytes(session.download)}`; +} diff --git a/web/static/js/main.js b/web/static/js/main.js new file mode 100644 index 0000000000000000000000000000000000000000..0935d090055b52ce6c75f778b3b6afa1d92e7138 --- /dev/null +++ b/web/static/js/main.js @@ -0,0 +1,149 @@ +// Main JavaScript file for Outline VPN + +// Helper function to format bytes to human-readable format +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +// Helper function to format duration +function formatDuration(milliseconds) { + const seconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ${hours % 24}h`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +} + +// Show toast notification +function showToast(message, type = 'info') { + const toast = document.createElement('div'); + toast.className = `toast align-items-center text-white bg-${type} border-0`; + toast.setAttribute('role', 'alert'); + toast.setAttribute('aria-live', 'assertive'); + toast.setAttribute('aria-atomic', 'true'); + + toast.innerHTML = ` +
+
${message}
+ +
+ `; + + const container = document.createElement('div'); + container.className = 'toast-container position-fixed bottom-0 end-0 p-3'; + container.appendChild(toast); + document.body.appendChild(container); + + const bsToast = new bootstrap.Toast(toast); + bsToast.show(); + + toast.addEventListener('hidden.bs.toast', () => { + container.remove(); + }); +} + +// Copy text to clipboard +function copyToClipboard(text, successMessage = 'Copied to clipboard!') { + navigator.clipboard.writeText(text) + .then(() => showToast(successMessage, 'success')) + .catch(() => showToast('Failed to copy text', 'danger')); +} + +// Download file helper +function downloadFile(content, filename, type = 'application/json') { + const blob = new Blob([content], { type }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); +} + +// Form validation +function validateForm(formElement) { + const requiredFields = formElement.querySelectorAll('[required]'); + let isValid = true; + + requiredFields.forEach(field => { + if (!field.value.trim()) { + isValid = false; + field.classList.add('is-invalid'); + } else { + field.classList.remove('is-invalid'); + } + }); + + return isValid; +} + +// Password strength checker +function checkPasswordStrength(password) { + let strength = 0; + const messages = []; + + if (password.length >= 8) strength++; + else messages.push('Password should be at least 8 characters long'); + + if (password.match(/[a-z]/)) strength++; + if (password.match(/[A-Z]/)) strength++; + else messages.push('Include at least one uppercase letter'); + + if (password.match(/[0-9]/)) strength++; + else messages.push('Include at least one number'); + + if (password.match(/[^a-zA-Z0-9]/)) strength++; + else messages.push('Include at least one special character'); + + return { + score: strength, + messages: messages, + label: ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'][strength - 1] || 'Very Weak' + }; +} + +// Initialize password strength meter if it exists +document.addEventListener('DOMContentLoaded', () => { + const passwordInput = document.querySelector('input[type="password"]'); + if (passwordInput) { + const feedbackDiv = document.createElement('div'); + feedbackDiv.className = 'password-strength-meter mt-2'; + passwordInput.parentNode.appendChild(feedbackDiv); + + passwordInput.addEventListener('input', (e) => { + const strength = checkPasswordStrength(e.target.value); + const color = ['danger', 'warning', 'info', 'primary', 'success'][strength.score - 1] || 'danger'; + + feedbackDiv.innerHTML = ` +
+
+
+
+ ${strength.label} + `; + }); + } +}); + +// Initialize tooltips +document.addEventListener('DOMContentLoaded', () => { + const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl); + }); +}); diff --git a/web/templates/admin.html b/web/templates/admin.html new file mode 100644 index 0000000000000000000000000000000000000000..5453d7cfcb52d9b6fc246022d68743280abcdda4 --- /dev/null +++ b/web/templates/admin.html @@ -0,0 +1,289 @@ +{% extends "base.html" %} + +{% block title %}Server Administration - Outline VPN{% endblock %} + +{% block content %} +
+ {% include 'sidebar.html' %} + +
+
+
+

Server Administration

+
+ + +
+
+ +
+ +
+
+

Server Configuration

+
+ +
+

Network Settings

+
+
+ + +
+
+ + +
+
+
+ + +
+

Protocol Settings

+
+
+ + +
+
+ + +
+
+
+ + +
+

Security Settings

+
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+
+ + +
+
+

Audit Log

+
+ + +
+
+
+ + + + + + + + + + + {% for log in audit_logs %} + + + + + + + {% endfor %} + +
TimestampUserActionDetails
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ log.username }}{{ log.action }}{{ log.details }}
+
+
+
+ + +
+ +
+

System Health

+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ + +
+

Active Alerts

+
+ {% for alert in active_alerts %} +
+ +
+ {{ alert.title }} +

{{ alert.message }}

+ {{ alert.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} +
+
+ {% endfor %} + {% if not active_alerts %} +

No active alerts

+ {% endif %} +
+
+
+
+
+
+
+ + + + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000000000000000000000000000000000000..8a351c55f4dd243cdbff20b0d7c1efc35658bb51 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,82 @@ + + + + + + {% block title %}Outline VPN{% endblock %} + + + + + + {% block extra_css %}{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + + + + + + + {% block extra_js %}{% endblock %} + + diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000000000000000000000000000000000000..8611fedf15b802379753296c408867337d222616 --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,662 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Outline VPN{% endblock %} + +{% block extra_css %} + + + +{% endblock %} + +{% block content %} +
+ + + + +
+
+

Welcome back, {{ current_user.username }}

+ +
+ + +
+
+

Connection Status

+
+ + Connected +
+
+ + Uptime: 12h 30m +
+
+ +
+

Data Usage

+
+ 450.5 GB +
+
+ + +2.3% from last week +
+
+ +
+

Active Sessions

+
+ 3 +
+
+ + Across all devices +
+
+ +
+

Network Speed

+
+ 125 Mbps +
+
+ + Average speed +
+
+
+ + +
+

Traffic Overview

+ +
+ + +
+
+

Active Sessions

+ +
+
+ + + + + + + + + + + + + {% for session in active_sessions %} + + + + + + + + + {% endfor %} + +
DeviceIP AddressLocationConnected SinceStatusActions
+
+ + {{ session.device_name }} +
+
{{ session.ip_address }}{{ session.location }}{{ session.connected_since }} + + {{ session.status|title }} + + +
+
+
+ + +
+
+
+

Quick Actions

+
+ + + +
+
+
+
+
+

System Status

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + + + + +{% endblock %} +
+
+
+
Data Upload
+

0 B

+
+
+
+
+
+
+
+
+
+
Data Download
+

0 B

+
+
+
+
+
+
+
+
+
+
Connected Since
+

-

+ +
+
+
+ + + +
+
+
Active Sessions
+
+
+
+ +
+
+
+ + +
+ +
+
+
Quick Actions
+
+
+
+ + +
+
+
+ + +
+
+
Active Protocols
+
+
+
+ +
+
+
+
+ + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} +
+
+
+
Usage History
+ +
+
+
+ + +
+
+
+
+
VPN Configuration
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+
+
+
+
+
+
Quick Setup Guide
+
    +
  1. Download the Outline Client for your device
  2. +
  3. Copy your access key from this dashboard
  4. +
  5. Open the Outline Client and paste your access key
  6. +
  7. Click "Connect" to start using the VPN
  8. +
+ +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/web/templates/forgot_password.html b/web/templates/forgot_password.html new file mode 100644 index 0000000000000000000000000000000000000000..e7e78584ff22ddef914617e1c6874c886c5950a3 --- /dev/null +++ b/web/templates/forgot_password.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block title %}Forgot Password - Outline VPN{% endblock %} + +{% block content %} +
+
+
+
+

Reset Password

+

Enter your email address and we'll send you instructions to reset your password.

+
+
+ + +
+
+ + Back to Login +
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..8a8f04660eaeacf628f208b9be489ee7b0307dc7 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block title %}Welcome to Outline VPN{% endblock %} + +{% block content %} +
+

Welcome to Outline VPN

+
+

+ Secure, fast, and reliable VPN service for all your needs. + Access the internet freely and privately with our advanced VPN solution. +

+ +
+
+ +
+
+
+
+
+
Secure Connection
+

+ Military-grade encryption keeps your data safe and private. +

+
+
+
+
+
+
+
High Speed
+

+ Optimized servers ensure fast and reliable connections. +

+
+
+
+
+
+
+
Easy to Use
+

+ Simple setup and user-friendly interface for all devices. +

+
+
+
+
+
+{% endblock %} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000000000000000000000000000000000000..ecb06641444d4c1f3048f5816545a86904c5cdb3 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} + +{% block title %}Login - Outline VPN{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+
+

Login

+
+
+ + +
+
+ + + +
+
+ + +
+
+ + Forgot Password? +
+
+
+

Don't have an account? Sign up

+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/web/templates/profile.html b/web/templates/profile.html new file mode 100644 index 0000000000000000000000000000000000000000..1e6d483837ead666f6411d426c9e0778d91936c7 --- /dev/null +++ b/web/templates/profile.html @@ -0,0 +1,235 @@ +{% extends "base.html" %} + +{% block title %}User Profile - Outline VPN{% endblock %} + +{% block content %} +
+ {% include 'sidebar.html' %} + +
+
+

User Profile

+ +
+ +
+
+
+

Profile Information

+ +
+ +
+
+

Username

+

{{ current_user.username }}

+
+
+

Role

+

{{ current_user.role.value|title }}

+
+
+

Account Status

+

+ + {{ current_user.status.value|title }} +

+
+
+

Member Since

+

{{ current_user.created_at.strftime('%B %d, %Y') }}

+
+
+
+ + +
+

Security Settings

+ + +
+
+
+

Password

+

Last changed {{ current_user.password_changed_at.strftime('%B %d, %Y') if current_user.password_changed_at else 'Never' }}

+
+ +
+
+ + +
+
+
+

Two-Factor Authentication

+

Add an extra layer of security to your account

+
+ +
+
+
+
+ + +
+
+

Active Sessions

+
+ {% for session in user_sessions %} +
+
+
+
{{ session.device_info }}
+ {{ session.ip_address }} +
+ {% if session.is_current %} + Current + {% endif %} +
+
+ Last active: {{ session.last_active.strftime('%B %d, %Y %H:%M') }} +
+ {% if not session.is_current %} + + {% endif %} +
+ {% endfor %} +
+
+
+
+
+
+
+ + + + + + + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/web/templates/signup.html b/web/templates/signup.html new file mode 100644 index 0000000000000000000000000000000000000000..0f050db23be39b039fdd3bcff21379ad02422a0e --- /dev/null +++ b/web/templates/signup.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block title %}Sign Up - Outline VPN{% endblock %} + +{% block content %} +
+
+
+
+

Create Account

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+

Already have an account? Sign in

+
+
+
+
+
+ +{% block extra_js %} + +{% endblock %} +{% endblock %}