diff --git a/.gitignore b/.gitignore index 67d96fd..b07e67a 100755 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,16 @@ debug.log staticfiles/ *.__pycache__.* celerybeat-schedule* + +# macOS system files +._* +.DS_Store + +# Virtual environments +venv/ +.venv/ +env/ + +# Temporary files +/tmp/ +*.tmp diff --git a/Dockerfile b/Dockerfile index fea50f8..b645813 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,12 +15,22 @@ ENV BRANCH_NAME=${BRANCH_NAME} WORKDIR /app # Install system dependencies first (this layer will be cached) -RUN apk update && apk add git +RUN apk update && apk add git curl unzip # Copy and install Python dependencies (this layer will be cached when requirements.txt doesn't change) COPY ./requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +# Install Xray-core +RUN XRAY_VERSION=$(curl -s https://api.github.com/repos/XTLS/Xray-core/releases/latest | sed -n 's/.*"tag_name": "\([^"]*\)".*/\1/p') && \ + curl -L -o /tmp/xray.zip "https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/Xray-linux-64.zip" && \ + cd /tmp && unzip xray.zip && \ + ls -la /tmp/ && \ + find /tmp -name "xray" -type f && \ + cp xray /usr/local/bin/xray && \ + chmod +x /usr/local/bin/xray && \ + rm -rf /tmp/xray.zip /tmp/xray + # Copy the rest of the application code (this layer will change frequently) COPY . . diff --git a/mysite/settings.py b/mysite/settings.py index baabe40..b3aed1c 100644 --- a/mysite/settings.py +++ b/mysite/settings.py @@ -11,7 +11,7 @@ ENV = environ.Env( environ.Env.read_env() BASE_DIR = Path(__file__).resolve().parent.parent -SECRET_KEY=ENV('SECRET_KEY', default=get_random_secret_key()) +SECRET_KEY=ENV('SECRET_KEY', default='django-insecure-change-me-in-production') TIME_ZONE = ENV('TIMEZONE', default='Asia/Nicosia') EXTERNAL_ADDRESS = ENV('EXTERNAL_ADDRESS', default='https://example.org') @@ -140,7 +140,10 @@ BUILD_DATE = ENV('BUILD_DATE', default='unknown') TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'vpn', 'templates')], + 'DIRS': [ + os.path.join(BASE_DIR, 'templates'), + os.path.join(BASE_DIR, 'vpn', 'templates') + ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ diff --git a/mysite/urls.py b/mysite/urls.py index 1b13fd3..9569f1d 100644 --- a/mysite/urls.py +++ b/mysite/urls.py @@ -17,12 +17,13 @@ Including another URLconf from django.contrib import admin from django.urls import path, include from django.views.generic import RedirectView -from vpn.views import shadowsocks, userFrontend, userPortal +from vpn.views import shadowsocks, userFrontend, userPortal, xray_subscription urlpatterns = [ path('admin/', admin.site.urls), path('ss/', shadowsocks, name='shadowsocks'), path('dynamic/', shadowsocks, name='shadowsocks'), + path('xray/', xray_subscription, name='xray_subscription'), path('stat/', userFrontend, name='userFrontend'), path('u/', userPortal, name='userPortal'), path('', RedirectView.as_view(url='/admin/', permanent=False)), diff --git a/requirements.txt b/requirements.txt index e8f8713..a38331e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ whitenoise==6.9.0 psycopg2-binary==2.9.10 setuptools==75.2.0 shortuuid==1.0.13 +cryptography==45.0.5 diff --git a/static/admin/js/xray_inbound_defaults.js b/static/admin/js/xray_inbound_defaults.js new file mode 100644 index 0000000..d3a67f7 --- /dev/null +++ b/static/admin/js/xray_inbound_defaults.js @@ -0,0 +1,289 @@ +// Xray Inbound Auto-Fill Helper +console.log('Xray inbound helper script loaded'); + +// Protocol configurations based on Xray documentation +const protocolConfigs = { + 'vless': { + port: 443, + network: 'tcp', + security: 'tls', + description: 'VLESS - Lightweight protocol with UUID authentication' + }, + 'vmess': { + port: 443, + network: 'ws', + security: 'tls', + description: 'VMess - V2Ray protocol with encryption and authentication' + }, + 'trojan': { + port: 443, + network: 'tcp', + security: 'tls', + description: 'Trojan - TLS-based protocol mimicking HTTPS traffic' + }, + 'shadowsocks': { + port: 8388, + network: 'tcp', + security: 'none', + ss_method: 'aes-256-gcm', + description: 'Shadowsocks - SOCKS5 proxy with encryption' + } +}; + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', function() { + console.log('DOM ready, initializing Xray helper'); + + // Add help text and generate buttons + addHelpText(); + addGenerateButtons(); + + // Watch for protocol field changes + const protocolField = document.getElementById('id_protocol'); + if (protocolField) { + protocolField.addEventListener('change', function() { + handleProtocolChange(this.value); + }); + + // Auto-fill on initial load if new inbound + if (protocolField.value && isNewInbound()) { + handleProtocolChange(protocolField.value); + } + } +}); + +function isNewInbound() { + // Check if this is a new inbound (no port value set) + const portField = document.getElementById('id_port'); + return !portField || !portField.value; +} + +function handleProtocolChange(protocol) { + if (!protocol || !protocolConfigs[protocol]) { + return; + } + + const config = protocolConfigs[protocol]; + + // Only auto-fill for new inbounds to avoid overwriting user data + if (isNewInbound()) { + console.log('Auto-filling fields for new', protocol, 'inbound'); + autoFillFields(protocol, config); + showMessage(`Auto-filled ${protocol.toUpperCase()} configuration`, 'info'); + } +} + +function autoFillFields(protocol, config) { + // Fill basic fields only if they're empty + fillIfEmpty('id_port', config.port); + fillIfEmpty('id_network', config.network); + fillIfEmpty('id_security', config.security); + + // Protocol-specific fields + if (config.ss_method && protocol === 'shadowsocks') { + fillIfEmpty('id_ss_method', config.ss_method); + } + + // Generate helpful JSON configs + generateJsonConfigs(protocol, config); +} + +function fillIfEmpty(fieldId, value) { + const field = document.getElementById(fieldId); + if (field && !field.value && value !== undefined) { + field.value = value; + field.dispatchEvent(new Event('change', { bubbles: true })); + } +} + +function generateJsonConfigs(protocol, config) { + // Generate stream settings + const streamField = document.getElementById('id_stream_settings'); + if (streamField && !streamField.value) { + const streamSettings = getStreamSettings(protocol, config.network); + if (streamSettings) { + streamField.value = JSON.stringify(streamSettings, null, 2); + } + } + + // Generate sniffing settings + const sniffingField = document.getElementById('id_sniffing_settings'); + if (sniffingField && !sniffingField.value) { + const sniffingSettings = { + enabled: true, + destOverride: ['http', 'tls'], + metadataOnly: false + }; + sniffingField.value = JSON.stringify(sniffingSettings, null, 2); + } +} + +function getStreamSettings(protocol, network) { + const settings = {}; + + switch (network) { + case 'ws': + settings.wsSettings = { + path: '/ws', + headers: { + Host: 'example.com' + } + }; + break; + case 'grpc': + settings.grpcSettings = { + serviceName: 'GunService' + }; + break; + case 'h2': + settings.httpSettings = { + host: ['example.com'], + path: '/path' + }; + break; + case 'tcp': + settings.tcpSettings = { + header: { + type: 'none' + } + }; + break; + case 'kcp': + settings.kcpSettings = { + mtu: 1350, + tti: 50, + uplinkCapacity: 5, + downlinkCapacity: 20, + congestion: false, + readBufferSize: 2, + writeBufferSize: 2, + header: { + type: 'none' + } + }; + break; + } + + return Object.keys(settings).length > 0 ? settings : null; +} + +function addHelpText() { + // Add help text to complex fields + addFieldHelp('id_stream_settings', + 'Transport settings: TCP (none), WebSocket (path/host), gRPC (serviceName), etc. Format: JSON'); + + addFieldHelp('id_sniffing_settings', + 'Traffic sniffing for routing: enabled, destOverride ["http","tls"], metadataOnly'); + + addFieldHelp('id_tls_cert_file', + 'TLS certificate file path (required for TLS security). Example: /path/to/cert.pem'); + + addFieldHelp('id_tls_key_file', + 'TLS private key file path (required for TLS security). Example: /path/to/key.pem'); + + addFieldHelp('id_protocol', + 'VLESS: lightweight + UUID | VMess: V2Ray encrypted | Trojan: HTTPS-like | Shadowsocks: SOCKS5'); + + addFieldHelp('id_network', + 'Transport: tcp (direct), ws (WebSocket), grpc (HTTP/2), h2 (HTTP/2), kcp (mKCP)'); + + addFieldHelp('id_security', + 'Encryption: none (no TLS), tls (standard TLS), reality (advanced steganography)'); +} + +function addFieldHelp(fieldId, helpText) { + const field = document.getElementById(fieldId); + if (!field) return; + + const helpDiv = document.createElement('div'); + helpDiv.className = 'help'; + helpDiv.style.cssText = 'font-size: 11px; color: #666; margin-top: 2px; line-height: 1.3;'; + helpDiv.textContent = helpText; + + field.parentNode.appendChild(helpDiv); +} + +function showMessage(message, type = 'info') { + const messageDiv = document.createElement('div'); + messageDiv.className = `alert alert-${type}`; + messageDiv.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + z-index: 9999; + padding: 12px 20px; + border-radius: 4px; + background: ${type === 'success' ? '#d4edda' : '#cce7ff'}; + border: 1px solid ${type === 'success' ? '#c3e6cb' : '#b8daff'}; + color: ${type === 'success' ? '#155724' : '#004085'}; + font-weight: 500; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + `; + messageDiv.textContent = message; + + document.body.appendChild(messageDiv); + + setTimeout(() => { + messageDiv.remove(); + }, 3000); +} + +// Helper functions for generating values +function generateRandomString(length = 8) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +function generateShortId() { + return Math.random().toString(16).substr(2, 8); +} + +function suggestPort(protocol) { + const ports = { + 'vless': [443, 8443, 2053, 2083], + 'vmess': [443, 80, 8080, 8443], + 'trojan': [443, 8443, 2087], + 'shadowsocks': [8388, 1080, 8080] + }; + const protocolPorts = ports[protocol] || [443]; + return protocolPorts[Math.floor(Math.random() * protocolPorts.length)]; +} + +// Add generate buttons to fields +function addGenerateButtons() { + console.log('Adding generate buttons'); + + // Add tag generator + addGenerateButton('id_tag', '🎲', () => `inbound-${generateShortId()}`); + + // Add port suggestion based on protocol + addGenerateButton('id_port', '🎯', () => { + const protocol = document.getElementById('id_protocol')?.value; + return suggestPort(protocol); + }); +} + +function addGenerateButton(fieldId, icon, generator) { + const field = document.getElementById(fieldId); + if (!field || field.nextElementSibling?.classList.contains('generate-btn')) return; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'generate-btn btn btn-sm btn-secondary'; + button.innerHTML = icon; + button.title = 'Generate value'; + button.style.cssText = 'margin-left: 5px; padding: 2px 6px; font-size: 12px;'; + + button.addEventListener('click', () => { + const value = generator(); + field.value = value; + showMessage(`Generated: ${value}`, 'success'); + field.dispatchEvent(new Event('change', { bubbles: true })); + }); + + field.parentNode.insertBefore(button, field.nextSibling); +} \ No newline at end of file diff --git a/templates/admin/create_xray_inbound.html b/templates/admin/create_xray_inbound.html new file mode 100644 index 0000000..5d7af3e --- /dev/null +++ b/templates/admin/create_xray_inbound.html @@ -0,0 +1,202 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+

