""" 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: ctx_link = self.client.generate_client_link(inbound.tag, user_obj) # Replace hostname in the generated link if ctx_link and '://' in ctx_link: protocol, rest = ctx_link.split('://', 1) if '@' in rest: user_part, host_part = rest.split('@', 1) if ':' in host_part: old_host, port_and_params = host_part.split(':', 1) return f"{protocol}://{user_part}@{client_hostname}:{port_and_params}" # Fallback if link parsing fails return self._generate_fallback_uri(inbound, client, client_hostname, inbound.port) 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: ctx_link = self.client.generate_client_link(inbound.tag, user_obj) # VMess uses base64 encoded JSON, need to decode and fix hostname if ctx_link and ctx_link.startswith('vmess://'): import base64, json encoded_part = ctx_link[8:] # Remove vmess:// try: decoded = base64.b64decode(encoded_part).decode('utf-8') config = json.loads(decoded) config['add'] = client_hostname # Fix hostname fixed_config = base64.b64encode(json.dumps(config).encode('utf-8')).decode('utf-8') return f"vmess://{fixed_config}" except Exception: pass # Fallback if link parsing fails return self._generate_fallback_uri(inbound, client, client_hostname, inbound.port) 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: ctx_link = self.client.generate_client_link(inbound.tag, user_obj) # Replace hostname in the generated link if ctx_link and '://' in ctx_link: protocol, rest = ctx_link.split('://', 1) if '@' in rest: password_part, host_part = rest.split('@', 1) if ':' in host_part: old_host, port_and_params = host_part.split(':', 1) return f"{protocol}://{password_part}@{client_hostname}:{port_and_params}" # Fallback if link parsing fails return self._generate_fallback_uri(inbound, client, client_hostname, inbound.port) 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=tcp#name params = { 'encryption': 'none', 'type': 'tcp' } 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=auto&type=tcp#name params = { 'encryption': 'auto', 'type': 'tcp' } 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=tcp#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']) params = { 'type': 'tcp' } 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 = '
' html += f'
Status: {"🟢 Online" if status.get("online") else "🔴 Offline"}
' html += f'
Inbounds: {status.get("inbounds_count", 0)}
' html += f'
Total Users: {status.get("total_users", 0)}
' if 'total_traffic' in status: up_gb = status['total_traffic']['uplink'] / (1024**3) down_gb = status['total_traffic']['downlink'] / (1024**3) html += f'
Total Traffic: ↑ {up_gb:.2f} GB ↓ {down_gb:.2f} GB
' html += '
' return mark_safe(html) except Exception as e: return mark_safe(f'Error: {str(e)}') return "N/A" @admin.display(description='Export Configuration') def export_configuration_display(self, obj): """Display export configuration and actions.""" if not obj or not obj.pk: return mark_safe('
Export will be available after saving
') try: # Build export data export_data = { 'name': obj.name, 'type': 'xray_core', 'grpc': { 'address': obj.grpc_address, 'port': obj.grpc_port }, 'inbounds': [] } # Add inbound configurations for inbound in obj.inbounds.all(): inbound_data = { 'tag': inbound.tag, 'port': inbound.port, 'protocol': inbound.protocol, 'network': inbound.network, 'security': inbound.security, 'clients': inbound.clients.count() } export_data['inbounds'].append(inbound_data) json_str = json.dumps(export_data, indent=2) from django.utils.html import escape escaped_json = escape(json_str) html = f'''
''' return mark_safe(html) except Exception as e: return mark_safe(f'
Error generating export: {e}
') @admin.display(description='Server Statistics & Users') def server_statistics_display(self, obj): """Display server statistics and user management.""" if not obj or not obj.pk: return mark_safe('
Statistics will be available after saving
') try: from vpn.models import ACL, UserStatistics from django.utils import timezone from django.utils.timezone import localtime from datetime import timedelta # Get statistics user_count = ACL.objects.filter(server=obj).count() client_count = XrayClient.objects.filter(inbound__server=obj).count() html = '
' # Overall Statistics html += '
' html += '
' html += f'
ACL Users: {user_count}
' html += f'
Configured Clients: {client_count}
' html += f'
Inbounds: {obj.inbounds.count()}
' html += '
' html += '
' # Users with access acls = ACL.objects.filter(server=obj).select_related('user') if acls: html += '
👥 Users with Access
' for acl in acls: user = acl.user # Get client info clients = XrayClient.objects.filter(inbound__server=obj, user=user) html += '
' html += f'
{user.username}' if user.comment: html += f' - {user.comment}' html += '
' if clients.exists(): for client in clients: up_gb = client.up / (1024**3) down_gb = client.down / (1024**3) status = '🟢' if client.enable else '🔴' html += f'
' html += f'{status} {client.inbound.tag} ({client.inbound.protocol}) - ↑ {up_gb:.2f} GB ↓ {down_gb:.2f} GB' html += '
' else: html += '
⚠️ No client configured
' html += '
' else: html += '
No users assigned to this server
' html += '
' return mark_safe(html) except Exception as e: return mark_safe(f'
Error loading statistics: {e}
') def get_urls(self): from django.urls import path urls = super().get_urls() custom_urls = [ path('/sync/', self.admin_site.admin_view(self.sync_server_view), name='xraycoreserver_sync'), ] return custom_urls + urls def sync_server_view(self, request, object_id): """View to sync server configuration.""" from django.http import JsonResponse from django.shortcuts import redirect from django.contrib import messages try: server = XrayCoreServer.objects.get(pk=object_id) result = server.sync() if request.headers.get('Accept') == 'application/json': # AJAX request return JsonResponse({ 'success': True, 'message': f'Server "{server.name}" synchronized successfully', 'details': result }) else: # Regular HTTP request - redirect back with message messages.success(request, f'Server "{server.name}" synchronized successfully!') return redirect(f'/admin/vpn/xraycoreserver/{object_id}/change/') except Exception as e: if request.headers.get('Accept') == 'application/json': return JsonResponse({ 'success': False, 'error': str(e) }, status=500) else: messages.error(request, f'Sync failed: {str(e)}') return redirect(f'/admin/vpn/xraycoreserver/{object_id}/change/') @admin.display(description='Create Inbound') def create_inbound_button(self, obj): if obj and obj.pk: create_url = reverse('admin:create_xray_inbound', args=[obj.pk]) return mark_safe(f''' ➕ Create Inbound ''') return "-" def get_urls(self): """Add custom URLs for inbound management.""" urls = super().get_urls() custom_urls = [ path('/create-inbound/', self.admin_site.admin_view(self.create_inbound_view), name='create_xray_inbound'), ] return custom_urls + urls def create_inbound_view(self, request, server_id): """View for creating new inbounds.""" try: server = XrayCoreServer.objects.get(pk=server_id) if request.method == 'POST': protocol = request.POST.get('protocol') port = int(request.POST.get('port')) tag = request.POST.get('tag') network = request.POST.get('network', 'tcp') security = request.POST.get('security', 'none') # Create inbound using server method inbound = server.create_inbound( protocol=protocol, port=port, tag=tag, network=network, security=security ) messages.success(request, f'Inbound "{inbound.tag}" created successfully!') return redirect(f'/admin/vpn/xrayinbound/{inbound.pk}/change/') # GET request - show form context = { 'title': f'Create Inbound for {server.name}', 'server': server, 'protocols': ['vless', 'vmess', 'trojan'], 'networks': ['tcp', 'ws', 'grpc', 'h2'], 'securities': ['none', 'tls', 'reality'], } return render(request, 'admin/create_xray_inbound.html', context) except XrayCoreServer.DoesNotExist: messages.error(request, 'Server not found') return redirect('/admin/vpn/xraycoreserver/') except Exception as e: messages.error(request, f'Failed to create inbound: {e}') return redirect(f'/admin/vpn/xraycoreserver/{server_id}/change/') def save_model(self, request, obj, form, change): """Override save to set server_type.""" obj.server_type = 'xray_core' super().save_model(request, obj, form, change) def get_model_perms(self, request): """It disables display for sub-model.""" return {} class Media: js = ('admin/js/xray_inbound_defaults.js',) css = {'all': ('admin/css/vpn_admin.css',)} @admin.register(XrayInboundServer) class XrayInboundServerAdmin(PolymorphicChildModelAdmin): """Admin for XrayInboundServer to display inbounds as servers.""" base_model = XrayInboundServer show_in_index = True # Show in main server list list_display = ('name', 'server_type', 'comment', 'client_count', 'registration_date') list_filter = ('server_type', 'xray_inbound__protocol', 'xray_inbound__network') search_fields = ('name', 'comment', 'xray_inbound__tag') readonly_fields = ('server_type', 'registration_date', 'client_count') fieldsets = ( ('Server Information', { 'fields': ('name', 'server_type', 'comment', 'registration_date') }), ('Inbound Configuration', { 'fields': ('xray_inbound',), 'description': 'The actual Xray inbound this server represents' }), ) def client_count(self, obj): if obj.xray_inbound: return obj.xray_inbound.clients.count() return 0 client_count.short_description = 'Clients' def has_add_permission(self, request): # Prevent manual creation - these should be auto-created return False def has_delete_permission(self, request, obj=None): # Allow deleting individual inbound servers return True def save_model(self, request, obj, form, change): """Set server_type on save.""" if obj.xray_inbound: obj.server_type = f'xray_{obj.xray_inbound.protocol}' super().save_model(request, obj, form, change) def get_urls(self): """Add sync URL for XrayInboundServer.""" from django.urls import path urls = super().get_urls() custom_urls = [ path('/sync/', self.admin_site.admin_view(self.sync_server_view), name='xrayinboundserver_sync'), ] return custom_urls + urls def sync_server_view(self, request, object_id): """Sync this inbound server by delegating to parent server.""" try: inbound_server = XrayInboundServer.objects.get(pk=object_id) if not inbound_server.xray_inbound: messages.error(request, "No inbound configuration found") return redirect('admin:vpn_server_changelist') # Delegate to parent server's sync parent_server = inbound_server.xray_inbound.server parent_admin = XrayCoreServerAdmin(XrayCoreServer, self.admin_site) # Call parent server's sync method return parent_admin.sync_server_view(request, parent_server.pk) except XrayInboundServer.DoesNotExist: messages.error(request, f"Xray Inbound Server with ID {object_id} not found") return redirect('admin:vpn_server_changelist') except Exception as e: messages.error(request, f"Error during sync: {e}") return redirect('admin:vpn_server_changelist') def get_model_perms(self, request): """Show this model in admin.""" return { 'add': self.has_add_permission(request), 'change': self.has_change_permission(request), 'delete': self.has_delete_permission(request), 'view': self.has_view_permission(request), } @admin.register(XrayInbound) class XrayInboundAdmin(admin.ModelAdmin): list_display = ('tag', 'server', 'port', 'protocol', 'network', 'security', 'enabled', 'client_count') list_filter = ('server', 'protocol', 'network', 'security', 'enabled') search_fields = ('tag', 'server__name') list_editable = ('enabled',) inlines = [XrayClientInline] def get_fieldsets(self, request, obj=None): """Customize fieldsets based on whether object exists.""" if obj is None: # Adding new inbound return ( ('Basic Information', { 'fields': ('server', 'tag', 'protocol', 'port', 'listen', 'server_address', 'enabled') }), ('Transport & Security', { 'fields': ('network', 'security') }), ('Protocol-Specific Settings', { 'fields': ('ss_method', 'ss_password'), 'classes': ('collapse',), 'description': 'Settings specific to certain protocols' }), ('TLS Configuration', { 'fields': ('tls_cert_file', 'tls_key_file', 'tls_alpn'), 'classes': ('collapse',), }), ('Advanced Settings', { 'fields': ('stream_settings', 'sniffing_settings'), 'classes': ('collapse',), }), ) else: # Editing existing inbound return ( ('Basic Information', { 'fields': ('server', 'tag', 'protocol', 'port', 'listen', 'server_address', 'enabled') }), ('Transport & Security', { 'fields': ('network', 'security') }), ('Protocol-Specific Settings', { 'fields': ('ss_method', 'ss_password'), 'classes': ('collapse',), 'description': 'Settings specific to certain protocols' }), ('TLS Configuration', { 'fields': ('tls_cert_file', 'tls_key_file', 'tls_alpn'), 'classes': ('collapse',), }), ('Advanced Settings', { 'fields': ('stream_settings', 'sniffing_settings'), 'classes': ('collapse',), }), ) def get_readonly_fields(self, request, obj=None): """Set readonly fields based on context.""" if obj is None: # Adding new inbound return () else: # Editing existing inbound return () def client_count(self, obj): return obj.clients.count() client_count.short_description = 'Clients' class Media: js = ('admin/js/xray_inbound_defaults.js',) css = {'all': ('admin/css/vpn_admin.css',)} @admin.register(XrayClient) class XrayClientAdmin(admin.ModelAdmin): list_display = ('user', 'inbound', 'email', 'enable', 'traffic_display', 'created_at') list_filter = ('inbound__server', 'inbound', 'enable', 'created_at') search_fields = ('user__username', 'email', 'uuid') list_editable = ('enable',) raw_id_fields = ('user',) fieldsets = ( ('Basic Information', { 'fields': ('inbound', 'user', 'email', 'enable') }), ('Authentication', { 'fields': ('uuid', 'level') }), ('Protocol-Specific', { 'fields': ('flow', 'alter_id', 'password'), 'classes': ('collapse',), }), ('Limits', { 'fields': ('total_gb', 'expiry_time'), 'classes': ('collapse',), }), ('Statistics', { 'fields': ('up', 'down', 'created_at', 'updated_at'), 'classes': ('collapse',), }), ) readonly_fields = ('uuid', 'created_at', 'updated_at') def traffic_display(self, obj): up_gb = obj.up / (1024**3) down_gb = obj.down / (1024**3) return f"↑ {up_gb:.2f} GB ↓ {down_gb:.2f} GB" traffic_display.short_description = 'Traffic' def get_queryset(self, request): qs = super().get_queryset(request) return qs.select_related('user', 'inbound', 'inbound__server') # Automatic sync triggers @receiver(post_save, sender=XrayInbound) def trigger_sync_on_inbound_change(sender, instance, created, **kwargs): """Trigger sync when inbound is created or modified.""" if created or instance.enabled: from vpn.tasks import sync_server logger.info(f"Triggering sync for server {instance.server.name} due to inbound {instance.tag} change") sync_server.delay(instance.server.id) @receiver(post_save, sender=XrayClient) def trigger_sync_on_client_change(sender, instance, created, **kwargs): """Trigger sync when client is created or modified.""" from vpn.tasks import sync_server server = instance.inbound.server logger.info(f"Triggering sync for server {server.name} due to client {instance.email} change") sync_server.delay(server.id) @receiver(post_delete, sender=XrayClient) def trigger_sync_on_client_delete(sender, instance, **kwargs): """Trigger sync when client is deleted.""" from vpn.tasks import sync_server server = instance.inbound.server logger.info(f"Triggering sync for server {server.name} due to client {instance.email} deletion") sync_server.delay(server.id) @receiver(post_save, sender='vpn.ACL') def trigger_sync_on_acl_change(sender, instance, created, **kwargs): """Trigger sync when ACL is created or modified to ensure users are added to inbounds.""" server = instance.server.get_real_instance() if isinstance(server, XrayCoreServer): from vpn.tasks import sync_server logger.info(f"Triggering sync for server {server.name} due to ACL change for user {instance.user.username}") sync_server.delay(server.id) @receiver(post_delete, sender='vpn.ACL') def trigger_sync_on_acl_delete(sender, instance, **kwargs): """Trigger sync when ACL is deleted to ensure users are removed from inbounds.""" server = instance.server.get_real_instance() if isinstance(server, XrayCoreServer): from vpn.tasks import sync_server logger.info(f"Triggering sync for server {server.name} due to ACL deletion for user {instance.user.username}") sync_server.delay(server.id)