Xray works.

This commit is contained in:
AB from home.homenet
2025-08-08 07:39:01 +03:00
parent fe56811b33
commit 2fe59062c9
12 changed files with 452 additions and 74 deletions

View File

@@ -441,17 +441,18 @@ class CertificateAdmin(admin.ModelAdmin):
@admin.register(Inbound) @admin.register(Inbound)
class InboundAdmin(admin.ModelAdmin): class InboundAdmin(admin.ModelAdmin):
"""Admin for inbound management""" """Admin for inbound template management"""
list_display = ( list_display = (
'name', 'protocol', 'port', 'network', 'name', 'protocol', 'port', 'network',
'security', 'certificate_status', 'group_count' 'security', 'certificate_status', 'group_count'
) )
list_filter = ('protocol', 'network', 'security') list_filter = ('protocol', 'network', 'security')
search_fields = ('name', 'domain') search_fields = ('name',)
fieldsets = ( fieldsets = (
('Basic Configuration', { ('Basic Configuration', {
'fields': ('name', 'protocol', 'port', 'domain') 'fields': ('name', 'protocol', 'port'),
'description': 'Domain will be taken from server client_hostname when deployed'
}), }),
('Transport & Security', { ('Transport & Security', {
'fields': ('network', 'security', 'certificate', 'listen_address') 'fields': ('network', 'security', 'certificate', 'listen_address')
@@ -523,7 +524,6 @@ class InboundInline(admin.TabularInline):
verbose_name_plural = "Inbounds in this group" verbose_name_plural = "Inbounds in this group"
@admin.register(SubscriptionGroup)
class SubscriptionGroupAdmin(admin.ModelAdmin): class SubscriptionGroupAdmin(admin.ModelAdmin):
"""Admin for subscription groups""" """Admin for subscription groups"""
list_display = ('name', 'is_active', 'inbound_count', 'user_count', 'created_at') 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' group_statistics.short_description = 'Group Statistics'
class UserSubscriptionInline(admin.TabularInline): class UserSubscriptionInline(admin.TabularInline):
"""Inline for user subscriptions""" """Inline for user subscriptions"""
model = UserSubscription model = UserSubscription
@@ -673,21 +674,19 @@ def add_subscription_management_to_user(UserAdmin):
UserAdmin.subscription_groups_widget = subscription_groups_widget UserAdmin.subscription_groups_widget = subscription_groups_widget
# Register admin for UserSubscription (if needed separately) # UserSubscription admin will be integrated into unified Subscriptions admin
@admin.register(UserSubscription)
class UserSubscriptionAdmin(admin.ModelAdmin): 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_display = ('user', 'subscription_group', 'active', 'created_at')
list_filter = ('active', 'subscription_group') list_filter = ('active', 'subscription_group')
search_fields = ('user__username', 'subscription_group__name') search_fields = ('user__username', 'subscription_group__name')
date_hierarchy = 'created_at' date_hierarchy = 'created_at'
def has_add_permission(self, request): def has_add_permission(self, request):
# Prefer managing through User admin return True # Allow adding subscriptions
return False
@admin.register(ServerInbound) # ServerInbound admin is integrated into XrayServerV2 admin, not shown in main menu
class ServerInboundAdmin(admin.ModelAdmin): class ServerInboundAdmin(admin.ModelAdmin):
"""Admin for server-inbound deployment tracking""" """Admin for server-inbound deployment tracking"""
list_display = ('server', 'inbound', 'active', 'deployed_at', 'updated_at') list_display = ('server', 'inbound', 'active', 'deployed_at', 'updated_at')
@@ -696,27 +695,173 @@ class ServerInboundAdmin(admin.ModelAdmin):
date_hierarchy = 'deployed_at' date_hierarchy = 'deployed_at'
fieldsets = ( fieldsets = (
('Deployment', { ('Template Deployment', {
'fields': ('server', 'inbound', 'active') 'fields': ('server', 'inbound', 'active')
}), }),
('Configuration', {
'fields': ('deployment_config_display', 'deployment_config'),
'classes': ('collapse',)
}),
('Timestamps', { ('Timestamps', {
'fields': ('deployed_at', 'updated_at'), 'fields': ('deployed_at', 'updated_at'),
'classes': ('collapse',) 'classes': ('collapse',)
}) })
) )
readonly_fields = ('deployment_config_display', 'deployed_at', 'updated_at') readonly_fields = ('deployed_at', 'updated_at')
def deployment_config_display(self, obj):
"""Display deployment config in formatted JSON""" # Unified Subscriptions Admin with tabs
if obj.deployment_config: @admin.register(SubscriptionGroup)
return format_html( class UnifiedSubscriptionsAdmin(admin.ModelAdmin):
'<pre style="background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;">{}</pre>', """Unified admin for managing both Subscription Groups and User Subscriptions"""
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. ' +
'<br><strong>🚀 Auto-sync enabled:</strong> Changes will be automatically deployed to servers!'
}),
('Statistics', {
'fields': ('group_statistics',),
'classes': ('collapse',)
})
) )
return 'No additional deployment configuration'
deployment_config_display.short_description = 'Deployment Config Preview' 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.'
)
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 = '<div style="background: #f4f4f4; padding: 10px; border-radius: 4px;">'
for key, value in stats.items():
if isinstance(value, list):
value = ', '.join(map(str, value))
html += f'<div><strong>{key}:</strong> {value}</div>'
html += '</div>'
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)

View File

@@ -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',
),
]