{{ title }}

+
+
+
+ {% csrf_token %} + +
+
+
+ + +
+
+ +
+
+ + +
+
+
+ +
+
+
+ + +
+
+ +
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+ Note: The inbound will be created on both the Django database and the Xray server via gRPC API. +
+
+ +
+ + + Cancel + +
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/vpn/admin.py b/vpn/admin.py index ac2b431..5058aa2 100644 --- a/vpn/admin.py +++ b/vpn/admin.py @@ -26,7 +26,9 @@ from .server_plugins import ( OutlineServer, OutlineServerAdmin, XrayCoreServer, - XrayCoreServerAdmin) + XrayCoreServerAdmin, + XrayInbound, + XrayClient) @admin.register(TaskExecutionLog) @@ -265,6 +267,7 @@ class ServerAdmin(PolymorphicParentModelAdmin): custom_urls = [ path('move-clients/', self.admin_site.admin_view(self.move_clients_view), name='server_move_clients'), path('/check-status/', self.admin_site.admin_view(self.check_server_status_view), name='server_check_status'), + path('/sync/', self.admin_site.admin_view(self.sync_server_view), name='server_sync'), ] return custom_urls + urls @@ -492,6 +495,7 @@ class ServerAdmin(PolymorphicParentModelAdmin): # Check server status based on type from vpn.server_plugins.outline import OutlineServer + from vpn.server_plugins.xray_core import XrayCoreServer if isinstance(real_server, OutlineServer): try: @@ -519,9 +523,51 @@ class ServerAdmin(PolymorphicParentModelAdmin): 'status': 'error', 'message': f'Connection error: {str(e)[:100]}' }) + + elif isinstance(real_server, XrayCoreServer): + try: + logger.info(f"Checking Xray server: {server.name}") + # Try to get server status from Xray + status = real_server.get_server_status() + if status and isinstance(status, dict): + if status.get('status') == 'online' or 'version' in status: + inbounds_count = real_server.inbounds.count() + clients_count = sum(inbound.clients.count() for inbound in real_server.inbounds.all()) + message = f'Server is online. Inbounds: {inbounds_count}, Clients: {clients_count}' + if 'version' in status: + message += f', Version: {status["version"]}' + + logger.info(f"Xray server {server.name} is online: {message}") + return JsonResponse({ + 'success': True, + 'status': 'online', + 'message': message + }) + else: + logger.warning(f"Xray server {server.name} returned status: {status}") + return JsonResponse({ + 'success': True, + 'status': 'offline', + 'message': f'Server status: {status.get("message", "Unknown error")}' + }) + else: + logger.warning(f"Xray server {server.name} returned no status") + return JsonResponse({ + 'success': True, + 'status': 'offline', + 'message': 'Server not responding' + }) + except Exception as e: + logger.error(f"Error checking Xray server {server.name}: {e}") + return JsonResponse({ + 'success': True, + 'status': 'error', + 'message': f'Connection error: {str(e)[:100]}' + }) + else: - # For non-Outline servers, just return basic info - logger.info(f"Non-Outline server {server.name}, type: {server.server_type}") + # For other server types, just return basic info + logger.info(f"Server {server.name}, type: {server.server_type}") return JsonResponse({ 'success': True, 'status': 'unknown', @@ -808,6 +854,29 @@ class ServerAdmin(PolymorphicParentModelAdmin): 'acl_set__user' ) return qs + + def sync_server_view(self, request, object_id): + """Dispatch sync to appropriate server type.""" + from django.shortcuts import redirect, get_object_or_404 + from django.contrib import messages + from vpn.server_plugins import XrayCoreServer + + try: + server = get_object_or_404(Server, pk=object_id) + real_server = server.get_real_instance() + + # Handle XrayCoreServer + if isinstance(real_server, XrayCoreServer): + return redirect(f'/admin/vpn/xraycoreserver/{real_server.pk}/sync/') + + # Fallback for other server types + else: + messages.info(request, f"Sync not implemented for server type: {real_server.server_type}") + return redirect('admin:vpn_server_changelist') + + except Exception as e: + messages.error(request, f"Error during sync: {e}") + return redirect('admin:vpn_server_changelist') #admin.site.register(User, UserAdmin) @admin.register(User) diff --git a/vpn/migrations/0009_xraycoreserver_alter_server_server_type.py b/vpn/migrations/0009_xraycoreserver_alter_server_server_type.py new file mode 100644 index 0000000..4371962 --- /dev/null +++ b/vpn/migrations/0009_xraycoreserver_alter_server_server_type.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1.7 on 2025-07-27 17:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0008_rename_vpn_accessl_acl_lin_b23c6e_idx_vpn_accessl_acl_lin_9f3bc5_idx'), + ] + + operations = [ + migrations.CreateModel( + name='XrayCoreServer', + fields=[ + ('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')), + ('api_address', models.CharField(help_text='Xray Core API address (e.g., http://127.0.0.1:8080)', max_length=255)), + ('api_port', models.IntegerField(default=8080, help_text='API port for management interface')), + ('api_token', models.CharField(blank=True, help_text='API authentication token', max_length=255)), + ('server_address', models.CharField(help_text='Server address for clients to connect', max_length=255)), + ('server_port', models.IntegerField(default=443, help_text='Server port for client connections')), + ('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('shadowsocks', 'Shadowsocks'), ('trojan', 'Trojan')], default='vless', help_text='Primary protocol for this server', max_length=20)), + ('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY'), ('xtls', 'XTLS')], default='tls', help_text='Security layer configuration', max_length=20)), + ('transport', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('http', 'HTTP/2'), ('grpc', 'gRPC'), ('quic', 'QUIC')], default='tcp', help_text='Transport protocol', max_length=20)), + ('config_json', models.JSONField(blank=True, default=dict, help_text='Complete Xray configuration in JSON format')), + ('panel_url', models.CharField(blank=True, help_text='Web panel URL if using 3X-UI or similar management panel', max_length=255)), + ('panel_username', models.CharField(blank=True, help_text='Panel admin username', max_length=100)), + ('panel_password', models.CharField(blank=True, help_text='Panel admin password', max_length=100)), + ], + options={ + 'verbose_name': 'Xray Core Server', + 'verbose_name_plural': 'Xray Core Servers', + }, + bases=('vpn.server',), + ), + migrations.AlterField( + model_name='server', + name='server_type', + field=models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard'), ('xray_core', 'Xray Core')], editable=False, max_length=50), + ), + ] diff --git a/vpn/migrations/0010_remove_xraycoreserver_api_address_and_more.py b/vpn/migrations/0010_remove_xraycoreserver_api_address_and_more.py new file mode 100644 index 0000000..19d99b3 --- /dev/null +++ b/vpn/migrations/0010_remove_xraycoreserver_api_address_and_more.py @@ -0,0 +1,137 @@ +# Generated by Django 5.1.7 on 2025-07-28 22:34 + +import django.contrib.postgres.fields +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0009_xraycoreserver_alter_server_server_type'), + ] + + operations = [ + migrations.RemoveField( + model_name='xraycoreserver', + name='api_address', + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='api_port', + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='api_token', + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='config_json', + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='panel_password', + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='panel_url', + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='panel_username', + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='protocol', + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='security', + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='server_address', + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='server_port', + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='transport', + ), + migrations.AddField( + model_name='xraycoreserver', + name='default_protocol', + field=models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], default='vless', help_text='Default protocol for new inbounds', max_length=20), + ), + migrations.AddField( + model_name='xraycoreserver', + name='enable_stats', + field=models.BooleanField(default=True, help_text='Enable traffic statistics tracking'), + ), + migrations.AddField( + model_name='xraycoreserver', + name='grpc_address', + field=models.CharField(default='127.0.0.1', help_text='Xray Core gRPC API address', max_length=255), + ), + migrations.AddField( + model_name='xraycoreserver', + name='grpc_port', + field=models.IntegerField(default=10085, help_text='gRPC API port (usually 10085)'), + ), + migrations.CreateModel( + name='XrayInbound', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tag', models.CharField(help_text='Unique identifier for this inbound', max_length=100)), + ('port', models.IntegerField(help_text='Port to listen on')), + ('listen', models.CharField(default='0.0.0.0', help_text='IP address to listen on', max_length=255)), + ('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], max_length=20)), + ('enabled', models.BooleanField(default=True)), + ('is_default', models.BooleanField(default=False, help_text='Use this inbound for new users by default')), + ('network', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('http', 'HTTP/2'), ('grpc', 'gRPC'), ('quic', 'QUIC')], default='tcp', max_length=20)), + ('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY')], default='none', max_length=20)), + ('server_address', models.CharField(blank=True, help_text='Public server address for client connections (if different from listen address)', max_length=255)), + ('ss_method', models.CharField(blank=True, default='chacha20-ietf-poly1305', help_text='Shadowsocks encryption method', max_length=50)), + ('ss_password', models.CharField(blank=True, help_text='Shadowsocks password (for single-user mode)', max_length=255)), + ('tls_cert_file', models.CharField(blank=True, max_length=255)), + ('tls_key_file', models.CharField(blank=True, max_length=255)), + ('tls_alpn', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), blank=True, default=list, size=None)), + ('stream_settings', models.JSONField(blank=True, default=dict)), + ('sniffing_settings', models.JSONField(blank=True, default=dict)), + ('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inbounds', to='vpn.xraycoreserver')), + ], + options={ + 'ordering': ['port'], + 'unique_together': {('server', 'port'), ('server', 'tag')}, + }, + ), + migrations.CreateModel( + name='XrayClient', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('email', models.CharField(help_text='Email for statistics', max_length=255)), + ('level', models.IntegerField(default=0)), + ('enable', models.BooleanField(default=True)), + ('flow', models.CharField(blank=True, help_text='VLESS flow control', max_length=50)), + ('alter_id', models.IntegerField(default=0, help_text='VMess alterId')), + ('password', models.CharField(blank=True, help_text='Password for Trojan/Shadowsocks', max_length=255)), + ('total_gb', models.IntegerField(blank=True, help_text='Traffic limit in GB', null=True)), + ('expiry_time', models.DateTimeField(blank=True, help_text='Account expiration time', null=True)), + ('up', models.BigIntegerField(default=0, help_text='Upload bytes')), + ('down', models.BigIntegerField(default=0, help_text='Download bytes')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('inbound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clients', to='vpn.xrayinbound')), + ], + options={ + 'ordering': ['created_at'], + 'unique_together': {('inbound', 'user')}, + }, + ), + ] diff --git a/vpn/migrations/0011_xrayinboundproxy_and_more.py b/vpn/migrations/0011_xrayinboundproxy_and_more.py new file mode 100644 index 0000000..aa1c8ca --- /dev/null +++ b/vpn/migrations/0011_xrayinboundproxy_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.7 on 2025-07-31 21:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0010_remove_xraycoreserver_api_address_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='XrayInboundProxy', + fields=[ + ], + options={ + 'verbose_name': 'Xray Inbound (Server View)', + 'verbose_name_plural': 'Xray Inbounds (Server View)', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('vpn.xrayinbound',), + ), + migrations.RemoveField( + model_name='xraycoreserver', + name='default_protocol', + ), + migrations.RemoveField( + model_name='xrayinbound', + name='is_default', + ), + ] diff --git a/vpn/migrations/0012_xrayinboundserver_delete_xrayinboundproxy.py b/vpn/migrations/0012_xrayinboundserver_delete_xrayinboundproxy.py new file mode 100644 index 0000000..fd70eb1 --- /dev/null +++ b/vpn/migrations/0012_xrayinboundserver_delete_xrayinboundproxy.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.7 on 2025-07-31 21:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0011_xrayinboundproxy_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='XrayInboundServer', + fields=[ + ('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')), + ('xray_inbound', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='server_proxy', to='vpn.xrayinbound')), + ], + options={ + 'verbose_name': 'Xray Inbound Server', + 'verbose_name_plural': 'Xray Inbound Servers', + }, + bases=('vpn.server',), + ), + migrations.DeleteModel( + name='XrayInboundProxy', + ), + ] diff --git a/vpn/migrations/0013_add_client_hostname.py b/vpn/migrations/0013_add_client_hostname.py new file mode 100644 index 0000000..d43de30 --- /dev/null +++ b/vpn/migrations/0013_add_client_hostname.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-07-31 22:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0012_xrayinboundserver_delete_xrayinboundproxy'), + ] + + operations = [ + migrations.AddField( + model_name='xraycoreserver', + name='client_hostname', + field=models.CharField(default='127.0.0.1', help_text='Hostname or IP address for client connections (what clients use to connect)', max_length=255), + ), + ] diff --git a/vpn/migrations/0014_alter_xraycoreserver_client_hostname_and_more.py b/vpn/migrations/0014_alter_xraycoreserver_client_hostname_and_more.py new file mode 100644 index 0000000..1d059dc --- /dev/null +++ b/vpn/migrations/0014_alter_xraycoreserver_client_hostname_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2025-08-04 22:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0013_add_client_hostname'), + ] + + operations = [ + migrations.AlterField( + model_name='xraycoreserver', + name='client_hostname', + field=models.CharField(default='127.0.0.1', help_text='Hostname or IP address for client connections', max_length=255), + ), + migrations.AlterField( + model_name='xrayinbound', + name='server_address', + field=models.CharField(blank=True, help_text='Public server address for client connections', max_length=255), + ), + ] diff --git a/vpn/server_plugins/__init__.py b/vpn/server_plugins/__init__.py index c3eeb1c..0b5d5d9 100644 --- a/vpn/server_plugins/__init__.py +++ b/vpn/server_plugins/__init__.py @@ -1,5 +1,5 @@ from .generic import Server from .outline import OutlineServer, OutlineServerAdmin from .wireguard import WireguardServer, WireguardServerAdmin -from .xray_core import XrayCoreServer, XrayCoreServerAdmin +from .xray_core import XrayCoreServer, XrayCoreServerAdmin, XrayInbound, XrayClient, XrayInboundServer, XrayInboundServerAdmin from .urls import urlpatterns \ No newline at end of file diff --git a/vpn/server_plugins/xray_core.py b/vpn/server_plugins/xray_core.py index 4cef1eb..50b3f98 100644 --- a/vpn/server_plugins/xray_core.py +++ b/vpn/server_plugins/xray_core.py @@ -1,322 +1,2109 @@ -from django.db import models -from django.contrib import admin -from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicChildModelFilter -from .generic import Server -import logging -from typing import Optional, Dict, Any, List +""" +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. + + Supports VLESS, VMess, Shadowsocks, and Trojan protocols through gRPC API. """ - - # API Configuration - api_address = models.CharField( + + # gRPC API Configuration + grpc_address = models.CharField( max_length=255, - help_text="Xray Core API address (e.g., http://127.0.0.1:8080)" + default="127.0.0.1", + help_text="Xray Core gRPC API address" ) - api_port = models.IntegerField( - default=8080, - help_text="API port for management interface" + grpc_port = models.IntegerField( + default=10085, + help_text="gRPC API port (usually 10085)" ) - api_token = models.CharField( + + # Client connection hostname + client_hostname = models.CharField( max_length=255, - blank=True, - help_text="API authentication token" + default="127.0.0.1", + help_text="Hostname or IP address for client connections" ) - - # Server Configuration - server_address = models.CharField( - max_length=255, - help_text="Server address for clients to connect" + + # Stats Configuration + enable_stats = models.BooleanField( + default=True, + help_text="Enable traffic statistics tracking" ) - server_port = models.IntegerField( - default=443, - help_text="Server port for client connections" - ) - - # Protocol Configuration - protocol = models.CharField( - max_length=20, - choices=[ - ('vless', 'VLESS'), - ('vmess', 'VMess'), - ('shadowsocks', 'Shadowsocks'), - ('trojan', 'Trojan'), - ], - default='vless', - help_text="Primary protocol for this server" - ) - - # Security Configuration - security = models.CharField( - max_length=20, - choices=[ - ('none', 'None'), - ('tls', 'TLS'), - ('reality', 'REALITY'), - ('xtls', 'XTLS'), - ], - default='tls', - help_text="Security layer configuration" - ) - - # Transport Configuration - transport = models.CharField( - max_length=20, - choices=[ - ('tcp', 'TCP'), - ('ws', 'WebSocket'), - ('http', 'HTTP/2'), - ('grpc', 'gRPC'), - ('quic', 'QUIC'), - ], - default='tcp', - help_text="Transport protocol" - ) - - # Configuration JSON - config_json = models.JSONField( - default=dict, - blank=True, - help_text="Complete Xray configuration in JSON format" - ) - - # Panel Configuration (if using 3X-UI or similar) - panel_url = models.CharField( - max_length=255, - blank=True, - help_text="Web panel URL if using 3X-UI or similar management panel" - ) - panel_username = models.CharField( - max_length=100, - blank=True, - help_text="Panel admin username" - ) - panel_password = models.CharField( - max_length=100, - blank=True, - help_text="Panel admin password" - ) - + 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"Xray Core Server: {self.name} ({self.protocol.upper()})" - - def get_server_status(self) -> Dict[str, Any]: - """ - Get server status information. - Mock implementation for now. - """ - logger.info(f"Getting status for Xray Core server: {self.name}") - - # TODO: Implement actual API call to get server status - return { - 'online': True, - 'version': '1.8.0', - 'uptime': '7 days', - 'clients_online': 42, - 'total_traffic': '1.2 TB', - 'protocol': self.protocol, - 'transport': self.transport, - 'security': self.security, - } - - def sync(self) -> bool: - """ - Sync server configuration. - Mock implementation for now. - """ - logger.info(f"Syncing Xray Core server: {self.name}") - - # TODO: Implement actual configuration sync - # This would typically: - # 1. Connect to Xray API or panel - # 2. Push configuration updates - # 3. Reload Xray service - - return True - - def sync_users(self) -> Dict[str, Any]: - """ - Sync all users on the server. - Mock implementation for now. - """ - logger.info(f"Syncing users for Xray Core server: {self.name}") - - # TODO: Implement actual user sync - # This would typically: - # 1. Get list of users from database - # 2. Get list of users from Xray server - # 3. Add missing users - # 4. Remove extra users - # 5. Update user configurations - - return { - 'synced': 10, - 'added': 2, - 'removed': 1, - 'errors': 0, - } - - def add_user(self, user_id: str, email: str) -> Dict[str, Any]: - """ - Add a user to the server. - Mock implementation for now. - """ - logger.info(f"Adding user {email} to Xray Core server: {self.name}") - - # TODO: Implement actual user addition - # This would typically: - # 1. Generate user UUID - # 2. Create user configuration based on protocol - # 3. Add user to Xray server via API - # 4. Return connection details - - import uuid - user_uuid = str(uuid.uuid4()) - - # Mock connection string based on protocol - if self.protocol == 'vless': - connection_string = f"vless://{user_uuid}@{self.server_address}:{self.server_port}?encryption=none&security={self.security}&type={self.transport}#{self.name}" - elif self.protocol == 'vmess': - # VMess requires base64 encoding of config - vmess_config = { - "v": "2", - "ps": self.name, - "add": self.server_address, - "port": str(self.server_port), - "id": user_uuid, - "aid": "0", - "net": self.transport, - "type": "none", - "tls": self.security, + 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) } - import base64 - config_str = base64.b64encode(json.dumps(vmess_config).encode()).decode() - connection_string = f"vmess://{config_str}" - else: - connection_string = f"{self.protocol}://{user_uuid}@{self.server_address}:{self.server_port}" - + + 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': user_uuid, - 'email': email, + '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={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 get_user(self, user_id: str) -> Optional[Dict[str, Any]]: - """ - Get user information from server. - Mock implementation for now. - """ - logger.info(f"Getting user {user_id} from Xray Core server: {self.name}") - - # TODO: Implement actual user retrieval - # This would typically: - # 1. Query Xray API for user info - # 2. Return user configuration and statistics - - return { - 'user_id': user_id, - 'email': 'user@example.com', - 'created': '2024-01-01', - 'traffic_used': '100 GB', - 'traffic_limit': '1000 GB', - 'expire_date': '2024-12-31', - 'online': True, + + 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" + } + ] + } } - - def delete_user(self, user_id: str) -> bool: - """ - Remove user from server. - Mock implementation for now. - """ - logger.info(f"Deleting user {user_id} from Xray Core server: {self.name}") - - # TODO: Implement actual user deletion - # This would typically: - # 1. Remove user from Xray configuration - # 2. Reload Xray service - # 3. Return success/failure - - return True - - def get_user_statistics(self, user_id: str) -> Dict[str, Any]: - """ - Get user traffic statistics. - Mock implementation for now. - """ - logger.info(f"Getting statistics for user {user_id} on Xray Core server: {self.name}") - - # TODO: Implement actual statistics retrieval - # This would typically: - # 1. Query Xray stats API - # 2. Parse and return traffic data - - return { - 'user_id': user_id, - 'download': 50 * 1024 * 1024 * 1024, # 50 GB in bytes - 'upload': 10 * 1024 * 1024 * 1024, # 10 GB in bytes - 'total': 60 * 1024 * 1024 * 1024, # 60 GB in bytes - 'last_seen': '2024-01-27 12:00:00', + + # 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 - - fieldsets = ( - ('Basic Information', { - 'fields': ('name', 'comment', 'server_type'), - }), - ('API Configuration', { - 'fields': ('api_address', 'api_port', 'api_token'), - 'classes': ('collapse',), - }), - ('Server Configuration', { - 'fields': ('server_address', 'server_port'), - }), - ('Protocol Settings', { - 'fields': ('protocol', 'security', 'transport'), - }), - ('Panel Configuration (Optional)', { - 'fields': ('panel_url', 'panel_username', 'panel_password'), - 'classes': ('collapse',), - }), - ('Advanced Configuration', { - 'fields': ('config_json',), - 'classes': ('collapse',), - }), + + list_display = ( + 'name', + 'grpc_address', + 'grpc_port', + 'client_hostname', + 'inbound_count', + 'user_count', + 'server_status_inline', + 'registration_date' ) - - list_display = ('name', 'server_address', 'protocol', 'security', 'transport', 'get_status_display') - list_filter = ('protocol', 'security', 'transport') - search_fields = ('name', 'server_address', 'comment') - - def get_status_display(self, obj): - """Display server status in admin list.""" + + 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 '✅ Online' + return mark_safe('✅ Online') else: - return '❌ Offline' - except Exception: - return '⚠️ Unknown' - get_status_display.short_description = 'Status' - + 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) \ No newline at end of file + 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) \ No newline at end of file diff --git a/vpn/tasks.py b/vpn/tasks.py index 3db5474..9800331 100644 --- a/vpn/tasks.py +++ b/vpn/tasks.py @@ -49,6 +49,108 @@ def cleanup_task_logs(): logger.error(f"Error cleaning up task logs: {e}") return f"Error cleaning up task logs: {e}" + +@shared_task(name="sync_xray_inbounds", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 30}) +def sync_xray_inbounds(self, server_id): + """Stage 1: Sync inbounds for Xray server.""" + from vpn.server_plugins import Server + from vpn.server_plugins.xray_core import XrayCoreServer + + start_time = time.time() + task_id = self.request.id + server = None + + try: + server = Server.objects.get(id=server_id) + + if not isinstance(server.get_real_instance(), XrayCoreServer): + error_message = f"Server {server.name} is not an Xray server" + logger.error(error_message) + create_task_log(task_id, "sync_xray_inbounds", "Wrong server type", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time) + return {"error": error_message} + + create_task_log(task_id, "sync_xray_inbounds", f"Starting inbound sync for {server.name}", 'STARTED', server=server) + logger.info(f"Starting inbound sync for Xray server {server.name}") + + real_server = server.get_real_instance() + inbound_result = real_server.sync_inbounds() + + success_message = f"Successfully synced inbounds for {server.name}" + logger.info(f"{success_message}. Result: {inbound_result}") + + create_task_log(task_id, "sync_xray_inbounds", "Inbound sync completed", 'SUCCESS', server=server, message=f"{success_message}. Result: {inbound_result}", execution_time=time.time() - start_time) + + return inbound_result + + except Server.DoesNotExist: + error_message = f"Server with id {server_id} not found" + logger.error(error_message) + create_task_log(task_id, "sync_xray_inbounds", "Server not found", 'FAILURE', message=error_message, execution_time=time.time() - start_time) + return {"error": error_message} + except Exception as e: + error_message = f"Error syncing inbounds for {server.name if server else server_id}: {e}" + logger.error(error_message) + + if self.request.retries < 3: + retry_message = f"Retrying inbound sync for {server.name if server else server_id} (attempt {self.request.retries + 1})" + logger.info(retry_message) + create_task_log(task_id, "sync_xray_inbounds", "Retrying inbound sync", 'RETRY', server=server, message=retry_message) + raise self.retry(countdown=30) + + create_task_log(task_id, "sync_xray_inbounds", "Inbound sync failed after retries", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time) + return {"error": error_message} + + +@shared_task(name="sync_xray_users", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 30}) +def sync_xray_users(self, server_id): + """Stage 2: Sync users for Xray server.""" + from vpn.server_plugins import Server + from vpn.server_plugins.xray_core import XrayCoreServer + + start_time = time.time() + task_id = self.request.id + server = None + + try: + server = Server.objects.get(id=server_id) + + if not isinstance(server.get_real_instance(), XrayCoreServer): + error_message = f"Server {server.name} is not an Xray server" + logger.error(error_message) + create_task_log(task_id, "sync_xray_users", "Wrong server type", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time) + return {"error": error_message} + + create_task_log(task_id, "sync_xray_users", f"Starting user sync for {server.name}", 'STARTED', server=server) + logger.info(f"Starting user sync for Xray server {server.name}") + + real_server = server.get_real_instance() + user_result = real_server.sync_users() + + success_message = f"Successfully synced {user_result.get('users_added', 0)} users for {server.name}" + logger.info(f"{success_message}. Result: {user_result}") + + create_task_log(task_id, "sync_xray_users", "User sync completed", 'SUCCESS', server=server, message=f"{success_message}. Result: {user_result}", execution_time=time.time() - start_time) + + return user_result + + except Server.DoesNotExist: + error_message = f"Server with id {server_id} not found" + logger.error(error_message) + create_task_log(task_id, "sync_xray_users", "Server not found", 'FAILURE', message=error_message, execution_time=time.time() - start_time) + return {"error": error_message} + except Exception as e: + error_message = f"Error syncing users for {server.name if server else server_id}: {e}" + logger.error(error_message) + + if self.request.retries < 3: + retry_message = f"Retrying user sync for {server.name if server else server_id} (attempt {self.request.retries + 1})" + logger.info(retry_message) + create_task_log(task_id, "sync_xray_users", "Retrying user sync", 'RETRY', server=server, message=retry_message) + raise self.retry(countdown=30) + + create_task_log(task_id, "sync_xray_users", "User sync failed after retries", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time) + return {"error": error_message} + class TaskFailedException(Exception): def __init__(self, message=""): self.message = message @@ -145,15 +247,66 @@ def sync_users(self, server_id): create_task_log(task_id, "sync_all_users_on_server", f"Found {user_count} users to sync", 'STARTED', server=server, message=f"Users: {user_list}") - sync_result = server.sync_users() + # For Xray servers, use separate staged sync tasks + from vpn.server_plugins.xray_core import XrayCoreServer + if isinstance(server.get_real_instance(), XrayCoreServer): + logger.info(f"Performing staged sync for Xray server {server.name}") + try: + # Stage 1: Sync inbounds first + logger.info(f"Stage 1: Syncing inbounds for {server.name}") + inbound_task = sync_xray_inbounds.apply_async(args=[server.id]) + inbound_result = inbound_task.get() # Wait for completion + logger.info(f"Inbound sync result for {server.name}: {inbound_result}") + + if "error" in inbound_result: + logger.error(f"Inbound sync failed, skipping user sync: {inbound_result['error']}") + sync_result = inbound_result + else: + # Stage 2: Sync users after inbounds are ready + logger.info(f"Stage 2: Syncing users for {server.name}") + user_task = sync_xray_users.apply_async(args=[server.id]) + user_result = user_task.get() # Wait for completion + logger.info(f"User sync result for {server.name}: {user_result}") + + # Combine results + if "error" in user_result: + sync_result = { + "status": "Staged sync partially failed", + "inbounds": inbound_result.get("inbounds", []), + "users": f"User sync failed: {user_result['error']}" + } + else: + sync_result = { + "status": "Staged sync completed 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"Staged sync failed for Xray server {server.name}: {e}") + # Fallback to regular user sync only + sync_result = server.sync_users() + else: + # For non-Xray servers, just sync users + sync_result = server.sync_users() - if sync_result: + # Check if sync was successful (can be boolean or dict/string) + sync_successful = bool(sync_result) and ( + sync_result is not False and + (isinstance(sync_result, str) and "failed" not in sync_result.lower()) or + isinstance(sync_result, dict) or + sync_result is True + ) + + if sync_successful: success_message = f"Successfully synced {user_count} users for server {server.name}" + if isinstance(sync_result, (str, dict)): + success_message += f". Details: {sync_result}" logger.info(success_message) create_task_log(task_id, "sync_all_users_on_server", "User sync completed", 'SUCCESS', server=server, message=success_message, execution_time=time.time() - start_time) return success_message else: - error_message = f"Sync failed for server {server.name}" + error_message = f"Sync failed for server {server.name}. Result: {sync_result}" create_task_log(task_id, "sync_all_users_on_server", "User sync failed", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time) raise TaskFailedException(error_message) diff --git a/vpn/templates/vpn/user_portal.html b/vpn/templates/vpn/user_portal.html index a5b7ea3..8f4975a 100644 --- a/vpn/templates/vpn/user_portal.html +++ b/vpn/templates/vpn/user_portal.html @@ -473,6 +473,20 @@

