Fixed multiuser outline and xray .

This commit is contained in:
AB from home.homenet
2025-08-08 12:41:33 +03:00
parent 4c32679d86
commit 05465f9595
5 changed files with 134 additions and 105 deletions

View File

@@ -333,3 +333,10 @@ table.changelist-results td:nth-child(6) {
margin: 0 2px; margin: 0 2px;
display: inline-block; 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;
}

View File

@@ -928,12 +928,38 @@ class ServerAdmin(PolymorphicParentModelAdmin):
return redirect('admin:vpn_server_changelist') return redirect('admin:vpn_server_changelist')
#admin.site.register(User, UserAdmin) #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(
'<span style="font-weight: bold;">{}</span> link(s)',
count
)
@admin.register(User) @admin.register(User)
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
form = UserForm form = UserForm
list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count') list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count')
search_fields = ('username', 'hash') 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: class Media:
css = { css = {
@@ -945,7 +971,7 @@ class UserAdmin(admin.ModelAdmin):
'fields': ('username', 'first_name', 'last_name', 'email', 'comment') 'fields': ('username', 'first_name', 'last_name', 'email', 'comment')
}), }),
('Access Information', { ('Access Information', {
'fields': ('hash_link', 'is_active') 'fields': ('hash_link', 'is_active', 'vpn_access_summary')
}), }),
('Statistics & Server Management', { ('Statistics & Server Management', {
'fields': ('user_statistics_summary',), '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 = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
# Legacy VPN section
html += '<div style="margin-bottom: 15px;">'
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">📡 Legacy VPN (Outline/Wireguard)</h4>'
if acl_count > 0:
html += f'<p style="margin: 5px 0;">✅ Access to <strong>{acl_count}</strong> server(s)</p>'
html += f'<p style="margin: 5px 0;">🔗 Total links: <strong>{legacy_links}</strong></p>'
else:
html += '<p style="margin: 5px 0; color: #6c757d;">No legacy VPN access</p>'
html += '</div>'
# Xray section
html += '<div>'
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">🚀 Xray VPN</h4>'
if xray_groups:
html += f'<p style="margin: 5px 0;">✅ Active subscriptions: <strong>{len(xray_groups)}</strong></p>'
html += '<ul style="margin: 5px 0; padding-left: 20px;">'
for group in xray_groups:
html += f'<li>{group}</li>'
html += '</ul>'
else:
html += '<p style="margin: 5px 0; color: #6c757d;">No Xray subscriptions</p>'
html += '</div>'
html += '</div>'
return format_html(html)
@admin.display(description='User Portal', ordering='hash') @admin.display(description='User Portal', ordering='hash')
def hash_link(self, obj): def hash_link(self, obj):
portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.hash}" portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.hash}"
@@ -1016,9 +1086,22 @@ class UserAdmin(admin.ModelAdmin):
links = list(acl.links.all()) links = list(acl.links.all())
# Server header (no slow server status checks) # 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'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">' html += f'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">'
html += f'<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600;">{type_icon} {server.name}</h5>' html += f'<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600;">{type_icon} {server.name} ({type_label})</h5>'
# Server stats # Server stats
server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None) 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 += '<h5 style="margin: 0 0 8px 0; color: #0c5460; font-size: 13px;"> Available Servers</h5>' html += '<h5 style="margin: 0 0 8px 0; color: #0c5460; font-size: 13px;"> Available Servers</h5>'
html += '<div style="display: flex; gap: 8px; flex-wrap: wrap;">' html += '<div style="display: flex; gap: 8px; flex-wrap: wrap;">'
for server in unassigned_servers: 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'<button type="button" class="btn btn-sm btn-outline-info btn-sm-custom add-server-btn" ' html += f'<button type="button" class="btn btn-sm btn-outline-info btn-sm-custom add-server-btn" '
html += f'data-server-id="{server.id}" data-server-name="{server.name}">' html += f'data-server-id="{server.id}" data-server-name="{server.name}" '
html += f'{type_icon} {server.name}' html += f'title="{type_label} server">'
html += f'{type_icon} {server.name} ({type_label})'
html += f'</button>' html += f'</button>'
html += '</div></div>' html += '</div></div>'
@@ -1305,24 +1402,9 @@ class UserAdmin(admin.ModelAdmin):
return super().change_view(request, object_id, form_url, extra_context) return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change): # Removed save_model as we no longer manage servers directly through the form
import logging # Legacy VPN access is now managed through the ACL admin interface
logger = logging.getLogger(__name__) # Xray access is managed through the UserXraySubscriptionInline
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}")
# Note: get_or_create will use the default save() method which creates default links # Note: get_or_create will use the default save() method which creates default links
@admin.register(AccessLog) @admin.register(AccessLog)