View File

@@ -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'},
),
]

View File

@@ -200,11 +200,6 @@ class Inbound(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
help_text="Certificate for TLS" help_text="Certificate for TLS"
) )
domain = models.CharField(
max_length=255,
blank=True,
help_text="Client connection domain"
)
# Full configuration for Xray # Full configuration for Xray
full_config = models.JSONField( full_config = models.JSONField(
@@ -228,8 +223,8 @@ class Inbound(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
verbose_name = "Inbound" verbose_name = "Inbound Template"
verbose_name_plural = "Inbounds" verbose_name_plural = "Inbound Templates"
ordering = ['protocol', 'port'] ordering = ['protocol', 'port']
unique_together = [['port', 'listen_address']] unique_together = [['port', 'listen_address']]
@@ -322,14 +317,14 @@ class Inbound(models.Model):
elif self.network == "http": elif self.network == "http":
stream_settings["httpSettings"] = { stream_settings["httpSettings"] = {
"path": f"/{self.name}", "path": f"/{self.name}",
"host": [self.domain] if self.domain else [] "host": [] # Will be filled when deployed to server
} }
# Add security settings # Add security settings
if self.security == "tls": if self.security == "tls":
stream_settings["security"] = "tls" stream_settings["security"] = "tls"
tls_settings = { tls_settings = {
"serverName": self.domain or "localhost", "serverName": "localhost", # Will be replaced with server hostname when deployed
"alpn": ["h2", "http/1.1"] "alpn": ["h2", "http/1.1"]
} }
@@ -347,8 +342,8 @@ class Inbound(models.Model):
stream_settings["security"] = "reality" stream_settings["security"] = "reality"
# Reality settings would be configured here # Reality settings would be configured here
stream_settings["realitySettings"] = { stream_settings["realitySettings"] = {
"dest": self.domain or "example.com:443", "dest": "example.com:443", # Will be replaced with server hostname when deployed
"serverNames": [self.domain] if self.domain else ["example.com"], "serverNames": ["example.com"], # Will be replaced with server hostname when deployed
"privateKey": "", # Would be generated "privateKey": "", # Would be generated
"shortIds": [""] # Would be generated "shortIds": [""] # Would be generated
} }
@@ -380,8 +375,8 @@ class SubscriptionGroup(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
verbose_name = "Subscription Group" verbose_name = "Subscriptions"
verbose_name_plural = "Subscription Groups" verbose_name_plural = "Subscriptions"
ordering = ['name'] ordering = ['name']
def __str__(self): def __str__(self):

View File

@@ -535,7 +535,7 @@ class XrayServerV2(Server):
try: try:
# Generate connection string directly # Generate connection string directly
from vpn.views import generate_xray_connection_string 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: if connection_string:
configs.append({ configs.append({
@@ -680,11 +680,22 @@ class XrayServerV2(Server):
return f"Xray Server v2: {self.name}" 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): class XrayServerV2Admin(admin.ModelAdmin):
list_display = ['name', 'client_hostname', 'api_address', 'api_enabled', 'stats_enabled', 'registration_date'] list_display = ['name', 'client_hostname', 'api_address', 'api_enabled', 'stats_enabled', 'registration_date']
list_filter = ['api_enabled', 'stats_enabled', 'registration_date'] list_filter = ['api_enabled', 'stats_enabled', 'registration_date']
search_fields = ['name', 'client_hostname', 'comment'] search_fields = ['name', 'client_hostname', 'comment']
readonly_fields = ['server_type', 'registration_date'] readonly_fields = ['server_type', 'registration_date']
inlines = [ServerInboundInline]
fieldsets = [ fieldsets = [
('Basic Information', { ('Basic Information', {
@@ -693,7 +704,7 @@ class XrayServerV2Admin(admin.ModelAdmin):
('Connection Settings', { ('Connection Settings', {
'fields': ('client_hostname', 'api_address') 'fields': ('client_hostname', 'api_address')
}), }),
('Features', { ('API Settings', {
'fields': ('api_enabled', 'stats_enabled') 'fields': ('api_enabled', 'stats_enabled')
}), }),
('Timestamps', { ('Timestamps', {

View File

@@ -175,6 +175,43 @@ def subscription_group_inbounds_changed(sender, instance, action, pk_set, **kwar
transaction.on_commit(lambda: job.apply_async()) 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) @receiver(post_save, sender=UserSubscription)
def user_subscription_created_or_updated(sender, instance, created, **kwargs): def user_subscription_created_or_updated(sender, instance, created, **kwargs):
""" """
@@ -270,3 +307,40 @@ def subscription_group_updated(sender, instance, created, **kwargs):
if tasks: if tasks:
job = group(tasks) job = group(tasks)
transaction.on_commit(lambda: job.apply_async()) 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())

View File

@@ -0,0 +1,20 @@
{% extends "admin/change_form.html" %}
{% block content %}
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
👥 User Subscriptions
</a>
</div>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "admin/change_list.html" %}
{% block content %}
{% if show_tab_navigation %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if current_tab == 'subscription_groups' %}#417690{% else %}transparent{% endif %}; color: {% if current_tab == 'subscription_groups' %}#417690{% else %}#666{% endif %};">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if current_tab == 'user_subscriptions' %}#417690{% else %}transparent{% endif %}; color: {% if current_tab == 'user_subscriptions' %}#417690{% else %}#666{% endif %};">
👥 User Subscriptions
</a>
</div>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "admin/change_form.html" %}
{% block content %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
👥 User Subscriptions
</a>
</div>
</div>
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "admin/change_list.html" %}
{% block content %}
<div class="module" style="margin-bottom: 20px;">
<div style="display: flex; border-bottom: 1px solid #ddd;">
<a href="{% url 'admin:vpn_subscriptiongroup_changelist' %}"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid transparent; color: #666;">
📋 Subscription Groups
</a>
<a href="/admin/vpn/usersubscription/"
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid #417690; color: #417690;">
👥 User Subscriptions
</a>
</div>
</div>
{{ block.super }}
{% endblock %}

View File

@@ -497,7 +497,7 @@
<div class="server-info"> <div class="server-info">
<div class="server-name">{{ group_name }}</div> <div class="server-name">{{ group_name }}</div>
<div class="server-stats"> <div class="server-stats">
<span class="connection-count">🔗 {{ group_data.inbounds|length }} inbound(s)</span> <span class="connection-count">🔗 {{ group_data.deployed_count }} inbound(s)</span>
</div> </div>
</div> </div>
<div class="server-type">Xray Group</div> <div class="server-type">Xray Group</div>
@@ -517,7 +517,7 @@
<div class="link-info"> <div class="link-info">
<div class="link-comment">🚀 {{ group_name }} Subscription</div> <div class="link-comment">🚀 {{ group_name }} Subscription</div>
<div class="link-stats"> <div class="link-stats">
<span class="last-used">🔗 {{ group_data.inbounds|length }} inbound(s)</span> <span class="last-used">🔗 {{ group_data.deployed_count }} inbound(s)</span>
</div> </div>
</div> </div>
<div class="usage-chart"> <div class="usage-chart">
@@ -525,7 +525,7 @@
<div style="display: flex; flex-direction: column; gap: 5px; align-items: center;"> <div style="display: flex; flex-direction: column; gap: 5px; align-items: center;">
{% for inbound_data in group_data.inbounds %} {% for inbound_data in group_data.inbounds %}
<div style="background: rgba(74, 222, 128, 0.2); padding: 3px 8px; border-radius: 8px; font-size: 0.7rem; color: #4ade80;"> <div style="background: rgba(74, 222, 128, 0.2); padding: 3px 8px; border-radius: 8px; font-size: 0.7rem; color: #4ade80;">
{{ inbound_data.protocol|upper }}:{{ inbound_data.port }} {{ inbound_data.server_name }}: {{ inbound_data.protocol|upper }}:{{ inbound_data.port }}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -57,8 +57,12 @@ def userPortal(request, user_hash):
group_name = group.name group_name = group.name
logger.debug(f"Processing subscription group {group_name}") logger.debug(f"Processing subscription group {group_name}")
# Get all inbounds for this group # Get all deployed inbounds for this group (count actual server deployments)
group_inbounds = group.inbounds.all() 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 # Calculate connections for this specific group
group_connections = AccessLog.objects.filter( group_connections = AccessLog.objects.filter(
@@ -73,9 +77,12 @@ def userPortal(request, user_hash):
'subscription': subscription, 'subscription': subscription,
'inbounds': [], 'inbounds': [],
'total_connections': group_connections, '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}") logger.debug(f"Processing inbound {inbound.name} in group {group_name}")
# Generate connection URLs based on protocol # Generate connection URLs based on protocol
@@ -84,7 +91,7 @@ def userPortal(request, user_hash):
if inbound.protocol == 'vless': if inbound.protocol == 'vless':
# Generate VLESS URL - this is a placeholder implementation # Generate VLESS URL - this is a placeholder implementation
# In the real implementation, you'd generate proper VLESS URLs with user UUID # 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({ connection_urls.append({
'url': connection_url, 'url': connection_url,
'protocol': 'VLESS', 'protocol': 'VLESS',
@@ -92,7 +99,7 @@ def userPortal(request, user_hash):
}) })
elif inbound.protocol == 'vmess': elif inbound.protocol == 'vmess':
# Generate VMess URL - placeholder # 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({ connection_urls.append({
'url': connection_url, 'url': connection_url,
'protocol': 'VMess', 'protocol': 'VMess',
@@ -100,7 +107,7 @@ def userPortal(request, user_hash):
}) })
elif inbound.protocol == 'trojan': elif inbound.protocol == 'trojan':
# Generate Trojan URL - placeholder # 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({ connection_urls.append({
'url': connection_url, 'url': connection_url,
'protocol': 'Trojan', 'protocol': 'Trojan',
@@ -108,7 +115,7 @@ def userPortal(request, user_hash):
}) })
elif inbound.protocol == 'shadowsocks': elif inbound.protocol == 'shadowsocks':
# Generate Shadowsocks URL - placeholder # 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({ connection_urls.append({
'url': connection_url, 'url': connection_url,
'protocol': 'Shadowsocks', 'protocol': 'Shadowsocks',
@@ -120,11 +127,13 @@ def userPortal(request, user_hash):
'connection_urls': connection_urls, 'connection_urls': connection_urls,
'protocol': inbound.protocol.upper(), 'protocol': inbound.protocol.upper(),
'port': inbound.port, 'port': inbound.port,
'domain': inbound.domain or EXTERNAL_ADDRESS, 'domain': EXTERNAL_ADDRESS,
'network': inbound.network, 'network': inbound.network,
'security': inbound.security, 'security': inbound.security,
'connections': 0, # Placeholder during transition 'connections': 0, # Placeholder during transition
'last_access_display': "Never used", # Placeholder '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) groups_data[group_name]['inbounds'].append(inbound_data)
@@ -365,12 +374,23 @@ def xray_subscription(request, user_hash):
# Get all inbounds from this group # Get all inbounds from this group
for inbound in group.inbounds.all(): for inbound in group.inbounds.all():
try: try:
# Generate connection string based on protocol # Find all servers where this inbound is deployed
connection_string = generate_xray_connection_string(user, inbound) from .models_xray import ServerInbound
deployed_servers = ServerInbound.objects.filter(
inbound=inbound,
active=True
).select_related('server')
# 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: if connection_string:
subscription_configs.append(connection_string) subscription_configs.append(connection_string)
logger.info(f"Added {inbound.protocol} config for inbound {inbound.name}") logger.info(f"Added {inbound.protocol} config for inbound {inbound.name} on server {server.name}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to generate config for inbound {inbound.name}: {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 # Get all inbounds from this group
for inbound in group.inbounds.all(): for inbound in group.inbounds.all():
try: try:
# Generate connection string # Find all servers where this inbound is deployed
connection_string = generate_xray_connection_string(user, inbound) from .models_xray import ServerInbound
deployed_servers = ServerInbound.objects.filter(
inbound=inbound,
active=True
).select_related('server')
# 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: if connection_string:
config_name = f"{server.name} {inbound.name}"
group_configs.append({ group_configs.append({
'name': inbound.name, 'name': config_name,
'protocol': inbound.protocol.upper(), 'protocol': inbound.protocol.upper(),
'port': inbound.port, 'port': inbound.port,
'network': inbound.network, 'network': inbound.network,
'security': inbound.security, 'security': inbound.security,
'domain': inbound.domain, 'domain': host,
'connection_string': connection_string 'connection_string': connection_string
}) })
@@ -481,7 +513,7 @@ def xray_subscription_json(request, user, user_hash):
return JsonResponse({'error': str(e)}, status=500) 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""" """Generate Xray connection string for user and inbound"""
import uuid import uuid
import base64 import base64
@@ -492,8 +524,8 @@ def generate_xray_connection_string(user, inbound):
# Generate user UUID based on user ID and inbound # Generate user UUID based on user ID and inbound
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}")) user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
# Get host (domain or EXTERNAL_ADDRESS) # Get host (use server's client_hostname if available, fallback to EXTERNAL_ADDRESS)
host = inbound.domain if inbound.domain else EXTERNAL_ADDRESS host = server_hostname if server_hostname else EXTERNAL_ADDRESS
if inbound.protocol == 'vless': if inbound.protocol == 'vless':
# VLESS URL format: vless://uuid@host:port?params#name # VLESS URL format: vless://uuid@host:port?params#name
@@ -504,8 +536,8 @@ def generate_xray_connection_string(user, inbound):
if inbound.security != 'none': if inbound.security != 'none':
params.append(f"security={inbound.security}") params.append(f"security={inbound.security}")
if inbound.security == 'tls' and inbound.domain: if inbound.security == 'tls' and host:
params.append(f"sni={inbound.domain}") params.append(f"sni={host}")
if inbound.network == 'ws': if inbound.network == 'ws':
params.append(f"path=/{inbound.name}") params.append(f"path=/{inbound.name}")
@@ -515,13 +547,17 @@ def generate_xray_connection_string(user, inbound):
param_string = '&'.join(params) param_string = '&'.join(params)
query_part = f"?{param_string}" if param_string else "" 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': elif inbound.protocol == 'vmess':
# VMess JSON format encoded in base64 # 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 = { vmess_config = {
"v": "2", "v": "2",
"ps": inbound.name, "ps": config_name,
"add": host, "add": host,
"port": str(inbound.port), "port": str(inbound.port),
"id": user_uuid, "id": user_uuid,
@@ -529,7 +565,7 @@ def generate_xray_connection_string(user, inbound):
"scy": "auto", "scy": "auto",
"net": inbound.network, "net": inbound.network,
"type": "none", "type": "none",
"host": inbound.domain if inbound.domain else "", "host": host if host else "",
"path": f"/{inbound.name}" if inbound.network == 'ws' else "", "path": f"/{inbound.name}" if inbound.network == 'ws' else "",
"tls": inbound.security if inbound.security != 'none' 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 # Use user UUID as password
params = [] params = []
if inbound.security != 'none' and inbound.domain: if inbound.security != 'none' and host:
params.append(f"sni={inbound.domain}") params.append(f"sni={host}")
if inbound.network != 'tcp': if inbound.network != 'tcp':
params.append(f"type={inbound.network}") params.append(f"type={inbound.network}")
@@ -556,11 +592,14 @@ def generate_xray_connection_string(user, inbound):
param_string = '&'.join(params) param_string = '&'.join(params)
query_part = f"?{param_string}" if param_string else "" 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: else:
# Fallback for unknown protocols # 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 return connection_string