📊 Statistics are updated every 5 minutes and show your connection history

{% endif %} + + + {% if has_xray_servers and user_links %} +
+

🚀 Xray Universal Subscription

+

+ One link for all your Xray protocols (VLESS, VMess, Trojan) +

+ +
+ {% endif %} {% if servers_data %} diff --git a/vpn/views.py b/vpn/views.py index e5aed0d..a9c2571 100644 --- a/vpn/views.py +++ b/vpn/views.py @@ -140,14 +140,23 @@ def userPortal(request, user_hash): logger.info(f"Prepared data for {len(servers_data)} servers and {total_links} total links") logger.info(f"Portal statistics: total_connections={total_connections}, recent_connections={recent_connections}") + # Check if user has access to any Xray servers + from vpn.server_plugins import XrayCoreServer, XrayInboundServer + has_xray_servers = any( + isinstance(acl_link.acl.server.get_real_instance(), (XrayCoreServer, XrayInboundServer)) + for acl_link in acl_links + ) + context = { 'user': user, + 'user_links': acl_links, # For accessing user's links in template 'servers_data': servers_data, 'total_servers': len(servers_data), 'total_links': total_links, 'total_connections': total_connections, 'recent_connections': recent_connections, 'external_address': EXTERNAL_ADDRESS, + 'has_xray_servers': has_xray_servers, } logger.debug(f"Context prepared with keys: {list(context.keys())}") @@ -279,3 +288,113 @@ def shadowsocks(request, link): return HttpResponse(response, content_type=f"{ 'application/json; charset=utf-8' if request.GET.get('mode') == 'json' else 'text/html' }") + +def xray_subscription(request, link): + """ + Return Xray subscription with all available protocols for the user. + This generates a single subscription link that includes all inbounds the user has access to. + """ + from .models import ACLLink, AccessLog + from vpn.server_plugins import XrayCoreServer, XrayInboundServer + import logging + from django.utils import timezone + import base64 + + logger = logging.getLogger(__name__) + + try: + acl_link = get_object_or_404(ACLLink, link=link) + acl = acl_link.acl + logger.info(f"Found ACL link for user {acl.user.username} on server {acl.server.name}") + except Http404: + logger.warning(f"ACL link not found: {link}") + AccessLog.objects.create( + user=None, + server="Unknown", + acl_link_id=link, + action="Failed", + data=f"ACL not found for link: {link}" + ) + return HttpResponse("Not found", status=404) + + try: + # Get all servers this user has access to + user_acls = acl.user.acl_set.all() + subscription_configs = [] + + for user_acl in user_acls: + server = user_acl.server.get_real_instance() + + # Handle XrayInboundServer (individual inbounds) + if isinstance(server, XrayInboundServer): + if server.xray_inbound: + config = server.get_user(acl.user, raw=True) + if config and 'connection_string' in config: + subscription_configs.append(config['connection_string']) + logger.info(f"Added XrayInboundServer config for {server.name}") + + # Handle XrayCoreServer (parent server with multiple inbounds) + elif isinstance(server, XrayCoreServer): + try: + # Get all inbounds for this server that have this user + for inbound in server.inbounds.filter(enabled=True): + # Check if user has a client in this inbound + client = inbound.clients.filter(user=acl.user).first() + if client: + connection_string = server._generate_connection_string(client) + if connection_string: + subscription_configs.append(connection_string) + logger.info(f"Added inbound {inbound.tag} config for user {acl.user.username}") + except Exception as e: + logger.warning(f"Failed to get configs from XrayCoreServer {server.name}: {e}") + + if not subscription_configs: + logger.warning(f"No Xray configurations found for user {acl.user.username}") + AccessLog.objects.create( + user=acl.user.username, + server="Multiple", + acl_link_id=acl_link.link, + action="Failed", + data="No Xray configurations available" + ) + return HttpResponse("No configurations available", status=404) + + # Join all configs with newlines and encode in base64 for subscription format + subscription_content = '\n'.join(subscription_configs) + logger.info(f"Raw subscription content for {acl.user.username}:\n{subscription_content}") + + subscription_b64 = base64.b64encode(subscription_content.encode('utf-8')).decode('utf-8') + logger.info(f"Base64 subscription length: {len(subscription_b64)}") + + # Update last access time + acl_link.last_access_time = timezone.now() + acl_link.save(update_fields=['last_access_time']) + + # Create access log + AccessLog.objects.create( + user=acl.user.username, + server="Xray-Subscription", + acl_link_id=acl_link.link, + action="Success", + data=f"Generated subscription with {len(subscription_configs)} configs. Content: {subscription_content[:200]}..." + ) + + logger.info(f"Generated Xray subscription for {acl.user.username} with {len(subscription_configs)} configs") + + # Return with proper headers for subscription + response = HttpResponse(subscription_b64, content_type="text/plain; charset=utf-8") + response['Content-Disposition'] = 'attachment; filename="xray_subscription.txt"' + response['Cache-Control'] = 'no-cache' + return response + + except Exception as e: + logger.error(f"Failed to generate Xray subscription for {acl.user.username}: {e}") + AccessLog.objects.create( + user=acl.user.username, + server="Xray-Subscription", + acl_link_id=acl_link.link, + action="Failed", + data=f"Failed to generate subscription: {e}" + ) + return HttpResponse(f"Error generating subscription: {e}", status=500) + diff --git a/vpn/xray_api/__init__.py b/vpn/xray_api/__init__.py new file mode 100644 index 0000000..1acef3a --- /dev/null +++ b/vpn/xray_api/__init__.py @@ -0,0 +1,23 @@ +""" +Xray Manager - Python library for managing Xray proxy server via gRPC API. + +Supports VLESS, VMess, and Trojan protocols. +""" + +from .client import XrayClient +from .models import User, VlessUser, VmessUser, TrojanUser, Stats +from .exceptions import XrayError, APIError, InboundNotFoundError, UserNotFoundError + +__version__ = "1.0.0" +__all__ = [ + "XrayClient", + "User", + "VlessUser", + "VmessUser", + "TrojanUser", + "Stats", + "XrayError", + "APIError", + "InboundNotFoundError", + "UserNotFoundError" +] \ No newline at end of file diff --git a/vpn/xray_api/client.py b/vpn/xray_api/client.py new file mode 100644 index 0000000..959ffae --- /dev/null +++ b/vpn/xray_api/client.py @@ -0,0 +1,577 @@ +""" +Main Xray client for managing proxy server via gRPC API. +""" + +import json +import logging +import subprocess +from typing import Any, Dict, List, Optional + +from .exceptions import APIError, InboundNotFoundError, UserNotFoundError +from .models import Stats, TrojanUser, User, VlessUser, VmessUser +from .protocols import TrojanProtocol, VlessProtocol, VmessProtocol + +logger = logging.getLogger(__name__) + + +class XrayClient: + """Main client for Xray server management.""" + + def __init__(self, server: str): + """ + Initialize Xray client. + + Args: + server: Xray gRPC API server address (host:port) + """ + self.server = server + self.hostname = server.split(':')[0] # Extract hostname for client links + + # Protocol handlers + self._protocols = {} + + # Inbound management + + def add_vless_inbound(self, port: int, users: List[VlessUser], tag: Optional[str] = None, + listen: str = "0.0.0.0", network: str = "tcp") -> None: + """Add VLESS inbound with users.""" + protocol = VlessProtocol(port, tag, listen, network) + self._protocols[protocol.tag] = protocol + config = protocol.create_inbound_config(users) + self._add_inbound(config) + + def add_vmess_inbound(self, port: int, users: List[VmessUser], tag: Optional[str] = None, + listen: str = "0.0.0.0", network: str = "tcp") -> None: + """Add VMess inbound with users.""" + protocol = VmessProtocol(port, tag, listen, network) + self._protocols[protocol.tag] = protocol + config = protocol.create_inbound_config(users) + self._add_inbound(config) + + def add_trojan_inbound(self, port: int, users: List[TrojanUser], tag: Optional[str] = None, + listen: str = "0.0.0.0", network: str = "tcp", + cert_pem: Optional[str] = None, key_pem: Optional[str] = None, + hostname: Optional[str] = None) -> None: + """Add Trojan inbound with users and optional custom certificates.""" + hostname = hostname or self.hostname + protocol = TrojanProtocol(port, tag, listen, network, cert_pem, key_pem, hostname) + self._protocols[protocol.tag] = protocol + config = protocol.create_inbound_config(users) + self._add_inbound(config) + + + def remove_inbound(self, protocol_type_or_tag: str) -> None: + """ + Remove inbound by protocol type or tag. + + Args: + protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag + """ + # Try to find by protocol type first + tag_map = { + 'vless': 'vless-inbound', + 'vmess': 'vmess-inbound', + 'trojan': 'trojan-inbound' + } + + tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag) + + config = {"tag": tag} + self._remove_inbound(config) + + if tag in self._protocols: + del self._protocols[tag] + + def list_inbounds(self) -> List[Dict[str, Any]]: + """List all inbounds.""" + return self._list_inbounds() + + # User management + + def add_user(self, protocol_type_or_tag: str, user: User) -> None: + """ + Add user to existing inbound by recreating it with updated users. + + Args: + protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag + user: User object matching the protocol type + """ + tag_map = { + 'vless': 'vless-inbound', + 'vmess': 'vmess-inbound', + 'trojan': 'trojan-inbound' + } + + # Check if it's a protocol type or direct tag + tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag) + + # If protocol not registered, we need to get inbound info first + if tag not in self._protocols: + logger.debug(f"Protocol for tag '{tag}' not registered, attempting to retrieve inbound info") + + # Try to get inbound info to determine protocol + try: + inbounds = self._list_inbounds() + if isinstance(inbounds, dict) and 'inbounds' in inbounds: + inbound_list = inbounds['inbounds'] + else: + inbound_list = inbounds if isinstance(inbounds, list) else [] + + # Find the inbound by tag + for inbound in inbound_list: + if inbound.get('tag') == tag: + # Determine protocol from proxySettings + proxy_settings = inbound.get('proxySettings', {}) + typed_message = proxy_settings.get('_TypedMessage_', '') + + if 'vless' in typed_message.lower(): + from .protocols import VlessProtocol + port = inbound.get('receiverSettings', {}).get('portList', 443) + listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0') + network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp') + protocol = VlessProtocol(port, tag, listen, network) + self._protocols[tag] = protocol + elif 'vmess' in typed_message.lower(): + from .protocols import VmessProtocol + port = inbound.get('receiverSettings', {}).get('portList', 443) + listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0') + network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp') + protocol = VmessProtocol(port, tag, listen, network) + self._protocols[tag] = protocol + elif 'trojan' in typed_message.lower(): + from .protocols import TrojanProtocol + port = inbound.get('receiverSettings', {}).get('portList', 443) + listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0') + network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp') + protocol = TrojanProtocol(port, tag, listen, network, hostname=self.hostname) + self._protocols[tag] = protocol + break + except Exception as e: + logger.error(f"Failed to retrieve inbound info for tag '{tag}': {e}") + + if tag not in self._protocols: + raise InboundNotFoundError(f"Inbound {protocol_type_or_tag} not found or could not determine protocol") + + protocol = self._protocols[tag] + + # Use the recreate method since direct API doesn't work reliably + self._recreate_inbound_with_user(protocol, user) + + def remove_user(self, protocol_type_or_tag: str, email: str) -> None: + """ + Remove user from inbound by recreating it without the user. + + Args: + protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag + email: User email to remove + """ + tag_map = { + 'vless': 'vless-inbound', + 'vmess': 'vmess-inbound', + 'trojan': 'trojan-inbound' + } + + tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag) + + # Use same logic as add_user to find/register protocol + if tag not in self._protocols: + logger.debug(f"Protocol for tag '{tag}' not registered, attempting to retrieve inbound info") + + # Try to get inbound info to determine protocol + try: + inbounds = self._list_inbounds() + if isinstance(inbounds, dict) and 'inbounds' in inbounds: + inbound_list = inbounds['inbounds'] + else: + inbound_list = inbounds if isinstance(inbounds, list) else [] + + # Find the inbound by tag + for inbound in inbound_list: + if inbound.get('tag') == tag: + # Determine protocol from proxySettings + proxy_settings = inbound.get('proxySettings', {}) + typed_message = proxy_settings.get('_TypedMessage_', '') + + if 'vless' in typed_message.lower(): + from .protocols import VlessProtocol + port = inbound.get('receiverSettings', {}).get('portList', 443) + listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0') + network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp') + protocol = VlessProtocol(port, tag, listen, network) + self._protocols[tag] = protocol + elif 'vmess' in typed_message.lower(): + from .protocols import VmessProtocol + port = inbound.get('receiverSettings', {}).get('portList', 443) + listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0') + network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp') + protocol = VmessProtocol(port, tag, listen, network) + self._protocols[tag] = protocol + elif 'trojan' in typed_message.lower(): + from .protocols import TrojanProtocol + port = inbound.get('receiverSettings', {}).get('portList', 443) + listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0') + network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp') + protocol = TrojanProtocol(port, tag, listen, network, hostname=self.hostname) + self._protocols[tag] = protocol + break + except Exception as e: + logger.error(f"Failed to retrieve inbound info for tag '{tag}': {e}") + + if tag not in self._protocols: + raise InboundNotFoundError(f"Inbound {protocol_type_or_tag} not found or could not determine protocol") + + protocol = self._protocols[tag] + + # Use the recreate method + self._recreate_inbound_without_user(protocol, email) + + # Client link generation + + def generate_client_link(self, protocol_type_or_tag: str, user: User) -> str: + """ + Generate client connection link. + + Args: + protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag + user: User object + + Returns: + Client connection link (vless://, vmess://, trojan://) + """ + # First try to find by protocol type + tag_map = { + 'vless': 'vless-inbound', + 'vmess': 'vmess-inbound', + 'trojan': 'trojan-inbound' + } + + # Check if it's a protocol type or direct tag + tag = tag_map.get(protocol_type_or_tag) + if tag and tag in self._protocols: + protocol = self._protocols[tag] + elif protocol_type_or_tag in self._protocols: + protocol = self._protocols[protocol_type_or_tag] + else: + # Try to find any protocol matching the type + for stored_tag, stored_protocol in self._protocols.items(): + if protocol_type_or_tag in ['vless', 'vmess', 'trojan']: + protocol_class_name = f"{protocol_type_or_tag.title()}Protocol" + if stored_protocol.__class__.__name__ == protocol_class_name: + protocol = stored_protocol + break + else: + raise InboundNotFoundError(f"Protocol {protocol_type_or_tag} not configured") + + return protocol.generate_client_link(user, self.hostname) + + # Statistics + + def get_server_stats(self) -> Dict[str, Any]: + """Get server system statistics.""" + return self._get_stats_sys() + + def get_user_stats(self, protocol_type: str, email: str) -> Stats: + """ + Get user traffic statistics. + + Args: + protocol_type: Protocol type + email: User email + + Returns: + Stats object with uplink/downlink data + """ + # Implementation would require stats queries + # This is a placeholder for the interface + return Stats(uplink=0, downlink=0) + + # Private API methods + + def _add_inbound(self, config: Dict[str, Any]) -> None: + """Add inbound via API.""" + result = self._run_api_command("adi", stdin_data=json.dumps(config)) + if "error" in result.get("stderr", "").lower(): + raise APIError(f"Failed to add inbound: {result['stderr']}") + + def _remove_inbound(self, config: Dict[str, Any]) -> None: + """Remove inbound via API.""" + tag = config.get("tag") + if tag: + # Use tag directly as argument instead of JSON + result = self._run_api_command("rmi", args=[tag]) + else: + # Fallback to JSON if no tag + result = self._run_api_command("rmi", stdin_data=json.dumps(config)) + + if result["returncode"] != 0 and "no inbound to remove" not in result.get("stderr", ""): + raise APIError(f"Failed to remove inbound: {result['stderr']}") + + def _list_inbounds(self) -> List[Dict[str, Any]]: + """List inbounds via API.""" + result = self._run_api_command("lsi") + if result["returncode"] != 0: + raise APIError(f"Failed to list inbounds: {result['stderr']}") + + try: + return json.loads(result["stdout"]) + except json.JSONDecodeError: + raise APIError("Invalid JSON response from API") + + def _add_user(self, config: Dict[str, Any]) -> None: + """Add user via API.""" + logger.debug(f"Adding user with config: {json.dumps(config, indent=2)}") + result = self._run_api_command("adu", stdin_data=json.dumps(config)) + + if result["returncode"] != 0 or "error" in result.get("stderr", "").lower(): + logger.error(f"Failed to add user. stdout: {result['stdout']}, stderr: {result['stderr']}") + raise APIError(f"Failed to add user: {result['stderr'] or result['stdout']}") + + logger.info(f"User {config.get('email')} added successfully to inbound {config.get('tag')}") + + def _remove_user(self, inbound_tag: str, email: str) -> None: + """Remove user via API.""" + result = self._run_api_command("rmu", args=[f"--email={email}", inbound_tag]) + if "error" in result.get("stderr", "").lower(): + raise APIError(f"Failed to remove user: {result['stderr']}") + + def _get_stats_sys(self) -> Dict[str, Any]: + """Get system stats via API.""" + result = self._run_api_command("statssys") + if result["returncode"] != 0: + raise APIError(f"Failed to get stats: {result['stderr']}") + + try: + return json.loads(result["stdout"]) + except json.JSONDecodeError: + raise APIError("Invalid JSON response from API") + + def _build_user_config(self, tag: str, user: User, protocol) -> Dict[str, Any]: + """ + Build user configuration for Xray API. + + Args: + tag: Inbound tag + user: User object (VlessUser, VmessUser, or TrojanUser) + protocol: Protocol handler + + Returns: + User configuration dict for Xray API + """ + from .models import VlessUser, VmessUser, TrojanUser + + base_config = { + "tag": tag, + "email": user.email + } + + if isinstance(user, VlessUser): + base_config["account"] = { + "_TypedMessage_": "xray.proxy.vless.Account", + "id": user.uuid + } + elif isinstance(user, VmessUser): + base_config["account"] = { + "_TypedMessage_": "xray.proxy.vmess.Account", + "id": user.uuid, + "alterId": getattr(user, 'alter_id', 0) + } + elif isinstance(user, TrojanUser): + base_config["account"] = { + "_TypedMessage_": "xray.proxy.trojan.Account", + "password": user.password + } + else: + raise ValueError(f"Unsupported user type: {type(user)}") + + return base_config + + def _recreate_inbound_without_user(self, protocol, email: str) -> None: + """ + Recreate inbound without specified user. + """ + # Get existing users from the inbound + existing_users = self._get_existing_users(protocol.tag) + + # Filter out the user to remove + all_users = [user for user in existing_users if user.email != email] + + if len(all_users) == len(existing_users): + logger.warning(f"User {email} not found in inbound {protocol.tag}") + return + + # Remove existing inbound + try: + self.remove_inbound(protocol.tag) + except Exception as e: + logger.warning(f"Failed to remove inbound {protocol.tag}: {e}") + + # Recreate inbound with remaining users + if hasattr(protocol, '__class__') and 'Vless' in protocol.__class__.__name__: + self.add_vless_inbound( + port=protocol.port, + users=all_users, + tag=protocol.tag, + listen=protocol.listen, + network=protocol.network + ) + elif hasattr(protocol, '__class__') and 'Vmess' in protocol.__class__.__name__: + self.add_vmess_inbound( + port=protocol.port, + users=all_users, + tag=protocol.tag, + listen=protocol.listen, + network=protocol.network + ) + elif hasattr(protocol, '__class__') and 'Trojan' in protocol.__class__.__name__: + self.add_trojan_inbound( + port=protocol.port, + users=all_users, + tag=protocol.tag, + listen=protocol.listen, + network=protocol.network, + hostname=getattr(protocol, 'hostname', 'localhost') + ) + + def _recreate_inbound_with_user(self, protocol, new_user: User) -> None: + """ + Recreate inbound with existing users plus new user. + This is a workaround since Xray API doesn't support reliable dynamic user addition. + """ + # Get existing users from the inbound + existing_users = self._get_existing_users(protocol.tag) + + # Check if user already exists + for existing_user in existing_users: + if existing_user.email == new_user.email: + return # User already exists, no need to recreate + + # Add new user to existing users list + all_users = existing_users + [new_user] + + # Remove existing inbound + try: + self.remove_inbound(protocol.tag) + except Exception as e: + # If removal fails, log but continue - inbound might not exist + pass + + # Recreate inbound with all users + if hasattr(protocol, '__class__') and 'Vless' in protocol.__class__.__name__: + self.add_vless_inbound( + port=protocol.port, + users=all_users, + tag=protocol.tag, + listen=protocol.listen, + network=protocol.network + ) + elif hasattr(protocol, '__class__') and 'Vmess' in protocol.__class__.__name__: + self.add_vmess_inbound( + port=protocol.port, + users=all_users, + tag=protocol.tag, + listen=protocol.listen, + network=protocol.network + ) + elif hasattr(protocol, '__class__') and 'Trojan' in protocol.__class__.__name__: + self.add_trojan_inbound( + port=protocol.port, + users=all_users, + tag=protocol.tag, + listen=protocol.listen, + network=protocol.network, + hostname=getattr(protocol, 'hostname', 'localhost') + ) + + def _get_existing_users(self, tag: str) -> List[User]: + """ + Get existing users from an inbound. + """ + from .models import VlessUser, VmessUser, TrojanUser + + try: + # Use inbounduser API command to get existing users + result = self._run_api_command("inbounduser", args=[f"-tag={tag}"]) + + if result["returncode"] != 0: + return [] # No users or inbound doesn't exist + + import json + user_data = json.loads(result["stdout"]) + users = [] + + if "users" in user_data: + for user_info in user_data["users"]: + email = user_info.get("email", "") + account = user_info.get("account", {}) + + # Determine protocol based on account type + account_type = account.get("_TypedMessage_", "") + + if "vless" in account_type.lower(): + users.append(VlessUser( + email=email, + uuid=account.get("id", "") + )) + elif "vmess" in account_type.lower(): + users.append(VmessUser( + email=email, + uuid=account.get("id", ""), + alter_id=account.get("alterId", 0) + )) + elif "trojan" in account_type.lower(): + users.append(TrojanUser( + email=email, + password=account.get("password", "") + )) + + return users + + except Exception as e: + # If we can't get existing users, return empty list + return [] + + def _run_api_command(self, command: str, args: List[str] = None, stdin_data: str = None) -> Dict[str, Any]: + """ + Run xray api command. + + Args: + command: API command (adi, rmi, lsi, etc.) + args: Additional command arguments + stdin_data: Data to pass via stdin + + Returns: + Dict with stdout, stderr, returncode + """ + cmd = ["xray", "api", command, f"--server={self.server}"] + if args: + cmd.extend(args) + + logger.debug(f"Running command: {' '.join(cmd)}") + if stdin_data: + logger.debug(f"With stdin data: {stdin_data}") + + try: + result = subprocess.run( + cmd, + input=stdin_data, + text=True, + capture_output=True, + timeout=30 + ) + + logger.debug(f"Command result - returncode: {result.returncode}, stdout: {result.stdout}, stderr: {result.stderr}") + + return { + "stdout": result.stdout, + "stderr": result.stderr, + "returncode": result.returncode + } + except subprocess.TimeoutExpired: + logger.error(f"API command timeout for: {' '.join(cmd)}") + raise APIError("API command timeout") + except FileNotFoundError: + logger.error("xray command not found in PATH") + raise APIError("xray command not found") + except Exception as e: + logger.error(f"Unexpected error running command: {e}") + raise APIError(f"Failed to run command: {e}") \ No newline at end of file diff --git a/vpn/xray_api/exceptions.py b/vpn/xray_api/exceptions.py new file mode 100644 index 0000000..fd1a38f --- /dev/null +++ b/vpn/xray_api/exceptions.py @@ -0,0 +1,33 @@ +""" +Custom exceptions for Xray Manager. +""" + + +class XrayError(Exception): + """Base exception for all Xray-related errors.""" + pass + + +class APIError(XrayError): + """Error occurred during API communication.""" + pass + + +class InboundNotFoundError(XrayError): + """Inbound with specified tag not found.""" + pass + + +class UserNotFoundError(XrayError): + """User with specified email not found.""" + pass + + +class ConfigurationError(XrayError): + """Error in Xray configuration.""" + pass + + +class CertificateError(XrayError): + """Error related to TLS certificates.""" + pass \ No newline at end of file diff --git a/vpn/xray_api/models.py b/vpn/xray_api/models.py new file mode 100644 index 0000000..ceeded1 --- /dev/null +++ b/vpn/xray_api/models.py @@ -0,0 +1,93 @@ +""" +Data models for Xray Manager. +""" + +from dataclasses import dataclass, field +from typing import Optional, Dict, Any +from .utils import generate_uuid + + +@dataclass +class User: + """Base user model.""" + email: str + level: int = 0 + + def to_dict(self) -> Dict[str, Any]: + """Convert user to dictionary representation.""" + return { + "email": self.email, + "level": self.level + } + + +@dataclass +class VlessUser(User): + """VLESS protocol user.""" + uuid: str = field(default_factory=generate_uuid) + + def to_dict(self) -> Dict[str, Any]: + base = super().to_dict() + base.update({ + "id": self.uuid + }) + return base + + +@dataclass +class VmessUser(User): + """VMess protocol user.""" + uuid: str = field(default_factory=generate_uuid) + alter_id: int = 0 + + def to_dict(self) -> Dict[str, Any]: + base = super().to_dict() + base.update({ + "id": self.uuid, + "alterId": self.alter_id + }) + return base + + +@dataclass +class TrojanUser(User): + """Trojan protocol user.""" + password: str = "" + + def to_dict(self) -> Dict[str, Any]: + base = super().to_dict() + base.update({ + "password": self.password + }) + return base + + + + +@dataclass +class Inbound: + """Inbound configuration.""" + tag: str + protocol: str + port: int + listen: str = "0.0.0.0" + + def to_dict(self) -> Dict[str, Any]: + return { + "tag": self.tag, + "protocol": self.protocol, + "port": self.port, + "listen": self.listen + } + + +@dataclass +class Stats: + """Statistics data.""" + uplink: int = 0 + downlink: int = 0 + + @property + def total(self) -> int: + """Total traffic (uplink + downlink).""" + return self.uplink + self.downlink \ No newline at end of file diff --git a/vpn/xray_api/protocols/__init__.py b/vpn/xray_api/protocols/__init__.py new file mode 100644 index 0000000..988493c --- /dev/null +++ b/vpn/xray_api/protocols/__init__.py @@ -0,0 +1,15 @@ +""" +Protocol-specific implementations for Xray Manager. +""" + +from .base import BaseProtocol +from .vless import VlessProtocol +from .vmess import VmessProtocol +from .trojan import TrojanProtocol + +__all__ = [ + "BaseProtocol", + "VlessProtocol", + "VmessProtocol", + "TrojanProtocol" +] \ No newline at end of file diff --git a/vpn/xray_api/protocols/base.py b/vpn/xray_api/protocols/base.py new file mode 100644 index 0000000..cd68a43 --- /dev/null +++ b/vpn/xray_api/protocols/base.py @@ -0,0 +1,45 @@ +""" +Base protocol implementation. +""" + +from abc import ABC, abstractmethod +from typing import List, Dict, Any, Optional +from ..models import User + + +class BaseProtocol(ABC): + """Base class for all protocol implementations.""" + + def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"): + self.port = port + self.tag = tag or self._default_tag() + self.listen = listen + self.network = network + + @abstractmethod + def _default_tag(self) -> str: + """Return default tag for this protocol.""" + pass + + @abstractmethod + def create_inbound_config(self, users: List[User]) -> Dict[str, Any]: + """Create inbound configuration for this protocol.""" + pass + + @abstractmethod + def create_user_config(self, user: User) -> Dict[str, Any]: + """Create user configuration for adding to existing inbound.""" + pass + + @abstractmethod + def generate_client_link(self, user: User, hostname: str) -> str: + """Generate client connection link.""" + pass + + def _base_inbound_config(self) -> Dict[str, Any]: + """Common inbound configuration.""" + return { + "listen": self.listen, + "port": self.port, + "tag": self.tag + } \ No newline at end of file diff --git a/vpn/xray_api/protocols/trojan.py b/vpn/xray_api/protocols/trojan.py new file mode 100644 index 0000000..f4e97a2 --- /dev/null +++ b/vpn/xray_api/protocols/trojan.py @@ -0,0 +1,80 @@ +""" +Trojan protocol implementation. +""" + +from typing import List, Dict, Any, Optional +from .base import BaseProtocol +from ..models import User, TrojanUser +from ..utils import generate_self_signed_cert, pem_to_lines +from ..exceptions import CertificateError + + +class TrojanProtocol(BaseProtocol): + """Trojan protocol handler.""" + + def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", + network: str = "tcp", cert_pem: Optional[str] = None, + key_pem: Optional[str] = None, hostname: str = "localhost"): + super().__init__(port, tag, listen, network) + self.hostname = hostname + + if cert_pem and key_pem: + self.cert_pem = cert_pem + self.key_pem = key_pem + else: + # Generate self-signed certificate + self.cert_pem, self.key_pem = generate_self_signed_cert(hostname) + + def _default_tag(self) -> str: + return "trojan-inbound" + + def create_inbound_config(self, users: List[TrojanUser]) -> Dict[str, Any]: + """Create Trojan inbound configuration.""" + config = self._base_inbound_config() + config.update({ + "protocol": "trojan", + "settings": { + "_TypedMessage_": "xray.proxy.trojan.Config", + "clients": [self._user_to_client(user) for user in users], + "fallbacks": [{"dest": 80}] + }, + "streamSettings": { + "network": self.network, + "security": "tls", + "tlsSettings": { + "alpn": ["http/1.1"], + "certificates": [{ + "certificate": pem_to_lines(self.cert_pem), + "key": pem_to_lines(self.key_pem), + "usage": "encipherment" + }] + } + } + }) + return {"inbounds": [config]} + + def create_user_config(self, user: TrojanUser) -> Dict[str, Any]: + """Create user configuration for Trojan.""" + return { + "inboundTag": self.tag, + "proxySettings": { + "_TypedMessage_": "xray.proxy.trojan.Config", + "clients": [self._user_to_client(user)] + } + } + + def generate_client_link(self, user: TrojanUser, hostname: str) -> str: + """Generate Trojan client link.""" + return f"trojan://{user.password}@{hostname}:{self.port}#{user.email}" + + def get_client_note(self) -> str: + """Get note for client configuration when using self-signed certificates.""" + return "Add 'allowInsecure: true' to TLS settings for self-signed certificates" + + def _user_to_client(self, user: TrojanUser) -> Dict[str, Any]: + """Convert TrojanUser to client configuration.""" + return { + "password": user.password, + "level": user.level, + "email": user.email + } \ No newline at end of file diff --git a/vpn/xray_api/protocols/vless.py b/vpn/xray_api/protocols/vless.py new file mode 100644 index 0000000..5a8e70f --- /dev/null +++ b/vpn/xray_api/protocols/vless.py @@ -0,0 +1,55 @@ +""" +VLESS protocol implementation. +""" + +from typing import List, Dict, Any, Optional +from .base import BaseProtocol +from ..models import User, VlessUser + + +class VlessProtocol(BaseProtocol): + """VLESS protocol handler.""" + + def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"): + super().__init__(port, tag, listen, network) + + def _default_tag(self) -> str: + return "vless-inbound" + + def create_inbound_config(self, users: List[VlessUser]) -> Dict[str, Any]: + """Create VLESS inbound configuration.""" + config = self._base_inbound_config() + config.update({ + "protocol": "vless", + "settings": { + "_TypedMessage_": "xray.proxy.vless.inbound.Config", + "clients": [self._user_to_client(user) for user in users], + "decryption": "none" + }, + "streamSettings": { + "network": self.network + } + }) + return {"inbounds": [config]} + + def create_user_config(self, user: VlessUser) -> Dict[str, Any]: + """Create user configuration for VLESS.""" + return { + "inboundTag": self.tag, + "proxySettings": { + "_TypedMessage_": "xray.proxy.vless.inbound.Config", + "clients": [self._user_to_client(user)] + } + } + + def generate_client_link(self, user: VlessUser, hostname: str) -> str: + """Generate VLESS client link.""" + return f"vless://{user.uuid}@{hostname}:{self.port}?encryption=none&type={self.network}#{user.email}" + + def _user_to_client(self, user: VlessUser) -> Dict[str, Any]: + """Convert VlessUser to client configuration.""" + return { + "id": user.uuid, + "level": user.level, + "email": user.email + } \ No newline at end of file diff --git a/vpn/xray_api/protocols/vmess.py b/vpn/xray_api/protocols/vmess.py new file mode 100644 index 0000000..6cf1b6f --- /dev/null +++ b/vpn/xray_api/protocols/vmess.py @@ -0,0 +1,73 @@ +""" +VMess protocol implementation. +""" + +import json +import base64 +from typing import List, Dict, Any, Optional +from .base import BaseProtocol +from ..models import User, VmessUser + + +class VmessProtocol(BaseProtocol): + """VMess protocol handler.""" + + def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"): + super().__init__(port, tag, listen, network) + + def _default_tag(self) -> str: + return "vmess-inbound" + + def create_inbound_config(self, users: List[VmessUser]) -> Dict[str, Any]: + """Create VMess inbound configuration.""" + config = self._base_inbound_config() + config.update({ + "protocol": "vmess", + "settings": { + "_TypedMessage_": "xray.proxy.vmess.inbound.Config", + "clients": [self._user_to_client(user) for user in users] + }, + "streamSettings": { + "network": self.network + } + }) + return {"inbounds": [config]} + + def create_user_config(self, user: VmessUser) -> Dict[str, Any]: + """Create user configuration for VMess.""" + return { + "inboundTag": self.tag, + "proxySettings": { + "_TypedMessage_": "xray.proxy.vmess.inbound.Config", + "clients": [self._user_to_client(user)] + } + } + + def generate_client_link(self, user: VmessUser, hostname: str) -> str: + """Generate VMess client link.""" + config = { + "v": "2", + "ps": user.email, + "add": hostname, + "port": str(self.port), + "id": user.uuid, + "aid": str(user.alter_id), + "net": self.network, + "type": "none", + "host": "", + "path": "", + "tls": "" + } + + config_json = json.dumps(config, separators=(',', ':')) + encoded = base64.b64encode(config_json.encode()).decode() + return f"vmess://{encoded}" + + def _user_to_client(self, user: VmessUser) -> Dict[str, Any]: + """Convert VmessUser to client configuration.""" + return { + "id": user.uuid, + "alterId": user.alter_id, + "level": user.level, + "email": user.email + } \ No newline at end of file diff --git a/vpn/xray_api/utils.py b/vpn/xray_api/utils.py new file mode 100644 index 0000000..cdbc797 --- /dev/null +++ b/vpn/xray_api/utils.py @@ -0,0 +1,77 @@ +""" +Utility functions for Xray Manager. +""" + +import uuid +import base64 +import secrets +from typing import List +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +import datetime + + +def generate_uuid() -> str: + """Generate a random UUID for VLESS/VMess users.""" + return str(uuid.uuid4()) + + + + +def generate_self_signed_cert(hostname: str = "localhost") -> tuple[str, str]: + """ + Generate self-signed certificate for Trojan. + + Args: + hostname: Common name for certificate + + Returns: + Tuple of (certificate_pem, private_key_pem) + """ + # Generate private key + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048 + ) + + # Create certificate + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute(NameOID.COMMON_NAME, hostname), + ]) + + cert = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.datetime.utcnow() + ).not_valid_after( + datetime.datetime.utcnow() + datetime.timedelta(days=365) + ).add_extension( + x509.SubjectAlternativeName([ + x509.DNSName(hostname), + ]), + critical=False, + ).sign(private_key, hashes.SHA256()) + + # Convert to PEM format + cert_pem = cert.public_bytes(serialization.Encoding.PEM) + key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + + return cert_pem.decode(), key_pem.decode() + + +def pem_to_lines(pem_data: str) -> List[str]: + """Convert PEM data to list of lines for Xray JSON format.""" + return pem_data.strip().split('\n') \ No newline at end of file