diff --git a/static/admin/css/vpn_admin.css b/static/admin/css/vpn_admin.css index ccc242d..a9ee975 100644 --- a/static/admin/css/vpn_admin.css +++ b/static/admin/css/vpn_admin.css @@ -333,3 +333,10 @@ table.changelist-results td:nth-child(6) { margin: 0 2px; display: inline-block; } + +/* Hide xray-subscriptions tab if it appears */ +#xray-subscriptions-tab, +a[href="#xray-subscriptions-tab"], +li:has(a[href="#xray-subscriptions-tab"]) { + display: none !important; +} diff --git a/vpn/admin.py b/vpn/admin.py index d8db717..24fa403 100644 --- a/vpn/admin.py +++ b/vpn/admin.py @@ -928,12 +928,38 @@ class ServerAdmin(PolymorphicParentModelAdmin): return redirect('admin:vpn_server_changelist') #admin.site.register(User, UserAdmin) +# Inline for legacy VPN access (Outline/Wireguard) +class UserACLInline(admin.TabularInline): + model = ACL + extra = 0 + fields = ('server', 'created_at', 'link_count') + readonly_fields = ('created_at', 'link_count') + verbose_name = "Legacy VPN Server Access" + verbose_name_plural = "Legacy VPN Server Access (Outline/Wireguard)" + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "server": + # Only show old-style servers (Outline/Wireguard) + kwargs["queryset"] = Server.objects.exclude(server_type='xray_v2') + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + @admin.display(description='Links') + def link_count(self, obj): + count = obj.links.count() + return format_html( + '{} link(s)', + count + ) + + + @admin.register(User) class UserAdmin(admin.ModelAdmin): form = UserForm list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count') search_fields = ('username', 'hash') - readonly_fields = ('hash_link', 'user_statistics_summary') + readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary') + inlines = [] # All VPN access info is now in vpn_access_summary class Media: css = { @@ -945,7 +971,7 @@ class UserAdmin(admin.ModelAdmin): 'fields': ('username', 'first_name', 'last_name', 'email', 'comment') }), ('Access Information', { - 'fields': ('hash_link', 'is_active') + 'fields': ('hash_link', 'is_active', 'vpn_access_summary') }), ('Statistics & Server Management', { 'fields': ('user_statistics_summary',), @@ -953,6 +979,50 @@ class UserAdmin(admin.ModelAdmin): }), ) + @admin.display(description='VPN Access Summary') + def vpn_access_summary(self, obj): + """Display summary of user's VPN access""" + if not obj.pk: + return "Save user first to see VPN access" + + # Get legacy VPN access + acl_count = ACL.objects.filter(user=obj).count() + legacy_links = ACLLink.objects.filter(acl__user=obj).count() + + # Get Xray access + from vpn.models_xray import UserSubscription + xray_subs = UserSubscription.objects.filter(user=obj, active=True).select_related('subscription_group') + xray_groups = [sub.subscription_group.name for sub in xray_subs] + + html = '
' + + # Legacy VPN section + html += '
' + html += '

📡 Legacy VPN (Outline/Wireguard)

' + if acl_count > 0: + html += f'

✅ Access to {acl_count} server(s)

' + html += f'

🔗 Total links: {legacy_links}

' + else: + html += '

No legacy VPN access

' + html += '
' + + # Xray section + html += '
' + html += '

🚀 Xray VPN

' + if xray_groups: + html += f'

✅ Active subscriptions: {len(xray_groups)}

' + html += '' + else: + html += '

No Xray subscriptions

