mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Xray works.
This commit is contained in:
@@ -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')
|
||||
|
||||
def deployment_config_display(self, obj):
|
||||
"""Display deployment config in formatted JSON"""
|
||||
if obj.deployment_config:
|
||||
return format_html(
|
||||
'<pre style="background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;">{}</pre>',
|
||||
json.dumps(obj.deployment_config, indent=2)
|
||||
|
||||
# Unified Subscriptions Admin with tabs
|
||||
@admin.register(SubscriptionGroup)
|
||||
class UnifiedSubscriptionsAdmin(admin.ModelAdmin):
|
||||
"""Unified admin for managing both Subscription Groups and User Subscriptions"""
|
||||
|
||||
# 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',)
|
||||
})
|
||||
)
|
||||
|
||||
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'
|
||||
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)
|
21
vpn/migrations/0022_remove_inbound_domain_field.py
Normal file
21
vpn/migrations/0022_remove_inbound_domain_field.py
Normal 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',
|
||||
),
|
||||
]
|
17
vpn/migrations/0023_alter_subscriptiongroup_options.py
Normal file
17
vpn/migrations/0023_alter_subscriptiongroup_options.py
Normal 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'},
|
||||
),
|
||||
]
|
@@ -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):
|
||||
|
@@ -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', {
|
||||
|
@@ -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):
|
||||
"""
|
||||
@@ -270,3 +307,40 @@ def subscription_group_updated(sender, instance, created, **kwargs):
|
||||
if tasks:
|
||||
job = group(tasks)
|
||||
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())
|
20
vpn/templates/admin/vpn/subscriptiongroup/change_form.html
Normal file
20
vpn/templates/admin/vpn/subscriptiongroup/change_form.html
Normal 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 %}
|
20
vpn/templates/admin/vpn/subscriptiongroup/change_list.html
Normal file
20
vpn/templates/admin/vpn/subscriptiongroup/change_list.html
Normal 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 %}
|
18
vpn/templates/admin/vpn/usersubscription/change_form.html
Normal file
18
vpn/templates/admin/vpn/usersubscription/change_form.html
Normal 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 %}
|
18
vpn/templates/admin/vpn/usersubscription/change_list.html
Normal file
18
vpn/templates/admin/vpn/usersubscription/change_list.html
Normal 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 %}
|
@@ -497,7 +497,7 @@
|
||||
<div class="server-info">
|
||||
<div class="server-name">{{ group_name }}</div>
|
||||
<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 class="server-type">Xray Group</div>
|
||||
@@ -517,7 +517,7 @@
|
||||
<div class="link-info">
|
||||
<div class="link-comment">🚀 {{ group_name }} Subscription</div>
|
||||
<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 class="usage-chart">
|
||||
@@ -525,7 +525,7 @@
|
||||
<div style="display: flex; flex-direction: column; gap: 5px; align-items: center;">
|
||||
{% 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;">
|
||||
{{ inbound_data.protocol|upper }}:{{ inbound_data.port }}
|
||||
{{ inbound_data.server_name }}: {{ inbound_data.protocol|upper }}:{{ inbound_data.port }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
101
vpn/views.py
101
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
|
||||
|
||||
|
Reference in New Issue
Block a user