View File

@@ -592,80 +592,12 @@ class UserSubscriptionInline(admin.TabularInline):
def add_subscription_management_to_user(UserAdmin): def add_subscription_management_to_user(UserAdmin):
"""Add subscription management to existing User admin""" """Add subscription management to existing User admin"""
# Add inline # Add inline only - no fieldset or widget
if hasattr(UserAdmin, 'inlines'): if hasattr(UserAdmin, 'inlines'):
UserAdmin.inlines = list(UserAdmin.inlines) + [UserSubscriptionInline] UserAdmin.inlines = list(UserAdmin.inlines) + [UserSubscriptionInline]
else: else:
UserAdmin.inlines = [UserSubscriptionInline] 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('<div style="color: #6c757d;">Save user first to manage subscriptions</div>')
# 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 = '<div style="background: #f8f9fa; padding: 15px; border-radius: 4px;">'
html += '<h4 style="margin-top: 0;">Available Subscription Groups:</h4>'
if all_groups:
html += '<div style="display: grid; gap: 10px;">'
for group in all_groups:
checked = 'checked' if group.id in user_groups else ''
status = '' if group.id in user_groups else ''
html += f'''
<div style="display: flex; align-items: center; gap: 10px; padding: 8px; background: white; border-radius: 4px;">
<span style="font-size: 18px;">{status}</span>
<label style="flex: 1; cursor: pointer;">
<strong>{group.name}</strong>
{f' - {group.description}' if group.description else ''}
<small style="color: #6c757d;"> ({group.inbound_count} inbounds)</small>
</label>
</div>
'''
html += '</div>'
html += '<div style="margin-top: 10px; color: #6c757d; font-size: 12px;">'
html += ' Use the inline form below to manage subscriptions'
html += '</div>'
else:
html += '<div style="color: #6c757d;">No active subscription groups available</div>'
html += '</div>'
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 # UserSubscription admin will be integrated into unified Subscriptions admin
class UserSubscriptionAdmin(admin.ModelAdmin): class UserSubscriptionAdmin(admin.ModelAdmin):

View File

@@ -1,14 +1,7 @@
from django import forms from django import forms
from .models import User from .models import User
from .server_plugins import Server
class UserForm(forms.ModelForm): class UserForm(forms.ModelForm):
servers = forms.ModelMultipleChoiceField(
queryset=Server.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False
)
class Meta: class Meta:
model = User model = User
fields = ['username', 'comment', 'servers'] fields = ['username', 'first_name', 'last_name', 'email', 'comment', 'is_active']

View File

@@ -438,8 +438,23 @@ def xray_subscription(request, user_hash):
# Return with proper headers for subscription # Return with proper headers for subscription
response = HttpResponse(subscription_b64, content_type="text/plain; charset=utf-8") 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' 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 return response
except Exception as e: 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 # VLESS URL format: vless://uuid@host:port?params#name
params = [] params = []
if inbound.network != 'tcp': # Always add transport type for VLESS
params.append(f"type={inbound.network}") params.append(f"type={inbound.network}")
if inbound.security != 'none': if inbound.security != 'none':
params.append(f"security={inbound.security}") params.append(f"security={inbound.security}")