' + html += '
' + + html += '
' + + return format_html(html) + @admin.display(description='User Portal', ordering='hash') def hash_link(self, obj): portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.hash}" @@ -1016,9 +1086,22 @@ class UserAdmin(admin.ModelAdmin): links = list(acl.links.all()) # Server header (no slow server status checks) - type_icon = 'đŸ”ĩ' if server.server_type == 'outline' else 'đŸŸĸ' if server.server_type == 'wireguard' else 'đŸŸŖ' if server.server_type == 'xray_core' else '' + # Determine server type icon and label + if server.server_type == 'Outline': + type_icon = 'đŸ”ĩ' + type_label = 'Outline' + elif server.server_type == 'Wireguard': + type_icon = 'đŸŸĸ' + type_label = 'Wireguard' + elif server.server_type in ['xray_core', 'xray_v2']: + type_icon = 'đŸŸŖ' + type_label = 'Xray' + else: + type_icon = '❓' + type_label = server.server_type + html += f'
' - html += f'
{type_icon} {server.name}
' + html += f'
{type_icon} {server.name} ({type_label})
' # Server stats server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None) @@ -1091,10 +1174,24 @@ class UserAdmin(admin.ModelAdmin): html += '
➕ Available Servers
' html += '
' for server in unassigned_servers: - type_icon = 'đŸ”ĩ' if server.server_type == 'outline' else 'đŸŸĸ' if server.server_type == 'wireguard' else 'đŸŸŖ' if server.server_type == 'xray_core' else '' + # Determine server type icon and label + if server.server_type == 'Outline': + type_icon = 'đŸ”ĩ' + type_label = 'Outline' + elif server.server_type == 'Wireguard': + type_icon = 'đŸŸĸ' + type_label = 'Wireguard' + elif server.server_type in ['xray_core', 'xray_v2']: + type_icon = 'đŸŸŖ' + type_label = 'Xray' + else: + type_icon = '❓' + type_label = server.server_type + html += f'' html += '
' @@ -1305,24 +1402,9 @@ class UserAdmin(admin.ModelAdmin): return super().change_view(request, object_id, form_url, extra_context) - def save_model(self, request, obj, form, change): - import logging - logger = logging.getLogger(__name__) - - super().save_model(request, obj, form, change) - selected_servers = form.cleaned_data.get('servers', []) - - # Remove ACLs that are no longer selected - removed_acls = ACL.objects.filter(user=obj).exclude(server__in=selected_servers) - for acl in removed_acls: - logger.info(f"Removing ACL for user {obj.username} from server {acl.server.name}") - removed_acls.delete() - - # Create new ACLs for newly selected servers (with default links) - for server in selected_servers: - acl, created = ACL.objects.get_or_create(user=obj, server=server) - if created: - logger.info(f"Created new ACL for user {obj.username} on server {server.name}") + # Removed save_model as we no longer manage servers directly through the form + # Legacy VPN access is now managed through the ACL admin interface + # Xray access is managed through the UserXraySubscriptionInline # Note: get_or_create will use the default save() method which creates default links @admin.register(AccessLog) diff --git a/vpn/admin_xray.py b/vpn/admin_xray.py index fbc85df..96f96a0 100644 --- a/vpn/admin_xray.py +++ b/vpn/admin_xray.py @@ -592,79 +592,11 @@ class UserSubscriptionInline(admin.TabularInline): def add_subscription_management_to_user(UserAdmin): """Add subscription management to existing User admin""" - # Add inline + # Add inline only - no fieldset or widget if hasattr(UserAdmin, 'inlines'): UserAdmin.inlines = list(UserAdmin.inlines) + [UserSubscriptionInline] else: UserAdmin.inlines = [UserSubscriptionInline] - - # Add custom fields to fieldsets - original_fieldsets = list(UserAdmin.fieldsets) - - # Find where to insert our fieldset - insert_index = len(original_fieldsets) - for i, (title, fields_dict) in enumerate(original_fieldsets): - if title and 'Statistics' in title: - insert_index = i + 1 - break - - # Insert our fieldset - subscription_fieldset = ( - 'Xray Subscriptions', { - 'fields': ('subscription_groups_widget',), - 'classes': ('wide',) - } - ) - original_fieldsets.insert(insert_index, subscription_fieldset) - UserAdmin.fieldsets = tuple(original_fieldsets) - - # Add readonly field - if hasattr(UserAdmin, 'readonly_fields'): - UserAdmin.readonly_fields = list(UserAdmin.readonly_fields) + ['subscription_groups_widget'] - else: - UserAdmin.readonly_fields = ['subscription_groups_widget'] - - # Add method for displaying subscription groups - def subscription_groups_widget(self, obj): - """Display subscription groups management widget""" - if not obj or not obj.pk: - return mark_safe('
Save user first to manage subscriptions
') - - # Get all groups and user's current subscriptions - all_groups = SubscriptionGroup.objects.filter(is_active=True) - user_groups = obj.xray_subscriptions.filter(active=True).values_list('subscription_group_id', flat=True) - - html = '
' - html += '

