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 %}
+
+ {% 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 %}
+
+ {% 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 %}
+
+
+ {{ 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 %}
+
+
+ {{ 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 @@
- 🔗 {{ group_data.inbounds|length }} inbound(s)
+ 🔗 {{ group_data.deployed_count }} inbound(s)
@@ -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