Files
OutFleet/vpn/server_plugins/xray_core.py
AB from home.homenet 1f7953a74c Fixed sub links generation
2025-08-05 01:40:10 +03:00

2141 lines
83 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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('<span style="color: green;">✅ Online</span>')
else:
return mark_safe(f'<span style="color: red;">❌ {status.get("error", "Offline")}</span>')
except Exception as e:
return mark_safe(f'<span style="color: red;">❌ Error: {str(e)}</span>')
@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'<span style="color: red;">Error: {status["error"]}</span>')
html = '<div style="font-family: monospace; background: #f8f9fa; padding: 10px; border-radius: 4px;">'
html += f'<div><strong>Status:</strong> {"🟢 Online" if status.get("online") else "🔴 Offline"}</div>'
html += f'<div><strong>Inbounds:</strong> {status.get("inbounds_count", 0)}</div>'
html += f'<div><strong>Total Users:</strong> {status.get("total_users", 0)}</div>'
if 'total_traffic' in status:
up_gb = status['total_traffic']['uplink'] / (1024**3)
down_gb = status['total_traffic']['downlink'] / (1024**3)
html += f'<div><strong>Total Traffic:</strong> ↑ {up_gb:.2f} GB ↓ {down_gb:.2f} GB</div>'
html += '</div>'
return mark_safe(html)
except Exception as e:
return mark_safe(f'<span style="color: red;">Error: {str(e)}</span>')
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('<div style="color: #6c757d; font-style: italic;">Export will be available after saving</div>')
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'''
<div>
<textarea id="export-json-config" class="vLargeTextField" rows="10" readonly
style="font-family: 'Courier New', monospace; font-size: 0.875rem; background-color: #f8f9fa; width: 100%;">{escaped_json}</textarea>
<div style="margin-top: 1rem;">
<button type="button" class="btn btn-sm btn-secondary"
onclick="var btn=this; document.getElementById('export-json-config').select(); document.execCommand('copy'); btn.innerHTML='✅ Copied!'; setTimeout(function(){{btn.innerHTML='📋 Copy Configuration';}}, 2000);">
📋 Copy Configuration
</button>
<button type="button" class="btn btn-sm btn-primary" style="margin-left: 10px;"
onclick="if(confirm('This will sync all inbounds and clients with Xray Core. Continue?')) {{ window.location.href='/admin/vpn/xraycoreserver/{obj.id}/sync/'; }}">
🔄 Sync with Xray
</button>
</div>
</div>
'''
return mark_safe(html)
except Exception as e:
return mark_safe(f'<div style="color: #dc3545;">Error generating export: {e}</div>')
@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('<div style="color: #6c757d; font-style: italic;">Statistics will be available after saving</div>')
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 = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 0.375rem; padding: 1rem;">'
# Overall Statistics
html += '<div style="background: #e7f3ff; border-left: 4px solid #007cba; padding: 12px; margin-bottom: 16px; border-radius: 4px;">'
html += '<div style="display: flex; gap: 20px; margin-bottom: 8px; flex-wrap: wrap;">'
html += f'<div><strong>ACL Users:</strong> {user_count}</div>'
html += f'<div><strong>Configured Clients:</strong> {client_count}</div>'
html += f'<div><strong>Inbounds:</strong> {obj.inbounds.count()}</div>'
html += '</div>'
html += '</div>'
# Users with access
acls = ACL.objects.filter(server=obj).select_related('user')
if acls:
html += '<h5 style="color: #495057; margin: 16px 0 8px 0;">👥 Users with Access</h5>'
for acl in acls:
user = acl.user
# Get client info
clients = XrayClient.objects.filter(inbound__server=obj, user=user)
html += '<div style="background: #ffffff; border: 1px solid #e9ecef; border-radius: 0.25rem; padding: 0.75rem; margin-bottom: 0.5rem;">'
html += f'<div style="font-weight: 500; font-size: 14px; color: #495057;">{user.username}'
if user.comment:
html += f' <span style="color: #6c757d; font-size: 12px; font-weight: normal;">- {user.comment}</span>'
html += '</div>'
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'<div style="font-size: 12px; color: #6c757d; margin-left: 20px;">'
html += f'{status} {client.inbound.tag} ({client.inbound.protocol}) - ↑ {up_gb:.2f} GB ↓ {down_gb:.2f} GB'
html += '</div>'
else:
html += '<div style="font-size: 12px; color: #dc3545; margin-left: 20px;">⚠️ No client configured</div>'
html += '</div>'
else:
html += '<div style="color: #6c757d; font-style: italic; text-align: center; padding: 20px;">No users assigned to this server</div>'
html += '</div>'
return mark_safe(html)
except Exception as e:
return mark_safe(f'<div style="color: #dc3545;">Error loading statistics: {e}</div>')
def get_urls(self):
from django.urls import path
urls = super().get_urls()
custom_urls = [
path('<int:object_id>/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'''
<a href="{create_url}" class="btn btn-primary btn-sm">
Create Inbound
</a>
''')
return "-"
def get_urls(self):
"""Add custom URLs for inbound management."""
urls = super().get_urls()
custom_urls = [
path('<int:server_id>/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('<int:object_id>/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)