""" Xray Core VPN server plugin implementation. This module provides Django models and admin interfaces for managing Xray Core servers, inbounds, and clients. Supports VLESS, VMess, and Trojan protocols. """ import base64 import json import logging import uuid from typing import Any, Dict, List, Optional from urllib.parse import quote from django.contrib import admin, messages from django.contrib.postgres.fields import ArrayField from django.db import models from django.db.models import Count, Sum from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.http import JsonResponse from django.shortcuts import redirect, render from django.urls import path, reverse from django.utils.safestring import mark_safe from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicChildModelFilter from .generic import Server logger = logging.getLogger(__name__) class XrayConnectionError(Exception): """Custom exception for Xray connection errors.""" def __init__(self, message: str, original_exception: Optional[Exception] = None): super().__init__(message) self.original_exception = original_exception class XrayCoreServer(Server): """ Xray Core VPN Server implementation. Supports VLESS, VMess, Shadowsocks, and Trojan protocols through gRPC API. """ # gRPC API Configuration grpc_address = models.CharField( max_length=255, default="127.0.0.1", help_text="Xray Core gRPC API address" ) grpc_port = models.IntegerField( default=10085, help_text="gRPC API port (usually 10085)" ) # Client connection hostname client_hostname = models.CharField( max_length=255, default="127.0.0.1", help_text="Hostname or IP address for client connections" ) # Stats Configuration enable_stats = models.BooleanField( default=True, help_text="Enable traffic statistics tracking" ) class Meta: verbose_name = "Xray Core Server" verbose_name_plural = "Xray Core Servers" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.logger = logging.getLogger(__name__) self._client = None def save(self, *args, **kwargs): """Set server type on save.""" self.server_type = 'xray_core' super().save(*args, **kwargs) def __str__(self): return f"{self.name} (Xray Core)" @property def client(self): """Get or create Xray gRPC client for communication.""" if self._client is None: try: from vpn.xray_api.client import XrayClient from vpn.xray_api.exceptions import APIError server_address = f"{self.grpc_address}:{self.grpc_port}" self._client = XrayClient(server_address) logger.info(f"[{self.name}] Created XrayClient for {server_address}") except Exception as e: logger.error(f"[{self.name}] Failed to create XrayClient: {e}") raise XrayConnectionError( "Failed to connect to Xray Core", original_exception=e ) return self._client def create_inbound( self, protocol: str, port: int, tag: Optional[str] = None, network: str = 'tcp', security: str = 'none', **kwargs ): """Create a new inbound dynamically.""" try: from vpn.xray_api.exceptions import APIError logger.info(f"[{self.name}] Creating {protocol} inbound on port {port}") # Create inbound in database first inbound = XrayInbound.objects.create( server=self, protocol=protocol, port=port, tag=tag or f"{protocol}-{port}", network=network, security=security, listen=kwargs.get('listen', '0.0.0.0'), enabled=True, **{k: v for k, v in kwargs.items() if k in ['ss_method', 'ss_password', 'stream_settings', 'sniffing_settings']} ) # Create inbound on Xray server using API library if protocol == 'vless': self.client.add_vless_inbound( port=port, users=[], tag=inbound.tag, listen=inbound.listen, network=network ) elif protocol == 'vmess': self.client.add_vmess_inbound( port=port, users=[], tag=inbound.tag, listen=inbound.listen, network=network ) elif protocol == 'trojan': self.client.add_trojan_inbound( port=port, users=[], tag=inbound.tag, listen=inbound.listen, network=network ) else: raise ValueError(f"Unsupported protocol: {protocol}") logger.info(f"[{self.name}] Inbound {inbound.tag} created successfully") return inbound except Exception as e: logger.error(f"[{self.name}] Failed to create inbound: {e}") if 'inbound' in locals(): inbound.delete() raise XrayConnectionError(f"Failed to create inbound: {e}") def delete_inbound(self, inbound_or_tag): """Delete an inbound.""" try: from vpn.xray_api.exceptions import APIError if isinstance(inbound_or_tag, str): inbound = self.inbounds.get(tag=inbound_or_tag) tag = inbound_or_tag else: inbound = inbound_or_tag tag = inbound.tag logger.info(f"[{self.name}] Deleting inbound {tag}") # Remove from Xray server first self.client.remove_inbound(tag) # Remove from database inbound.delete() logger.info(f"[{self.name}] Inbound {tag} deleted successfully") return True except Exception as e: logger.error(f"[{self.name}] Failed to delete inbound: {e}") raise XrayConnectionError(f"Failed to delete inbound: {e}") def get_server_status(self, raw: bool = False) -> Dict[str, Any]: """Get server status and statistics.""" status = {} try: # Get basic stats stats_info = self.client.get_server_stats() status.update({ 'online': True, 'stats_enabled': self.enable_stats, 'inbounds_count': self.inbounds.count(), 'total_users': 0, 'total_traffic': { 'uplink': 0, 'downlink': 0 } }) # Count users across all inbounds for inbound in self.inbounds.all(): status['total_users'] += inbound.clients.count() # Get traffic stats if available if stats_info and hasattr(stats_info, 'stat'): for stat in stats_info.stat: if 'user>>>' in stat.name: if '>>>traffic>>>uplink' in stat.name: status['total_traffic']['uplink'] += stat.value elif '>>>traffic>>>downlink' in stat.name: status['total_traffic']['downlink'] += stat.value if raw: status['raw_stats'] = stats_info except Exception as e: status.update({ 'online': False, 'error': str(e) }) return status def sync_inbounds(self) -> Dict[str, Any]: """Sync inbounds - create missing inbounds and register protocols.""" logger.info(f"[{self.name}] Starting inbound sync") try: inbound_results = [] # Get list of existing inbounds existing_inbound_tags = set() try: existing_inbounds = self.client.list_inbounds() logger.debug(f"[{self.name}] Raw inbounds response: {existing_inbounds}") # Handle both dict with 'inbounds' key and direct list if isinstance(existing_inbounds, dict) and 'inbounds' in existing_inbounds: inbound_list = existing_inbounds['inbounds'] elif isinstance(existing_inbounds, list): inbound_list = existing_inbounds else: logger.warning(f"[{self.name}] Unexpected inbounds format: {type(existing_inbounds)}") inbound_list = [] existing_inbound_tags = { inbound.get('tag') for inbound in inbound_list if isinstance(inbound, dict) and inbound.get('tag') } logger.info(f"[{self.name}] Found existing inbounds: {existing_inbound_tags}") except Exception as e: logger.debug(f"[{self.name}] Could not list existing inbounds: {e}") # Create missing inbounds and register protocols for inbound in self.inbounds.filter(enabled=True): try: if inbound.tag in existing_inbound_tags: logger.info(f"[{self.name}] Inbound {inbound.tag} already exists, registering protocol") inbound_results.append(f"✓ {inbound.tag} (existing)") else: logger.info(f"[{self.name}] Creating new inbound {inbound.tag}") # Create inbound with empty user list if inbound.protocol == 'vless': self.client.add_vless_inbound( port=inbound.port, users=[], tag=inbound.tag, listen=inbound.listen or "0.0.0.0", network=inbound.network or "tcp" ) elif inbound.protocol == 'vmess': self.client.add_vmess_inbound( port=inbound.port, users=[], tag=inbound.tag, listen=inbound.listen or "0.0.0.0", network=inbound.network or "tcp" ) elif inbound.protocol == 'trojan': self.client.add_trojan_inbound( port=inbound.port, users=[], tag=inbound.tag, listen=inbound.listen or "0.0.0.0", network=inbound.network or "tcp", hostname=self.client_hostname ) inbound_results.append(f"✓ {inbound.tag} (created)") logger.info(f"[{self.name}] Created new inbound {inbound.tag}") existing_inbound_tags.add(inbound.tag) # Register protocol in client (needed for add_user to work) self._register_protocol_for_inbound(inbound) except Exception as e: logger.error(f"[{self.name}] Failed to create/register inbound {inbound.tag}: {e}") inbound_results.append(f"✗ {inbound.tag}: {e}") logger.info(f"[{self.name}] Inbound sync completed") return { "status": "Inbounds synced successfully", "inbounds": inbound_results, "existing_tags": list(existing_inbound_tags) } except Exception as e: logger.error(f"[{self.name}] Inbound sync failed: {e}") raise XrayConnectionError("Failed to sync inbounds", original_exception=e) def sync_users(self) -> Dict[str, Any]: """Sync users - add all users with ACL links to their inbounds.""" logger.info(f"[{self.name}] Starting user sync") try: from vpn.models import ACL # Get all users that have ACL links to this server all_acls = ACL.objects.filter(server=self) acl_users = set(acl.user for acl in all_acls) logger.info(f"[{self.name}] Found {len(acl_users)} users with ACL links") if not acl_users: logger.info(f"[{self.name}] No users to sync") return { "status": "No users to sync", "users_added": 0 } # First, refresh protocol registrations to ensure they exist self._register_all_protocols() user_results = [] total_added = 0 for inbound in self.inbounds.filter(enabled=True): logger.info(f"[{self.name}] Adding users to inbound {inbound.tag}") # Get or create clients for users with ACL links for user in acl_users: try: # Get or create Django client client, created = XrayClient.objects.get_or_create( user=user, inbound=inbound, defaults={ 'email': f"{user.username}@{inbound.tag}", 'uuid': str(uuid.uuid4()), 'enable': True } ) # For Trojan, ensure password exists if inbound.protocol == 'trojan' and not client.password: client.password = str(uuid.uuid4()) client.save() # Create user object for API user_obj = inbound._client_to_user_obj(client) # Add user to inbound via API try: self.client.add_user(inbound.tag, user_obj) if created: logger.info(f"[{self.name}] Created and added user {user.username} to {inbound.tag}") else: logger.info(f"[{self.name}] Added existing user {user.username} to {inbound.tag}") total_added += 1 except Exception as api_error: logger.error(f"[{self.name}] API error adding user {user.username} to {inbound.tag}: {api_error}") user_results.append(f"✗ {user.username}@{inbound.tag}: {api_error}") except Exception as e: logger.error(f"[{self.name}] Failed to add user {user.username} to {inbound.tag}: {e}") user_results.append(f"✗ {user.username}@{inbound.tag}: {e}") user_sync_result = f"Added {total_added} users across all inbounds" logger.info(f"[{self.name}] {user_sync_result}") return { "status": "Users synced successfully", "users_added": total_added, "errors": user_results } except Exception as e: logger.error(f"[{self.name}] User sync failed: {e}") raise XrayConnectionError("Failed to sync users", original_exception=e) def _register_protocol_for_inbound(self, inbound): """Register protocol for a specific inbound.""" if inbound.tag not in self.client._protocols: logger.debug(f"[{self.name}] Registering protocol for inbound {inbound.tag}") if inbound.protocol == 'vless': from vpn.xray_api.protocols import VlessProtocol protocol = VlessProtocol( port=inbound.port, tag=inbound.tag, listen=inbound.listen or "0.0.0.0", network=inbound.network or "tcp" ) self.client._protocols[inbound.tag] = protocol elif inbound.protocol == 'vmess': from vpn.xray_api.protocols import VmessProtocol protocol = VmessProtocol( port=inbound.port, tag=inbound.tag, listen=inbound.listen or "0.0.0.0", network=inbound.network or "tcp" ) self.client._protocols[inbound.tag] = protocol elif inbound.protocol == 'trojan': from vpn.xray_api.protocols import TrojanProtocol protocol = TrojanProtocol( port=inbound.port, tag=inbound.tag, listen=inbound.listen or "0.0.0.0", network=inbound.network or "tcp", hostname=self.client_hostname or "localhost" ) self.client._protocols[inbound.tag] = protocol logger.debug(f"[{self.name}] Registered protocol {inbound.protocol} for inbound {inbound.tag}") def _register_all_protocols(self): """Register all inbound protocols in client for user management.""" logger.debug(f"[{self.name}] Registering all protocols") for inbound in self.inbounds.filter(enabled=True): self._register_protocol_for_inbound(inbound) def sync(self) -> Dict[str, Any]: """Comprehensive sync - calls inbound sync then user sync.""" logger.info(f"[{self.name}] Starting comprehensive sync") try: # Step 1: Sync inbounds inbound_result = self.sync_inbounds() # Step 2: Sync users user_result = self.sync_users() logger.info(f"[{self.name}] Comprehensive sync completed") return { "status": "Server synced successfully", "inbounds": inbound_result.get("inbounds", []), "users": f"Added {user_result.get('users_added', 0)} users across all inbounds" } except Exception as e: logger.error(f"[{self.name}] Comprehensive sync failed: {e}") raise XrayConnectionError("Failed to sync configuration", original_exception=e) def add_user(self, user): """Add a user to the server.""" logger.info(f"[{self.name}] Adding user {user.username}") try: # Check if user already exists existing_client = XrayClient.objects.filter( inbound__server=self, user=user ).first() if existing_client: logger.debug(f"[{self.name}] User {user.username} already exists") return self._build_user_response(existing_client) # Get first available enabled inbound inbound = self.inbounds.filter(enabled=True).first() if not inbound: logger.warning(f"[{self.name}] No enabled inbounds available for user {user.username}") return {"status": "No enabled inbounds available. Please create an inbound first."} # Create client client = XrayClient.objects.create( inbound=inbound, user=user, uuid=uuid.uuid4(), email=user.username, enable=True ) # Apply to Xray through gRPC result = self._apply_client_to_xray(user, target_inbound=inbound, action='add') if not result: # If direct API call fails, try using inbound sync method logger.warning(f"[{self.name}] Direct API add failed, trying inbound sync for user {user.username}") try: inbound.sync_to_server() logger.info(f"[{self.name}] Successfully synced inbound {inbound.tag} with user {user.username}") except Exception as sync_error: logger.error(f"[{self.name}] Inbound sync also failed: {sync_error}") raise XrayConnectionError(f"Failed to add user via API and sync: {sync_error}") logger.info(f"[{self.name}] User {user.username} added successfully") return self._build_user_response(client) except Exception as e: logger.error(f"[{self.name}] Failed to add user {user.username}: {e}") raise XrayConnectionError(f"Failed to add user: {e}") def get_user(self, user, raw: bool = False): """Get user information from server.""" try: client = XrayClient.objects.filter( inbound__server=self, user=user ).first() if not client: # Try to add user if not found (auto-create) logger.warning(f"[{self.name}] User {user.username} not found, attempting to create") return self.add_user(user) if raw: return client return self._build_user_response(client) except Exception as e: logger.error(f"[{self.name}] Failed to get user {user.username}: {e}") raise XrayConnectionError(f"Failed to get user: {e}") def delete_user(self, user): """Remove user from server.""" logger.info(f"[{self.name}] Deleting user {user.username}") try: clients = XrayClient.objects.filter( inbound__server=self, user=user ) if not clients.exists(): return {"status": "User not found on server. Nothing to do."} for client in clients: # Remove from Xray through gRPC self._apply_client_to_xray(user, target_inbound=client.inbound, action='remove') client.delete() logger.info(f"[{self.name}] User {user.username} deleted successfully") return {"status": "User was deleted"} except Exception as e: logger.error(f"[{self.name}] Failed to delete user {user.username}: {e}") raise XrayConnectionError(f"Failed to delete user: {e}") def get_user_statistics(self, user) -> Dict[str, Any]: """Get user traffic statistics.""" try: stats = { 'user': user.username, 'total_upload': 0, 'total_download': 0, 'clients': [] } clients = XrayClient.objects.filter( inbound__server=self, user=user ) for client in clients: client_stats = self._get_client_stats(client) stats['total_upload'] += client_stats['upload'] stats['total_download'] += client_stats['download'] stats['clients'].append({ 'inbound': client.inbound.tag, 'protocol': client.inbound.protocol, 'upload': client_stats['upload'], 'download': client_stats['download'], 'enable': client.enable }) return stats except Exception as e: logger.error(f"[{self.name}] Failed to get statistics for {user.username}: {e}") return { 'user': user.username, 'error': str(e) } def _apply_client_to_xray( self, user, target_inbound=None, action: str = 'add' ) -> bool: """Apply user to Xray inbound through gRPC API.""" try: from vpn.xray_api.models import VlessUser, VmessUser, TrojanUser from vpn.xray_api.exceptions import APIError # Determine which inbound to use if target_inbound: inbound = target_inbound else: # Fallback to first available inbound inbound = self.inbounds.filter(enabled=True).first() if not inbound: raise XrayConnectionError("No enabled inbounds available") # Get or create client for this user and inbound client, created = XrayClient.objects.get_or_create( user=user, inbound=inbound, defaults={ 'email': f"{user.username}@{inbound.tag}", 'uuid': str(uuid.uuid4()), 'enable': True, 'protocol': inbound.protocol } ) # Create user object based on protocol if inbound.protocol == 'vless': user_obj = VlessUser(email=client.email, uuid=str(client.uuid)) elif inbound.protocol == 'vmess': user_obj = VmessUser(email=client.email, uuid=str(client.uuid), alter_id=0) elif inbound.protocol == 'trojan': # For Trojan, we use password field (could be UUID or custom password) password = getattr(client, 'password', str(client.uuid)) user_obj = TrojanUser(email=client.email, password=password) else: raise ValueError(f"Unsupported protocol: {inbound.protocol}") if action == 'add': logger.debug(f"[{self.name}] Adding client {client.email} to inbound {inbound.tag}") self.client.add_user(inbound.tag, user_obj) logger.info(f"[{self.name}] User {user.username} added to inbound {inbound.tag}") return True elif action == 'remove': logger.debug(f"[{self.name}] Removing client {client.email} from inbound {inbound.tag}") self.client.remove_user(inbound.tag, client.email) return {"status": "User removed successfully"} return True except Exception as e: client_info = getattr(client, 'email', user.username) if 'client' in locals() else user.username logger.error(f"[{self.name}] Failed to {action} client {client_info}: {e}") return False def _get_client_stats(self, client) -> Dict[str, int]: """Get traffic statistics for a specific client.""" try: # Try to get real stats from Xray if hasattr(self.client, 'get_user_stats'): # Query user statistics from Xray stats_result = self.client.get_user_stats(client.protocol or 'vless', client.email) # Stats object has uplink and downlink attributes upload = getattr(stats_result, 'uplink', 0) download = getattr(stats_result, 'downlink', 0) # Update client model with fresh stats if upload > 0 or download > 0: client.up = upload client.down = download client.save(update_fields=['up', 'down']) return { 'upload': upload, 'download': download } else: # Fallback to stored values logger.debug(f"[{self.name}] Using stored stats for client {client.email}") return { 'upload': client.up, 'download': client.down } except Exception as e: logger.error(f"[{self.name}] Failed to get stats for client {client.email}: {e}") # Return stored values as fallback return { 'upload': client.up, 'download': client.down } def _build_user_response(self, client) -> Dict[str, Any]: """Build user response with connection details.""" inbound = client.inbound connection_string = self._generate_connection_string(client) return { 'user_id': str(client.uuid), 'email': client.email, 'protocol': inbound.protocol, 'connection_string': connection_string, 'qr_code': f"https://api.qrserver.com/v1/create-qr-code/?data={quote(connection_string)}", 'enable': client.enable, 'upload': client.up, 'download': client.down, 'total': client.up + client.down, 'expiry_time': client.expiry_time.isoformat() if client.expiry_time else None, 'total_gb': client.total_gb } def _generate_connection_string(self, client) -> str: """Generate connection string with proper hostname and parameters.""" try: # Use client_hostname instead of internal server address client_hostname = self.client_hostname or self.grpc_address inbound = client.inbound # Try API library first, but it might use wrong hostname from vpn.xray_api.models import VlessUser, VmessUser, TrojanUser # Create user object based on protocol if inbound.protocol == 'vless': user_obj = VlessUser(email=client.email, uuid=str(client.uuid)) try: # Use protocol library with database parameters from vpn.xray_api.protocols import VlessProtocol protocol_handler = VlessProtocol(inbound.port, inbound.tag, inbound.listen, inbound.network) # Get encryption setting from inbound encryption = getattr(inbound, 'vless_encryption', 'none') ctx_link = protocol_handler.generate_client_link( user_obj, client_hostname, network=inbound.network, security=inbound.security, encryption=encryption ) return ctx_link except Exception: return self._generate_fallback_uri(inbound, client, client_hostname, inbound.port) elif inbound.protocol == 'vmess': user_obj = VmessUser(email=client.email, uuid=str(client.uuid), alter_id=client.alter_id or 0) try: # Use protocol library with database parameters from vpn.xray_api.protocols import VmessProtocol protocol_handler = VmessProtocol(inbound.port, inbound.tag, inbound.listen, inbound.network) # Get encryption setting from inbound encryption = getattr(inbound, 'vmess_encryption', 'auto') ctx_link = protocol_handler.generate_client_link( user_obj, client_hostname, network=inbound.network, security=inbound.security, encryption=encryption ) return ctx_link except Exception: return self._generate_fallback_uri(inbound, client, client_hostname, inbound.port) elif inbound.protocol == 'trojan': # For Trojan, ensure we have a proper password password = getattr(client, 'password', None) if not password: # Generate a password based on UUID if none exists password = str(client.uuid).replace('-', '')[:16] # 16 char password # Save password to client client.password = password client.save(update_fields=['password']) user_obj = TrojanUser(email=client.email, password=password) try: # Use protocol library with database parameters from vpn.xray_api.protocols import TrojanProtocol protocol_handler = TrojanProtocol(inbound.port, inbound.tag, inbound.listen, inbound.network) ctx_link = protocol_handler.generate_client_link( user_obj, client_hostname, network=inbound.network, security=inbound.security ) return ctx_link except Exception: return self._generate_fallback_uri(inbound, client, client_hostname, inbound.port) # Fallback for unsupported protocols return self._generate_fallback_uri(inbound, client, client_hostname, inbound.port) except Exception as e: logger.warning(f"[{self.name}] Failed to generate client link: {e}") # Final fallback client_hostname = self.client_hostname or self.grpc_address return self._generate_fallback_uri(client.inbound, client, client_hostname, client.inbound.port) def _generate_fallback_uri(self, inbound, client, server_address: str, server_port: int) -> str: """Generate fallback URI with proper parameters for v2ray clients.""" from urllib.parse import urlencode if inbound.protocol == 'vless': # VLESS format: vless://uuid@host:port?encryption=none&type=network#name # Use encryption and network from inbound settings encryption = getattr(inbound, 'vless_encryption', 'none') network = inbound.network or 'tcp' security = inbound.security or 'none' params = { 'encryption': encryption, 'type': network } # Add security if enabled if security != 'none': params['security'] = security query_string = urlencode(params) return f"vless://{client.uuid}@{server_address}:{server_port}?{query_string}#{self.name}" elif inbound.protocol == 'vmess': # VMess format: vmess://uuid@host:port?encryption=method&type=network#name # Use encryption and network from inbound settings encryption = getattr(inbound, 'vmess_encryption', 'auto') network = inbound.network or 'tcp' security = inbound.security or 'none' params = { 'encryption': encryption, 'type': network } # Add security if enabled if security != 'none': params['security'] = security query_string = urlencode(params) return f"vmess://{client.uuid}@{server_address}:{server_port}?{query_string}#{self.name}" elif inbound.protocol == 'trojan': # Trojan format: trojan://password@host:port?type=network#name password = getattr(client, 'password', None) if not password: # Generate and save password if not exists password = str(client.uuid).replace('-', '')[:16] # 16 char password client.password = password client.save(update_fields=['password']) network = inbound.network or 'tcp' security = inbound.security or 'none' params = { 'type': network } # Add security if enabled if security != 'none': params['security'] = security query_string = urlencode(params) return f"trojan://{password}@{server_address}:{server_port}?{query_string}#{self.name}" else: # Generic fallback return f"{inbound.protocol}://{client.uuid}@{server_address}:{server_port}#{self.name}" def _build_full_config(self) -> Dict[str, Any]: """Build full Xray configuration based on production template.""" config = { "log": { "access": "/var/log/xray/access.log", "error": "/var/log/xray/error.log", "loglevel": "info" }, "api": { "tag": "api", "listen": f"{self.grpc_address}:{self.grpc_port}", "services": [ "HandlerService", "LoggerService", "StatsService", "ReflectionService" ] }, "stats": {}, "policy": { "levels": { "0": { "statsUserUplink": True, "statsUserDownlink": True } }, "system": { "statsInboundUplink": True, "statsInboundDownlink": True, "statsOutboundUplink": True, "statsOutboundDownlink": True } }, "dns": { "servers": [ "https+local://cloudflare-dns.com/dns-query", "1.1.1.1", "8.8.8.8" ] }, "inbounds": [ { "tag": "api", "listen": "127.0.0.1", "port": 8080, # Different from main API port for internal use "protocol": "dokodemo-door", "settings": { "address": "127.0.0.1" } } ], "outbounds": [ { "tag": "direct", "protocol": "freedom", "settings": {} }, { "tag": "blocked", "protocol": "blackhole", "settings": { "response": { "type": "http" } } } ], "routing": { "domainStrategy": "IPIfNonMatch", "rules": [ { "type": "field", "inboundTag": ["api"], "outboundTag": "api" }, { "type": "field", "protocol": ["bittorrent"], "outboundTag": "blocked" } ] } } # Add user inbounds for inbound in self.inbounds.filter(enabled=True): config["inbounds"].append(inbound.to_xray_config()) return config class XrayInbound(models.Model): """Xray inbound configuration.""" PROTOCOL_CHOICES = [ ('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks'), ] NETWORK_CHOICES = [ ('tcp', 'TCP'), ('ws', 'WebSocket'), ('http', 'HTTP/2'), ('grpc', 'gRPC'), ('quic', 'QUIC'), ] SECURITY_CHOICES = [ ('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY'), ] # Default configurations for different protocols PROTOCOL_DEFAULTS = { 'vless': { 'port': 443, 'network': 'tcp', 'security': 'tls', 'sniffing_settings': { 'enabled': True, 'destOverride': ['http', 'tls'], 'metadataOnly': False } }, 'vmess': { 'port': 443, 'network': 'ws', 'security': 'tls', 'stream_settings': { 'wsSettings': { 'path': '/ws', 'headers': { 'Host': 'www.cloudflare.com' } } }, 'sniffing_settings': { 'enabled': True, 'destOverride': ['http', 'tls'], 'metadataOnly': False } }, 'trojan': { 'port': 443, 'network': 'tcp', 'security': 'tls', 'sniffing_settings': { 'enabled': True, 'destOverride': ['http', 'tls'], 'metadataOnly': False } }, 'shadowsocks': { 'port': 8388, 'network': 'tcp', 'security': 'none', 'ss_method': 'chacha20-ietf-poly1305', 'sniffing_settings': { 'enabled': True, 'destOverride': ['http', 'tls'], 'metadataOnly': False } } } server = models.ForeignKey(XrayCoreServer, on_delete=models.CASCADE, related_name='inbounds') tag = models.CharField(max_length=100, help_text="Unique identifier for this inbound") port = models.IntegerField(help_text="Port to listen on") listen = models.CharField(max_length=255, default="0.0.0.0", help_text="IP address to listen on") protocol = models.CharField(max_length=20, choices=PROTOCOL_CHOICES) enabled = models.BooleanField(default=True) # Network settings network = models.CharField(max_length=20, choices=NETWORK_CHOICES, default='tcp') security = models.CharField(max_length=20, choices=SECURITY_CHOICES, default='none') # Server address for clients (if different from listen) server_address = models.CharField( max_length=255, blank=True, help_text="Public server address for client connections" ) # Protocol-specific settings # Shadowsocks ss_method = models.CharField( max_length=50, blank=True, default='chacha20-ietf-poly1305', help_text="Shadowsocks encryption method" ) ss_password = models.CharField( max_length=255, blank=True, help_text="Shadowsocks password (for single-user mode)" ) # TLS settings tls_cert_file = models.CharField(max_length=255, blank=True) tls_key_file = models.CharField(max_length=255, blank=True) tls_alpn = ArrayField(models.CharField(max_length=20), default=list, blank=True) # Advanced settings (JSON) stream_settings = models.JSONField(default=dict, blank=True) sniffing_settings = models.JSONField(default=dict, blank=True) class Meta: unique_together = [('server', 'tag'), ('server', 'port')] ordering = ['port'] def __str__(self): return f"{self.tag} ({self.protocol.upper()}:{self.port})" def save(self, *args, **kwargs): """Apply protocol defaults on creation.""" if not self.pk and self.protocol in self.PROTOCOL_DEFAULTS: defaults = self.PROTOCOL_DEFAULTS[self.protocol] # Apply defaults only if fields are not set if not self.port: self.port = defaults.get('port', 443) if not self.network: self.network = defaults.get('network', 'tcp') if not self.security: self.security = defaults.get('security', 'none') if not self.ss_method and 'ss_method' in defaults: self.ss_method = defaults['ss_method'] if not self.stream_settings and 'stream_settings' in defaults: self.stream_settings = defaults['stream_settings'] if not self.sniffing_settings and 'sniffing_settings' in defaults: self.sniffing_settings = defaults['sniffing_settings'] super().save(*args, **kwargs) def to_xray_config(self) -> Dict[str, Any]: """Convert to Xray inbound configuration.""" config = { "tag": self.tag, "port": self.port, "listen": self.listen, "protocol": self.protocol, "settings": self._build_protocol_settings(), "streamSettings": self._build_stream_settings() } if self.sniffing_settings: config["sniffing"] = self.sniffing_settings return config def _build_protocol_settings(self) -> Dict[str, Any]: """Build protocol-specific settings.""" settings = {} if self.protocol == 'vless': settings = { "decryption": "none", "clients": [] } elif self.protocol == 'vmess': settings = { "clients": [] } elif self.protocol == 'trojan': settings = { "clients": [] } elif self.protocol == 'shadowsocks': settings = { "method": self.ss_method, "password": self.ss_password, "clients": [] } # Add clients for client in self.clients.filter(enable=True): settings["clients"].append(client.to_xray_config()) return settings def _build_stream_settings(self) -> Dict[str, Any]: """Build stream settings.""" settings = { "network": self.network, "security": self.security } # Add custom stream settings if self.stream_settings: settings.update(self.stream_settings) # Add TLS settings if needed if self.security == 'tls' and (self.tls_cert_file or self.tls_key_file): settings["tlsSettings"] = { "certificates": [{ "certificateFile": self.tls_cert_file, "keyFile": self.tls_key_file }] } if self.tls_alpn: settings["tlsSettings"]["alpn"] = self.tls_alpn return settings def sync_to_server(self) -> bool: """Sync this inbound to the Xray server using API library.""" try: logger.info(f"Syncing inbound {self.tag} to server") # 1. First remove existing inbound if it exists try: self.server.client.remove_inbound(self.tag) logger.info(f"Removed existing inbound {self.tag}") except Exception: logger.debug(f"Inbound {self.tag} doesn't exist yet, proceeding with creation") # 2. Get all enabled users for this inbound users = [] for client in self.clients.filter(enable=True): users.append(self._client_to_user_obj(client)) logger.info(f"Preparing to add {len(users)} users to inbound {self.tag}") # 3. Add inbound with users using protocol-specific method if self.protocol == 'vless': result = self.server.client.add_vless_inbound( port=self.port, users=users, tag=self.tag, listen=self.listen or "0.0.0.0", network=self.network or "tcp" ) elif self.protocol == 'vmess': result = self.server.client.add_vmess_inbound( port=self.port, users=users, tag=self.tag, listen=self.listen or "0.0.0.0", network=self.network or "tcp" ) elif self.protocol == 'trojan': result = self.server.client.add_trojan_inbound( port=self.port, users=users, tag=self.tag, listen=self.listen or "0.0.0.0", network=self.network or "tcp" ) else: raise ValueError(f"Unsupported protocol: {self.protocol}") logger.info(f"Inbound {self.tag} created successfully with {len(users)} users. Result: {result}") return True except Exception as e: logger.error(f"Failed to sync inbound {self.tag}: {e}") return False def remove_from_server(self) -> bool: """Remove this inbound from the Xray server.""" try: logger.info(f"Removing inbound {self.tag} from server") self.server.client.remove_inbound(self.tag) logger.info(f"Inbound {self.tag} removed successfully") return True except Exception as e: logger.error(f"Failed to remove inbound {self.tag}: {e}") return False def _client_to_user_obj(self, client): """Convert XrayClient to API library user object.""" from vpn.xray_api.models import VlessUser, VmessUser, TrojanUser if self.protocol == 'vless': return VlessUser(email=client.email, uuid=str(client.uuid)) elif self.protocol == 'vmess': return VmessUser(email=client.email, uuid=str(client.uuid), alter_id=client.alter_id or 0) elif self.protocol == 'trojan': password = getattr(client, 'password', str(client.uuid)) return TrojanUser(email=client.email, password=password) else: raise ValueError(f"Unsupported protocol: {self.protocol}") def add_user(self, user): """Add user to this inbound.""" try: # Create XrayClient for this user in this inbound client = XrayClient.objects.create( inbound=self, user=user, email=user.username, enable=True ) # Add user to actual Xray server user_obj = self._client_to_user_obj(client) self.server.client.add_user(self.tag, user_obj) logger.info(f"Added user {user.username} to inbound {self.tag}") return client except Exception as e: logger.error(f"Failed to add user {user.username} to inbound {self.tag}: {e}") raise def remove_user(self, user) -> bool: """Remove user from this inbound.""" try: # Find and remove XrayClient client = self.clients.filter(user=user).first() if client: # Remove from Xray server self.server.client.remove_user(self.tag, client.email) # Remove from database client.delete() logger.info(f"Removed user {user.username} from inbound {self.tag}") return True else: logger.warning(f"User {user.username} not found in inbound {self.tag}") return False except Exception as e: logger.error(f"Failed to remove user {user.username} from inbound {self.tag}: {e}") raise class XrayInboundServer(Server): """Server model that represents a single Xray inbound as a server.""" # Reference to the actual XrayInbound xray_inbound = models.OneToOneField( XrayInbound, on_delete=models.CASCADE, related_name='server_proxy', null=True, blank=True ) class Meta: verbose_name = "Xray Inbound Server" verbose_name_plural = "Xray Inbound Servers" def save(self, *args, **kwargs): if self.xray_inbound: self.server_type = f'xray_{self.xray_inbound.protocol}' if not self.name: self.name = f"{self.xray_inbound.server.name}-{self.xray_inbound.tag}" if not self.comment: self.comment = f"{self.xray_inbound.protocol.upper()} inbound on port {self.xray_inbound.port}" super().save(*args, **kwargs) def __str__(self): if self.xray_inbound: return f"{self.xray_inbound.server.name}-{self.xray_inbound.tag}" return self.name or "Xray Inbound" def get_server_status(self, raw: bool = False) -> Dict[str, Any]: """Get status from parent Xray server.""" if self.xray_inbound: return self.xray_inbound.server.get_server_status(raw=raw) return {"error": "No inbound configured"} def add_user(self, user): """Add user to this specific inbound via parent server.""" if not self.xray_inbound: raise XrayConnectionError("No inbound configured") logger.info(f"[{self.name}] Adding user {user.username} to inbound {self.xray_inbound.tag}") try: # Delegate to parent server but specify the inbound parent_server = self.xray_inbound.server return parent_server._apply_client_to_xray(user, target_inbound=self.xray_inbound) except Exception as e: logger.error(f"[{self.name}] Failed to add user {user.username}: {e}") raise XrayConnectionError(f"Failed to add user: {e}") def remove_user(self, user): """Remove user from this specific inbound.""" if not self.xray_inbound: raise XrayConnectionError("No inbound configured") logger.info(f"[{self.name}] Removing user {user.username} from inbound {self.xray_inbound.tag}") try: # Find client for this user and inbound client = self.xray_inbound.clients.filter(user=user).first() if not client: return {"status": "User not found on this inbound"} # Use parent server to remove user from Xray via API parent_server = self.xray_inbound.server try: parent_server.client.remove_user(self.xray_inbound.tag, client.email) logger.info(f"[{self.name}] User {user.username} removed from Xray") except Exception as api_error: logger.warning(f"[{self.name}] API removal failed: {api_error}") # Remove from database client.delete() logger.info(f"[{self.name}] User {user.username} removed successfully") return {"status": "User was removed"} except Exception as e: logger.error(f"[{self.name}] Failed to remove user {user.username}: {e}") raise XrayConnectionError(f"Failed to remove user: {e}") def get_user(self, user, raw: bool = False): """Get user information from this inbound.""" if not self.xray_inbound: raise XrayConnectionError("No inbound configured") try: client = self.xray_inbound.clients.filter(user=user).first() if not client: # Auto-create user in this inbound logger.warning(f"[{self.name}] User {user.username} not found, attempting to create") return self.add_user(user) return self.xray_inbound.server._build_user_response(client) except Exception as e: logger.error(f"[{self.name}] Failed to get user {user.username}: {e}") raise XrayConnectionError(f"Failed to get user: {e}") def sync_users(self) -> bool: """Sync users for this inbound only.""" if not self.xray_inbound: logger.error(f"[{self.name}] No inbound configured") return False from vpn.models import User, ACL logger.debug(f"[{self.name}] Sync users for this inbound") try: # Get ACLs for this inbound server (not the parent Xray server) acls = ACL.objects.filter(server=self) acl_users = set(acl.user for acl in acls) # Get existing clients for this inbound only existing_clients = {client.user.id: client for client in self.xray_inbound.clients.all()} added = 0 removed = 0 # Add missing users to this inbound for user in acl_users: if user.id not in existing_clients: try: self.add_user(user=user) added += 1 logger.debug(f"[{self.name}] Added user {user.username}") except Exception as e: logger.error(f"[{self.name}] Failed to add user {user.username}: {e}") # Remove users without ACL from this inbound for user_id, client in existing_clients.items(): if client.user not in acl_users: try: self.delete_user(user=client.user) removed += 1 logger.debug(f"[{self.name}] Removed user {client.user.username}") except Exception as e: logger.error(f"[{self.name}] Failed to remove user {client.user.username}: {e}") logger.info(f"[{self.name}] Sync completed: {added} added, {removed} removed") return True except Exception as e: logger.error(f"[{self.name}] User sync failed: {e}") return False class XrayClient(models.Model): """Xray client (user) configuration.""" inbound = models.ForeignKey(XrayInbound, on_delete=models.CASCADE, related_name='clients') user = models.ForeignKey('vpn.User', on_delete=models.CASCADE) uuid = models.UUIDField(default=uuid.uuid4, unique=True) email = models.CharField(max_length=255, help_text="Email for statistics") level = models.IntegerField(default=0) enable = models.BooleanField(default=True) # Protocol-specific fields flow = models.CharField(max_length=50, blank=True, help_text="VLESS flow control") alter_id = models.IntegerField(default=0, help_text="VMess alterId") password = models.CharField(max_length=255, blank=True, help_text="Password for Trojan/Shadowsocks") # Limits total_gb = models.IntegerField(null=True, blank=True, help_text="Traffic limit in GB") expiry_time = models.DateTimeField(null=True, blank=True, help_text="Account expiration time") # Statistics up = models.BigIntegerField(default=0, help_text="Upload bytes") down = models.BigIntegerField(default=0, help_text="Download bytes") # Metadata created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: unique_together = [('inbound', 'user')] ordering = ['created_at'] def __str__(self): return f"{self.user.username} @ {self.inbound.tag}" def to_xray_config(self) -> Dict[str, Any]: """Convert to Xray client configuration.""" config = { "id": str(self.uuid), "email": self.email, "level": self.level } # Add protocol-specific fields if self.inbound.protocol == 'vless' and self.flow: config["flow"] = self.flow elif self.inbound.protocol == 'vmess': config["alterId"] = self.alter_id elif self.inbound.protocol in ['trojan', 'shadowsocks'] and self.password: config["password"] = self.password return config # Admin classes class XrayInboundInline(admin.TabularInline): model = XrayInbound extra = 0 fields = ('tag', 'port', 'protocol', 'network', 'security', 'enabled', 'client_count') readonly_fields = ('client_count',) def client_count(self, obj): if obj.pk: return obj.clients.count() return 0 client_count.short_description = 'Clients' class XrayClientInline(admin.TabularInline): model = XrayClient extra = 0 fields = ('user', 'email', 'enable', 'traffic_display', 'created_at') readonly_fields = ('traffic_display', 'created_at') raw_id_fields = ('user',) def traffic_display(self, obj): if obj.pk: up_gb = obj.up / (1024**3) down_gb = obj.down / (1024**3) return f"↑ {up_gb:.2f} GB ↓ {down_gb:.2f} GB" return "-" traffic_display.short_description = 'Traffic' @admin.register(XrayCoreServer) class XrayCoreServerAdmin(PolymorphicChildModelAdmin): base_model = XrayCoreServer show_in_index = False list_display = ( 'name', 'grpc_address', 'grpc_port', 'client_hostname', 'inbound_count', 'user_count', 'server_status_inline', 'registration_date' ) list_editable = ('grpc_address', 'grpc_port', 'client_hostname') exclude = ('server_type',) def get_fieldsets(self, request, obj=None): """Customize fieldsets based on whether object exists.""" if obj is None: # Adding new server return ( ('Basic Configuration', { 'fields': ('name', 'comment') }), ('gRPC API Configuration', { 'fields': ('grpc_address', 'grpc_port', 'client_hostname'), 'description': 'Configure connection to Xray Core gRPC API and client connection hostname' }), ('Settings', { 'fields': ('enable_stats',) }), ) else: # Editing existing server return ( ('Server Configuration', { 'fields': ('name', 'comment', 'registration_date') }), ('gRPC API Configuration', { 'fields': ('grpc_address', 'grpc_port', 'client_hostname') }), ('Settings', { 'fields': ('enable_stats',) }), ('Server Status', { 'fields': ('server_status_full',) }), ('Configuration Management', { 'fields': ('export_configuration_display', 'create_inbound_button') }), ('Statistics & Users', { 'fields': ('server_statistics_display',), 'classes': ('collapse',) }), ) inlines = [XrayInboundInline] readonly_fields = ('server_status_full', 'registration_date', 'export_configuration_display', 'server_statistics_display', 'create_inbound_button') def get_queryset(self, request): qs = super().get_queryset(request) qs = qs.annotate( user_count=Count('inbounds__clients__user', distinct=True) ) return qs @admin.display(description='Inbounds', ordering='inbound_count') def inbound_count(self, obj): return obj.inbounds.count() @admin.display(description='Users', ordering='user_count') def user_count(self, obj): return obj.user_count @admin.display(description='Status') def server_status_inline(self, obj): try: status = obj.get_server_status() if status.get('online'): return mark_safe('✅ Online') else: return mark_safe(f'❌ {status.get("error", "Offline")}') except Exception as e: return mark_safe(f'❌ Error: {str(e)}') @admin.display(description='Server Status') def server_status_full(self, obj): if obj and obj.pk: try: status = obj.get_server_status() if 'error' in status: return mark_safe(f'Error: {status["error"]}') html = '