diff --git a/vpn/admin_xray.py b/vpn/admin_xray.py index c468080..cf9b99d 100644 --- a/vpn/admin_xray.py +++ b/vpn/admin_xray.py @@ -441,17 +441,18 @@ class CertificateAdmin(admin.ModelAdmin): @admin.register(Inbound) class InboundAdmin(admin.ModelAdmin): - """Admin for inbound management""" + """Admin for inbound template management""" list_display = ( 'name', 'protocol', 'port', 'network', 'security', 'certificate_status', 'group_count' ) list_filter = ('protocol', 'network', 'security') - search_fields = ('name', 'domain') + search_fields = ('name',) fieldsets = ( ('Basic Configuration', { - 'fields': ('name', 'protocol', 'port', 'domain') + 'fields': ('name', 'protocol', 'port'), + 'description': 'Domain will be taken from server client_hostname when deployed' }), ('Transport & Security', { 'fields': ('network', 'security', 'certificate', 'listen_address') @@ -523,7 +524,6 @@ class InboundInline(admin.TabularInline): 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') @@ -584,6 +584,7 @@ class SubscriptionGroupAdmin(admin.ModelAdmin): group_statistics.short_description = 'Group Statistics' + class UserSubscriptionInline(admin.TabularInline): """Inline for user subscriptions""" model = UserSubscription @@ -673,21 +674,19 @@ def add_subscription_management_to_user(UserAdmin): UserAdmin.subscription_groups_widget = subscription_groups_widget -# Register admin for UserSubscription (if needed separately) -@admin.register(UserSubscription) +# UserSubscription admin will be integrated into unified Subscriptions admin class UserSubscriptionAdmin(admin.ModelAdmin): - """Standalone admin for user subscriptions""" + """Admin for user subscriptions (integrated into unified Subscriptions admin)""" 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 + return True # Allow adding subscriptions -@admin.register(ServerInbound) +# ServerInbound admin is integrated into XrayServerV2 admin, not shown in main menu class ServerInboundAdmin(admin.ModelAdmin): """Admin for server-inbound deployment tracking""" list_display = ('server', 'inbound', 'active', 'deployed_at', 'updated_at') @@ -696,27 +695,173 @@ class ServerInboundAdmin(admin.ModelAdmin): date_hierarchy = 'deployed_at' fieldsets = ( - ('Deployment', { + ('Template 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') + readonly_fields = ('deployed_at', 'updated_at') + + +# Unified Subscriptions Admin with tabs +@admin.register(SubscriptionGroup) +class UnifiedSubscriptionsAdmin(admin.ModelAdmin): + """Unified admin for managing both Subscription Groups and User Subscriptions""" - 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) + # Use SubscriptionGroup as the base model but provide access to UserSubscription via tabs + list_display = ('name', 'is_active', 'inbound_count', 'user_count', 'created_at') + list_filter = ('is_active',) + search_fields = ('name', 'description') + filter_horizontal = ('inbounds',) + + def get_urls(self): + """Add custom URLs for user subscriptions tab""" + urls = super().get_urls() + custom_urls = [ + path('user-subscriptions/', + self.admin_site.admin_view(self.user_subscriptions_view), + name='vpn_usersubscription_changelist_tab'), + ] + return custom_urls + urls + + def user_subscriptions_view(self, request): + """Redirect to user subscriptions with tab navigation""" + from django.shortcuts import redirect + return redirect('/admin/vpn/usersubscription/') + + def changelist_view(self, request, extra_context=None): + """Override changelist to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'subscription_groups' + }) + return super().changelist_view(request, extra_context) + + def change_view(self, request, object_id, form_url='', extra_context=None): + """Override change view to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'subscription_groups' + }) + return super().change_view(request, object_id, form_url, extra_context) + + def add_view(self, request, form_url='', extra_context=None): + """Override add view to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'subscription_groups' + }) + return super().add_view(request, form_url, extra_context) + + # Copy fieldsets and methods from SubscriptionGroupAdmin + fieldsets = ( + ('Group Information', { + 'fields': ('name', 'description', 'is_active') + }), + ('Inbounds', { + 'fields': ('inbounds',), + 'description': 'Select inbounds to include in this group. ' + + '
🚀 Auto-sync enabled: Changes will be automatically deployed to servers!' + }), + ('Statistics', { + 'fields': ('group_statistics',), + 'classes': ('collapse',) + }) + ) + + readonly_fields = ('group_statistics',) + + def save_model(self, request, obj, form, change): + """Override save to notify about auto-sync""" + super().save_model(request, obj, form, change) + if change: + messages.success( + request, + f'Subscription group "{obj.name}" updated. Changes will be automatically synchronized to all Xray servers.' ) - return 'No additional deployment configuration' - deployment_config_display.short_description = 'Deployment Config Preview' \ No newline at end of file + else: + messages.success( + request, + f'Subscription group "{obj.name}" created. Inbounds will be automatically deployed when you add them to this group.' + ) + + 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' + + +# UserSubscription admin with tab navigation (hidden from main menu) +@admin.register(UserSubscription) +class UserSubscriptionTabAdmin(UserSubscriptionAdmin): + """UserSubscription admin with tab navigation""" + + def has_module_permission(self, request): + """Hide this model from the main admin index""" + return False + + def has_view_permission(self, request, obj=None): + """Allow viewing through direct URL access""" + return request.user.is_staff + + def has_add_permission(self, request): + """Allow adding through direct URL access""" + return request.user.is_staff + + def has_change_permission(self, request, obj=None): + """Allow changing through direct URL access""" + return request.user.is_staff + + def has_delete_permission(self, request, obj=None): + """Allow deleting through direct URL access""" + return request.user.is_staff + + def changelist_view(self, request, extra_context=None): + """Override changelist to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'user_subscriptions' + }) + return super().changelist_view(request, extra_context) + + def change_view(self, request, object_id, form_url='', extra_context=None): + """Override change view to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'user_subscriptions' + }) + return super().change_view(request, object_id, form_url, extra_context) + + def add_view(self, request, form_url='', extra_context=None): + """Override add view to add tab navigation""" + extra_context = extra_context or {} + extra_context.update({ + 'show_tab_navigation': True, + 'current_tab': 'user_subscriptions' + }) + return super().add_view(request, form_url, extra_context) \ No newline at end of file diff --git a/vpn/migrations/0022_remove_inbound_domain_field.py b/vpn/migrations/0022_remove_inbound_domain_field.py new file mode 100644 index 0000000..0d6f95f --- /dev/null +++ b/vpn/migrations/0022_remove_inbound_domain_field.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.7 on 2025-08-08 04:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0021_remove_xray_configuration'), + ] + + operations = [ + migrations.AlterModelOptions( + name='inbound', + options={'ordering': ['protocol', 'port'], 'verbose_name': 'Inbound Template', 'verbose_name_plural': 'Inbound Templates'}, + ), + migrations.RemoveField( + model_name='inbound', + name='domain', + ), + ] diff --git a/vpn/migrations/0023_alter_subscriptiongroup_options.py b/vpn/migrations/0023_alter_subscriptiongroup_options.py new file mode 100644 index 0000000..aec3148 --- /dev/null +++ b/vpn/migrations/0023_alter_subscriptiongroup_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.7 on 2025-08-08 04:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0022_remove_inbound_domain_field'), + ] + + operations = [ + migrations.AlterModelOptions( + name='subscriptiongroup', + options={'ordering': ['name'], 'verbose_name': 'Subscriptions', 'verbose_name_plural': 'Subscriptions'}, + ), + ] diff --git a/vpn/models_xray.py b/vpn/models_xray.py index 0b00ee7..272e590 100644 --- a/vpn/models_xray.py +++ b/vpn/models_xray.py @@ -200,11 +200,6 @@ class Inbound(models.Model): 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( @@ -228,8 +223,8 @@ class Inbound(models.Model): updated_at = models.DateTimeField(auto_now=True) class Meta: - verbose_name = "Inbound" - verbose_name_plural = "Inbounds" + verbose_name = "Inbound Template" + verbose_name_plural = "Inbound Templates" ordering = ['protocol', 'port'] unique_together = [['port', 'listen_address']] @@ -322,14 +317,14 @@ class Inbound(models.Model): elif self.network == "http": stream_settings["httpSettings"] = { "path": f"/{self.name}", - "host": [self.domain] if self.domain else [] + "host": [] # Will be filled when deployed to server } # Add security settings if self.security == "tls": stream_settings["security"] = "tls" tls_settings = { - "serverName": self.domain or "localhost", + "serverName": "localhost", # Will be replaced with server hostname when deployed "alpn": ["h2", "http/1.1"] } @@ -347,8 +342,8 @@ class Inbound(models.Model): 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"], + "dest": "example.com:443", # Will be replaced with server hostname when deployed + "serverNames": ["example.com"], # Will be replaced with server hostname when deployed "privateKey": "", # Would be generated "shortIds": [""] # Would be generated } @@ -380,8 +375,8 @@ class SubscriptionGroup(models.Model): updated_at = models.DateTimeField(auto_now=True) class Meta: - verbose_name = "Subscription Group" - verbose_name_plural = "Subscription Groups" + verbose_name = "Subscriptions" + verbose_name_plural = "Subscriptions" ordering = ['name'] def __str__(self): diff --git a/vpn/server_plugins/xray_v2.py b/vpn/server_plugins/xray_v2.py index 037a664..a54bb63 100644 --- a/vpn/server_plugins/xray_v2.py +++ b/vpn/server_plugins/xray_v2.py @@ -535,7 +535,7 @@ class XrayServerV2(Server): try: # Generate connection string directly from vpn.views import generate_xray_connection_string - connection_string = generate_xray_connection_string(user, inbound) + connection_string = generate_xray_connection_string(user, inbound, self.name, self.client_hostname) if connection_string: configs.append({ @@ -680,11 +680,22 @@ class XrayServerV2(Server): return f"Xray Server v2: {self.name}" +class ServerInboundInline(admin.TabularInline): + """Inline for managing inbound templates on a server""" + from vpn.models_xray import ServerInbound + model = ServerInbound + extra = 0 + fields = ('inbound', 'active') + verbose_name = "Inbound Template" + verbose_name_plural = "Inbound Templates" + + 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'] + inlines = [ServerInboundInline] fieldsets = [ ('Basic Information', { @@ -693,7 +704,7 @@ class XrayServerV2Admin(admin.ModelAdmin): ('Connection Settings', { 'fields': ('client_hostname', 'api_address') }), - ('Features', { + ('API Settings', { 'fields': ('api_enabled', 'stats_enabled') }), ('Timestamps', { diff --git a/vpn/signals.py b/vpn/signals.py index 3c4a7ac..a23ccac 100644 --- a/vpn/signals.py +++ b/vpn/signals.py @@ -175,6 +175,43 @@ def subscription_group_inbounds_changed(sender, instance, action, pk_set, **kwar transaction.on_commit(lambda: job.apply_async()) +@receiver(post_save, sender=ServerInbound) +def server_inbound_created_or_updated(sender, instance, created, **kwargs): + """ + When ServerInbound is created or updated, immediately deploy the inbound + template to the server (not wait for subscription group changes) + """ + logger.info(f"ServerInbound {instance.inbound.name} {'created' if created else 'updated'} for server {instance.server.name}") + + if instance.active: + # Deploy inbound immediately + servers = [instance.server.get_real_instance()] + transaction.on_commit( + lambda: schedule_inbound_sync_for_servers(instance.inbound, servers) + ) + + # Schedule user sync after inbound deployment + transaction.on_commit(lambda: schedule_user_sync_for_servers(servers)) + else: + # Remove inbound from server if deactivated + logger.info(f"Removing inbound {instance.inbound.name} from server {instance.server.name} (deactivated)") + from .tasks import remove_inbound_from_server + task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name) + transaction.on_commit(lambda: task.apply_async()) + + +@receiver(post_delete, sender=ServerInbound) +def server_inbound_deleted(sender, instance, **kwargs): + """ + When ServerInbound is deleted, remove the inbound from the server + """ + logger.info(f"ServerInbound {instance.inbound.name} deleted from server {instance.server.name}") + + from .tasks import remove_inbound_from_server + task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name) + transaction.on_commit(lambda: task.apply_async()) + + @receiver(post_save, sender=UserSubscription) def user_subscription_created_or_updated(sender, instance, created, **kwargs): """ @@ -269,4 +306,41 @@ def subscription_group_updated(sender, instance, created, **kwargs): if tasks: job = group(tasks) - transaction.on_commit(lambda: job.apply_async()) \ No newline at end of file + transaction.on_commit(lambda: job.apply_async()) + + +@receiver(post_save, sender=ServerInbound) +def server_inbound_created_or_updated(sender, instance, created, **kwargs): + """ + When ServerInbound is created or updated, immediately deploy the inbound + template to the server (not wait for subscription group changes) + """ + logger.info(f"ServerInbound {instance.inbound.name} {'created' if created else 'updated'} for server {instance.server.name}") + + if instance.active: + # Deploy inbound immediately + servers = [instance.server.get_real_instance()] + transaction.on_commit( + lambda: schedule_inbound_sync_for_servers(instance.inbound, servers) + ) + + # Schedule user sync after inbound deployment + transaction.on_commit(lambda: schedule_user_sync_for_servers(servers)) + else: + # Remove inbound from server if deactivated + logger.info(f"Removing inbound {instance.inbound.name} from server {instance.server.name} (deactivated)") + from .tasks import remove_inbound_from_server + task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name) + transaction.on_commit(lambda: task.apply_async()) + + +@receiver(post_delete, sender=ServerInbound) +def server_inbound_deleted(sender, instance, **kwargs): + """ + When ServerInbound is deleted, remove the inbound from the server + """ + logger.info(f"ServerInbound {instance.inbound.name} deleted from server {instance.server.name}") + + from .tasks import remove_inbound_from_server + task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name) + transaction.on_commit(lambda: task.apply_async()) \ No newline at end of file diff --git a/vpn/templates/admin/vpn/subscriptiongroup/change_form.html b/vpn/templates/admin/vpn/subscriptiongroup/change_form.html new file mode 100644 index 0000000..ff202ac --- /dev/null +++ b/vpn/templates/admin/vpn/subscriptiongroup/change_form.html @@ -0,0 +1,20 @@ +{% extends "admin/change_form.html" %} + +{% block content %} + {% if show_tab_navigation %} +
+
+ + 📋 Subscription Groups + + + 👥 User Subscriptions + +
+
+ {% endif %} + + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/vpn/templates/admin/vpn/subscriptiongroup/change_list.html b/vpn/templates/admin/vpn/subscriptiongroup/change_list.html new file mode 100644 index 0000000..8c0ab41 --- /dev/null +++ b/vpn/templates/admin/vpn/subscriptiongroup/change_list.html @@ -0,0 +1,20 @@ +{% extends "admin/change_list.html" %} + +{% block content %} + {% if show_tab_navigation %} +
+
+ + 📋 Subscription Groups + + + 👥 User Subscriptions + +
+
+ {% endif %} + + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/vpn/templates/admin/vpn/usersubscription/change_form.html b/vpn/templates/admin/vpn/usersubscription/change_form.html new file mode 100644 index 0000000..b4d6a8f --- /dev/null +++ b/vpn/templates/admin/vpn/usersubscription/change_form.html @@ -0,0 +1,18 @@ +{% extends "admin/change_form.html" %} + +{% block content %} +
+
+ + 📋 Subscription Groups + + + 👥 User Subscriptions + +
+
+ + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/vpn/templates/admin/vpn/usersubscription/change_list.html b/vpn/templates/admin/vpn/usersubscription/change_list.html new file mode 100644 index 0000000..e1fe901 --- /dev/null +++ b/vpn/templates/admin/vpn/usersubscription/change_list.html @@ -0,0 +1,18 @@ +{% extends "admin/change_list.html" %} + +{% block content %} +
+
+ + 📋 Subscription Groups + + + 👥 User Subscriptions + +
+
+ + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/vpn/templates/vpn/user_portal.html b/vpn/templates/vpn/user_portal.html index a404d67..ed52865 100644 --- a/vpn/templates/vpn/user_portal.html +++ b/vpn/templates/vpn/user_portal.html @@ -497,7 +497,7 @@
{{ group_name }}
- 🔗 {{ group_data.inbounds|length }} inbound(s) + 🔗 {{ group_data.deployed_count }} inbound(s)
Xray Group
@@ -517,7 +517,7 @@
@@ -525,7 +525,7 @@
{% for inbound_data in group_data.inbounds %}
- {{ inbound_data.protocol|upper }}:{{ inbound_data.port }} + {{ inbound_data.server_name }}: {{ inbound_data.protocol|upper }}:{{ inbound_data.port }}
{% endfor %}
diff --git a/vpn/views.py b/vpn/views.py index af9107d..100a6d4 100644 --- a/vpn/views.py +++ b/vpn/views.py @@ -57,8 +57,12 @@ def userPortal(request, user_hash): group_name = group.name logger.debug(f"Processing subscription group {group_name}") - # Get all inbounds for this group - group_inbounds = group.inbounds.all() + # Get all deployed inbounds for this group (count actual server deployments) + from .models_xray import ServerInbound + deployed_inbounds = ServerInbound.objects.filter( + inbound__in=group.inbounds.all(), + active=True + ).select_related('inbound', 'server') # Calculate connections for this specific group group_connections = AccessLog.objects.filter( @@ -73,9 +77,12 @@ def userPortal(request, user_hash): 'subscription': subscription, 'inbounds': [], 'total_connections': group_connections, + 'deployed_count': deployed_inbounds.count(), # Actual deployed inbounds count } - for inbound in group_inbounds: + # Process each deployed inbound (each server-inbound combination) + for server_inbound in deployed_inbounds: + inbound = server_inbound.inbound logger.debug(f"Processing inbound {inbound.name} in group {group_name}") # Generate connection URLs based on protocol @@ -84,7 +91,7 @@ def userPortal(request, user_hash): if inbound.protocol == 'vless': # Generate VLESS URL - this is a placeholder implementation # In the real implementation, you'd generate proper VLESS URLs with user UUID - connection_url = f"vless://user-uuid@{inbound.domain or EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}" + connection_url = f"vless://user-uuid@{EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}" connection_urls.append({ 'url': connection_url, 'protocol': 'VLESS', @@ -92,7 +99,7 @@ def userPortal(request, user_hash): }) elif inbound.protocol == 'vmess': # Generate VMess URL - placeholder - connection_url = f"vmess://user-config@{inbound.domain or EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}" + connection_url = f"vmess://user-config@{EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}" connection_urls.append({ 'url': connection_url, 'protocol': 'VMess', @@ -100,7 +107,7 @@ def userPortal(request, user_hash): }) elif inbound.protocol == 'trojan': # Generate Trojan URL - placeholder - connection_url = f"trojan://user-password@{inbound.domain or EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}" + connection_url = f"trojan://user-password@{EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}" connection_urls.append({ 'url': connection_url, 'protocol': 'Trojan', @@ -108,7 +115,7 @@ def userPortal(request, user_hash): }) elif inbound.protocol == 'shadowsocks': # Generate Shadowsocks URL - placeholder - connection_url = f"ss://user-config@{inbound.domain or EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}" + connection_url = f"ss://user-config@{EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}" connection_urls.append({ 'url': connection_url, 'protocol': 'Shadowsocks', @@ -120,11 +127,13 @@ def userPortal(request, user_hash): 'connection_urls': connection_urls, 'protocol': inbound.protocol.upper(), 'port': inbound.port, - 'domain': inbound.domain or EXTERNAL_ADDRESS, + 'domain': EXTERNAL_ADDRESS, 'network': inbound.network, 'security': inbound.security, 'connections': 0, # Placeholder during transition 'last_access_display': "Never used", # Placeholder + 'server': server_inbound.server, # Server that deployed this inbound + 'server_name': server_inbound.server.name, # Server name for display } groups_data[group_name]['inbounds'].append(inbound_data) @@ -365,12 +374,23 @@ def xray_subscription(request, user_hash): # Get all inbounds from this group for inbound in group.inbounds.all(): try: - # Generate connection string based on protocol - connection_string = generate_xray_connection_string(user, inbound) + # Find all servers where this inbound is deployed + from .models_xray import ServerInbound + deployed_servers = ServerInbound.objects.filter( + inbound=inbound, + active=True + ).select_related('server') - if connection_string: - subscription_configs.append(connection_string) - logger.info(f"Added {inbound.protocol} config for inbound {inbound.name}") + # Generate connection string for each server where inbound is deployed + for server_inbound in deployed_servers: + server = server_inbound.server + # Get server's client_hostname for XrayServerV2 + server_hostname = getattr(server.get_real_instance(), 'client_hostname', None) + connection_string = generate_xray_connection_string(user, inbound, server.name, server_hostname) + + if connection_string: + subscription_configs.append(connection_string) + logger.info(f"Added {inbound.protocol} config for inbound {inbound.name} on server {server.name}") except Exception as e: logger.warning(f"Failed to generate config for inbound {inbound.name}: {e}") @@ -449,17 +469,29 @@ def xray_subscription_json(request, user, user_hash): # Get all inbounds from this group for inbound in group.inbounds.all(): try: - # Generate connection string - connection_string = generate_xray_connection_string(user, inbound) + # Find all servers where this inbound is deployed + from .models_xray import ServerInbound + deployed_servers = ServerInbound.objects.filter( + inbound=inbound, + active=True + ).select_related('server') - if connection_string: - group_configs.append({ - 'name': inbound.name, + # Generate connection string for each server where inbound is deployed + for server_inbound in deployed_servers: + server = server_inbound.server + # Get server's client_hostname for XrayServerV2 + server_hostname = getattr(server.get_real_instance(), 'client_hostname', None) + connection_string = generate_xray_connection_string(user, inbound, server.name, server_hostname) + + if connection_string: + config_name = f"{server.name} {inbound.name}" + group_configs.append({ + 'name': config_name, 'protocol': inbound.protocol.upper(), 'port': inbound.port, 'network': inbound.network, 'security': inbound.security, - 'domain': inbound.domain, + 'domain': host, 'connection_string': connection_string }) @@ -481,7 +513,7 @@ def xray_subscription_json(request, user, user_hash): return JsonResponse({'error': str(e)}, status=500) -def generate_xray_connection_string(user, inbound): +def generate_xray_connection_string(user, inbound, server_name=None, server_hostname=None): """Generate Xray connection string for user and inbound""" import uuid import base64 @@ -492,8 +524,8 @@ def generate_xray_connection_string(user, inbound): # Generate user UUID based on user ID and inbound user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}")) - # Get host (domain or EXTERNAL_ADDRESS) - host = inbound.domain if inbound.domain else EXTERNAL_ADDRESS + # Get host (use server's client_hostname if available, fallback to EXTERNAL_ADDRESS) + host = server_hostname if server_hostname else EXTERNAL_ADDRESS if inbound.protocol == 'vless': # VLESS URL format: vless://uuid@host:port?params#name @@ -504,8 +536,8 @@ def generate_xray_connection_string(user, inbound): if inbound.security != 'none': params.append(f"security={inbound.security}") - if inbound.security == 'tls' and inbound.domain: - params.append(f"sni={inbound.domain}") + if inbound.security == 'tls' and host: + params.append(f"sni={host}") if inbound.network == 'ws': params.append(f"path=/{inbound.name}") @@ -515,13 +547,17 @@ def generate_xray_connection_string(user, inbound): param_string = '&'.join(params) query_part = f"?{param_string}" if param_string else "" - connection_string = f"vless://{user_uuid}@{host}:{inbound.port}{query_part}#{quote(inbound.name)}" + # Generate config name: ServerName InboundName (e.g., "Israel VLESS-Premium") + config_name = f"{server_name} {inbound.name}" if server_name else inbound.name + connection_string = f"vless://{user_uuid}@{host}:{inbound.port}{query_part}#{quote(config_name)}" elif inbound.protocol == 'vmess': # VMess JSON format encoded in base64 + # Generate config name: ServerName InboundName (e.g., "Israel VMESS-Premium") + config_name = f"{server_name} {inbound.name}" if server_name else inbound.name vmess_config = { "v": "2", - "ps": inbound.name, + "ps": config_name, "add": host, "port": str(inbound.port), "id": user_uuid, @@ -529,7 +565,7 @@ def generate_xray_connection_string(user, inbound): "scy": "auto", "net": inbound.network, "type": "none", - "host": inbound.domain if inbound.domain else "", + "host": host if host else "", "path": f"/{inbound.name}" if inbound.network == 'ws' else "", "tls": inbound.security if inbound.security != 'none' else "" } @@ -543,8 +579,8 @@ def generate_xray_connection_string(user, inbound): # Use user UUID as password params = [] - if inbound.security != 'none' and inbound.domain: - params.append(f"sni={inbound.domain}") + if inbound.security != 'none' and host: + params.append(f"sni={host}") if inbound.network != 'tcp': params.append(f"type={inbound.network}") @@ -556,11 +592,14 @@ def generate_xray_connection_string(user, inbound): param_string = '&'.join(params) query_part = f"?{param_string}" if param_string else "" - connection_string = f"trojan://{user_uuid}@{host}:{inbound.port}{query_part}#{quote(inbound.name)}" + # Generate config name: ServerName InboundName (e.g., "Israel TROJAN-Premium") + config_name = f"{server_name} {inbound.name}" if server_name else inbound.name + connection_string = f"trojan://{user_uuid}@{host}:{inbound.port}{query_part}#{quote(config_name)}" else: # Fallback for unknown protocols - connection_string = f"{inbound.protocol}://{user_uuid}@{host}:{inbound.port}#{quote(inbound.name)}" + config_name = f"{server_name} {inbound.name}" if server_name else inbound.name + connection_string = f"{inbound.protocol}://{user_uuid}@{host}:{inbound.port}#{quote(config_name)}" return connection_string