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] [31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m
+ * Running on http://127.0.0.1:5000
+2025-08-18 16:30:04,609 [INFO] [33mPress CTRL+C to quit[0m
+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] "[35m[1mGET / HTTP/1.1[0m" 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] [31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m
+ * Running on http://127.0.0.1:5000
+2025-08-18 16:31:04,256 [INFO] [33mPress CTRL+C to quit[0m
+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] "[36mGET /static/css/style.css HTTP/1.1[0m" 304 -
+2025-08-18 16:31:37,738 [INFO] 127.0.0.1 - - [18/Aug/2025 16:31:37] "[36mGET /static/js/main.js HTTP/1.1[0m" 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] "[36mGET /static/css/style.css HTTP/1.1[0m" 304 -
+2025-08-18 16:31:45,807 [INFO] 127.0.0.1 - - [18/Aug/2025 16:31:45] "[36mGET /static/js/main.js HTTP/1.1[0m" 304 -
+2025-08-18 16:31:46,002 [INFO] 127.0.0.1 - - [18/Aug/2025 16:31:46] "[36mGET /static/css/style.css HTTP/1.1[0m" 304 -
+2025-08-18 16:32:17,741 [INFO] 127.0.0.1 - - [18/Aug/2025 16:32:17] "[35m[1mPOST /signup HTTP/1.1[0m" 500 -
+2025-08-18 16:32:17,763 [INFO] 127.0.0.1 - - [18/Aug/2025 16:32:17] "[36mGET /signup?__debugger__=yes&cmd=resource&f=style.css HTTP/1.1[0m" 304 -
+2025-08-18 16:32:17,765 [INFO] 127.0.0.1 - - [18/Aug/2025 16:32:17] "[36mGET /signup?__debugger__=yes&cmd=resource&f=debugger.js HTTP/1.1[0m" 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] "[36mGET /signup?__debugger__=yes&cmd=resource&f=console.png HTTP/1.1[0m" 304 -
+2025-08-18 16:32:17,822 [INFO] 127.0.0.1 - - [18/Aug/2025 16:32:17] "[36mGET /signup?__debugger__=yes&cmd=resource&f=style.css HTTP/1.1[0m" 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 = `
+
+ `;
+
+ 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
+
+
+
+
+
+
+
Audit Log
+
+
+
+
+
+
+
+
+
+ | Timestamp |
+ User |
+ Action |
+ Details |
+
+
+
+ {% for log in audit_logs %}
+
+ | {{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} |
+ {{ log.username }} |
+ {{ log.action }} |
+ {{ log.details }} |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
This will create a backup of your entire server configuration, including:
+
+ - Server settings
+ - User configurations
+ - Security settings
+ - Protocol configurations
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% 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 %}
+
+ {{ message }}
+
+
+ {% 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
+
+
+
+
+
+
+ | Device |
+ IP Address |
+ Location |
+ Connected Since |
+ Status |
+ Actions |
+
+
+
+ {% for session in active_sessions %}
+
+ |
+
+
+ {{ session.device_name }}
+
+ |
+ {{ session.ip_address }} |
+ {{ session.location }} |
+ {{ session.connected_since }} |
+
+
+ {{ session.status|title }}
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
Quick Actions
+
+
+
+
+
+
+
+
+
+
System Status
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+
+
+
+{% endblock %}
+
+
+
+
+
Data Download
+
0 B
+
+
+
+
+
+
+
+
Connected Since
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
+
+
+
+
+
+
+
+
VPN Configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Quick Setup Guide
+
+ - Download the Outline Client for your device
+ - Copy your access key from this dashboard
+ - Open the Outline Client and paste your access key
+ - Click "Connect" to start using the VPN
+
+
+
+
+
+
+{% 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.
+
+
+
+
+
+{% 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
+
+
+
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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if not current_user.two_factor_enabled %}
+
+

+
Scan this QR code with your authenticator app
+
or enter this code manually: {{ setup_code }}
+
+
+ {% else %}
+
+
+
Two-Factor Authentication is Enabled
+
+
+ {% endif %}
+
+
+
+
+{% 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 = `
+
+ `;
+
+ 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
+
+
+
+
+
+
+
Audit Log
+
+
+
+
+
+
+
+
+
+ | Timestamp |
+ User |
+ Action |
+ Details |
+
+
+
+ {% for log in audit_logs %}
+
+ | {{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} |
+ {{ log.username }} |
+ {{ log.action }} |
+ {{ log.details }} |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
This will create a backup of your entire server configuration, including:
+
+ - Server settings
+ - User configurations
+ - Security settings
+ - Protocol configurations
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% 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 %}
+
+ {{ message }}
+
+
+ {% 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
+
+
+
+
+
+
+ | Device |
+ IP Address |
+ Location |
+ Connected Since |
+ Status |
+ Actions |
+
+
+
+ {% for session in active_sessions %}
+
+ |
+
+
+ {{ session.device_name }}
+
+ |
+ {{ session.ip_address }} |
+ {{ session.location }} |
+ {{ session.connected_since }} |
+
+
+ {{ session.status|title }}
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
Quick Actions
+
+
+
+
+
+
+
+
+
+
System Status
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+
+
+
+{% endblock %}
+
+
+
+
+
Data Download
+
0 B
+
+
+
+
+
+
+
+
Connected Since
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
+
+
+
+
+
+
+
+
VPN Configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Quick Setup Guide
+
+ - Download the Outline Client for your device
+ - Copy your access key from this dashboard
+ - Open the Outline Client and paste your access key
+ - Click "Connect" to start using the VPN
+
+
+
+
+
+
+{% 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.
+
+
+
+
+
+{% 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
+
+
+
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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if not current_user.two_factor_enabled %}
+
+

+
Scan this QR code with your authenticator app
+
or enter this code manually: {{ setup_code }}
+
+
+ {% else %}
+
+
+
Two-Factor Authentication is Enabled
+
+
+ {% endif %}
+
+
+
+
+{% 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 %}