diff --git a/mysite/urls.py b/mysite/urls.py index 9569f1d..b6f6c0a 100644 --- a/mysite/urls.py +++ b/mysite/urls.py @@ -23,7 +23,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('ss/', shadowsocks, name='shadowsocks'), path('dynamic/', shadowsocks, name='shadowsocks'), - path('xray/', xray_subscription, name='xray_subscription'), + 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 a38331e..0e94a80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,6 @@ psycopg2-binary==2.9.10 setuptools==75.2.0 shortuuid==1.0.13 cryptography==45.0.5 +acme>=2.0.0 +cloudflare>=4.3.1 +josepy>=2.0.0 diff --git a/vpn/admin.py b/vpn/admin.py index 5058aa2..d8db717 100644 --- a/vpn/admin.py +++ b/vpn/admin.py @@ -25,10 +25,13 @@ from .server_plugins import ( WireguardServerAdmin, OutlineServer, OutlineServerAdmin, - XrayCoreServer, - XrayCoreServerAdmin, - XrayInbound, - XrayClient) + XrayServerV2, + XrayServerV2Admin) + +# Import new Xray admin configuration +from .admin_xray import add_subscription_management_to_user + +# This will be registered at the end of the file @admin.register(TaskExecutionLog) @@ -250,11 +253,11 @@ class LastAccessFilter(admin.SimpleListFilter): @admin.register(Server) class ServerAdmin(PolymorphicParentModelAdmin): base_model = Server - child_models = (OutlineServer, WireguardServer, XrayCoreServer) + child_models = (OutlineServer, WireguardServer, XrayServerV2) list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'server_status_compact', 'registration_date') search_fields = ('name', 'comment') list_filter = ('server_type', ) - actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers'] + actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers', 'sync_xray_inbounds', 'check_status'] class Media: css = { @@ -495,7 +498,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 + # Old xray_core module removed - skip this server type if isinstance(real_server, OutlineServer): try: @@ -524,41 +527,54 @@ class ServerAdmin(PolymorphicParentModelAdmin): 'message': f'Connection error: {str(e)[:100]}' }) - elif isinstance(real_server, XrayCoreServer): + elif isinstance(real_server, XrayServerV2): try: - logger.info(f"Checking Xray server: {server.name}") - # Try to get server status from Xray + logger.info(f"Checking Xray v2 server: {server.name}") + # Get server status from new Xray implementation 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"]}' + if status.get('accessible', False): + message = f'✅ Server is {status.get("status", "accessible")}. ' + message += f'Host: {status.get("client_hostname", "N/A")}, ' + message += f'API: {status.get("api_address", "N/A")}' - logger.info(f"Xray server {server.name} is online: {message}") + if status.get('api_connected'): + message += ' (Connected)' + # Add stats if available + api_stats = status.get('api_stats', {}) + if api_stats and isinstance(api_stats, dict): + if 'connection' in api_stats: + message += f', Stats: {api_stats.get("connection", "ok")}' + if api_stats.get('library') == 'not_available': + message += ' [Basic check only]' + elif status.get('api_error'): + message += f' ({status.get("api_error")})' + + message += f', Inbounds: {status.get("total_inbounds", 0)}' + + logger.info(f"Xray v2 server {server.name} status: {message}") return JsonResponse({ 'success': True, 'status': 'online', 'message': message }) else: - logger.warning(f"Xray server {server.name} returned status: {status}") + error_msg = status.get('error') or status.get('api_error', 'Unknown error') + logger.warning(f"Xray v2 server {server.name} not accessible: {error_msg}") return JsonResponse({ 'success': True, 'status': 'offline', - 'message': f'Server status: {status.get("message", "Unknown error")}' + 'message': f'❌ Server not accessible: {error_msg}' }) else: - logger.warning(f"Xray server {server.name} returned no status") + logger.warning(f"Xray v2 server {server.name} returned invalid status") return JsonResponse({ 'success': True, 'status': 'offline', - 'message': 'Server not responding' + 'message': 'Invalid server response' }) except Exception as e: - logger.error(f"Error checking Xray server {server.name}: {e}") + logger.error(f"Error checking Xray v2 server {server.name}: {e}") return JsonResponse({ 'success': True, 'status': 'error', @@ -723,6 +739,38 @@ class ServerAdmin(PolymorphicParentModelAdmin): ) sync_all_selected_servers.short_description = "🔄 Sync all users on selected servers" + + def check_status(self, request, queryset): + """Check status for selected servers""" + for server in queryset: + try: + status = server.get_server_status() + msg = f"{server.name}: {status.get('accessible', 'Unknown')} - {status.get('status', 'N/A')}" + self.message_user(request, msg, level=messages.INFO) + except Exception as e: + self.message_user(request, f"Error checking {server.name}: {e}", level=messages.ERROR) + check_status.short_description = "📊 Check server status" + + def sync_xray_inbounds(self, request, queryset): + """Sync inbounds for selected servers (Xray v2 only)""" + from .server_plugins.xray_v2 import XrayServerV2 + synced_count = 0 + + for server in queryset: + try: + real_server = server.get_real_instance() + if isinstance(real_server, XrayServerV2): + real_server.sync_inbounds() + synced_count += 1 + self.message_user(request, f"Scheduled inbound sync for {server.name}", level=messages.SUCCESS) + else: + self.message_user(request, f"{server.name} is not an Xray v2 server", level=messages.WARNING) + except Exception as e: + self.message_user(request, f"Error syncing inbounds for {server.name}: {e}", level=messages.ERROR) + + if synced_count > 0: + self.message_user(request, f"Scheduled inbound sync for {synced_count} server(s)", level=messages.SUCCESS) + sync_xray_inbounds.short_description = "🔧 Sync Xray inbounds" @admin.display(description='Server', ordering='name') def name_with_icon(self, obj): @@ -731,6 +779,7 @@ class ServerAdmin(PolymorphicParentModelAdmin): 'outline': 'đŸ”ĩ', 'wireguard': 'đŸŸĸ', 'xray_core': 'đŸŸŖ', + 'xray_v2': '🟡', } icon = icons.get(obj.server_type, '') name_part = f"{icon} {obj.name}" if icon else obj.name @@ -859,15 +908,15 @@ class ServerAdmin(PolymorphicParentModelAdmin): """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 + # XrayCoreServer removed - using XrayServerV2 now 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/') + # Handle XrayServerV2 + if isinstance(real_server, XrayServerV2): + return redirect(f'/admin/vpn/xrayserverv2/{real_server.pk}/sync/') # Fallback for other server types else: @@ -1834,3 +1883,13 @@ try: except ImportError: pass + +# Register XrayServerV2 admin +admin.site.register(XrayServerV2, XrayServerV2Admin) + +# Add subscription management to User admin +from django.contrib.admin import site +for model, admin_instance in site._registry.items(): + if model.__name__ == 'User' and hasattr(admin_instance, 'fieldsets'): + add_subscription_management_to_user(admin_instance.__class__) + break diff --git a/vpn/admin_xray.py b/vpn/admin_xray.py new file mode 100644 index 0000000..95b2643 --- /dev/null +++ b/vpn/admin_xray.py @@ -0,0 +1,595 @@ +""" +Admin interface for new Xray models. +""" + +import json +from django.contrib import admin, messages +from django.utils.safestring import mark_safe +from django.utils.html import format_html +from django.db import models +from django.forms import CheckboxSelectMultiple, Textarea +from django.shortcuts import render, redirect +from django.urls import path, reverse +from django.http import JsonResponse, HttpResponseRedirect + +from .models_xray import ( + XrayConfiguration, Credentials, Certificate, + Inbound, SubscriptionGroup, UserSubscription, ServerInbound +) + + +@admin.register(XrayConfiguration) +class XrayConfigurationAdmin(admin.ModelAdmin): + """Admin for global Xray configuration""" + list_display = ('grpc_address', 'default_client_hostname', 'stats_enabled', 'cert_renewal_days', 'updated_at') + fields = ( + 'grpc_address', 'default_client_hostname', + 'stats_enabled', 'cert_renewal_days', + 'created_at', 'updated_at' + ) + readonly_fields = ('created_at', 'updated_at') + + def has_add_permission(self, request): + # Only allow one configuration + return not XrayConfiguration.objects.exists() + + def has_delete_permission(self, request, obj=None): + return False + + +@admin.register(Credentials) +class CredentialsAdmin(admin.ModelAdmin): + """Admin for credentials management""" + list_display = ('name', 'cred_type', 'description', 'created_at') + list_filter = ('cred_type',) + search_fields = ('name', 'description') + + fieldsets = ( + ('Basic Information', { + 'fields': ('name', 'cred_type', 'description') + }), + ('Credentials Data', { + 'fields': ('credentials_help', 'credentials'), + 'description': 'Enter credentials as JSON. Example: {"api_token": "your_token", "email": "your_email"}' + }), + ('Preview', { + 'fields': ('credentials_display',), + 'classes': ('collapse',), + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }) + ) + + readonly_fields = ('credentials_display', 'credentials_help', 'created_at', 'updated_at') + + # Add JSON widget for better formatting + formfield_overrides = { + models.JSONField: {'widget': Textarea(attrs={'rows': 10, 'cols': 80, 'class': 'vLargeTextField'})}, + } + + def credentials_help(self, obj): + """Help text and examples for credentials field""" + examples = { + 'cloudflare': { + 'api_token': 'your_cloudflare_api_token', + 'email': 'your_email@example.com' + }, + 'dns_provider': { + 'api_key': 'your_dns_api_key', + 'secret': 'your_secret' + }, + 'email': { + 'smtp_host': 'smtp.example.com', + 'smtp_port': 587, + 'username': 'your_email', + 'password': 'your_password' + } + } + + html = '
' + html += '

JSON Examples:

' + + for cred_type, example in examples.items(): + html += '
' + html += '' + cred_type.title() + ':' + json_str = json.dumps(example, indent=2) + html += '
' + json_str + '
' + html += '
' + + html += '

Note: Make sure your JSON is valid. Use double quotes for strings.