Available Subscription Groups:

' - - if all_groups: - html += '
' - for group in all_groups: - checked = 'checked' if group.id in user_groups else '' - status = '✅' if group.id in user_groups else 'âŦœ' - - html += f''' -
- {status} - -
- ''' - html += '
' - html += '
' - html += 'â„šī¸ Use the inline form below to manage subscriptions' - html += '
' - else: - html += '
No active subscription groups available
' - - html += '
' - return mark_safe(html) - - subscription_groups_widget.short_description = 'Subscription Groups Overview' - UserAdmin.subscription_groups_widget = subscription_groups_widget # UserSubscription admin will be integrated into unified Subscriptions admin diff --git a/vpn/forms.py b/vpn/forms.py index c27d3f1..0e7300a 100644 --- a/vpn/forms.py +++ b/vpn/forms.py @@ -1,14 +1,7 @@ from django import forms from .models import User -from .server_plugins import Server class UserForm(forms.ModelForm): - servers = forms.ModelMultipleChoiceField( - queryset=Server.objects.all(), - widget=forms.CheckboxSelectMultiple, - required=False - ) - class Meta: model = User - fields = ['username', 'comment', 'servers'] + fields = ['username', 'first_name', 'last_name', 'email', 'comment', 'is_active'] diff --git a/vpn/views.py b/vpn/views.py index 301848b..a6f2725 100644 --- a/vpn/views.py +++ b/vpn/views.py @@ -438,8 +438,23 @@ def xray_subscription(request, user_hash): # Return with proper headers for subscription response = HttpResponse(subscription_b64, content_type="text/plain; charset=utf-8") - response['Content-Disposition'] = f'attachment; filename="{user.username}_xray_subscription.txt"' + response['Content-Disposition'] = f'attachment; filename="{user.username}"' response['Cache-Control'] = 'no-cache' + + # Add subscription-specific headers like other providers + import base64 as b64 + profile_title_b64 = b64.b64encode("OutFleet VPN".encode('utf-8')).decode('utf-8') + response['profile-title'] = f'base64:{profile_title_b64}' + response['profile-update-interval'] = '24' # Update every 24 hours + response['profile-web-page-url'] = f'https://{request.get_host()}/u/{user_hash}' + response['support-url'] = f'https://{request.get_host()}/admin/' + + # Add user info without limits (unlimited service) + # Set very high limits to indicate "unlimited" + import time + expire_timestamp = int(time.time()) + (365 * 24 * 60 * 60) # 1 year from now + response['subscription-userinfo'] = f'upload=0; download=0; total=1099511627776; expire={expire_timestamp}' + return response except Exception as e: @@ -540,8 +555,8 @@ def generate_xray_connection_string(user, inbound, server_name=None, server_host # VLESS URL format: vless://uuid@host:port?params#name params = [] - if inbound.network != 'tcp': - params.append(f"type={inbound.network}") + # Always add transport type for VLESS + params.append(f"type={inbound.network}") if inbound.security != 'none': params.append(f"security={inbound.security}")