' + html += '
' + + return mark_safe(html) + credentials_help.short_description = 'Credentials Format Help' + + def credentials_display(self, obj): + """Display credentials in a safe format""" + if obj.credentials: + # Hide sensitive values + safe_creds = {} + for key, value in obj.credentials.items(): + if any(sensitive in key.lower() for sensitive in ['token', 'key', 'password', 'secret']): + safe_creds[key] = '*' * 8 + else: + safe_creds[key] = value + + return format_html( + '
{}
', + json.dumps(safe_creds, indent=2) + ) + return '-' + credentials_display.short_description = 'Credentials (Preview)' + + +@admin.register(Certificate) +class CertificateAdmin(admin.ModelAdmin): + """Admin for certificate management""" + list_display = ( + 'domain', 'cert_type', 'status_display', + 'expires_at', 'auto_renew', 'action_buttons' + ) + list_filter = ('cert_type', 'auto_renew') + search_fields = ('domain',) + + fieldsets = ( + ('Certificate Request', { + 'fields': ('domain', 'cert_type', 'acme_email', 'credentials', 'auto_renew'), + 'description': 'For Let\'s Encrypt certificates, provide email for ACME registration and select credentials with Cloudflare API token.' + }), + ('Certificate Status', { + 'fields': ('generation_help', 'status_display', 'expires_at'), + 'classes': ('wide',) + }), + ('Certificate Data', { + 'fields': ('certificate_preview', 'certificate_pem', 'private_key_pem'), + 'classes': ('collapse',), + 'description': 'Certificate data (auto-generated for Let\'s Encrypt)' + }), + ('Renewal Settings', { + 'fields': ('last_renewed',), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }) + ) + + readonly_fields = ( + 'certificate_preview', 'status_display', 'generation_help', + 'expires_at', 'last_renewed', 'created_at', 'updated_at' + ) + + def generation_help(self, obj): + """Show help text for certificate generation""" + if not obj.pk: + return mark_safe('
' + '

How it works:

' + '
    ' + '
  1. Fill in the domain name
  2. ' + '
  3. Select certificate type (Let\'s Encrypt recommended)
  4. ' + '
  5. For Let\'s Encrypt: provide email for ACME account registration
  6. ' + '
  7. Select credentials with Cloudflare API token
  8. ' + '
  9. Save - certificate will be generated automatically
  10. ' + '
' + '
') + + if obj.cert_type == 'letsencrypt' and not obj.certificate_pem: + return mark_safe('
' + '

âŗ Certificate not generated yet

' + '

Certificate will be generated automatically using Let\'s Encrypt DNS-01 challenge.

' + '
') + + if obj.certificate_pem: + days = obj.days_until_expiration if obj.days_until_expiration is not None else 'Unknown' + return mark_safe('
' + '

✅ Certificate generated successfully

' + f'

Expires: {obj.expires_at}

' + f'

Days remaining: {days}

' + '
') + + return '-' + generation_help.short_description = 'Certificate Generation Status' + + def status_display(self, obj): + """Display certificate status""" + if obj.is_expired: + return format_html( + '❌ Expired' + ) + elif obj.needs_renewal: + return format_html( + 'âš ī¸ Needs renewal ({} days)', + obj.days_until_expiration + ) + else: + return format_html( + '✅ Valid ({} days)', + obj.days_until_expiration + ) + status_display.short_description = 'Status' + + def certificate_preview(self, obj): + """Preview certificate info""" + if obj.certificate_pem: + lines = obj.certificate_pem.strip().split('\n') + preview = '\n'.join(lines[:5] + ['...'] + lines[-3:]) + return format_html( + '
{}
', + preview + ) + return '-' + certificate_preview.short_description = 'Certificate Preview' + + def action_buttons(self, obj): + """Action buttons for certificate""" + buttons = [] + + if obj.needs_renewal and obj.auto_renew: + renew_url = reverse('admin:certificate_renew', args=[obj.pk]) + buttons.append( + f'🔄 Renew Now' + ) + + if obj.cert_type == 'self_signed': + regenerate_url = reverse('admin:certificate_regenerate', args=[obj.pk]) + buttons.append( + f'🔄 Regenerate' + ) + + return format_html(' '.join(buttons)) if buttons else '-' + action_buttons.short_description = 'Actions' + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('/renew/', + self.admin_site.admin_view(self.renew_certificate_view), + name='certificate_renew'), + path('/regenerate/', + self.admin_site.admin_view(self.regenerate_certificate_view), + name='certificate_regenerate'), + ] + return custom_urls + urls + + def renew_certificate_view(self, request, cert_id): + """Renew Let's Encrypt certificate""" + try: + cert = Certificate.objects.get(pk=cert_id) + # TODO: Implement renewal logic + messages.success(request, f'Certificate for {cert.domain} renewed successfully!') + except Exception as e: + messages.error(request, f'Failed to renew certificate: {e}') + + return redirect('admin:vpn_certificate_change', cert_id) + + def regenerate_certificate_view(self, request, cert_id): + """Regenerate self-signed certificate""" + try: + cert = Certificate.objects.get(pk=cert_id) + # TODO: Implement regeneration logic + messages.success(request, f'Certificate for {cert.domain} regenerated successfully!') + except Exception as e: + messages.error(request, f'Failed to regenerate certificate: {e}') + + return redirect('admin:vpn_certificate_change', cert_id) + + def save_model(self, request, obj, form, change): + """Auto-generate certificate for Let's Encrypt after saving""" + super().save_model(request, obj, form, change) + + # Auto-generate Let's Encrypt certificate if needed + if obj.cert_type == 'letsencrypt' and not obj.certificate_pem: + try: + self.generate_letsencrypt_certificate(obj, request) + except Exception as e: + messages.warning(request, f'Certificate saved but auto-generation failed: {e}') + + def generate_letsencrypt_certificate(self, cert_obj, request): + """Generate Let's Encrypt certificate using DNS-01 challenge""" + if not cert_obj.credentials: + messages.error(request, 'Credentials required for Let\'s Encrypt certificate generation') + return + + if not cert_obj.acme_email: + messages.error(request, 'ACME email address required for Let\'s Encrypt certificate generation') + return + + try: + from vpn.letsencrypt.letsencrypt_dns import get_certificate_for_domain + from datetime import datetime, timedelta + from django.utils import timezone + + # Get Cloudflare credentials + api_token = cert_obj.credentials.get_credential('api_token') + + if not api_token: + messages.error(request, 'Cloudflare API token not found in credentials') + return + + messages.info(request, f'🔄 Generating Let\'s Encrypt certificate for {cert_obj.domain} using {cert_obj.acme_email}...') + + # Schedule certificate generation via Celery + from vpn.tasks import generate_certificate_task + task = generate_certificate_task.delay(cert_obj.id) + + messages.success( + request, + f'🔄 Certificate generation scheduled for {cert_obj.domain}. Task ID: {task.id}' + ) + + except ImportError: + messages.warning(request, 'Let\'s Encrypt DNS challenge library not available') + except Exception as e: + messages.error(request, f'Failed to generate certificate: {str(e)}') + # Log the full error for debugging + import logging + logger = logging.getLogger(__name__) + logger.error(f'Certificate generation failed for {cert_obj.domain}: {e}', exc_info=True) + + +@admin.register(Inbound) +class InboundAdmin(admin.ModelAdmin): + """Admin for inbound management""" + list_display = ( + 'name', 'protocol', 'port', 'network', + 'security', 'certificate_status', 'group_count' + ) + list_filter = ('protocol', 'network', 'security') + search_fields = ('name', 'domain') + + fieldsets = ( + ('Basic Configuration', { + 'fields': ('name', 'protocol', 'port', 'domain') + }), + ('Transport & Security', { + 'fields': ('network', 'security', 'certificate', 'listen_address') + }), + ('Advanced Settings', { + 'fields': ('enable_sniffing', 'full_config_display'), + 'classes': ('collapse',), + 'description': 'Configuration is auto-generated based on basic settings above' + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }) + ) + + readonly_fields = ('full_config_display', 'created_at', 'updated_at') + + def certificate_status(self, obj): + """Display certificate status""" + if obj.security == 'tls': + if obj.certificate: + if obj.certificate.is_expired: + return format_html('❌ Expired') + else: + return format_html('✅ Valid') + else: + return format_html('âš ī¸ No cert') + return format_html('-') + certificate_status.short_description = 'Cert Status' + + def group_count(self, obj): + """Number of groups this inbound belongs to""" + return obj.subscriptiongroup_set.count() + group_count.short_description = 'Groups' + + def full_config_display(self, obj): + """Display full config in formatted JSON""" + if obj.full_config: + return format_html( + '
{}
', + json.dumps(obj.full_config, indent=2) + ) + return 'Not generated yet' + full_config_display.short_description = 'Configuration Preview' + + def save_model(self, request, obj, form, change): + """Generate config on save""" + try: + # Always regenerate config to reflect any changes + obj.build_config() + messages.success(request, f'✅ Configuration generated successfully for {obj.protocol.upper()} inbound on port {obj.port}') + except Exception as e: + messages.warning(request, f'Inbound saved but config generation failed: {e}') + # Set empty dict if generation fails + if not obj.full_config: + obj.full_config = {} + + super().save_model(request, obj, form, change) + + +class InboundInline(admin.TabularInline): + """Inline for inbounds in subscription groups""" + model = SubscriptionGroup.inbounds.through + extra = 1 + verbose_name = "Inbound" + verbose_name_plural = "Inbounds in this group" + + +@admin.register(SubscriptionGroup) +class SubscriptionGroupAdmin(admin.ModelAdmin): + """Admin for subscription groups""" + list_display = ('name', 'is_active', 'inbound_count', 'user_count', 'created_at') + list_filter = ('is_active',) + search_fields = ('name', 'description') + filter_horizontal = ('inbounds',) + + fieldsets = ( + ('Group Information', { + 'fields': ('name', 'description', 'is_active') + }), + ('Inbounds', { + 'fields': ('inbounds',), + 'description': 'Select inbounds to include in this group' + }), + ('Statistics', { + 'fields': ('group_statistics',), + 'classes': ('collapse',) + }) + ) + + readonly_fields = ('group_statistics',) + + def group_statistics(self, obj): + """Display group statistics""" + if obj.pk: + stats = { + 'Total Inbounds': obj.inbound_count, + 'Active Users': obj.user_count, + 'Protocols': list(obj.inbounds.values_list('protocol', flat=True).distinct()), + 'Ports': list(obj.inbounds.values_list('port', flat=True).distinct()) + } + + html = '
' + for key, value in stats.items(): + if isinstance(value, list): + value = ', '.join(map(str, value)) + html += f'
{key}: {value}
' + html += '
' + + return format_html(html) + return 'Save to see statistics' + group_statistics.short_description = 'Group Statistics' + + +class UserSubscriptionInline(admin.TabularInline): + """Inline for user subscriptions""" + model = UserSubscription + extra = 0 + fields = ('subscription_group', 'active', 'created_at') + readonly_fields = ('created_at',) + verbose_name = "Subscription Group" + verbose_name_plural = "User's Subscription Groups" + + +# Extension for User admin +def add_subscription_management_to_user(UserAdmin): + """Add subscription management to existing User admin""" + + # Add inline + if hasattr(UserAdmin, 'inlines'): + UserAdmin.inlines = list(UserAdmin.inlines) + [UserSubscriptionInline] + else: + UserAdmin.inlines = [UserSubscriptionInline] + + # Add custom fields to fieldsets + original_fieldsets = list(UserAdmin.fieldsets) + + # Find where to insert our fieldset + insert_index = len(original_fieldsets) + for i, (title, fields_dict) in enumerate(original_fieldsets): + if title and 'Statistics' in title: + insert_index = i + 1 + break + + # Insert our fieldset + subscription_fieldset = ( + 'Xray Subscriptions', { + 'fields': ('subscription_groups_widget',), + 'classes': ('wide',) + } + ) + original_fieldsets.insert(insert_index, subscription_fieldset) + UserAdmin.fieldsets = tuple(original_fieldsets) + + # Add readonly field + if hasattr(UserAdmin, 'readonly_fields'): + UserAdmin.readonly_fields = list(UserAdmin.readonly_fields) + ['subscription_groups_widget'] + else: + UserAdmin.readonly_fields = ['subscription_groups_widget'] + + # Add method for displaying subscription groups + def subscription_groups_widget(self, obj): + """Display subscription groups management widget""" + if not obj or not obj.pk: + return mark_safe('
Save user first to manage subscriptions
') + + # Get all groups and user's current subscriptions + all_groups = SubscriptionGroup.objects.filter(is_active=True) + user_groups = obj.xray_subscriptions.filter(active=True).values_list('subscription_group_id', flat=True) + + html = '
' + html += '

Available Subscription Groups:

' + + if all_groups: + html += '
' + for group in all_groups: + checked = 'checked' if group.id in user_groups else '' + status = '✅' if group.id in user_groups else 'âŦœ' + + html += f''' +
+ {status} + +
+ ''' + html += '
' + html += '
' + html += 'â„šī¸ Use the inline form below to manage subscriptions' + html += '
' + else: + html += '
No active subscription groups available
' + + html += '
' + return mark_safe(html) + + subscription_groups_widget.short_description = 'Subscription Groups Overview' + UserAdmin.subscription_groups_widget = subscription_groups_widget + + +# Register admin for UserSubscription (if needed separately) +@admin.register(UserSubscription) +class UserSubscriptionAdmin(admin.ModelAdmin): + """Standalone admin for user subscriptions""" + list_display = ('user', 'subscription_group', 'active', 'created_at') + list_filter = ('active', 'subscription_group') + search_fields = ('user__username', 'subscription_group__name') + date_hierarchy = 'created_at' + + def has_add_permission(self, request): + # Prefer managing through User admin + return False + + +@admin.register(ServerInbound) +class ServerInboundAdmin(admin.ModelAdmin): + """Admin for server-inbound deployment tracking""" + list_display = ('server', 'inbound', 'active', 'deployed_at', 'updated_at') + list_filter = ('active', 'inbound__protocol', 'deployed_at') + search_fields = ('server__name', 'inbound__name') + date_hierarchy = 'deployed_at' + + fieldsets = ( + ('Deployment', { + 'fields': ('server', 'inbound', 'active') + }), + ('Configuration', { + 'fields': ('deployment_config_display', 'deployment_config'), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('deployed_at', 'updated_at'), + 'classes': ('collapse',) + }) + ) + + readonly_fields = ('deployment_config_display', 'deployed_at', 'updated_at') + + def deployment_config_display(self, obj): + """Display deployment config in formatted JSON""" + if obj.deployment_config: + return format_html( + '
{}
', + json.dumps(obj.deployment_config, indent=2) + ) + return 'No additional deployment configuration' + deployment_config_display.short_description = 'Deployment Config Preview' \ No newline at end of file diff --git a/vpn/letsencrypt/__init__.py b/vpn/letsencrypt/__init__.py new file mode 100644 index 0000000..d47fd53 --- /dev/null +++ b/vpn/letsencrypt/__init__.py @@ -0,0 +1,13 @@ +"""Let's Encrypt DNS Challenge Library for OutFleet""" + +from .letsencrypt_dns import ( + AcmeDnsChallenge, + get_certificate, + get_certificate_for_domain +) + +__all__ = [ + 'AcmeDnsChallenge', + 'get_certificate', + 'get_certificate_for_domain' +] \ No newline at end of file diff --git a/vpn/letsencrypt/letsencrypt_dns.py b/vpn/letsencrypt/letsencrypt_dns.py new file mode 100644 index 0000000..17e40ff --- /dev/null +++ b/vpn/letsencrypt/letsencrypt_dns.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +""" +Let's Encrypt/ZeroSSL Certificate Library with Cloudflare DNS Challenge +Generate publicly trusted SSL certificates using ACME DNS-01 challenge +""" + +import time +import logging +from typing import List, Tuple + +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from acme import client, messages, challenges, errors +from acme.client import ClientV2 +import josepy as jose +from cloudflare import Cloudflare + +logger = logging.getLogger(__name__) + +class AcmeDnsChallenge: + """ACME DNS-01 Challenge handler with Cloudflare API""" + + def __init__(self, cloudflare_token: str, acme_directory: str = None): + """ + Initialize ACME DNS challenge handler + + Args: + cloudflare_token: Cloudflare API token with DNS edit permissions + acme_directory: ACME directory URL (defaults to Let's Encrypt production) + """ + self.cf_token = cloudflare_token + self.cf = Cloudflare(api_token=cloudflare_token) + + # ACME directory URLs + self.acme_directories = { + 'letsencrypt': 'https://acme-v02.api.letsencrypt.org/directory', + 'letsencrypt_staging': 'https://acme-staging-v02.api.letsencrypt.org/directory', + 'zerossl': 'https://acme.zerossl.com/v2/DV90' + } + + self.acme_directory = acme_directory or self.acme_directories['letsencrypt'] + self.acme_client = None + self.account_key = None + + def _generate_account_key(self) -> jose.JWKRSA: + """Generate RSA private key for ACME account""" + # Generate cryptography key first + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048 + ) + # Convert to josepy format for ACME + return jose.JWKRSA(key=private_key) + + def _get_zone_id(self, domain: str) -> str: + """Get Cloudflare zone ID for domain""" + try: + # Get base domain (remove subdomains) + parts = domain.split('.') + if len(parts) >= 2: + base_domain = '.'.join(parts[-2:]) + else: + base_domain = domain + + zones = self.cf.zones.list(name=base_domain) + if not zones.result: + raise ValueError(f"Domain {base_domain} not found in Cloudflare") + + return zones.result[0].id + except Exception as e: + logger.error(f"Failed to get zone ID for {domain}: {e}") + raise + + def _create_dns_record(self, domain: str, name: str, content: str) -> str: + """Create DNS TXT record for ACME challenge""" + try: + zone_id = self._get_zone_id(domain) + + result = self.cf.dns.records.create( + zone_id=zone_id, + name=name, + type='TXT', + content=content, + ttl=60 # 1 minute TTL for faster propagation + ) + logger.info(f"Created DNS record: {name} = {content}") + return result.id + + except Exception as e: + logger.error(f"Failed to create DNS record {name}: {e}") + raise + + def _delete_dns_record(self, domain: str, record_id: str): + """Delete DNS TXT record""" + try: + zone_id = self._get_zone_id(domain) + self.cf.dns.records.delete(zone_id=zone_id, dns_record_id=record_id) + logger.info(f"Deleted DNS record: {record_id}") + except Exception as e: + logger.warning(f"Failed to delete DNS record {record_id}: {e}") + + def _wait_for_dns_propagation(self, record_name: str, expected_value: str, wait_time: int = 20): + """Wait for DNS record to propagate - no local checks, just wait""" + logger.info(f"Waiting {wait_time} seconds for DNS propagation of {record_name}...") + logger.info(f"Record value: {expected_value}") + logger.info("(No local DNS checks - Let's Encrypt servers will verify)") + + time.sleep(wait_time) + + logger.info("DNS propagation wait completed - proceeding with challenge") + return True + + def create_acme_client(self, email: str, accept_tos: bool = True) -> ClientV2: + """Create and register ACME client""" + if self.acme_client: + return self.acme_client + + try: + logger.info("Generating ACME account key...") + # Generate account key + self.account_key = self._generate_account_key() + logger.info("Account key generated successfully") + + logger.info(f"Connecting to ACME directory: {self.acme_directory}") + # Create ACME client + net = client.ClientNetwork(self.account_key, user_agent='letsencrypt-dns-lib/1.0') + logger.info("Getting ACME directory...") + directory_response = net.get(self.acme_directory) + logger.info(f"Directory response status: {directory_response.status_code}") + directory = messages.Directory.from_json(directory_response.json()) + logger.info("ACME directory loaded successfully") + + self.acme_client = ClientV2(directory, net=net) + logger.info("ACME client created successfully") + + # Register account + logger.info(f"Registering ACME account for email: {email}") + try: + registration = messages.NewRegistration.from_data( + email=email, + terms_of_service_agreed=accept_tos + ) + logger.info("Sending account registration...") + account = self.acme_client.new_account(registration) + logger.info(f"ACME account registered: {account.uri}") + + except errors.ConflictError as e: + logger.info(f"Account already exists (ConflictError): {e}") + # Account already exists + account = self.acme_client.query_registration(messages.NewRegistration()) + logger.info("Using existing ACME account") + except Exception as reg_e: + logger.error(f"Account registration failed: {reg_e}") + logger.error(f"Registration error type: {type(reg_e).__name__}") + raise + + return self.acme_client + + except Exception as e: + logger.error(f"Failed to create ACME client: {e}") + logger.error(f"Error type: {type(e).__name__}") + import traceback + logger.error(f"Full traceback: {traceback.format_exc()}") + raise + + def request_certificate(self, domains: List[str], email: str, + key_size: int = 2048) -> Tuple[str, str]: + """ + Request certificate using DNS-01 challenge + + Args: + domains: List of domain names for certificate + email: Email for ACME account registration + key_size: RSA key size for certificate + + Returns: + Tuple of (certificate_pem, private_key_pem) + """ + logger.info(f"Requesting certificate for domains: {domains}") + + try: + # Create ACME client + logger.info("Creating ACME client...") + acme_client = self.create_acme_client(email) + logger.info("ACME client created successfully") + except Exception as e: + logger.error(f"Failed to create ACME client: {e}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + raise + + try: + # Generate private key for certificate + logger.info(f"Generating {key_size}-bit RSA private key...") + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=key_size + ) + logger.info("Private key generated successfully") + + # Create CSR + logger.info(f"Creating CSR for domains: {domains}") + csr_obj = x509.CertificateSigningRequestBuilder().subject_name( + x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, domains[0]) + ]) + ).add_extension( + x509.SubjectAlternativeName([ + x509.DNSName(domain) for domain in domains + ]), + critical=False + ).sign(private_key, hashes.SHA256()) + + # Convert CSR to PEM format for ACME + csr_pem = csr_obj.public_bytes(serialization.Encoding.PEM) + logger.info("CSR created successfully") + + # Request certificate + logger.info("Requesting certificate order from ACME...") + order = acme_client.new_order(csr_pem) + logger.info(f"Created ACME order: {order.uri}") + except Exception as e: + logger.error(f"Failed during CSR/order creation: {e}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + raise + + # Process challenges - collect all challenges first, then create DNS records + dns_records = [] + challenges_to_answer = [] + + try: + # First pass: collect all challenges and create DNS records + for authorization in order.authorizations: + domain = authorization.body.identifier.value + logger.info(f"Processing authorization for: {domain}") + + # Find DNS-01 challenge + dns_challenge = None + for challenge in authorization.body.challenges: + if isinstance(challenge.chall, challenges.DNS01): + dns_challenge = challenge + break + + if not dns_challenge: + raise ValueError(f"No DNS-01 challenge found for {domain}") + + # Calculate challenge response + response, validation = dns_challenge.response_and_validation(acme_client.net.key) + + # For wildcard domains, use base domain for DNS record + if domain.startswith('*.'): + dns_domain = domain[2:] # Remove *. prefix + else: + dns_domain = domain + + # Create DNS record + record_name = f"_acme-challenge.{dns_domain}" + + # Check if we already created this DNS record + existing_record = None + for existing_domain, existing_id, existing_validation in dns_records: + if existing_domain == dns_domain: + existing_record = (existing_domain, existing_id, existing_validation) + break + + if existing_record: + logger.info(f"DNS record already exists for {dns_domain}, reusing...") + record_id = existing_record[1] + # Verify the validation value matches + if existing_record[2] != validation: + logger.warning(f"Validation values differ for {dns_domain}! This may cause issues.") + else: + logger.info(f"Creating DNS record for {dns_domain}...") + record_id = self._create_dns_record(dns_domain, record_name, validation) + dns_records.append((dns_domain, record_id, validation)) + + # Store challenge to answer later + challenges_to_answer.append((dns_challenge, response, domain, dns_domain)) + + # Wait for DNS propagation once for all records + if dns_records: + logger.info(f"Waiting for DNS propagation for {len(dns_records)} DNS records...") + for dns_domain, record_id, validation in dns_records: + record_name = f"_acme-challenge.{dns_domain}" + self._wait_for_dns_propagation(record_name, validation) + + # Second pass: answer all challenges + for dns_challenge, response, domain, dns_domain in challenges_to_answer: + logger.info(f"Responding to DNS challenge for {domain}...") + challenge_response = acme_client.answer_challenge(dns_challenge, response) + logger.info(f"Challenge response sent for {domain}") + + # Finalize order + logger.info("Finalizing certificate order...") + order = acme_client.poll_and_finalize(order) + + # Get certificate + certificate_pem = order.fullchain_pem + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ).decode('utf-8') + + logger.info("Certificate obtained successfully!") + return certificate_pem, private_key_pem + + finally: + # Clean up DNS records + for dns_domain, record_id, validation in dns_records: + try: + self._delete_dns_record(dns_domain, record_id) + except Exception as e: + logger.warning(f"Failed to cleanup DNS record for {dns_domain}: {e}") + +def get_certificate(domains: List[str], email: str, cloudflare_token: str, + provider: str = 'letsencrypt', staging: bool = False) -> Tuple[str, str]: + """ + Simple function to get Let's Encrypt/ZeroSSL certificate + + Args: + domains: List of domains for certificate + email: Email for ACME registration + cloudflare_token: Cloudflare API token + provider: 'letsencrypt' or 'zerossl' + staging: Use staging environment (for testing) + + Returns: + Tuple of (certificate_pem, private_key_pem) + """ + # Select ACME directory + acme_dns = AcmeDnsChallenge(cloudflare_token) + + if provider == 'letsencrypt': + if staging: + acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt_staging'] + else: + acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt'] + elif provider == 'zerossl': + acme_dns.acme_directory = acme_dns.acme_directories['zerossl'] + else: + raise ValueError("Provider must be 'letsencrypt' or 'zerossl'") + + return acme_dns.request_certificate(domains, email) + +def get_certificate_for_domain(domain: str, email: str, cloudflare_token: str, + include_wildcard: bool = False, **kwargs) -> Tuple[str, str]: + """ + Helper function to get certificate for single domain (compatible with Cloudflare cert lib) + + Args: + domain: Primary domain + email: Email for ACME registration + cloudflare_token: Cloudflare API token + include_wildcard: Include wildcard subdomain + **kwargs: Additional arguments (provider, staging) + + Returns: + Tuple of (certificate_pem, private_key_pem) + """ + domains = [domain] + if include_wildcard: + domains.append(f"*.{domain}") + + return get_certificate(domains, email, cloudflare_token, **kwargs) + +if __name__ == "__main__": + # Example usage + import sys + + if len(sys.argv) != 4: + print("Usage: python letsencrypt_dns.py ") + sys.exit(1) + + domain, email, token = sys.argv[1:4] + + try: + cert_pem, key_pem = get_certificate_for_domain( + domain=domain, + email=email, + cloudflare_token=token, + include_wildcard=True, + staging=True # Use staging for testing + ) + + print(f"Certificate obtained for {domain}") + print(f"Certificate length: {len(cert_pem)} bytes") + print(f"Private key length: {len(key_pem)} bytes") + + # Save to files + with open(f"{domain}.crt", 'w') as f: + f.write(cert_pem) + with open(f"{domain}.key", 'w') as f: + f.write(key_pem) + + print(f"Saved: {domain}.crt, {domain}.key") + + except Exception as e: + print(f"Error: {e}") + sys.exit(1) \ No newline at end of file diff --git a/vpn/migrations/0015_remove_old_xray_models.py b/vpn/migrations/0015_remove_old_xray_models.py new file mode 100644 index 0000000..286e1f6 --- /dev/null +++ b/vpn/migrations/0015_remove_old_xray_models.py @@ -0,0 +1,32 @@ +# Generated manually to properly remove old Xray models + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0014_alter_xraycoreserver_client_hostname_and_more'), + ] + + operations = [ + # Remove unique_together first to avoid field reference issues + migrations.AlterUniqueTogether( + name='xrayinbound', + unique_together=None, + ), + + # Remove old models completely + migrations.DeleteModel( + name='XrayClient', + ), + migrations.DeleteModel( + name='XrayInbound', + ), + migrations.DeleteModel( + name='XrayInboundServer', + ), + migrations.DeleteModel( + name='XrayCoreServer', + ), + ] \ No newline at end of file diff --git a/vpn/migrations/0016_add_new_xray_models.py b/vpn/migrations/0016_add_new_xray_models.py new file mode 100644 index 0000000..5f3ae33 --- /dev/null +++ b/vpn/migrations/0016_add_new_xray_models.py @@ -0,0 +1,127 @@ +# Generated manually to add new Xray models + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0015_remove_old_xray_models'), + ] + + operations = [ + migrations.CreateModel( + name='XrayConfiguration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('grpc_address', models.CharField(default='127.0.0.1:10085', help_text='Xray gRPC API address (host:port)', max_length=255)), + ('default_client_hostname', models.CharField(help_text='Default hostname for client connections', max_length=255)), + ('stats_enabled', models.BooleanField(default=True, help_text='Enable traffic statistics')), + ('cert_renewal_days', models.IntegerField(default=60, help_text='Renew certificates X days before expiration')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Xray Configuration', + 'verbose_name_plural': 'Xray Configuration', + }, + ), + migrations.CreateModel( + name='Credentials', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Descriptive name for these credentials', max_length=100, unique=True)), + ('cred_type', models.CharField(choices=[('cloudflare', 'Cloudflare API'), ('dns_provider', 'DNS Provider'), ('email', 'Email SMTP'), ('other', 'Other')], help_text='Type of credentials', max_length=20)), + ('credentials', models.JSONField(help_text="Credentials data (e.g., {'api_token': '...', 'email': '...'})")), + ('description', models.TextField(blank=True, help_text='Description of what these credentials are used for')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Credentials', + 'verbose_name_plural': 'Credentials', + 'ordering': ['cred_type', 'name'], + }, + ), + migrations.CreateModel( + name='Certificate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('domain', models.CharField(help_text='Domain name for this certificate', max_length=255, unique=True)), + ('certificate_pem', models.TextField(help_text='Certificate in PEM format')), + ('private_key_pem', models.TextField(help_text='Private key in PEM format')), + ('cert_type', models.CharField(choices=[('self_signed', 'Self-Signed'), ('letsencrypt', "Let's Encrypt"), ('custom', 'Custom')], help_text='Type of certificate', max_length=20)), + ('expires_at', models.DateTimeField(help_text='Certificate expiration date')), + ('auto_renew', models.BooleanField(default=True, help_text='Automatically renew certificate before expiration')), + ('last_renewed', models.DateTimeField(blank=True, help_text='Last renewal timestamp', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('credentials', models.ForeignKey(blank=True, help_text="Credentials for Let's Encrypt (Cloudflare API)", null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.credentials')), + ], + options={ + 'verbose_name': 'Certificate', + 'verbose_name_plural': 'Certificates', + 'ordering': ['domain'], + }, + ), + migrations.CreateModel( + name='Inbound', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Unique identifier for this inbound', max_length=100, unique=True)), + ('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], help_text='Protocol type', max_length=20)), + ('port', models.IntegerField(help_text='Port to listen on')), + ('network', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('grpc', 'gRPC'), ('http', 'HTTP/2'), ('quic', 'QUIC')], default='tcp', help_text='Transport protocol', max_length=20)), + ('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY')], default='none', help_text='Security type', max_length=20)), + ('domain', models.CharField(blank=True, help_text='Client connection domain', max_length=255)), + ('full_config', models.JSONField(default=dict, help_text='Complete configuration for creating inbound on server')), + ('listen_address', models.CharField(default='0.0.0.0', help_text='IP address to listen on', max_length=45)), + ('enable_sniffing', models.BooleanField(default=True, help_text='Enable protocol sniffing')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('certificate', models.ForeignKey(blank=True, help_text='Certificate for TLS', null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.certificate')), + ], + options={ + 'verbose_name': 'Inbound', + 'verbose_name_plural': 'Inbounds', + 'ordering': ['protocol', 'port'], + 'unique_together': {('port', 'listen_address')}, + }, + ), + migrations.CreateModel( + name='SubscriptionGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text="Group name (e.g., 'VLESS Premium', 'VMess Basic')", max_length=100, unique=True)), + ('description', models.TextField(blank=True, help_text='Description of this subscription group')), + ('is_active', models.BooleanField(default=True, help_text='Whether this group is active')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('inbounds', models.ManyToManyField(blank=True, help_text='Inbounds included in this group', to='vpn.inbound')), + ], + options={ + 'verbose_name': 'Subscription Group', + 'verbose_name_plural': 'Subscription Groups', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='UserSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('active', models.BooleanField(default=True, help_text='Whether this subscription is active')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('subscription_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vpn.subscriptiongroup')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='xray_subscriptions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'User Subscription', + 'verbose_name_plural': 'User Subscriptions', + 'ordering': ['user__username', 'subscription_group__name'], + 'unique_together': {('user', 'subscription_group')}, + }, + ), + ] \ No newline at end of file diff --git a/vpn/migrations/0017_xrayserverv2_alter_server_server_type_serverinbound.py b/vpn/migrations/0017_xrayserverv2_alter_server_server_type_serverinbound.py new file mode 100644 index 0000000..904176a --- /dev/null +++ b/vpn/migrations/0017_xrayserverv2_alter_server_server_type_serverinbound.py @@ -0,0 +1,52 @@ +# Generated by Django 5.1.7 on 2025-08-07 13:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0016_add_new_xray_models'), + ] + + operations = [ + migrations.CreateModel( + name='XrayServerV2', + 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')), + ('client_hostname', models.CharField(help_text='Client connection hostname (what users see in their configs)', max_length=255)), + ('api_address', models.CharField(default='127.0.0.1:10085', help_text='Xray gRPC API address for management', max_length=255)), + ('api_enabled', models.BooleanField(default=True, help_text='Enable gRPC API for user management')), + ('stats_enabled', models.BooleanField(default=True, help_text='Enable traffic statistics collection')), + ], + options={ + 'verbose_name': 'Xray Server v2', + 'verbose_name_plural': 'Xray Servers v2', + }, + bases=('vpn.server',), + ), + migrations.AlterField( + model_name='server', + name='server_type', + field=models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard'), ('xray_core', 'Xray Core'), ('xray_v2', 'Xray Server v2')], editable=False, max_length=50), + ), + migrations.CreateModel( + name='ServerInbound', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('active', models.BooleanField(default=True, help_text='Whether this inbound is active on the server')), + ('deployed_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deployment_config', models.JSONField(blank=True, default=dict, help_text='Server-specific deployment configuration')), + ('inbound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployed_servers', to='vpn.inbound')), + ('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployed_inbounds', to='vpn.server')), + ], + options={ + 'verbose_name': 'Server Inbound Deployment', + 'verbose_name_plural': 'Server Inbound Deployments', + 'ordering': ['server__name', 'inbound__name'], + 'unique_together': {('server', 'inbound')}, + }, + ), + ] diff --git a/vpn/migrations/0018_alter_certificate_certificate_pem_and_more.py b/vpn/migrations/0018_alter_certificate_certificate_pem_and_more.py new file mode 100644 index 0000000..345185a --- /dev/null +++ b/vpn/migrations/0018_alter_certificate_certificate_pem_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.7 on 2025-08-07 14:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0017_xrayserverv2_alter_server_server_type_serverinbound'), + ] + + operations = [ + migrations.AlterField( + model_name='certificate', + name='certificate_pem', + field=models.TextField(blank=True, help_text="Certificate in PEM format (auto-generated for Let's Encrypt)"), + ), + migrations.AlterField( + model_name='certificate', + name='expires_at', + field=models.DateTimeField(blank=True, help_text='Certificate expiration date (auto-filled after generation)', null=True), + ), + migrations.AlterField( + model_name='certificate', + name='private_key_pem', + field=models.TextField(blank=True, help_text="Private key in PEM format (auto-generated for Let's Encrypt)"), + ), + ] diff --git a/vpn/migrations/0019_certificate_acme_email.py b/vpn/migrations/0019_certificate_acme_email.py new file mode 100644 index 0000000..349f720 --- /dev/null +++ b/vpn/migrations/0019_certificate_acme_email.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-08-07 14:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0018_alter_certificate_certificate_pem_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='certificate', + name='acme_email', + field=models.EmailField(blank=True, help_text="Email address for ACME account registration (required for Let's Encrypt)", max_length=254), + ), + ] diff --git a/vpn/migrations/0020_alter_inbound_full_config.py b/vpn/migrations/0020_alter_inbound_full_config.py new file mode 100644 index 0000000..66248b8 --- /dev/null +++ b/vpn/migrations/0020_alter_inbound_full_config.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-08-07 15:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0019_certificate_acme_email'), + ] + + operations = [ + migrations.AlterField( + model_name='inbound', + name='full_config', + field=models.JSONField(blank=True, default=dict, help_text='Complete configuration for creating inbound on server (auto-generated if empty)'), + ), + ] diff --git a/vpn/models.py b/vpn/models.py index fc48415..1ab9d07 100644 --- a/vpn/models.py +++ b/vpn/models.py @@ -167,3 +167,10 @@ class ACLLink(models.Model): def __str__(self): return self.link + + +# Import new Xray models +from .models_xray import ( + XrayConfiguration, Credentials, Certificate, + Inbound, SubscriptionGroup, UserSubscription +) diff --git a/vpn/models_xray.py b/vpn/models_xray.py new file mode 100644 index 0000000..673c8c1 --- /dev/null +++ b/vpn/models_xray.py @@ -0,0 +1,488 @@ +""" +New Xray models for flexible inbound and subscription management. +""" + +import json +import uuid +from datetime import datetime, timedelta +from django.db import models +from django.core.exceptions import ValidationError +from django.utils import timezone + + +class XrayConfiguration(models.Model): + """Global Xray configuration - Admin menu settings""" + grpc_address = models.CharField( + max_length=255, + default="127.0.0.1:10085", + help_text="Xray gRPC API address (host:port)" + ) + default_client_hostname = models.CharField( + max_length=255, + help_text="Default hostname for client connections" + ) + stats_enabled = models.BooleanField( + default=True, + help_text="Enable traffic statistics" + ) + cert_renewal_days = models.IntegerField( + default=60, + help_text="Renew certificates X days before expiration" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Xray Configuration" + verbose_name_plural = "Xray Configuration" + + def __str__(self): + return f"Xray Config - {self.grpc_address}" + + def save(self, *args, **kwargs): + # Ensure only one configuration exists + if not self.pk and XrayConfiguration.objects.exists(): + raise ValidationError("Only one Xray configuration allowed") + super().save(*args, **kwargs) + + +class Credentials(models.Model): + """Universal credentials storage for various services""" + CRED_TYPES = [ + ('cloudflare', 'Cloudflare API'), + ('dns_provider', 'DNS Provider'), + ('email', 'Email SMTP'), + ('other', 'Other') + ] + + name = models.CharField( + max_length=100, + unique=True, + help_text="Descriptive name for these credentials" + ) + cred_type = models.CharField( + max_length=20, + choices=CRED_TYPES, + help_text="Type of credentials" + ) + credentials = models.JSONField( + help_text="Credentials data (e.g., {'api_token': '...', 'email': '...'})" + ) + description = models.TextField( + blank=True, + help_text="Description of what these credentials are used for" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Credentials" + verbose_name_plural = "Credentials" + ordering = ['cred_type', 'name'] + + def __str__(self): + return f"{self.name} ({self.get_cred_type_display()})" + + def get_credential(self, key: str, default=None): + """Safely get credential value""" + return self.credentials.get(key, default) + + +class Certificate(models.Model): + """SSL/TLS Certificate management""" + CERT_TYPES = [ + ('self_signed', 'Self-Signed'), + ('letsencrypt', "Let's Encrypt"), + ('custom', 'Custom') + ] + + domain = models.CharField( + max_length=255, + unique=True, + help_text="Domain name for this certificate" + ) + certificate_pem = models.TextField( + blank=True, + help_text="Certificate in PEM format (auto-generated for Let's Encrypt)" + ) + private_key_pem = models.TextField( + blank=True, + help_text="Private key in PEM format (auto-generated for Let's Encrypt)" + ) + cert_type = models.CharField( + max_length=20, + choices=CERT_TYPES, + help_text="Type of certificate" + ) + expires_at = models.DateTimeField( + null=True, + blank=True, + help_text="Certificate expiration date (auto-filled after generation)" + ) + credentials = models.ForeignKey( + Credentials, + null=True, + blank=True, + on_delete=models.SET_NULL, + help_text="Credentials for Let's Encrypt (Cloudflare API)" + ) + acme_email = models.EmailField( + blank=True, + help_text="Email address for ACME account registration (required for Let's Encrypt)" + ) + auto_renew = models.BooleanField( + default=True, + help_text="Automatically renew certificate before expiration" + ) + last_renewed = models.DateTimeField( + null=True, + blank=True, + help_text="Last renewal timestamp" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Certificate" + verbose_name_plural = "Certificates" + ordering = ['domain'] + + def __str__(self): + return f"{self.domain} ({self.get_cert_type_display()})" + + @property + def is_expired(self): + """Check if certificate is expired""" + if not self.expires_at: + return False + return timezone.now() > self.expires_at + + @property + def days_until_expiration(self): + """Days until certificate expires""" + if not self.expires_at: + return None + delta = self.expires_at - timezone.now() + return delta.days + + @property + def needs_renewal(self): + """Check if certificate needs renewal""" + if not self.auto_renew or not self.expires_at: + return False + + try: + config = XrayConfiguration.objects.first() + renewal_days = config.cert_renewal_days if config else 60 + except: + renewal_days = 60 + + days_left = self.days_until_expiration + if days_left is None: + return False + + return days_left <= renewal_days + + +class Inbound(models.Model): + """Independent inbound configuration""" + PROTOCOLS = [ + ('vless', 'VLESS'), + ('vmess', 'VMess'), + ('trojan', 'Trojan'), + ('shadowsocks', 'Shadowsocks') + ] + + NETWORKS = [ + ('tcp', 'TCP'), + ('ws', 'WebSocket'), + ('grpc', 'gRPC'), + ('http', 'HTTP/2'), + ('quic', 'QUIC') + ] + + SECURITIES = [ + ('none', 'None'), + ('tls', 'TLS'), + ('reality', 'REALITY') + ] + + name = models.CharField( + max_length=100, + unique=True, + help_text="Unique identifier for this inbound" + ) + protocol = models.CharField( + max_length=20, + choices=PROTOCOLS, + help_text="Protocol type" + ) + port = models.IntegerField( + help_text="Port to listen on" + ) + network = models.CharField( + max_length=20, + choices=NETWORKS, + default='tcp', + help_text="Transport protocol" + ) + security = models.CharField( + max_length=20, + choices=SECURITIES, + default='none', + help_text="Security type" + ) + certificate = models.ForeignKey( + Certificate, + null=True, + blank=True, + on_delete=models.SET_NULL, + help_text="Certificate for TLS" + ) + domain = models.CharField( + max_length=255, + blank=True, + help_text="Client connection domain" + ) + + # Full configuration for Xray + full_config = models.JSONField( + default=dict, + blank=True, + help_text="Complete configuration for creating inbound on server (auto-generated if empty)" + ) + + # Additional settings + listen_address = models.CharField( + max_length=45, + default="0.0.0.0", + help_text="IP address to listen on" + ) + enable_sniffing = models.BooleanField( + default=True, + help_text="Enable protocol sniffing" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Inbound" + verbose_name_plural = "Inbounds" + ordering = ['protocol', 'port'] + unique_together = [['port', 'listen_address']] + + def __str__(self): + return f"{self.name} ({self.protocol.upper()}:{self.port})" + + def generate_tag(self): + """Generate unique tag for inbound""" + return f"{self.protocol}-{self.port}-{uuid.uuid4().hex[:8]}" + + def build_config(self): + """Build full configuration for Xray""" + try: + # Build basic Xray inbound configuration + config = { + "tag": self.name, + "port": self.port, + "listen": self.listen_address, + "protocol": self.protocol, + "settings": self._build_protocol_settings(), + "streamSettings": self._build_stream_settings(), + "sniffing": { + "enabled": self.enable_sniffing, + "destOverride": ["http", "tls"] + } if self.enable_sniffing else {} + } + + # Store the built config + self.full_config = config + return self.full_config + + except Exception as e: + # Fallback to basic config if detailed build fails + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Failed to build detailed config for {self.name}: {e}") + + self.full_config = { + "tag": self.name, + "port": self.port, + "listen": self.listen_address, + "protocol": self.protocol, + "settings": {}, + "streamSettings": {} + } + return self.full_config + + def _build_protocol_settings(self): + """Build protocol-specific settings""" + settings = {} + + if self.protocol == 'vless': + settings = { + "clients": [], # Will be populated when users are added + "decryption": "none" + } + elif self.protocol == 'vmess': + settings = { + "clients": [] # Will be populated when users are added + } + elif self.protocol == 'trojan': + settings = { + "clients": [] # Will be populated when users are added + } + elif self.protocol == 'shadowsocks': + settings = { + "method": "aes-128-gcm", # Default method + "password": "", # Will be set when configured + "network": "tcp,udp" + } + + return settings + + def _build_stream_settings(self): + """Build stream transport settings""" + stream_settings = { + "network": self.network + } + + # Add network-specific settings + if self.network == "ws": + stream_settings["wsSettings"] = { + "path": f"/{self.name}", + "headers": {} + } + elif self.network == "grpc": + stream_settings["grpcSettings"] = { + "serviceName": self.name + } + elif self.network == "http": + stream_settings["httpSettings"] = { + "path": f"/{self.name}", + "host": [self.domain] if self.domain else [] + } + + # Add security settings + if self.security == "tls": + stream_settings["security"] = "tls" + tls_settings = { + "serverName": self.domain or "localhost", + "alpn": ["h2", "http/1.1"] + } + + if self.certificate: + tls_settings.update({ + "certificates": [{ + "certificateFile": f"/etc/xray/certs/{self.certificate.domain}.crt", + "keyFile": f"/etc/xray/certs/{self.certificate.domain}.key" + }] + }) + + stream_settings["tlsSettings"] = tls_settings + + elif self.security == "reality": + stream_settings["security"] = "reality" + # Reality settings would be configured here + stream_settings["realitySettings"] = { + "dest": self.domain or "example.com:443", + "serverNames": [self.domain] if self.domain else ["example.com"], + "privateKey": "", # Would be generated + "shortIds": [""] # Would be generated + } + + return stream_settings + + +class SubscriptionGroup(models.Model): + """Groups of inbounds for subscription management""" + name = models.CharField( + max_length=100, + unique=True, + help_text="Group name (e.g., 'VLESS Premium', 'VMess Basic')" + ) + description = models.TextField( + blank=True, + help_text="Description of this subscription group" + ) + inbounds = models.ManyToManyField( + Inbound, + blank=True, + help_text="Inbounds included in this group" + ) + is_active = models.BooleanField( + default=True, + help_text="Whether this group is active" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Subscription Group" + verbose_name_plural = "Subscription Groups" + ordering = ['name'] + + def __str__(self): + return self.name + + @property + def inbound_count(self): + """Number of inbounds in this group""" + return self.inbounds.count() + + @property + def user_count(self): + """Number of users subscribed to this group""" + return self.usersubscription_set.filter(active=True).count() + + +class UserSubscription(models.Model): + """User subscriptions to groups""" + user = models.ForeignKey( + 'User', + on_delete=models.CASCADE, + related_name='xray_subscriptions' + ) + subscription_group = models.ForeignKey( + SubscriptionGroup, + on_delete=models.CASCADE + ) + active = models.BooleanField( + default=True, + help_text="Whether this subscription is active" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "User Subscription" + verbose_name_plural = "User Subscriptions" + unique_together = ['user', 'subscription_group'] + ordering = ['user__username', 'subscription_group__name'] + + def __str__(self): + return f"{self.user.username} - {self.subscription_group.name}" + + +class ServerInbound(models.Model): + """Many-to-many relationship between servers and inbounds to track deployment""" + server = models.ForeignKey('Server', on_delete=models.CASCADE, related_name='deployed_inbounds') + inbound = models.ForeignKey(Inbound, on_delete=models.CASCADE, related_name='deployed_servers') + active = models.BooleanField(default=True, help_text="Whether this inbound is active on the server") + + deployed_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + # Store deployment-specific configuration if needed + deployment_config = models.JSONField(default=dict, blank=True, help_text="Server-specific deployment configuration") + + class Meta: + verbose_name = "Server Inbound Deployment" + verbose_name_plural = "Server Inbound Deployments" + ordering = ['server__name', 'inbound__name'] + unique_together = [('server', 'inbound')] + + def __str__(self): + status = "Active" if self.active else "Inactive" + return f"{self.server.name} -> {self.inbound.name} ({status})" \ No newline at end of file diff --git a/vpn/server_plugins/__init__.py b/vpn/server_plugins/__init__.py index 0b5d5d9..b6fc15b 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, XrayInbound, XrayClient, XrayInboundServer, XrayInboundServerAdmin +from .xray_v2 import XrayServerV2, XrayServerV2Admin from .urls import urlpatterns \ No newline at end of file diff --git a/vpn/server_plugins/generic.py b/vpn/server_plugins/generic.py index 40075d5..a7ccf57 100644 --- a/vpn/server_plugins/generic.py +++ b/vpn/server_plugins/generic.py @@ -7,6 +7,7 @@ class Server(PolymorphicModel): ('Outline', 'Outline'), ('Wireguard', 'Wireguard'), ('xray_core', 'Xray Core'), + ('xray_v2', 'Xray Server v2'), ) name = models.CharField(max_length=100, help_text="Server name") diff --git a/vpn/server_plugins/urls.py b/vpn/server_plugins/urls.py index 53385d7..b355df3 100644 --- a/vpn/server_plugins/urls.py +++ b/vpn/server_plugins/urls.py @@ -1,6 +1,7 @@ from django.urls import path -from vpn.views import shadowsocks +from vpn.views import shadowsocks, xray_subscription urlpatterns = [ path('ss//', shadowsocks, name='shadowsocks'), + path('xray//', xray_subscription, name='xray_subscription'), ] \ No newline at end of file diff --git a/vpn/server_plugins/xray_core.py b/vpn/server_plugins/xray_core.py deleted file mode 100644 index ea6db68..0000000 --- a/vpn/server_plugins/xray_core.py +++ /dev/null @@ -1,2141 +0,0 @@ -""" -Xray Core VPN server plugin implementation. - -This module provides Django models and admin interfaces for managing Xray Core -servers, inbounds, and clients. Supports VLESS, VMess, and Trojan protocols. -""" - -import base64 -import json -import logging -import uuid -from typing import Any, Dict, List, Optional -from urllib.parse import quote - -from django.contrib import admin, messages -from django.contrib.postgres.fields import ArrayField -from django.db import models -from django.db.models import Count, Sum -from django.db.models.signals import post_delete, post_save -from django.dispatch import receiver -from django.http import JsonResponse -from django.shortcuts import redirect, render -from django.urls import path, reverse -from django.utils.safestring import mark_safe -from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicChildModelFilter - -from .generic import Server - -logger = logging.getLogger(__name__) - - -class XrayConnectionError(Exception): - """Custom exception for Xray connection errors.""" - - def __init__(self, message: str, original_exception: Optional[Exception] = None): - super().__init__(message) - self.original_exception = original_exception - - -class XrayCoreServer(Server): - """ - Xray Core VPN Server implementation. - - Supports VLESS, VMess, Shadowsocks, and Trojan protocols through gRPC API. - """ - - # gRPC API Configuration - grpc_address = models.CharField( - max_length=255, - default="127.0.0.1", - help_text="Xray Core gRPC API address" - ) - grpc_port = models.IntegerField( - default=10085, - help_text="gRPC API port (usually 10085)" - ) - - # Client connection hostname - client_hostname = models.CharField( - max_length=255, - default="127.0.0.1", - help_text="Hostname or IP address for client connections" - ) - - # Stats Configuration - enable_stats = models.BooleanField( - default=True, - help_text="Enable traffic statistics tracking" - ) - - class Meta: - verbose_name = "Xray Core Server" - verbose_name_plural = "Xray Core Servers" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.logger = logging.getLogger(__name__) - self._client = None - - def save(self, *args, **kwargs): - """Set server type on save.""" - self.server_type = 'xray_core' - super().save(*args, **kwargs) - - def __str__(self): - return f"{self.name} (Xray Core)" - - @property - def client(self): - """Get or create Xray gRPC client for communication.""" - if self._client is None: - try: - from vpn.xray_api.client import XrayClient - from vpn.xray_api.exceptions import APIError - - server_address = f"{self.grpc_address}:{self.grpc_port}" - self._client = XrayClient(server_address) - - logger.info(f"[{self.name}] Created XrayClient for {server_address}") - - except Exception as e: - logger.error(f"[{self.name}] Failed to create XrayClient: {e}") - raise XrayConnectionError( - "Failed to connect to Xray Core", - original_exception=e - ) - - return self._client - - def create_inbound( - self, - protocol: str, - port: int, - tag: Optional[str] = None, - network: str = 'tcp', - security: str = 'none', - **kwargs - ): - """Create a new inbound dynamically.""" - try: - from vpn.xray_api.exceptions import APIError - - logger.info(f"[{self.name}] Creating {protocol} inbound on port {port}") - - # Create inbound in database first - inbound = XrayInbound.objects.create( - server=self, - protocol=protocol, - port=port, - tag=tag or f"{protocol}-{port}", - network=network, - security=security, - listen=kwargs.get('listen', '0.0.0.0'), - enabled=True, - **{k: v for k, v in kwargs.items() - if k in ['ss_method', 'ss_password', 'stream_settings', 'sniffing_settings']} - ) - - # Create inbound on Xray server using API library - if protocol == 'vless': - self.client.add_vless_inbound( - port=port, - users=[], - tag=inbound.tag, - listen=inbound.listen, - network=network - ) - elif protocol == 'vmess': - self.client.add_vmess_inbound( - port=port, - users=[], - tag=inbound.tag, - listen=inbound.listen, - network=network - ) - elif protocol == 'trojan': - self.client.add_trojan_inbound( - port=port, - users=[], - tag=inbound.tag, - listen=inbound.listen, - network=network - ) - else: - raise ValueError(f"Unsupported protocol: {protocol}") - - logger.info(f"[{self.name}] Inbound {inbound.tag} created successfully") - return inbound - - except Exception as e: - logger.error(f"[{self.name}] Failed to create inbound: {e}") - if 'inbound' in locals(): - inbound.delete() - raise XrayConnectionError(f"Failed to create inbound: {e}") - - def delete_inbound(self, inbound_or_tag): - """Delete an inbound.""" - try: - from vpn.xray_api.exceptions import APIError - - if isinstance(inbound_or_tag, str): - inbound = self.inbounds.get(tag=inbound_or_tag) - tag = inbound_or_tag - else: - inbound = inbound_or_tag - tag = inbound.tag - - logger.info(f"[{self.name}] Deleting inbound {tag}") - - # Remove from Xray server first - self.client.remove_inbound(tag) - - # Remove from database - inbound.delete() - - logger.info(f"[{self.name}] Inbound {tag} deleted successfully") - return True - - except Exception as e: - logger.error(f"[{self.name}] Failed to delete inbound: {e}") - raise XrayConnectionError(f"Failed to delete inbound: {e}") - - def get_server_status(self, raw: bool = False) -> Dict[str, Any]: - """Get server status and statistics.""" - status = {} - - try: - # Get basic stats - stats_info = self.client.get_server_stats() - - status.update({ - 'online': True, - 'stats_enabled': self.enable_stats, - 'inbounds_count': self.inbounds.count(), - 'total_users': 0, - 'total_traffic': { - 'uplink': 0, - 'downlink': 0 - } - }) - - # Count users across all inbounds - for inbound in self.inbounds.all(): - status['total_users'] += inbound.clients.count() - - # Get traffic stats if available - if stats_info and hasattr(stats_info, 'stat'): - for stat in stats_info.stat: - if 'user>>>' in stat.name: - if '>>>traffic>>>uplink' in stat.name: - status['total_traffic']['uplink'] += stat.value - elif '>>>traffic>>>downlink' in stat.name: - status['total_traffic']['downlink'] += stat.value - - if raw: - status['raw_stats'] = stats_info - - except Exception as e: - status.update({ - 'online': False, - 'error': str(e) - }) - - return status - - def sync_inbounds(self) -> Dict[str, Any]: - """Sync inbounds - create missing inbounds and register protocols.""" - logger.info(f"[{self.name}] Starting inbound sync") - try: - inbound_results = [] - - # Get list of existing inbounds - existing_inbound_tags = set() - try: - existing_inbounds = self.client.list_inbounds() - logger.debug(f"[{self.name}] Raw inbounds response: {existing_inbounds}") - - # Handle both dict with 'inbounds' key and direct list - if isinstance(existing_inbounds, dict) and 'inbounds' in existing_inbounds: - inbound_list = existing_inbounds['inbounds'] - elif isinstance(existing_inbounds, list): - inbound_list = existing_inbounds - else: - logger.warning(f"[{self.name}] Unexpected inbounds format: {type(existing_inbounds)}") - inbound_list = [] - - existing_inbound_tags = { - inbound.get('tag') for inbound in inbound_list - if isinstance(inbound, dict) and inbound.get('tag') - } - logger.info(f"[{self.name}] Found existing inbounds: {existing_inbound_tags}") - except Exception as e: - logger.debug(f"[{self.name}] Could not list existing inbounds: {e}") - - # Create missing inbounds and register protocols - for inbound in self.inbounds.filter(enabled=True): - try: - if inbound.tag in existing_inbound_tags: - logger.info(f"[{self.name}] Inbound {inbound.tag} already exists, registering protocol") - inbound_results.append(f"✓ {inbound.tag} (existing)") - else: - logger.info(f"[{self.name}] Creating new inbound {inbound.tag}") - - # Create inbound with empty user list - if inbound.protocol == 'vless': - self.client.add_vless_inbound( - port=inbound.port, - users=[], - tag=inbound.tag, - listen=inbound.listen or "0.0.0.0", - network=inbound.network or "tcp" - ) - elif inbound.protocol == 'vmess': - self.client.add_vmess_inbound( - port=inbound.port, - users=[], - tag=inbound.tag, - listen=inbound.listen or "0.0.0.0", - network=inbound.network or "tcp" - ) - elif inbound.protocol == 'trojan': - self.client.add_trojan_inbound( - port=inbound.port, - users=[], - tag=inbound.tag, - listen=inbound.listen or "0.0.0.0", - network=inbound.network or "tcp", - hostname=self.client_hostname - ) - - inbound_results.append(f"✓ {inbound.tag} (created)") - logger.info(f"[{self.name}] Created new inbound {inbound.tag}") - existing_inbound_tags.add(inbound.tag) - - # Register protocol in client (needed for add_user to work) - self._register_protocol_for_inbound(inbound) - - except Exception as e: - logger.error(f"[{self.name}] Failed to create/register inbound {inbound.tag}: {e}") - inbound_results.append(f"✗ {inbound.tag}: {e}") - - logger.info(f"[{self.name}] Inbound sync completed") - return { - "status": "Inbounds synced successfully", - "inbounds": inbound_results, - "existing_tags": list(existing_inbound_tags) - } - - except Exception as e: - logger.error(f"[{self.name}] Inbound sync failed: {e}") - raise XrayConnectionError("Failed to sync inbounds", original_exception=e) - - def sync_users(self) -> Dict[str, Any]: - """Sync users - add all users with ACL links to their inbounds.""" - logger.info(f"[{self.name}] Starting user sync") - try: - from vpn.models import ACL - - # Get all users that have ACL links to this server - all_acls = ACL.objects.filter(server=self) - acl_users = set(acl.user for acl in all_acls) - logger.info(f"[{self.name}] Found {len(acl_users)} users with ACL links") - - if not acl_users: - logger.info(f"[{self.name}] No users to sync") - return { - "status": "No users to sync", - "users_added": 0 - } - - # First, refresh protocol registrations to ensure they exist - self._register_all_protocols() - - user_results = [] - total_added = 0 - - for inbound in self.inbounds.filter(enabled=True): - logger.info(f"[{self.name}] Adding users to inbound {inbound.tag}") - - # Get or create clients for users with ACL links - for user in acl_users: - try: - # Get or create Django client - client, created = XrayClient.objects.get_or_create( - user=user, - inbound=inbound, - defaults={ - 'email': f"{user.username}@{inbound.tag}", - 'uuid': str(uuid.uuid4()), - 'enable': True - } - ) - - # For Trojan, ensure password exists - if inbound.protocol == 'trojan' and not client.password: - client.password = str(uuid.uuid4()) - client.save() - - # Create user object for API - user_obj = inbound._client_to_user_obj(client) - - # Add user to inbound via API - try: - self.client.add_user(inbound.tag, user_obj) - - if created: - logger.info(f"[{self.name}] Created and added user {user.username} to {inbound.tag}") - else: - logger.info(f"[{self.name}] Added existing user {user.username} to {inbound.tag}") - total_added += 1 - - except Exception as api_error: - logger.error(f"[{self.name}] API error adding user {user.username} to {inbound.tag}: {api_error}") - user_results.append(f"✗ {user.username}@{inbound.tag}: {api_error}") - - except Exception as e: - logger.error(f"[{self.name}] Failed to add user {user.username} to {inbound.tag}: {e}") - user_results.append(f"✗ {user.username}@{inbound.tag}: {e}") - - user_sync_result = f"Added {total_added} users across all inbounds" - logger.info(f"[{self.name}] {user_sync_result}") - - return { - "status": "Users synced successfully", - "users_added": total_added, - "errors": user_results - } - - except Exception as e: - logger.error(f"[{self.name}] User sync failed: {e}") - raise XrayConnectionError("Failed to sync users", original_exception=e) - - def _register_protocol_for_inbound(self, inbound): - """Register protocol for a specific inbound.""" - if inbound.tag not in self.client._protocols: - logger.debug(f"[{self.name}] Registering protocol for inbound {inbound.tag}") - - if inbound.protocol == 'vless': - from vpn.xray_api.protocols import VlessProtocol - protocol = VlessProtocol( - port=inbound.port, - tag=inbound.tag, - listen=inbound.listen or "0.0.0.0", - network=inbound.network or "tcp" - ) - self.client._protocols[inbound.tag] = protocol - elif inbound.protocol == 'vmess': - from vpn.xray_api.protocols import VmessProtocol - protocol = VmessProtocol( - port=inbound.port, - tag=inbound.tag, - listen=inbound.listen or "0.0.0.0", - network=inbound.network or "tcp" - ) - self.client._protocols[inbound.tag] = protocol - elif inbound.protocol == 'trojan': - from vpn.xray_api.protocols import TrojanProtocol - protocol = TrojanProtocol( - port=inbound.port, - tag=inbound.tag, - listen=inbound.listen or "0.0.0.0", - network=inbound.network or "tcp", - hostname=self.client_hostname or "localhost" - ) - self.client._protocols[inbound.tag] = protocol - - logger.debug(f"[{self.name}] Registered protocol {inbound.protocol} for inbound {inbound.tag}") - - def _register_all_protocols(self): - """Register all inbound protocols in client for user management.""" - logger.debug(f"[{self.name}] Registering all protocols") - for inbound in self.inbounds.filter(enabled=True): - self._register_protocol_for_inbound(inbound) - - def sync(self) -> Dict[str, Any]: - """Comprehensive sync - calls inbound sync then user sync.""" - logger.info(f"[{self.name}] Starting comprehensive sync") - try: - # Step 1: Sync inbounds - inbound_result = self.sync_inbounds() - - # Step 2: Sync users - user_result = self.sync_users() - - logger.info(f"[{self.name}] Comprehensive sync completed") - return { - "status": "Server synced successfully", - "inbounds": inbound_result.get("inbounds", []), - "users": f"Added {user_result.get('users_added', 0)} users across all inbounds" - } - - except Exception as e: - logger.error(f"[{self.name}] Comprehensive sync failed: {e}") - raise XrayConnectionError("Failed to sync configuration", original_exception=e) - - def add_user(self, user): - """Add a user to the server.""" - logger.info(f"[{self.name}] Adding user {user.username}") - - try: - # Check if user already exists - existing_client = XrayClient.objects.filter( - inbound__server=self, - user=user - ).first() - - if existing_client: - logger.debug(f"[{self.name}] User {user.username} already exists") - return self._build_user_response(existing_client) - - # Get first available enabled inbound - inbound = self.inbounds.filter(enabled=True).first() - - if not inbound: - logger.warning(f"[{self.name}] No enabled inbounds available for user {user.username}") - return {"status": "No enabled inbounds available. Please create an inbound first."} - - # Create client - client = XrayClient.objects.create( - inbound=inbound, - user=user, - uuid=uuid.uuid4(), - email=user.username, - enable=True - ) - - # Apply to Xray through gRPC - result = self._apply_client_to_xray(user, target_inbound=inbound, action='add') - if not result: - # If direct API call fails, try using inbound sync method - logger.warning(f"[{self.name}] Direct API add failed, trying inbound sync for user {user.username}") - try: - inbound.sync_to_server() - logger.info(f"[{self.name}] Successfully synced inbound {inbound.tag} with user {user.username}") - except Exception as sync_error: - logger.error(f"[{self.name}] Inbound sync also failed: {sync_error}") - raise XrayConnectionError(f"Failed to add user via API and sync: {sync_error}") - - logger.info(f"[{self.name}] User {user.username} added successfully") - return self._build_user_response(client) - - except Exception as e: - logger.error(f"[{self.name}] Failed to add user {user.username}: {e}") - raise XrayConnectionError(f"Failed to add user: {e}") - - def get_user(self, user, raw: bool = False): - """Get user information from server.""" - try: - client = XrayClient.objects.filter( - inbound__server=self, - user=user - ).first() - - if not client: - # Try to add user if not found (auto-create) - logger.warning(f"[{self.name}] User {user.username} not found, attempting to create") - return self.add_user(user) - - if raw: - return client - - return self._build_user_response(client) - - except Exception as e: - logger.error(f"[{self.name}] Failed to get user {user.username}: {e}") - raise XrayConnectionError(f"Failed to get user: {e}") - - def delete_user(self, user): - """Remove user from server.""" - logger.info(f"[{self.name}] Deleting user {user.username}") - - try: - clients = XrayClient.objects.filter( - inbound__server=self, - user=user - ) - - if not clients.exists(): - return {"status": "User not found on server. Nothing to do."} - - for client in clients: - # Remove from Xray through gRPC - self._apply_client_to_xray(user, target_inbound=client.inbound, action='remove') - client.delete() - - logger.info(f"[{self.name}] User {user.username} deleted successfully") - return {"status": "User was deleted"} - - except Exception as e: - logger.error(f"[{self.name}] Failed to delete user {user.username}: {e}") - raise XrayConnectionError(f"Failed to delete user: {e}") - - def get_user_statistics(self, user) -> Dict[str, Any]: - """Get user traffic statistics.""" - try: - stats = { - 'user': user.username, - 'total_upload': 0, - 'total_download': 0, - 'clients': [] - } - - clients = XrayClient.objects.filter( - inbound__server=self, - user=user - ) - - for client in clients: - client_stats = self._get_client_stats(client) - stats['total_upload'] += client_stats['upload'] - stats['total_download'] += client_stats['download'] - stats['clients'].append({ - 'inbound': client.inbound.tag, - 'protocol': client.inbound.protocol, - 'upload': client_stats['upload'], - 'download': client_stats['download'], - 'enable': client.enable - }) - - return stats - - except Exception as e: - logger.error(f"[{self.name}] Failed to get statistics for {user.username}: {e}") - return { - 'user': user.username, - 'error': str(e) - } - - def _apply_client_to_xray( - self, - user, - target_inbound=None, - action: str = 'add' - ) -> bool: - """Apply user to Xray inbound through gRPC API.""" - try: - from vpn.xray_api.models import VlessUser, VmessUser, TrojanUser - from vpn.xray_api.exceptions import APIError - - # Determine which inbound to use - if target_inbound: - inbound = target_inbound - else: - # Fallback to first available inbound - inbound = self.inbounds.filter(enabled=True).first() - if not inbound: - raise XrayConnectionError("No enabled inbounds available") - - # Get or create client for this user and inbound - client, created = XrayClient.objects.get_or_create( - user=user, - inbound=inbound, - defaults={ - 'email': f"{user.username}@{inbound.tag}", - 'uuid': str(uuid.uuid4()), - 'enable': True, - 'protocol': inbound.protocol - } - ) - - # Create user object based on protocol - if inbound.protocol == 'vless': - user_obj = VlessUser(email=client.email, uuid=str(client.uuid)) - elif inbound.protocol == 'vmess': - user_obj = VmessUser(email=client.email, uuid=str(client.uuid), alter_id=0) - elif inbound.protocol == 'trojan': - # For Trojan, we use password field (could be UUID or custom password) - password = getattr(client, 'password', str(client.uuid)) - user_obj = TrojanUser(email=client.email, password=password) - else: - raise ValueError(f"Unsupported protocol: {inbound.protocol}") - - if action == 'add': - logger.debug(f"[{self.name}] Adding client {client.email} to inbound {inbound.tag}") - self.client.add_user(inbound.tag, user_obj) - logger.info(f"[{self.name}] User {user.username} added to inbound {inbound.tag}") - return True - - elif action == 'remove': - logger.debug(f"[{self.name}] Removing client {client.email} from inbound {inbound.tag}") - self.client.remove_user(inbound.tag, client.email) - return {"status": "User removed successfully"} - - return True - - except Exception as e: - client_info = getattr(client, 'email', user.username) if 'client' in locals() else user.username - logger.error(f"[{self.name}] Failed to {action} client {client_info}: {e}") - return False - - def _get_client_stats(self, client) -> Dict[str, int]: - """Get traffic statistics for a specific client.""" - try: - # Try to get real stats from Xray - if hasattr(self.client, 'get_user_stats'): - # Query user statistics from Xray - stats_result = self.client.get_user_stats(client.protocol or 'vless', client.email) - - # Stats object has uplink and downlink attributes - upload = getattr(stats_result, 'uplink', 0) - download = getattr(stats_result, 'downlink', 0) - - # Update client model with fresh stats - if upload > 0 or download > 0: - client.up = upload - client.down = download - client.save(update_fields=['up', 'down']) - - return { - 'upload': upload, - 'download': download - } - else: - # Fallback to stored values - logger.debug(f"[{self.name}] Using stored stats for client {client.email}") - return { - 'upload': client.up, - 'download': client.down - } - - except Exception as e: - logger.error(f"[{self.name}] Failed to get stats for client {client.email}: {e}") - # Return stored values as fallback - return { - 'upload': client.up, - 'download': client.down - } - - def _build_user_response(self, client) -> Dict[str, Any]: - """Build user response with connection details.""" - inbound = client.inbound - connection_string = self._generate_connection_string(client) - - return { - 'user_id': str(client.uuid), - 'email': client.email, - 'protocol': inbound.protocol, - 'connection_string': connection_string, - 'qr_code': f"https://api.qrserver.com/v1/create-qr-code/?data={quote(connection_string)}", - 'enable': client.enable, - 'upload': client.up, - 'download': client.down, - 'total': client.up + client.down, - 'expiry_time': client.expiry_time.isoformat() if client.expiry_time else None, - 'total_gb': client.total_gb - } - - def _generate_connection_string(self, client) -> str: - """Generate connection string with proper hostname and parameters.""" - try: - # Use client_hostname instead of internal server address - client_hostname = self.client_hostname or self.grpc_address - inbound = client.inbound - - # Try API library first, but it might use wrong hostname - from vpn.xray_api.models import VlessUser, VmessUser, TrojanUser - - # Create user object based on protocol - if inbound.protocol == 'vless': - user_obj = VlessUser(email=client.email, uuid=str(client.uuid)) - try: - # Use protocol library with database parameters - from vpn.xray_api.protocols import VlessProtocol - protocol_handler = VlessProtocol(inbound.port, inbound.tag, inbound.listen, inbound.network) - - # Get encryption setting from inbound - encryption = getattr(inbound, 'vless_encryption', 'none') - - ctx_link = protocol_handler.generate_client_link( - user_obj, - client_hostname, - network=inbound.network, - security=inbound.security, - encryption=encryption - ) - return ctx_link - except Exception: - return self._generate_fallback_uri(inbound, client, client_hostname, inbound.port) - - elif inbound.protocol == 'vmess': - user_obj = VmessUser(email=client.email, uuid=str(client.uuid), alter_id=client.alter_id or 0) - try: - # Use protocol library with database parameters - from vpn.xray_api.protocols import VmessProtocol - protocol_handler = VmessProtocol(inbound.port, inbound.tag, inbound.listen, inbound.network) - - # Get encryption setting from inbound - encryption = getattr(inbound, 'vmess_encryption', 'auto') - - ctx_link = protocol_handler.generate_client_link( - user_obj, - client_hostname, - network=inbound.network, - security=inbound.security, - encryption=encryption - ) - return ctx_link - except Exception: - return self._generate_fallback_uri(inbound, client, client_hostname, inbound.port) - - elif inbound.protocol == 'trojan': - # For Trojan, ensure we have a proper password - password = getattr(client, 'password', None) - if not password: - # Generate a password based on UUID if none exists - password = str(client.uuid).replace('-', '')[:16] # 16 char password - # Save password to client - client.password = password - client.save(update_fields=['password']) - - user_obj = TrojanUser(email=client.email, password=password) - try: - # Use protocol library with database parameters - from vpn.xray_api.protocols import TrojanProtocol - protocol_handler = TrojanProtocol(inbound.port, inbound.tag, inbound.listen, inbound.network) - - ctx_link = protocol_handler.generate_client_link( - user_obj, - client_hostname, - network=inbound.network, - security=inbound.security - ) - return ctx_link - except Exception: - return self._generate_fallback_uri(inbound, client, client_hostname, inbound.port) - - # Fallback for unsupported protocols - return self._generate_fallback_uri(inbound, client, client_hostname, inbound.port) - - except Exception as e: - logger.warning(f"[{self.name}] Failed to generate client link: {e}") - # Final fallback - client_hostname = self.client_hostname or self.grpc_address - return self._generate_fallback_uri(client.inbound, client, client_hostname, client.inbound.port) - - def _generate_fallback_uri(self, inbound, client, server_address: str, server_port: int) -> str: - """Generate fallback URI with proper parameters for v2ray clients.""" - from urllib.parse import urlencode - - if inbound.protocol == 'vless': - # VLESS format: vless://uuid@host:port?encryption=none&type=network#name - # Use encryption and network from inbound settings - encryption = getattr(inbound, 'vless_encryption', 'none') - network = inbound.network or 'tcp' - security = inbound.security or 'none' - - params = { - 'encryption': encryption, - 'type': network - } - - # Add security if enabled - if security != 'none': - params['security'] = security - - query_string = urlencode(params) - return f"vless://{client.uuid}@{server_address}:{server_port}?{query_string}#{self.name}" - - elif inbound.protocol == 'vmess': - # VMess format: vmess://uuid@host:port?encryption=method&type=network#name - # Use encryption and network from inbound settings - encryption = getattr(inbound, 'vmess_encryption', 'auto') - network = inbound.network or 'tcp' - security = inbound.security or 'none' - - params = { - 'encryption': encryption, - 'type': network - } - - # Add security if enabled - if security != 'none': - params['security'] = security - - query_string = urlencode(params) - return f"vmess://{client.uuid}@{server_address}:{server_port}?{query_string}#{self.name}" - - elif inbound.protocol == 'trojan': - # Trojan format: trojan://password@host:port?type=network#name - password = getattr(client, 'password', None) - if not password: - # Generate and save password if not exists - password = str(client.uuid).replace('-', '')[:16] # 16 char password - client.password = password - client.save(update_fields=['password']) - - network = inbound.network or 'tcp' - security = inbound.security or 'none' - - params = { - 'type': network - } - - # Add security if enabled - if security != 'none': - params['security'] = security - - query_string = urlencode(params) - return f"trojan://{password}@{server_address}:{server_port}?{query_string}#{self.name}" - - else: - # Generic fallback - return f"{inbound.protocol}://{client.uuid}@{server_address}:{server_port}#{self.name}" - - def _build_full_config(self) -> Dict[str, Any]: - """Build full Xray configuration based on production template.""" - config = { - "log": { - "access": "/var/log/xray/access.log", - "error": "/var/log/xray/error.log", - "loglevel": "info" - }, - "api": { - "tag": "api", - "listen": f"{self.grpc_address}:{self.grpc_port}", - "services": [ - "HandlerService", - "LoggerService", - "StatsService", - "ReflectionService" - ] - }, - "stats": {}, - "policy": { - "levels": { - "0": { - "statsUserUplink": True, - "statsUserDownlink": True - } - }, - "system": { - "statsInboundUplink": True, - "statsInboundDownlink": True, - "statsOutboundUplink": True, - "statsOutboundDownlink": True - } - }, - "dns": { - "servers": [ - "https+local://cloudflare-dns.com/dns-query", - "1.1.1.1", - "8.8.8.8" - ] - }, - "inbounds": [ - { - "tag": "api", - "listen": "127.0.0.1", - "port": 8080, # Different from main API port for internal use - "protocol": "dokodemo-door", - "settings": { - "address": "127.0.0.1" - } - } - ], - "outbounds": [ - { - "tag": "direct", - "protocol": "freedom", - "settings": {} - }, - { - "tag": "blocked", - "protocol": "blackhole", - "settings": { - "response": { - "type": "http" - } - } - } - ], - "routing": { - "domainStrategy": "IPIfNonMatch", - "rules": [ - { - "type": "field", - "inboundTag": ["api"], - "outboundTag": "api" - }, - { - "type": "field", - "protocol": ["bittorrent"], - "outboundTag": "blocked" - } - ] - } - } - - # Add user inbounds - for inbound in self.inbounds.filter(enabled=True): - config["inbounds"].append(inbound.to_xray_config()) - - return config - - -class XrayInbound(models.Model): - """Xray inbound configuration.""" - - PROTOCOL_CHOICES = [ - ('vless', 'VLESS'), - ('vmess', 'VMess'), - ('trojan', 'Trojan'), - ('shadowsocks', 'Shadowsocks'), - ] - - NETWORK_CHOICES = [ - ('tcp', 'TCP'), - ('ws', 'WebSocket'), - ('http', 'HTTP/2'), - ('grpc', 'gRPC'), - ('quic', 'QUIC'), - ] - - SECURITY_CHOICES = [ - ('none', 'None'), - ('tls', 'TLS'), - ('reality', 'REALITY'), - ] - - # Default configurations for different protocols - PROTOCOL_DEFAULTS = { - 'vless': { - 'port': 443, - 'network': 'tcp', - 'security': 'tls', - 'sniffing_settings': { - 'enabled': True, - 'destOverride': ['http', 'tls'], - 'metadataOnly': False - } - }, - 'vmess': { - 'port': 443, - 'network': 'ws', - 'security': 'tls', - 'stream_settings': { - 'wsSettings': { - 'path': '/ws', - 'headers': { - 'Host': 'www.cloudflare.com' - } - } - }, - 'sniffing_settings': { - 'enabled': True, - 'destOverride': ['http', 'tls'], - 'metadataOnly': False - } - }, - 'trojan': { - 'port': 443, - 'network': 'tcp', - 'security': 'tls', - 'sniffing_settings': { - 'enabled': True, - 'destOverride': ['http', 'tls'], - 'metadataOnly': False - } - }, - 'shadowsocks': { - 'port': 8388, - 'network': 'tcp', - 'security': 'none', - 'ss_method': 'chacha20-ietf-poly1305', - 'sniffing_settings': { - 'enabled': True, - 'destOverride': ['http', 'tls'], - 'metadataOnly': False - } - } - } - - server = models.ForeignKey(XrayCoreServer, on_delete=models.CASCADE, related_name='inbounds') - tag = models.CharField(max_length=100, help_text="Unique identifier for this inbound") - port = models.IntegerField(help_text="Port to listen on") - listen = models.CharField(max_length=255, default="0.0.0.0", help_text="IP address to listen on") - protocol = models.CharField(max_length=20, choices=PROTOCOL_CHOICES) - enabled = models.BooleanField(default=True) - - # Network settings - network = models.CharField(max_length=20, choices=NETWORK_CHOICES, default='tcp') - security = models.CharField(max_length=20, choices=SECURITY_CHOICES, default='none') - - # Server address for clients (if different from listen) - server_address = models.CharField( - max_length=255, - blank=True, - help_text="Public server address for client connections" - ) - - # Protocol-specific settings - # Shadowsocks - ss_method = models.CharField( - max_length=50, - blank=True, - default='chacha20-ietf-poly1305', - help_text="Shadowsocks encryption method" - ) - ss_password = models.CharField( - max_length=255, - blank=True, - help_text="Shadowsocks password (for single-user mode)" - ) - - # TLS settings - tls_cert_file = models.CharField(max_length=255, blank=True) - tls_key_file = models.CharField(max_length=255, blank=True) - tls_alpn = ArrayField(models.CharField(max_length=20), default=list, blank=True) - - # Advanced settings (JSON) - stream_settings = models.JSONField(default=dict, blank=True) - sniffing_settings = models.JSONField(default=dict, blank=True) - - class Meta: - unique_together = [('server', 'tag'), ('server', 'port')] - ordering = ['port'] - - def __str__(self): - return f"{self.tag} ({self.protocol.upper()}:{self.port})" - - def save(self, *args, **kwargs): - """Apply protocol defaults on creation.""" - if not self.pk and self.protocol in self.PROTOCOL_DEFAULTS: - defaults = self.PROTOCOL_DEFAULTS[self.protocol] - - # Apply defaults only if fields are not set - if not self.port: - self.port = defaults.get('port', 443) - if not self.network: - self.network = defaults.get('network', 'tcp') - if not self.security: - self.security = defaults.get('security', 'none') - if not self.ss_method and 'ss_method' in defaults: - self.ss_method = defaults['ss_method'] - if not self.stream_settings and 'stream_settings' in defaults: - self.stream_settings = defaults['stream_settings'] - if not self.sniffing_settings and 'sniffing_settings' in defaults: - self.sniffing_settings = defaults['sniffing_settings'] - - super().save(*args, **kwargs) - - def to_xray_config(self) -> Dict[str, Any]: - """Convert to Xray inbound configuration.""" - config = { - "tag": self.tag, - "port": self.port, - "listen": self.listen, - "protocol": self.protocol, - "settings": self._build_protocol_settings(), - "streamSettings": self._build_stream_settings() - } - - if self.sniffing_settings: - config["sniffing"] = self.sniffing_settings - - return config - - def _build_protocol_settings(self) -> Dict[str, Any]: - """Build protocol-specific settings.""" - settings = {} - - if self.protocol == 'vless': - settings = { - "decryption": "none", - "clients": [] - } - elif self.protocol == 'vmess': - settings = { - "clients": [] - } - elif self.protocol == 'trojan': - settings = { - "clients": [] - } - elif self.protocol == 'shadowsocks': - settings = { - "method": self.ss_method, - "password": self.ss_password, - "clients": [] - } - - # Add clients - for client in self.clients.filter(enable=True): - settings["clients"].append(client.to_xray_config()) - - return settings - - def _build_stream_settings(self) -> Dict[str, Any]: - """Build stream settings.""" - settings = { - "network": self.network, - "security": self.security - } - - # Add custom stream settings - if self.stream_settings: - settings.update(self.stream_settings) - - # Add TLS settings if needed - if self.security == 'tls' and (self.tls_cert_file or self.tls_key_file): - settings["tlsSettings"] = { - "certificates": [{ - "certificateFile": self.tls_cert_file, - "keyFile": self.tls_key_file - }] - } - if self.tls_alpn: - settings["tlsSettings"]["alpn"] = self.tls_alpn - - return settings - - def sync_to_server(self) -> bool: - """Sync this inbound to the Xray server using API library.""" - try: - logger.info(f"Syncing inbound {self.tag} to server") - - # 1. First remove existing inbound if it exists - try: - self.server.client.remove_inbound(self.tag) - logger.info(f"Removed existing inbound {self.tag}") - except Exception: - logger.debug(f"Inbound {self.tag} doesn't exist yet, proceeding with creation") - - # 2. Get all enabled users for this inbound - users = [] - for client in self.clients.filter(enable=True): - users.append(self._client_to_user_obj(client)) - - logger.info(f"Preparing to add {len(users)} users to inbound {self.tag}") - - # 3. Add inbound with users using protocol-specific method - if self.protocol == 'vless': - result = self.server.client.add_vless_inbound( - port=self.port, - users=users, - tag=self.tag, - listen=self.listen or "0.0.0.0", - network=self.network or "tcp" - ) - elif self.protocol == 'vmess': - result = self.server.client.add_vmess_inbound( - port=self.port, - users=users, - tag=self.tag, - listen=self.listen or "0.0.0.0", - network=self.network or "tcp" - ) - elif self.protocol == 'trojan': - result = self.server.client.add_trojan_inbound( - port=self.port, - users=users, - tag=self.tag, - listen=self.listen or "0.0.0.0", - network=self.network or "tcp" - ) - else: - raise ValueError(f"Unsupported protocol: {self.protocol}") - - logger.info(f"Inbound {self.tag} created successfully with {len(users)} users. Result: {result}") - return True - - except Exception as e: - logger.error(f"Failed to sync inbound {self.tag}: {e}") - return False - - def remove_from_server(self) -> bool: - """Remove this inbound from the Xray server.""" - try: - logger.info(f"Removing inbound {self.tag} from server") - self.server.client.remove_inbound(self.tag) - logger.info(f"Inbound {self.tag} removed successfully") - return True - except Exception as e: - logger.error(f"Failed to remove inbound {self.tag}: {e}") - return False - - def _client_to_user_obj(self, client): - """Convert XrayClient to API library user object.""" - from vpn.xray_api.models import VlessUser, VmessUser, TrojanUser - - if self.protocol == 'vless': - return VlessUser(email=client.email, uuid=str(client.uuid)) - elif self.protocol == 'vmess': - return VmessUser(email=client.email, uuid=str(client.uuid), alter_id=client.alter_id or 0) - elif self.protocol == 'trojan': - password = getattr(client, 'password', str(client.uuid)) - return TrojanUser(email=client.email, password=password) - else: - raise ValueError(f"Unsupported protocol: {self.protocol}") - - def add_user(self, user): - """Add user to this inbound.""" - try: - # Create XrayClient for this user in this inbound - client = XrayClient.objects.create( - inbound=self, - user=user, - email=user.username, - enable=True - ) - - # Add user to actual Xray server - user_obj = self._client_to_user_obj(client) - self.server.client.add_user(self.tag, user_obj) - - logger.info(f"Added user {user.username} to inbound {self.tag}") - return client - - except Exception as e: - logger.error(f"Failed to add user {user.username} to inbound {self.tag}: {e}") - raise - - def remove_user(self, user) -> bool: - """Remove user from this inbound.""" - try: - # Find and remove XrayClient - client = self.clients.filter(user=user).first() - if client: - # Remove from Xray server - self.server.client.remove_user(self.tag, client.email) - - # Remove from database - client.delete() - logger.info(f"Removed user {user.username} from inbound {self.tag}") - return True - else: - logger.warning(f"User {user.username} not found in inbound {self.tag}") - return False - - except Exception as e: - logger.error(f"Failed to remove user {user.username} from inbound {self.tag}: {e}") - raise - - -class XrayInboundServer(Server): - """Server model that represents a single Xray inbound as a server.""" - - # Reference to the actual XrayInbound - xray_inbound = models.OneToOneField( - XrayInbound, - on_delete=models.CASCADE, - related_name='server_proxy', - null=True, - blank=True - ) - - class Meta: - verbose_name = "Xray Inbound Server" - verbose_name_plural = "Xray Inbound Servers" - - def save(self, *args, **kwargs): - if self.xray_inbound: - self.server_type = f'xray_{self.xray_inbound.protocol}' - if not self.name: - self.name = f"{self.xray_inbound.server.name}-{self.xray_inbound.tag}" - if not self.comment: - self.comment = f"{self.xray_inbound.protocol.upper()} inbound on port {self.xray_inbound.port}" - super().save(*args, **kwargs) - - def __str__(self): - if self.xray_inbound: - return f"{self.xray_inbound.server.name}-{self.xray_inbound.tag}" - return self.name or "Xray Inbound" - - def get_server_status(self, raw: bool = False) -> Dict[str, Any]: - """Get status from parent Xray server.""" - if self.xray_inbound: - return self.xray_inbound.server.get_server_status(raw=raw) - return {"error": "No inbound configured"} - - def add_user(self, user): - """Add user to this specific inbound via parent server.""" - if not self.xray_inbound: - raise XrayConnectionError("No inbound configured") - - logger.info(f"[{self.name}] Adding user {user.username} to inbound {self.xray_inbound.tag}") - - try: - # Delegate to parent server but specify the inbound - parent_server = self.xray_inbound.server - return parent_server._apply_client_to_xray(user, target_inbound=self.xray_inbound) - - except Exception as e: - logger.error(f"[{self.name}] Failed to add user {user.username}: {e}") - raise XrayConnectionError(f"Failed to add user: {e}") - - def remove_user(self, user): - """Remove user from this specific inbound.""" - if not self.xray_inbound: - raise XrayConnectionError("No inbound configured") - - logger.info(f"[{self.name}] Removing user {user.username} from inbound {self.xray_inbound.tag}") - - try: - # Find client for this user and inbound - client = self.xray_inbound.clients.filter(user=user).first() - - if not client: - return {"status": "User not found on this inbound"} - - # Use parent server to remove user from Xray via API - parent_server = self.xray_inbound.server - - try: - parent_server.client.remove_user(self.xray_inbound.tag, client.email) - logger.info(f"[{self.name}] User {user.username} removed from Xray") - except Exception as api_error: - logger.warning(f"[{self.name}] API removal failed: {api_error}") - - # Remove from database - client.delete() - - logger.info(f"[{self.name}] User {user.username} removed successfully") - return {"status": "User was removed"} - - except Exception as e: - logger.error(f"[{self.name}] Failed to remove user {user.username}: {e}") - raise XrayConnectionError(f"Failed to remove user: {e}") - - def get_user(self, user, raw: bool = False): - """Get user information from this inbound.""" - if not self.xray_inbound: - raise XrayConnectionError("No inbound configured") - - try: - client = self.xray_inbound.clients.filter(user=user).first() - - if not client: - # Auto-create user in this inbound - logger.warning(f"[{self.name}] User {user.username} not found, attempting to create") - return self.add_user(user) - - return self.xray_inbound.server._build_user_response(client) - - except Exception as e: - logger.error(f"[{self.name}] Failed to get user {user.username}: {e}") - raise XrayConnectionError(f"Failed to get user: {e}") - - def sync_users(self) -> bool: - """Sync users for this inbound only.""" - if not self.xray_inbound: - logger.error(f"[{self.name}] No inbound configured") - return False - - from vpn.models import User, ACL - logger.debug(f"[{self.name}] Sync users for this inbound") - - try: - # Get ACLs for this inbound server (not the parent Xray server) - acls = ACL.objects.filter(server=self) - acl_users = set(acl.user for acl in acls) - - # Get existing clients for this inbound only - existing_clients = {client.user.id: client for client in self.xray_inbound.clients.all()} - - added = 0 - removed = 0 - - # Add missing users to this inbound - for user in acl_users: - if user.id not in existing_clients: - try: - self.add_user(user=user) - added += 1 - logger.debug(f"[{self.name}] Added user {user.username}") - except Exception as e: - logger.error(f"[{self.name}] Failed to add user {user.username}: {e}") - - # Remove users without ACL from this inbound - for user_id, client in existing_clients.items(): - if client.user not in acl_users: - try: - self.delete_user(user=client.user) - removed += 1 - logger.debug(f"[{self.name}] Removed user {client.user.username}") - except Exception as e: - logger.error(f"[{self.name}] Failed to remove user {client.user.username}: {e}") - - logger.info(f"[{self.name}] Sync completed: {added} added, {removed} removed") - return True - - except Exception as e: - logger.error(f"[{self.name}] User sync failed: {e}") - return False - - -class XrayClient(models.Model): - """Xray client (user) configuration.""" - - inbound = models.ForeignKey(XrayInbound, on_delete=models.CASCADE, related_name='clients') - user = models.ForeignKey('vpn.User', on_delete=models.CASCADE) - uuid = models.UUIDField(default=uuid.uuid4, unique=True) - email = models.CharField(max_length=255, help_text="Email for statistics") - level = models.IntegerField(default=0) - enable = models.BooleanField(default=True) - - # Protocol-specific fields - flow = models.CharField(max_length=50, blank=True, help_text="VLESS flow control") - alter_id = models.IntegerField(default=0, help_text="VMess alterId") - password = models.CharField(max_length=255, blank=True, help_text="Password for Trojan/Shadowsocks") - - # Limits - total_gb = models.IntegerField(null=True, blank=True, help_text="Traffic limit in GB") - expiry_time = models.DateTimeField(null=True, blank=True, help_text="Account expiration time") - - # Statistics - up = models.BigIntegerField(default=0, help_text="Upload bytes") - down = models.BigIntegerField(default=0, help_text="Download bytes") - - # Metadata - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - unique_together = [('inbound', 'user')] - ordering = ['created_at'] - - def __str__(self): - return f"{self.user.username} @ {self.inbound.tag}" - - def to_xray_config(self) -> Dict[str, Any]: - """Convert to Xray client configuration.""" - config = { - "id": str(self.uuid), - "email": self.email, - "level": self.level - } - - # Add protocol-specific fields - if self.inbound.protocol == 'vless' and self.flow: - config["flow"] = self.flow - elif self.inbound.protocol == 'vmess': - config["alterId"] = self.alter_id - elif self.inbound.protocol in ['trojan', 'shadowsocks'] and self.password: - config["password"] = self.password - - return config - - -# Admin classes -class XrayInboundInline(admin.TabularInline): - model = XrayInbound - extra = 0 - fields = ('tag', 'port', 'protocol', 'network', 'security', 'enabled', 'client_count') - readonly_fields = ('client_count',) - - def client_count(self, obj): - if obj.pk: - return obj.clients.count() - return 0 - client_count.short_description = 'Clients' - - -class XrayClientInline(admin.TabularInline): - model = XrayClient - extra = 0 - fields = ('user', 'email', 'enable', 'traffic_display', 'created_at') - readonly_fields = ('traffic_display', 'created_at') - raw_id_fields = ('user',) - - def traffic_display(self, obj): - if obj.pk: - up_gb = obj.up / (1024**3) - down_gb = obj.down / (1024**3) - return f"↑ {up_gb:.2f} GB ↓ {down_gb:.2f} GB" - return "-" - traffic_display.short_description = 'Traffic' - - -@admin.register(XrayCoreServer) -class XrayCoreServerAdmin(PolymorphicChildModelAdmin): - base_model = XrayCoreServer - show_in_index = False - - list_display = ( - 'name', - 'grpc_address', - 'grpc_port', - 'client_hostname', - 'inbound_count', - 'user_count', - 'server_status_inline', - 'registration_date' - ) - - list_editable = ('grpc_address', 'grpc_port', 'client_hostname') - exclude = ('server_type',) - - def get_fieldsets(self, request, obj=None): - """Customize fieldsets based on whether object exists.""" - if obj is None: # Adding new server - return ( - ('Basic Configuration', { - 'fields': ('name', 'comment') - }), - ('gRPC API Configuration', { - 'fields': ('grpc_address', 'grpc_port', 'client_hostname'), - 'description': 'Configure connection to Xray Core gRPC API and client connection hostname' - }), - ('Settings', { - 'fields': ('enable_stats',) - }), - ) - else: # Editing existing server - return ( - ('Server Configuration', { - 'fields': ('name', 'comment', 'registration_date') - }), - ('gRPC API Configuration', { - 'fields': ('grpc_address', 'grpc_port', 'client_hostname') - }), - ('Settings', { - 'fields': ('enable_stats',) - }), - ('Server Status', { - 'fields': ('server_status_full',) - }), - ('Configuration Management', { - 'fields': ('export_configuration_display', 'create_inbound_button') - }), - ('Statistics & Users', { - 'fields': ('server_statistics_display',), - 'classes': ('collapse',) - }), - ) - - inlines = [XrayInboundInline] - readonly_fields = ('server_status_full', 'registration_date', 'export_configuration_display', 'server_statistics_display', 'create_inbound_button') - - def get_queryset(self, request): - qs = super().get_queryset(request) - qs = qs.annotate( - user_count=Count('inbounds__clients__user', distinct=True) - ) - return qs - - @admin.display(description='Inbounds', ordering='inbound_count') - def inbound_count(self, obj): - return obj.inbounds.count() - - @admin.display(description='Users', ordering='user_count') - def user_count(self, obj): - return obj.user_count - - @admin.display(description='Status') - def server_status_inline(self, obj): - try: - status = obj.get_server_status() - if status.get('online'): - return mark_safe('✅ Online') - else: - return mark_safe(f'❌ {status.get("error", "Offline")}') - except Exception as e: - return mark_safe(f'❌ Error: {str(e)}') - - @admin.display(description='Server Status') - def server_status_full(self, obj): - if obj and obj.pk: - try: - status = obj.get_server_status() - if 'error' in status: - return mark_safe(f'Error: {status["error"]}') - - html = '
' - html += f'
Status: {"đŸŸĸ Online" if status.get("online") else "🔴 Offline"}
' - html += f'
Inbounds: {status.get("inbounds_count", 0)}
' - html += f'
Total Users: {status.get("total_users", 0)}
' - - if 'total_traffic' in status: - up_gb = status['total_traffic']['uplink'] / (1024**3) - down_gb = status['total_traffic']['downlink'] / (1024**3) - html += f'
Total Traffic: ↑ {up_gb:.2f} GB ↓ {down_gb:.2f} GB
' - - html += '
' - return mark_safe(html) - - except Exception as e: - return mark_safe(f'Error: {str(e)}') - return "N/A" - - @admin.display(description='Export Configuration') - def export_configuration_display(self, obj): - """Display export configuration and actions.""" - if not obj or not obj.pk: - return mark_safe('
Export will be available after saving
') - - try: - # Build export data - export_data = { - 'name': obj.name, - 'type': 'xray_core', - 'grpc': { - 'address': obj.grpc_address, - 'port': obj.grpc_port - }, - 'inbounds': [] - } - - # Add inbound configurations - for inbound in obj.inbounds.all(): - inbound_data = { - 'tag': inbound.tag, - 'port': inbound.port, - 'protocol': inbound.protocol, - 'network': inbound.network, - 'security': inbound.security, - 'clients': inbound.clients.count() - } - export_data['inbounds'].append(inbound_data) - - json_str = json.dumps(export_data, indent=2) - from django.utils.html import escape - escaped_json = escape(json_str) - - html = f''' -
- -
- - -
-
- ''' - - return mark_safe(html) - - except Exception as e: - return mark_safe(f'
Error generating export: {e}
') - - @admin.display(description='Server Statistics & Users') - def server_statistics_display(self, obj): - """Display server statistics and user management.""" - if not obj or not obj.pk: - return mark_safe('
Statistics will be available after saving
') - - try: - from vpn.models import ACL, UserStatistics - from django.utils import timezone - from django.utils.timezone import localtime - from datetime import timedelta - - # Get statistics - user_count = ACL.objects.filter(server=obj).count() - client_count = XrayClient.objects.filter(inbound__server=obj).count() - - html = '
' - - # Overall Statistics - html += '
' - html += '
' - html += f'
ACL Users: {user_count}
' - html += f'
Configured Clients: {client_count}
' - html += f'
Inbounds: {obj.inbounds.count()}
' - html += '
' - html += '
' - - # Users with access - acls = ACL.objects.filter(server=obj).select_related('user') - - if acls: - html += '
đŸ‘Ĩ Users with Access
' - - for acl in acls: - user = acl.user - - # Get client info - clients = XrayClient.objects.filter(inbound__server=obj, user=user) - - html += '
' - html += f'
{user.username}' - if user.comment: - html += f' - {user.comment}' - html += '
' - - if clients.exists(): - for client in clients: - up_gb = client.up / (1024**3) - down_gb = client.down / (1024**3) - status = 'đŸŸĸ' if client.enable else '🔴' - html += f'
' - html += f'{status} {client.inbound.tag} ({client.inbound.protocol}) - ↑ {up_gb:.2f} GB ↓ {down_gb:.2f} GB' - html += '
' - else: - html += '
âš ī¸ No client configured
' - - html += '
' - else: - html += '
No users assigned to this server
' - - html += '
' - return mark_safe(html) - - except Exception as e: - return mark_safe(f'
Error loading statistics: {e}
') - - def get_urls(self): - from django.urls import path - urls = super().get_urls() - custom_urls = [ - path('/sync/', self.admin_site.admin_view(self.sync_server_view), name='xraycoreserver_sync'), - ] - return custom_urls + urls - - def sync_server_view(self, request, object_id): - """View to sync server configuration.""" - from django.http import JsonResponse - from django.shortcuts import redirect - from django.contrib import messages - - try: - server = XrayCoreServer.objects.get(pk=object_id) - result = server.sync() - - if request.headers.get('Accept') == 'application/json': - # AJAX request - return JsonResponse({ - 'success': True, - 'message': f'Server "{server.name}" synchronized successfully', - 'details': result - }) - else: - # Regular HTTP request - redirect back with message - messages.success(request, f'Server "{server.name}" synchronized successfully!') - return redirect(f'/admin/vpn/xraycoreserver/{object_id}/change/') - - except Exception as e: - if request.headers.get('Accept') == 'application/json': - return JsonResponse({ - 'success': False, - 'error': str(e) - }, status=500) - else: - messages.error(request, f'Sync failed: {str(e)}') - return redirect(f'/admin/vpn/xraycoreserver/{object_id}/change/') - - @admin.display(description='Create Inbound') - def create_inbound_button(self, obj): - if obj and obj.pk: - create_url = reverse('admin:create_xray_inbound', args=[obj.pk]) - return mark_safe(f''' - - ➕ Create Inbound - - ''') - return "-" - - def get_urls(self): - """Add custom URLs for inbound management.""" - urls = super().get_urls() - custom_urls = [ - path('/create-inbound/', - self.admin_site.admin_view(self.create_inbound_view), - name='create_xray_inbound'), - ] - return custom_urls + urls - - def create_inbound_view(self, request, server_id): - """View for creating new inbounds.""" - try: - server = XrayCoreServer.objects.get(pk=server_id) - - if request.method == 'POST': - protocol = request.POST.get('protocol') - port = int(request.POST.get('port')) - tag = request.POST.get('tag') - network = request.POST.get('network', 'tcp') - security = request.POST.get('security', 'none') - - # Create inbound using server method - inbound = server.create_inbound( - protocol=protocol, - port=port, - tag=tag, - network=network, - security=security - ) - - messages.success(request, f'Inbound "{inbound.tag}" created successfully!') - return redirect(f'/admin/vpn/xrayinbound/{inbound.pk}/change/') - - # GET request - show form - context = { - 'title': f'Create Inbound for {server.name}', - 'server': server, - 'protocols': ['vless', 'vmess', 'trojan'], - 'networks': ['tcp', 'ws', 'grpc', 'h2'], - 'securities': ['none', 'tls', 'reality'], - } - - return render(request, 'admin/create_xray_inbound.html', context) - - except XrayCoreServer.DoesNotExist: - messages.error(request, 'Server not found') - return redirect('/admin/vpn/xraycoreserver/') - except Exception as e: - messages.error(request, f'Failed to create inbound: {e}') - return redirect(f'/admin/vpn/xraycoreserver/{server_id}/change/') - - def save_model(self, request, obj, form, change): - """Override save to set server_type.""" - obj.server_type = 'xray_core' - super().save_model(request, obj, form, change) - - def get_model_perms(self, request): - """It disables display for sub-model.""" - return {} - - class Media: - js = ('admin/js/xray_inbound_defaults.js',) - css = {'all': ('admin/css/vpn_admin.css',)} - - -@admin.register(XrayInboundServer) -class XrayInboundServerAdmin(PolymorphicChildModelAdmin): - """Admin for XrayInboundServer to display inbounds as servers.""" - base_model = XrayInboundServer - show_in_index = True # Show in main server list - - list_display = ('name', 'server_type', 'comment', 'client_count', 'registration_date') - list_filter = ('server_type', 'xray_inbound__protocol', 'xray_inbound__network') - search_fields = ('name', 'comment', 'xray_inbound__tag') - readonly_fields = ('server_type', 'registration_date', 'client_count') - - fieldsets = ( - ('Server Information', { - 'fields': ('name', 'server_type', 'comment', 'registration_date') - }), - ('Inbound Configuration', { - 'fields': ('xray_inbound',), - 'description': 'The actual Xray inbound this server represents' - }), - ) - - def client_count(self, obj): - if obj.xray_inbound: - return obj.xray_inbound.clients.count() - return 0 - client_count.short_description = 'Clients' - - def has_add_permission(self, request): - # Prevent manual creation - these should be auto-created - return False - - def has_delete_permission(self, request, obj=None): - # Allow deleting individual inbound servers - return True - - def save_model(self, request, obj, form, change): - """Set server_type on save.""" - if obj.xray_inbound: - obj.server_type = f'xray_{obj.xray_inbound.protocol}' - super().save_model(request, obj, form, change) - - def get_urls(self): - """Add sync URL for XrayInboundServer.""" - from django.urls import path - urls = super().get_urls() - custom_urls = [ - path('/sync/', self.admin_site.admin_view(self.sync_server_view), name='xrayinboundserver_sync'), - ] - return custom_urls + urls - - def sync_server_view(self, request, object_id): - """Sync this inbound server by delegating to parent server.""" - try: - inbound_server = XrayInboundServer.objects.get(pk=object_id) - if not inbound_server.xray_inbound: - messages.error(request, "No inbound configuration found") - return redirect('admin:vpn_server_changelist') - - # Delegate to parent server's sync - parent_server = inbound_server.xray_inbound.server - parent_admin = XrayCoreServerAdmin(XrayCoreServer, self.admin_site) - - # Call parent server's sync method - return parent_admin.sync_server_view(request, parent_server.pk) - - except XrayInboundServer.DoesNotExist: - messages.error(request, f"Xray Inbound Server with ID {object_id} not found") - return redirect('admin:vpn_server_changelist') - except Exception as e: - messages.error(request, f"Error during sync: {e}") - return redirect('admin:vpn_server_changelist') - - def get_model_perms(self, request): - """Show this model in admin.""" - return { - 'add': self.has_add_permission(request), - 'change': self.has_change_permission(request), - 'delete': self.has_delete_permission(request), - 'view': self.has_view_permission(request), - } - - -@admin.register(XrayInbound) -class XrayInboundAdmin(admin.ModelAdmin): - list_display = ('tag', 'server', 'port', 'protocol', 'network', 'security', 'enabled', 'client_count') - list_filter = ('server', 'protocol', 'network', 'security', 'enabled') - search_fields = ('tag', 'server__name') - list_editable = ('enabled',) - - inlines = [XrayClientInline] - - def get_fieldsets(self, request, obj=None): - """Customize fieldsets based on whether object exists.""" - if obj is None: # Adding new inbound - return ( - ('Basic Information', { - 'fields': ('server', 'tag', 'protocol', 'port', 'listen', 'server_address', 'enabled') - }), - ('Transport & Security', { - 'fields': ('network', 'security') - }), - ('Protocol-Specific Settings', { - 'fields': ('ss_method', 'ss_password'), - 'classes': ('collapse',), - 'description': 'Settings specific to certain protocols' - }), - ('TLS Configuration', { - 'fields': ('tls_cert_file', 'tls_key_file', 'tls_alpn'), - 'classes': ('collapse',), - }), - ('Advanced Settings', { - 'fields': ('stream_settings', 'sniffing_settings'), - 'classes': ('collapse',), - }), - ) - else: # Editing existing inbound - return ( - ('Basic Information', { - 'fields': ('server', 'tag', 'protocol', 'port', 'listen', 'server_address', 'enabled') - }), - ('Transport & Security', { - 'fields': ('network', 'security') - }), - ('Protocol-Specific Settings', { - 'fields': ('ss_method', 'ss_password'), - 'classes': ('collapse',), - 'description': 'Settings specific to certain protocols' - }), - ('TLS Configuration', { - 'fields': ('tls_cert_file', 'tls_key_file', 'tls_alpn'), - 'classes': ('collapse',), - }), - ('Advanced Settings', { - 'fields': ('stream_settings', 'sniffing_settings'), - 'classes': ('collapse',), - }), - ) - - def get_readonly_fields(self, request, obj=None): - """Set readonly fields based on context.""" - if obj is None: # Adding new inbound - return () - else: # Editing existing inbound - return () - - def client_count(self, obj): - return obj.clients.count() - client_count.short_description = 'Clients' - - class Media: - js = ('admin/js/xray_inbound_defaults.js',) - css = {'all': ('admin/css/vpn_admin.css',)} - - -@admin.register(XrayClient) -class XrayClientAdmin(admin.ModelAdmin): - list_display = ('user', 'inbound', 'email', 'enable', 'traffic_display', 'created_at') - list_filter = ('inbound__server', 'inbound', 'enable', 'created_at') - search_fields = ('user__username', 'email', 'uuid') - list_editable = ('enable',) - raw_id_fields = ('user',) - - fieldsets = ( - ('Basic Information', { - 'fields': ('inbound', 'user', 'email', 'enable') - }), - ('Authentication', { - 'fields': ('uuid', 'level') - }), - ('Protocol-Specific', { - 'fields': ('flow', 'alter_id', 'password'), - 'classes': ('collapse',), - }), - ('Limits', { - 'fields': ('total_gb', 'expiry_time'), - 'classes': ('collapse',), - }), - ('Statistics', { - 'fields': ('up', 'down', 'created_at', 'updated_at'), - 'classes': ('collapse',), - }), - ) - - readonly_fields = ('uuid', 'created_at', 'updated_at') - - def traffic_display(self, obj): - up_gb = obj.up / (1024**3) - down_gb = obj.down / (1024**3) - return f"↑ {up_gb:.2f} GB ↓ {down_gb:.2f} GB" - traffic_display.short_description = 'Traffic' - - def get_queryset(self, request): - qs = super().get_queryset(request) - return qs.select_related('user', 'inbound', 'inbound__server') - - -# Automatic sync triggers -@receiver(post_save, sender=XrayInbound) -def trigger_sync_on_inbound_change(sender, instance, created, **kwargs): - """Trigger sync when inbound is created or modified.""" - if created or instance.enabled: - from vpn.tasks import sync_server - logger.info(f"Triggering sync for server {instance.server.name} due to inbound {instance.tag} change") - sync_server.delay(instance.server.id) - - -@receiver(post_save, sender=XrayClient) -def trigger_sync_on_client_change(sender, instance, created, **kwargs): - """Trigger sync when client is created or modified.""" - from vpn.tasks import sync_server - server = instance.inbound.server - logger.info(f"Triggering sync for server {server.name} due to client {instance.email} change") - sync_server.delay(server.id) - - -@receiver(post_delete, sender=XrayClient) -def trigger_sync_on_client_delete(sender, instance, **kwargs): - """Trigger sync when client is deleted.""" - from vpn.tasks import sync_server - server = instance.inbound.server - logger.info(f"Triggering sync for server {server.name} due to client {instance.email} deletion") - sync_server.delay(server.id) - - -@receiver(post_save, sender='vpn.ACL') -def trigger_sync_on_acl_change(sender, instance, created, **kwargs): - """Trigger sync when ACL is created or modified to ensure users are added to inbounds.""" - server = instance.server.get_real_instance() - if isinstance(server, XrayCoreServer): - from vpn.tasks import sync_server - logger.info(f"Triggering sync for server {server.name} due to ACL change for user {instance.user.username}") - sync_server.delay(server.id) - - -@receiver(post_delete, sender='vpn.ACL') -def trigger_sync_on_acl_delete(sender, instance, **kwargs): - """Trigger sync when ACL is deleted to ensure users are removed from inbounds.""" - server = instance.server.get_real_instance() - if isinstance(server, XrayCoreServer): - from vpn.tasks import sync_server - logger.info(f"Triggering sync for server {server.name} due to ACL deletion for user {instance.user.username}") - sync_server.delay(server.id) \ No newline at end of file diff --git a/vpn/server_plugins/xray_v2.py b/vpn/server_plugins/xray_v2.py new file mode 100644 index 0000000..2435f97 --- /dev/null +++ b/vpn/server_plugins/xray_v2.py @@ -0,0 +1,726 @@ +import logging +from django.db import models +from django.contrib import admin +from .generic import Server +from vpn.models_xray import XrayConfiguration, Inbound, UserSubscription + +logger = logging.getLogger(__name__) + + +class XrayServerV2(Server): + """ + New Xray server that works with subscription groups and inbounds. + This server can host multiple inbounds and users access them through subscription groups. + """ + client_hostname = models.CharField( + max_length=255, + help_text="Client connection hostname (what users see in their configs)" + ) + api_address = models.CharField( + max_length=255, + default="127.0.0.1:10085", + help_text="Xray gRPC API address for management" + ) + api_enabled = models.BooleanField( + default=True, + help_text="Enable gRPC API for user management" + ) + stats_enabled = models.BooleanField( + default=True, + help_text="Enable traffic statistics collection" + ) + + class Meta: + verbose_name = "Xray Server v2" + verbose_name_plural = "Xray Servers v2" + + def save(self, *args, **kwargs): + if not self.server_type: + self.server_type = 'xray_v2' + super().save(*args, **kwargs) + + def get_server_status(self): + """Get server status including active inbounds""" + try: + # Get basic server information + active_inbounds = self.get_active_inbounds() + + # Try to connect to Xray API if enabled + api_status = False + api_error = None + api_stats = {} + + if self.api_enabled: + try: + # Try different methods to check server status + import socket + import json + + # Parse API address + host, port = self.api_address.split(':') + port = int(port) + + # Test basic connection + logger.info(f"Testing connection to Xray API at {host}:{port}") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + result = sock.connect_ex((host, port)) + sock.close() + + if result == 0: + api_status = True + logger.info(f"Successfully connected to Xray API at {self.api_address}") + + # Try to get stats if library is available + try: + from vpn.xray_api_v2.server_manager import ServerManager + manager = ServerManager(self.api_address) + api_stats = manager.get_server_stats() + logger.info(f"Got server stats: {api_stats}") + except ImportError: + logger.info("Xray API v2 library not available, but connection successful") + api_stats = {"connection": "ok", "library": "not_available"} + except Exception as stats_e: + logger.warning(f"Connection OK but stats failed: {stats_e}") + api_stats = {"connection": "ok", "stats_error": str(stats_e)} + else: + api_error = f"Connection failed to {host}:{port}" + logger.warning(f"Failed to connect to Xray API at {self.api_address}: {api_error}") + + except Exception as e: + api_error = f"Connection test failed: {str(e)}" + logger.warning(f"Failed to test connection to Xray API for server {self.name}: {e}") + else: + api_error = "API disabled in server settings" + logger.info(f"API disabled for server {self.name}") + + # Build status response + status = { + 'server_name': self.name, + 'server_type': 'Xray Server v2', + 'client_hostname': self.client_hostname, + 'api_address': self.api_address, + 'api_enabled': self.api_enabled, + 'api_connected': api_status, + 'api_error': api_error, + 'api_stats': api_stats, + 'stats_enabled': self.stats_enabled, + 'total_inbounds': active_inbounds.count(), + 'inbound_ports': [], # Will be populated when ServerInbound model is fully implemented + 'accessible': api_status if self.api_enabled else True, # Consider accessible if API disabled + 'status': 'Connected' if api_status else 'API Issue' if self.api_enabled else 'No API Check' + } + + logger.info(f"Server status for {self.name}: {status['status']}") + return status + + except Exception as e: + logger.error(f"Failed to get status for Xray server {self.name}: {e}") + return { + 'error': str(e), + 'server_name': self.name, + 'server_type': 'Xray Server v2', + 'accessible': False, + 'status': 'Error' + } + + def get_active_inbounds(self): + """Get all inbounds that are deployed on this server""" + try: + from vpn.models_xray import ServerInbound + return ServerInbound.objects.filter(server=self, active=True).select_related('inbound') + except ImportError: + # ServerInbound model doesn't exist yet, return empty queryset + from django.db.models import QuerySet + from vpn.models_xray import Inbound + return Inbound.objects.none() + except Exception as e: + logger.warning(f"Error getting active inbounds for server {self.name}: {e}") + from vpn.models_xray import Inbound + return Inbound.objects.none() + + def sync_users(self): + """Sync all users who have subscription groups containing inbounds on this server""" + try: + from vpn.tasks import sync_server_users + task = sync_server_users.delay(self.id) + logger.info(f"Scheduled user sync for Xray server {self.name} - task ID: {task.id}") + # Return success to indicate task was scheduled + return {"status": "scheduled", "task_id": str(task.id)} + except Exception as e: + logger.error(f"Failed to schedule user sync for server {self.name}: {e}") + return {"status": "failed", "error": str(e)} + + def sync_inbounds(self): + """Deploy all required inbounds on this server based on subscription groups""" + try: + from vpn.tasks import sync_server_inbounds + task = sync_server_inbounds.delay(self.id) + logger.info(f"Scheduled inbound sync for Xray server {self.name} - task ID: {task.id}") + # Return None to match old behavior + return None + except Exception as e: + logger.error(f"Failed to schedule inbound sync for server {self.name}: {e}") + return None + + def deploy_inbound(self, inbound, users=None): + """Deploy a specific inbound on this server with optional users""" + try: + from vpn.xray_api_v2.client import XrayClient + import uuid + + logger.info(f"Deploying inbound {inbound.name} with protocol {inbound.protocol} on port {inbound.port}") + client = XrayClient(server=self.api_address) + + # Build user configs if users are provided + user_configs = [] + if users: + for user in users: + user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}")) + + if inbound.protocol == 'vless': + user_config = { + "email": f"{user.username}@{self.name}", + "id": user_uuid, + "level": 0 + } + elif inbound.protocol == 'vmess': + user_config = { + "email": f"{user.username}@{self.name}", + "id": user_uuid, + "level": 0, + "alterId": 0 + } + elif inbound.protocol == 'trojan': + user_config = { + "email": f"{user.username}@{self.name}", + "password": user_uuid, + "level": 0 + } + else: + logger.warning(f"Unsupported protocol {inbound.protocol} for user {user.username}") + continue + + user_configs.append(user_config) + logger.info(f"Added user {user.username} to inbound config") + + # Build proper inbound configuration based on protocol + if inbound.full_config: + inbound_config = inbound.full_config.copy() # Make a copy to modify + logger.info(f"Using existing full_config for inbound {inbound.name}") + + # Add users to the config if provided + if user_configs: + if 'settings' not in inbound_config: + inbound_config['settings'] = {} + inbound_config['settings']['clients'] = user_configs + logger.info(f"Added {len(user_configs)} users to full_config") + + # If inbound has a certificate, update the config to use inline certificates + if inbound.certificate and inbound.certificate.certificate_pem: + logger.info(f"Updating full_config with inline certificate for {inbound.certificate.domain}") + + # Convert PEM to lines for Xray format + cert_lines = inbound.certificate.certificate_pem.strip().split('\n') + key_lines = inbound.certificate.private_key_pem.strip().split('\n') + + # Update streamSettings if it exists + if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]: + inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{ + "certificate": cert_lines, + "key": key_lines, + "usage": "encipherment" + }] + logger.info("Updated existing tlsSettings with inline certificate") + else: + # Build full config based on protocol + inbound_config = { + "tag": inbound.name, + "port": inbound.port, + "protocol": inbound.protocol, + "listen": inbound.listen_address or "0.0.0.0", + } + + # Add protocol-specific settings + if inbound.protocol == 'vless': + inbound_config["settings"] = { + "clients": user_configs, # Add users during creation + "decryption": "none" + } + if inbound.network == 'ws': + inbound_config["streamSettings"] = { + "network": "ws", + "wsSettings": { + "path": f"/{inbound.name}" + } + } + elif inbound.network == 'tcp': + inbound_config["streamSettings"] = { + "network": "tcp" + } + elif inbound.protocol == 'vmess': + inbound_config["settings"] = { + "clients": user_configs # Add users during creation + } + if inbound.network == 'ws': + inbound_config["streamSettings"] = { + "network": "ws", + "wsSettings": { + "path": f"/{inbound.name}" + } + } + elif inbound.network == 'tcp': + inbound_config["streamSettings"] = { + "network": "tcp" + } + elif inbound.protocol == 'trojan': + inbound_config["settings"] = { + "clients": user_configs # Add users during creation + } + inbound_config["streamSettings"] = { + "network": "tcp", + "security": "tls" + } + + # Trojan always requires TLS certificate + if inbound.certificate and inbound.certificate.certificate_pem: + logger.info(f"Using certificate for Trojan inbound on domain {inbound.certificate.domain}") + # Convert PEM to lines for Xray format + cert_lines = inbound.certificate.certificate_pem.strip().split('\n') + key_lines = inbound.certificate.private_key_pem.strip().split('\n') + + inbound_config["streamSettings"]["tlsSettings"] = { + "certificates": [{ + "certificate": cert_lines, + "key": key_lines, + "usage": "encipherment" + }] + } + else: + logger.error(f"Trojan protocol requires certificate, but none found for inbound {inbound.name}!") + inbound_config["streamSettings"]["tlsSettings"] = { + "certificates": [] + } + + # Add TLS if specified + if inbound.security == 'tls' and inbound.protocol != 'trojan': + if "streamSettings" not in inbound_config: + inbound_config["streamSettings"] = {} + inbound_config["streamSettings"]["security"] = "tls" + + # Check if inbound has a certificate + if inbound.certificate and inbound.certificate.certificate_pem: + logger.info(f"Using certificate for domain {inbound.certificate.domain}") + # Convert PEM to lines for Xray format + cert_lines = inbound.certificate.certificate_pem.strip().split('\n') + key_lines = inbound.certificate.private_key_pem.strip().split('\n') + + inbound_config["streamSettings"]["tlsSettings"] = { + "certificates": [{ + "certificate": cert_lines, + "key": key_lines, + "usage": "encipherment" + }] + } + else: + logger.warning(f"No certificate found for inbound {inbound.name}, TLS will not work!") + inbound_config["streamSettings"]["tlsSettings"] = { + "certificates": [] + } + + logger.info(f"Inbound config: {inbound_config}") + + # Add inbound using the client's add_inbound method which handles wrapping + try: + result = client.add_inbound(inbound_config) + logger.info(f"Deploy inbound result: {result}") + + # Check if command was successful + if result is not None and not (isinstance(result, dict) and 'error' in result): + # Mark as deployed on this server + from vpn.models_xray import ServerInbound + ServerInbound.objects.update_or_create( + server=self, + inbound=inbound, + defaults={'active': True} + ) + logger.info(f"Successfully deployed inbound {inbound.name} on server {self.name}") + return True + else: + logger.error(f"Failed to deploy inbound {inbound.name} on server {self.name}. Result: {result}") + return False + except Exception as cmd_error: + logger.error(f"Command execution error: {cmd_error}") + return False + + except Exception as e: + logger.error(f"Error deploying inbound {inbound.name} on server {self.name}: {e}") + return False + + def add_user_to_inbound(self, user, inbound): + """Add a user to a specific inbound on this server using inbound recreation approach""" + try: + from vpn.xray_api_v2.client import XrayClient + import uuid + + logger.info(f"Adding user {user.username} to inbound {inbound.name} using inbound recreation") + client = XrayClient(server=self.api_address) + + # Generate user UUID based on username and inbound + user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}")) + logger.info(f"Generated UUID for user {user.username}: {user_uuid}") + + # Build user config based on protocol + if inbound.protocol == 'vless': + user_config = { + "email": f"{user.username}@{self.name}", + "id": user_uuid, + "level": 0 + } + elif inbound.protocol == 'vmess': + user_config = { + "email": f"{user.username}@{self.name}", + "id": user_uuid, + "level": 0, + "alterId": 0 + } + elif inbound.protocol == 'trojan': + user_config = { + "email": f"{user.username}@{self.name}", + "password": user_uuid, + "level": 0 + } + else: + logger.error(f"Unsupported protocol: {inbound.protocol}") + return False + + try: + # First, get existing inbound to check for other users + existing_result = client.execute_command('lsi') + existing_inbound = None + + if existing_result and 'inbounds' in existing_result: + for ib in existing_result['inbounds']: + if ib.get('tag') == inbound.name: + existing_inbound = ib + break + + if not existing_inbound: + logger.warning(f"Inbound {inbound.name} not found on server, deploying it first") + # Deploy the inbound if it doesn't exist + if not self.deploy_inbound(inbound): + logger.error(f"Failed to deploy inbound {inbound.name}") + return False + # Get the inbound config we just created + existing_inbound = {"settings": {"clients": []}} + + # Get existing users from the inbound + existing_users = existing_inbound.get('settings', {}).get('clients', []) + logger.info(f"Found {len(existing_users)} existing users in inbound {inbound.name}") + + # Check if user already exists + for existing_user in existing_users: + if existing_user.get('email') == f"{user.username}@{self.name}": + logger.info(f"User {user.username} already exists in inbound {inbound.name}") + return True + + # Add new user to existing users list + existing_users.append(user_config) + logger.info(f"Creating new inbound with {len(existing_users)} users including {user.username}") + + # Remove the old inbound + logger.info(f"Removing old inbound {inbound.name}") + client.remove_inbound(inbound.name) + + # Recreate inbound with updated user list + if inbound.full_config: + inbound_config = inbound.full_config.copy() + if 'settings' not in inbound_config: + inbound_config['settings'] = {} + inbound_config['settings']['clients'] = existing_users + + # Handle certificate embedding if needed + if inbound.certificate and inbound.certificate.certificate_pem: + cert_lines = inbound.certificate.certificate_pem.strip().split('\n') + key_lines = inbound.certificate.private_key_pem.strip().split('\n') + + if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]: + inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{ + "certificate": cert_lines, + "key": key_lines, + "usage": "encipherment" + }] + else: + # Build config from scratch with the users + inbound_config = { + "tag": inbound.name, + "port": inbound.port, + "protocol": inbound.protocol, + "listen": inbound.listen_address or "0.0.0.0", + "settings": {} + } + + if inbound.protocol in ['vless', 'vmess']: + inbound_config["settings"]["clients"] = existing_users + if inbound.protocol == 'vless': + inbound_config["settings"]["decryption"] = "none" + elif inbound.protocol == 'trojan': + inbound_config["settings"]["clients"] = existing_users + + logger.info(f"Deploying updated inbound with users: {[u.get('email') for u in existing_users]}") + result = client.add_inbound(inbound_config) + + if result is not None and not (isinstance(result, dict) and 'error' in result): + logger.info(f"Successfully added user {user.username} to inbound {inbound.name} via inbound recreation") + return True + else: + logger.error(f"Failed to recreate inbound {inbound.name} with user. Result: {result}") + return False + + except Exception as cmd_error: + logger.error(f"Error during inbound recreation: {cmd_error}") + return False + + except Exception as e: + logger.error(f"Error adding user {user.username} to inbound {inbound.name} on server {self.name}: {e}") + return False + + def remove_user_from_inbound(self, user, inbound): + """Remove a user from a specific inbound on this server""" + try: + from vpn.xray_api_v2.client import XrayClient + + client = XrayClient(server=self.api_address) + + # Remove user using the client's remove_users method + user_email = f"{user.username}@{self.name}" + logger.info(f"Removing user {user_email} from inbound {inbound.name}") + + result = client.remove_users(inbound.name, user_email) + logger.info(f"Remove user result: {result}") + + if result is not None and not (isinstance(result, dict) and 'error' in result): + logger.info(f"Successfully removed user {user.username} from inbound {inbound.name} on server {self.name}") + return True + else: + logger.error(f"Failed to remove user {user.username} from inbound {inbound.name} on server {self.name}. Result: {result}") + return False + + except Exception as e: + logger.error(f"Error removing user {user.username} from inbound {inbound.name} on server {self.name}: {e}") + return False + + def get_user_configs(self, user): + """Generate all connection configs for a user on this server""" + configs = [] + + try: + # Get all subscription groups for this user + user_subscriptions = UserSubscription.objects.filter( + user=user, + active=True, + subscription_group__is_active=True + ).select_related('subscription_group').prefetch_related('subscription_group__inbounds') + + for subscription in user_subscriptions: + group = subscription.subscription_group + + # Check which inbounds from this group are active on this server + active_inbounds = self.get_active_inbounds().filter( + inbound__in=group.inbounds.all() + ) + + for server_inbound in active_inbounds: + inbound = server_inbound.inbound + + try: + # Generate connection string directly + from vpn.views import generate_xray_connection_string + connection_string = generate_xray_connection_string(user, inbound) + + if connection_string: + configs.append({ + 'protocol': inbound.protocol, + 'inbound_name': inbound.name, + 'group_name': group.name, + 'connection_string': connection_string, + 'port': inbound.port, + 'network': inbound.network, + 'security': inbound.security + }) + + except Exception as e: + logger.warning(f"Failed to generate config for user {user.username} on inbound {inbound.name}: {e}") + continue + + logger.info(f"Generated {len(configs)} configs for user {user.username} on server {self.name}") + return configs + + except Exception as e: + logger.error(f"Error generating user configs for {user.username} on server {self.name}: {e}") + return [] + + def sync(self): + """Sync server configuration and users""" + try: + self.sync_inbounds() + self.sync_users() + logger.info(f"Full sync completed for server {self.name}") + except Exception as e: + logger.error(f"Sync failed for server {self.name}: {e}") + + def add_user(self, user, **kwargs): + """Add user to server - implemented through subscription groups""" + try: + from vpn.xray_api_v2.client import XrayClient + client = XrayClient(server=self.api_address) + + # Users are added through subscription groups in the new architecture + subscriptions = user.xray_subscriptions.filter(active=True) + added_count = 0 + + logger.info(f"User {user.username} has {subscriptions.count()} active subscriptions") + + if subscriptions.count() == 0: + logger.warning(f"User {user.username} has no active Xray subscriptions - cannot add to server") + return False + + # Get all inbounds that this user should have access to + inbounds_to_process = [] + for subscription in subscriptions: + logger.info(f"Processing subscription group: {subscription.subscription_group.name}") + for inbound in subscription.subscription_group.inbounds.all(): + if inbound not in inbounds_to_process: + inbounds_to_process.append(inbound) + logger.info(f"Added inbound {inbound.name} to processing list") + + # Get existing inbounds on server + try: + existing_result = client.execute_command('lsi') # List inbounds + existing_inbound_tags = set() + if existing_result and 'inbounds' in existing_result: + existing_inbound_tags = {ib.get('tag') for ib in existing_result['inbounds'] if ib.get('tag')} + logger.info(f"Existing inbound tags on server: {existing_inbound_tags}") + except Exception as e: + logger.warning(f"Failed to list inbounds: {e}") + existing_inbound_tags = set() + + # Process each inbound + for inbound in inbounds_to_process: + logger.info(f"Processing inbound: {inbound.name} (protocol: {inbound.protocol})") + + # Check if inbound exists on server + if inbound.name not in existing_inbound_tags: + logger.info(f"Inbound {inbound.name} doesn't exist on server, creating with user") + # Create the inbound with the user directly + if self.deploy_inbound(inbound, users=[user]): + logger.info(f"Successfully created inbound {inbound.name} with user {user.username}") + added_count += 1 + existing_inbound_tags.add(inbound.name) + + # Mark as deployed on this server + from vpn.models_xray import ServerInbound + ServerInbound.objects.update_or_create( + server=self, + inbound=inbound, + defaults={'active': True} + ) + else: + logger.error(f"Failed to create inbound {inbound.name} with user") + continue + else: + # Inbound exists, add user using recreation approach + logger.info(f"Inbound {inbound.name} exists, adding user via recreation") + if self.add_user_to_inbound(user, inbound): + added_count += 1 + logger.info(f"Successfully added user {user.username} to existing inbound {inbound.name}") + else: + logger.error(f"Failed to add user {user.username} to existing inbound {inbound.name}") + + logger.info(f"Added user {user.username} to {added_count} inbounds on server {self.name}") + return added_count > 0 + + except Exception as e: + logger.error(f"Failed to add user {user.username} to server {self.name}: {e}") + return False + + def get_user(self, user, raw=False): + """Get user configurations from server""" + try: + configs = self.get_user_configs(user) + if raw: + return { + 'configs': configs, + 'total_configs': len(configs) + } + return configs + + except Exception as e: + logger.error(f"Failed to get user {user.username} from server {self.name}: {e}") + return [] if not raw else {'error': str(e)} + + def delete_user(self, user): + """Remove user from server""" + try: + removed_count = 0 + subscriptions = user.xray_subscriptions.filter(active=True) + + for subscription in subscriptions: + for inbound in subscription.subscription_group.inbounds.all(): + if self.remove_user_from_inbound(user, inbound): + removed_count += 1 + + logger.info(f"Removed user {user.username} from {removed_count} inbounds on server {self.name}") + return removed_count > 0 + + except Exception as e: + logger.error(f"Failed to remove user {user.username} from server {self.name}: {e}") + return False + + def __str__(self): + return f"Xray Server v2: {self.name}" + + +class XrayServerV2Admin(admin.ModelAdmin): + list_display = ['name', 'client_hostname', 'api_address', 'api_enabled', 'stats_enabled', 'registration_date'] + list_filter = ['api_enabled', 'stats_enabled', 'registration_date'] + search_fields = ['name', 'client_hostname', 'comment'] + readonly_fields = ['server_type', 'registration_date'] + + fieldsets = [ + ('Basic Information', { + 'fields': ('name', 'comment', 'server_type') + }), + ('Connection Settings', { + 'fields': ('client_hostname', 'api_address') + }), + ('Features', { + 'fields': ('api_enabled', 'stats_enabled') + }), + ('Timestamps', { + 'fields': ('registration_date',), + 'classes': ('collapse',) + }) + ] + + actions = ['sync_users', 'sync_inbounds', 'get_status'] + + def sync_users(self, request, queryset): + for server in queryset: + server.sync_users() + self.message_user(request, f"Scheduled user sync for {queryset.count()} servers") + sync_users.short_description = "Sync users for selected servers" + + def sync_inbounds(self, request, queryset): + for server in queryset: + server.sync_inbounds() + self.message_user(request, f"Scheduled inbound sync for {queryset.count()} servers") + sync_inbounds.short_description = "Sync inbounds for selected servers" + + def get_status(self, request, queryset): + statuses = [] + for server in queryset: + status = server.get_server_status() + statuses.append(f"{server.name}: {status.get('accessible', 'Unknown')}") + self.message_user(request, f"Server statuses: {', '.join(statuses)}") + get_status.short_description = "Check status of selected servers" \ No newline at end of file diff --git a/vpn/tasks.py b/vpn/tasks.py index 9800331..b21af99 100644 --- a/vpn/tasks.py +++ b/vpn/tasks.py @@ -54,7 +54,7 @@ def cleanup_task_logs(): 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 + from vpn.server_plugins.xray_v2 import XrayServerV2 start_time = time.time() task_id = self.request.id @@ -63,7 +63,7 @@ def sync_xray_inbounds(self, server_id): try: server = Server.objects.get(id=server_id) - if not isinstance(server.get_real_instance(), XrayCoreServer): + if not isinstance(server.get_real_instance(), XrayServerV2): 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) @@ -105,7 +105,7 @@ def sync_xray_inbounds(self, server_id): 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 + from vpn.server_plugins.xray_v2 import XrayServerV2 start_time = time.time() task_id = self.request.id @@ -114,7 +114,7 @@ def sync_xray_users(self, server_id): try: server = Server.objects.get(id=server_id) - if not isinstance(server.get_real_instance(), XrayCoreServer): + if not isinstance(server.get_real_instance(), XrayServerV2): 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) @@ -247,45 +247,13 @@ 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}") - # 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() + # For Xray servers, use the new sync methods + from vpn.server_plugins.xray_v2 import XrayServerV2 + if isinstance(server.get_real_instance(), XrayServerV2): + logger.info(f"Using XrayServerV2 sync for server {server.name}") + # Just call the sync method which schedules tasks asynchronously + sync_result = server.sync_users() + logger.info(f"Scheduled async sync for Xray server {server.name}") else: # For non-Xray servers, just sync users sync_result = server.sync_users() @@ -566,4 +534,427 @@ def sync_user(self, user_id, server_id): if errors: raise TaskFailedException(message=f"Errors during task: {errors}") - return result \ No newline at end of file + return result + + +@shared_task(name="sync_user_xray_access", bind=True) +def sync_user_xray_access(self, user_id, server_id): + """ + Sync user's Xray access based on subscription groups. + Creates inbounds on server if needed and adds user to them. + """ + from .models import User, Server + from .models_xray import SubscriptionGroup, Inbound, XrayConfiguration + from vpn.xray_api_v2.client import XrayClient + + start_time = time.time() + task_id = self.request.id + + try: + user = User.objects.get(id=user_id) + server = Server.objects.get(id=server_id) + + # Get Xray configuration + xray_config = XrayConfiguration.objects.first() + if not xray_config: + raise ValueError("Xray configuration not found. Please configure in admin.") + + create_task_log( + task_id, "sync_user_xray_access", + f"Starting Xray sync for {user.username} on {server.name}", + 'STARTED', server=server, user=user + ) + + # Get user's active subscription groups + user_groups = SubscriptionGroup.objects.filter( + usersubscription__user=user, + usersubscription__active=True, + is_active=True + ).prefetch_related('inbounds') + + if not user_groups.exists(): + logger.info(f"User {user.username} has no active subscriptions") + return {"status": "No active subscriptions"} + + # Collect all inbounds from user's groups + user_inbounds = Inbound.objects.filter( + subscriptiongroup__in=user_groups + ).distinct() + + logger.info(f"User {user.username} has access to {user_inbounds.count()} inbounds") + + # Connect to Xray server + client = XrayClient(xray_config.grpc_address) + + # Get existing inbounds on server + try: + existing_result = client.execute_command('lsi') # List inbounds + existing_inbounds = existing_result.get('inbounds', []) if existing_result else [] + existing_tags = {ib.get('tag') for ib in existing_inbounds if ib.get('tag')} + except Exception as e: + logger.warning(f"Failed to list existing inbounds: {e}") + existing_tags = set() + + results = { + 'inbounds_created': [], + 'users_added': [], + 'errors': [] + } + + # Process each inbound + for inbound in user_inbounds: + try: + # Check if inbound exists on server + if inbound.name not in existing_tags: + logger.info(f"Creating inbound {inbound.name} on server") + + # Build inbound configuration + if not inbound.full_config: + inbound.build_config() + inbound.save() + + # Add inbound to server + client.execute_command('adi', json_files=[inbound.full_config]) + results['inbounds_created'].append(inbound.name) + + # Add user to inbound + logger.info(f"Adding user {user.username} to inbound {inbound.name}") + + # Create user config based on protocol + import uuid + + # Generate user UUID based on username and inbound + user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}")) + + if inbound.protocol == 'vless': + user_config = { + "email": f"{user.username}@{server.name}", + "id": user_uuid, + "level": 0 + } + elif inbound.protocol == 'vmess': + user_config = { + "email": f"{user.username}@{server.name}", + "id": user_uuid, + "level": 0, + "alterId": 0 + } + elif inbound.protocol == 'trojan': + user_config = { + "email": f"{user.username}@{server.name}", + "password": user_uuid, + "level": 0 + } + else: + logger.warning(f"Unsupported protocol: {inbound.protocol}") + continue + + # Add user to inbound + add_request = { + "inboundTag": inbound.name, + "user": user_config + } + + client.execute_command('adu', json_files=[add_request]) + results['users_added'].append(f"{user.username} -> {inbound.name}") + + except Exception as e: + error_msg = f"Error processing inbound {inbound.name}: {e}" + logger.error(error_msg) + results['errors'].append(error_msg) + + # Log results + success_msg = ( + f"Xray sync completed for {user.username}: " + f"Created {len(results['inbounds_created'])} inbounds, " + f"Added user to {len(results['users_added'])} inbounds" + ) + + create_task_log( + task_id, "sync_user_xray_access", + "Xray sync completed", 'SUCCESS', + server=server, user=user, + message=success_msg, + execution_time=time.time() - start_time + ) + + return results + + except Exception as e: + error_msg = f"Error in Xray sync: {e}" + logger.error(error_msg, exc_info=True) + + create_task_log( + task_id, "sync_user_xray_access", + "Xray sync failed", 'FAILURE', + message=error_msg, + execution_time=time.time() - start_time + ) + + raise + + +@shared_task(name="sync_server_users", bind=True) +def sync_server_users(self, server_id): + """ + Sync all users for a specific Xray server. + This is called by XrayServerV2.sync_users() + """ + from vpn.server_plugins import Server + from vpn.models import User, ACL + from vpn.models_xray import UserSubscription + + try: + server = Server.objects.get(id=server_id) + real_server = server.get_real_instance() + + # Get all users who should have access to this server + # For Xray v2, users access through subscription groups + users_to_sync = User.objects.filter( + xray_subscriptions__active=True, + xray_subscriptions__subscription_group__is_active=True + ).distinct() + + logger.info(f"Syncing {users_to_sync.count()} users for Xray server {server.name}") + + added_count = 0 + for user in users_to_sync: + try: + if real_server.add_user(user): + added_count += 1 + except Exception as e: + logger.error(f"Failed to sync user {user.username} on server {server.name}: {e}") + + logger.info(f"Successfully synced {added_count} users for server {server.name}") + return {"users_added": added_count, "total_users": users_to_sync.count()} + + except Exception as e: + logger.error(f"Error syncing users for server {server_id}: {e}") + raise + + +@shared_task(name="sync_server_inbounds", bind=True) +def sync_server_inbounds(self, server_id): + """ + Sync all inbounds for a specific Xray server. + This is called by XrayServerV2.sync_inbounds() + """ + from vpn.server_plugins import Server + from vpn.models_xray import SubscriptionGroup, ServerInbound + + try: + server = Server.objects.get(id=server_id) + real_server = server.get_real_instance() + + # Get all subscription groups + groups = SubscriptionGroup.objects.filter(is_active=True).prefetch_related('inbounds') + + deployed_count = 0 + for group in groups: + for inbound in group.inbounds.all(): + try: + if real_server.deploy_inbound(inbound): + deployed_count += 1 + logger.info(f"Deployed inbound {inbound.name} on server {server.name}") + except Exception as e: + logger.error(f"Failed to deploy inbound {inbound.name} on server {server.name}: {e}") + + logger.info(f"Successfully deployed {deployed_count} inbounds on server {server.name}") + return {"inbounds_deployed": deployed_count} + + except Exception as e: + logger.error(f"Error syncing inbounds for server {server_id}: {e}") + raise + + +@shared_task(name="generate_certificate_task", bind=True) +def generate_certificate_task(self, certificate_id): + """ + Generate Let's Encrypt certificate for a domain + """ + from .models_xray import Certificate + from vpn.letsencrypt.letsencrypt_dns import get_certificate_for_domain + from django.utils import timezone + from datetime import timedelta + + start_time = time.time() + task_id = self.request.id + + try: + cert = Certificate.objects.get(id=certificate_id) + + create_task_log( + task_id, "generate_certificate_task", + f"Starting certificate generation for {cert.domain}", + 'STARTED' + ) + + # Check if we have credentials + if not cert.credentials: + raise ValueError(f"No credentials configured for {cert.domain}") + + # Get Cloudflare token from credentials + cf_token = cert.credentials.get_credential('api_token') + if not cf_token: + raise ValueError(f"No Cloudflare API token found for {cert.domain}") + + logger.info(f"Generating certificate for {cert.domain} using email {cert.acme_email}") + + # Request certificate using the library function + cert_pem, key_pem = get_certificate_for_domain( + domain=cert.domain, + email=cert.acme_email, + cloudflare_token=cf_token, + staging=False # Production certificate + ) + + # Update certificate object + cert.certificate_pem = cert_pem + cert.private_key_pem = key_pem + cert.expires_at = timezone.now() + timedelta(days=90) # Let's Encrypt certs are valid for 90 days + cert.last_renewed = timezone.now() + cert.save() + + success_msg = f"Certificate for {cert.domain} generated successfully" + logger.info(success_msg) + + create_task_log( + task_id, "generate_certificate_task", + "Certificate generated", 'SUCCESS', + message=success_msg, + execution_time=time.time() - start_time + ) + + return {"status": "success", "domain": cert.domain} + + except Certificate.DoesNotExist: + error_msg = f"Certificate with id {certificate_id} not found" + logger.error(error_msg) + + create_task_log( + task_id, "generate_certificate_task", + "Certificate not found", 'FAILURE', + message=error_msg, + execution_time=time.time() - start_time + ) + raise + + except Exception as e: + error_msg = f"Failed to generate certificate: {e}" + logger.error(error_msg, exc_info=True) + + create_task_log( + task_id, "generate_certificate_task", + "Certificate generation failed", 'FAILURE', + message=error_msg, + execution_time=time.time() - start_time + ) + raise + + +@shared_task(name="renew_certificates", bind=True) +def renew_certificates(self): + """ + Check and renew certificates that are about to expire. + """ + from .models_xray import Certificate, XrayConfiguration + from .letsencrypt import get_certificate_for_domain + from datetime import datetime + + start_time = time.time() + task_id = self.request.id + + create_task_log(task_id, "renew_certificates", "Starting certificate renewal check", 'STARTED') + + try: + # Get certificates that need renewal + certs_to_renew = Certificate.objects.filter( + auto_renew=True, + cert_type='letsencrypt' + ) + + renewed_count = 0 + errors = [] + + for cert in certs_to_renew: + if not cert.needs_renewal: + continue + + try: + logger.info(f"Renewing certificate for {cert.domain}") + + # Check if we have credentials + if not cert.credentials: + logger.warning(f"No credentials configured for {cert.domain}") + continue + + # Get Cloudflare token from credentials + cf_token = cert.credentials.get_credential('api_token') + cf_email = cert.credentials.get_credential('email', 'admin@example.com') + + if not cf_token: + logger.error(f"No Cloudflare API token found for {cert.domain}") + continue + + # Renew certificate + cert_pem, key_pem = get_certificate_for_domain( + domain=cert.domain, + email=cf_email, + cloudflare_token=cf_token, + staging=False # Production certificate + ) + + # Update certificate + cert.certificate_pem = cert_pem + cert.private_key_pem = key_pem + cert.last_renewed = datetime.now() + cert.expires_at = datetime.now() + timedelta(days=90) # Let's Encrypt certs are valid for 90 days + cert.save() + + renewed_count += 1 + logger.info(f"Successfully renewed certificate for {cert.domain}") + + except Exception as e: + error_msg = f"Failed to renew certificate for {cert.domain}: {e}" + logger.error(error_msg) + errors.append(error_msg) + + # Summary + if renewed_count > 0 or errors: + summary = f"Renewed {renewed_count} certificates" + if errors: + summary += f", {len(errors)} errors" + + create_task_log( + task_id, "renew_certificates", + "Certificate renewal completed", + 'SUCCESS' if not errors else 'PARTIAL', + message=summary, + execution_time=time.time() - start_time + ) + else: + create_task_log( + task_id, "renew_certificates", + "No certificates need renewal", + 'SUCCESS', + execution_time=time.time() - start_time + ) + + return { + 'renewed': renewed_count, + 'errors': errors + } + + except Exception as e: + error_msg = f"Certificate renewal task failed: {e}" + logger.error(error_msg, exc_info=True) + + create_task_log( + task_id, "renew_certificates", + "Certificate renewal failed", + 'FAILURE', + message=error_msg, + execution_time=time.time() - start_time + ) + + raise \ No newline at end of file diff --git a/vpn/templates/vpn/user_portal.html b/vpn/templates/vpn/user_portal.html index fabb18e..bfa0cf5 100644 --- a/vpn/templates/vpn/user_portal.html +++ b/vpn/templates/vpn/user_portal.html @@ -450,12 +450,12 @@
Welcome back, {{ user.username }}
- {{ total_servers }} - Available Servers + {{ total_groups }} + Subscription Groups
- {{ total_links }} - Active Links + {{ total_inbounds }} + Available Inbounds
{{ total_connections }} @@ -475,87 +475,136 @@
- {% if has_xray_servers and user_links %} + {% if has_xray_access %}

🚀 Xray Universal Subscription

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

{% endif %}
- {% if servers_data %} + {% if groups_data %}
- {% for server_name, server_data in servers_data.items %} + {% for group_name, group_data in groups_data.items %}
-
{{ server_name }}
+
{{ group_name }}
- 📊 {{ server_data.total_connections }} uses + 📊 {{ group_data.total_connections }} uses + 🔗 {{ group_data.inbounds|length }} inbound(s)
-
{{ server_data.server_type }}
+
Xray Group
- {% if server_data.accessible %} -
-
- Online & Ready -
- {% else %} -
-
- Connection Issues -
- {% endif %} +
+
+ Active Subscription +
+ {% else %}
-

No VPN Access Available

-

You don't have access to any VPN servers yet. Please contact your administrator.

+

No Xray Subscriptions Available

+

You don't have access to any subscription groups yet. Please contact your administrator.

{% endif %} + + {% if has_old_links %} +

Legacy Shadowsocks Access

+
+ {% for acl_link in acl_links %} +
+
+
+
{{ acl_link.acl.server.name }}
+
+ 📊 Legacy +
+
+
Shadowsocks
+
+ +
+
+
+ Legacy Access +
+
+ + +
+ {% endfor %} +
+ {% endif %} +