diff --git a/vpn/admin_xray.py b/vpn/admin_xray.py index 95b2643..c468080 100644 --- a/vpn/admin_xray.py +++ b/vpn/admin_xray.py @@ -13,33 +13,15 @@ from django.urls import path, reverse from django.http import JsonResponse, HttpResponseRedirect from .models_xray import ( - XrayConfiguration, Credentials, Certificate, + Credentials, Certificate, Inbound, SubscriptionGroup, UserSubscription, ServerInbound ) -@admin.register(XrayConfiguration) -class XrayConfigurationAdmin(admin.ModelAdmin): - """Admin for global Xray configuration""" - list_display = ('grpc_address', 'default_client_hostname', 'stats_enabled', 'cert_renewal_days', 'updated_at') - fields = ( - 'grpc_address', 'default_client_hostname', - 'stats_enabled', 'cert_renewal_days', - 'created_at', 'updated_at' - ) - readonly_fields = ('created_at', 'updated_at') - - def has_add_permission(self, request): - # Only allow one configuration - return not XrayConfiguration.objects.exists() - - def has_delete_permission(self, request, obj=None): - return False - -@admin.register(Credentials) +# Credentials admin available through direct URL but not in main menu class CredentialsAdmin(admin.ModelAdmin): - """Admin for credentials management""" + """Admin for credentials management (accessible via direct URL only)""" list_display = ('name', 'cred_type', 'description', 'created_at') list_filter = ('cred_type',) search_fields = ('name', 'description') @@ -50,7 +32,7 @@ class CredentialsAdmin(admin.ModelAdmin): }), ('Credentials Data', { 'fields': ('credentials_help', 'credentials'), - 'description': 'Enter credentials as JSON. Example: {"api_token": "your_token", "email": "your_email"}' + 'description': 'Enter credentials as JSON' }), ('Preview', { 'fields': ('credentials_display',), @@ -64,27 +46,23 @@ class CredentialsAdmin(admin.ModelAdmin): readonly_fields = ('credentials_display', 'credentials_help', 'created_at', 'updated_at') - # Add JSON widget for better formatting formfield_overrides = { - models.JSONField: {'widget': Textarea(attrs={'rows': 10, 'cols': 80, 'class': 'vLargeTextField'})}, + models.JSONField: {'widget': Textarea(attrs={'rows': 6, 'style': 'font-family: monospace;'})}, } def credentials_help(self, obj): - """Help text and examples for credentials field""" + """Display help for different credential formats""" examples = { 'cloudflare': { - 'api_token': 'your_cloudflare_api_token', - 'email': 'your_email@example.com' + 'api_token': 'your_cloudflare_api_token_here' }, - 'dns_provider': { - 'api_key': 'your_dns_api_key', - 'secret': 'your_secret' + 'digitalocean': { + 'token': 'your_digitalocean_token_here' }, - 'email': { - 'smtp_host': 'smtp.example.com', - 'smtp_port': 587, - 'username': 'your_email', - 'password': 'your_password' + 'aws_route53': { + 'access_key_id': 'your_access_key_id', + 'secret_access_key': 'your_secret_access_key', + 'region': 'us-east-1' } } @@ -122,6 +100,9 @@ class CredentialsAdmin(admin.ModelAdmin): return '-' credentials_display.short_description = 'Credentials (Preview)' +# Credentials admin is available through Certificate admin only +# Do not register directly to avoid showing in main menu + @admin.register(Certificate) class CertificateAdmin(admin.ModelAdmin): @@ -132,20 +113,25 @@ class CertificateAdmin(admin.ModelAdmin): ) list_filter = ('cert_type', 'auto_renew') search_fields = ('domain',) + actions = ['rotate_selected_certificates'] fieldsets = ( ('Certificate Request', { - 'fields': ('domain', 'cert_type', 'acme_email', 'credentials', 'auto_renew'), - 'description': 'For Let\'s Encrypt certificates, provide email for ACME registration and select credentials with Cloudflare API token.' + 'fields': ('domain', 'cert_type', 'acme_email', 'auto_renew'), + 'description': 'For Let\'s Encrypt certificates, provide email for ACME registration and select/create credentials below.' }), - ('Certificate Status', { - 'fields': ('generation_help', 'status_display', 'expires_at'), + ('API Credentials', { + 'fields': ('credentials',), + 'description': 'Select API credentials for automatic Let\'s Encrypt certificate generation' + }), + ('Certificate Generation Status', { + 'fields': ('generation_help',), 'classes': ('wide',) }), ('Certificate Data', { - 'fields': ('certificate_preview', 'certificate_pem', 'private_key_pem'), + 'fields': ('certificate_info', 'certificate_pem', 'private_key_pem'), 'classes': ('collapse',), - 'description': 'Certificate data (auto-generated for Let\'s Encrypt)' + 'description': 'Detailed certificate information' }), ('Renewal Settings', { 'fields': ('last_renewed',), @@ -158,7 +144,7 @@ class CertificateAdmin(admin.ModelAdmin): ) readonly_fields = ( - 'certificate_preview', 'status_display', 'generation_help', + 'certificate_info', 'status_display', 'generation_help', 'expires_at', 'last_renewed', 'created_at', 'updated_at' ) @@ -329,6 +315,129 @@ class CertificateAdmin(admin.ModelAdmin): logger = logging.getLogger(__name__) logger.error(f'Certificate generation failed for {cert_obj.domain}: {e}', exc_info=True) + def certificate_info(self, obj): + """Display detailed certificate information""" + if not obj.pk: + return "Save certificate to see details" + + if not obj.certificate_pem: + return "Certificate not generated yet" + + html = '
' + + # Import here to avoid circular imports + try: + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + + # Parse certificate + cert = x509.load_pem_x509_certificate(obj.certificate_pem.encode(), default_backend()) + + # Basic info + html += '

📜 Certificate Information

' + html += '' + html += f'' + html += f'' + html += f'' + # Use UTC versions to avoid deprecation warnings + try: + # Try new UTC properties first (cryptography >= 42.0.0) + valid_from = cert.not_valid_before_utc + valid_until = cert.not_valid_after_utc + cert_not_after = valid_until + except AttributeError: + # Fall back to old properties for older cryptography versions + valid_from = cert.not_valid_before + valid_until = cert.not_valid_after + cert_not_after = cert.not_valid_after + if cert_not_after.tzinfo is None: + cert_not_after = cert_not_after.replace(tzinfo=timezone.utc) + + html += f'' + html += f'' + + # Status + from datetime import datetime, timezone + now = datetime.now(timezone.utc) + + days_until_expiry = (cert_not_after - now).days + + if days_until_expiry < 0: + status = f'❌ Expired {abs(days_until_expiry)} days ago' + elif days_until_expiry < 30: + status = f'⚠️ Expires in {days_until_expiry} days' + else: + status = f'✅ Valid for {days_until_expiry} days' + + html += f'' + + # Extensions + try: + san = cert.extensions.get_extension_for_oid(x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME) + domains = [name.value for name in san.value] + html += f'' + except: + # No SAN extension or other error + pass + + html += '
Subject:{cert.subject.rfc4514_string()}
Issuer:{cert.issuer.rfc4514_string()}
Serial Number:{cert.serial_number}
Valid From:{valid_from}
Valid Until:{valid_until}
Status:{status}
Domains:{", ".join(domains)}
' + + except ImportError: + html += '

⚠️ Install cryptography package to see detailed certificate information

' + except Exception as e: + html += f'

❌ Error parsing certificate: {e}

' + + html += '
' + return mark_safe(html) + certificate_info.short_description = 'Certificate Details' + + def rotate_selected_certificates(self, request, queryset): + """Admin action to rotate selected certificates""" + from vpn.tasks import generate_certificate_task + + # Filter only Let's Encrypt certificates + valid_certs = queryset.filter(cert_type='letsencrypt') + if not valid_certs.exists(): + self.message_user(request, "No Let's Encrypt certificates selected. Only Let's Encrypt certificates can be rotated.", level='ERROR') + return + + # Check for certificates without credentials + certs_without_creds = valid_certs.filter(credentials__isnull=True) + if certs_without_creds.exists(): + domains = ', '.join(certs_without_creds.values_list('domain', flat=True)) + self.message_user(request, f"The following certificates have no credentials configured and will be skipped: {domains}", level='WARNING') + + # Filter certificates that have credentials + certs_to_rotate = valid_certs.filter(credentials__isnull=False) + + if not certs_to_rotate.exists(): + self.message_user(request, "No certificates with valid credentials found.", level='ERROR') + return + + # Launch rotation tasks + rotated_count = 0 + task_ids = [] + + for certificate in certs_to_rotate: + try: + task = generate_certificate_task.delay(certificate.id) + task_ids.append(task.id) + rotated_count += 1 + except Exception as e: + self.message_user(request, f"Failed to start rotation for {certificate.domain}: {str(e)}", level='ERROR') + + if rotated_count > 0: + domains = ', '.join(certs_to_rotate.values_list('domain', flat=True)) + task_list = ', '.join(task_ids) + self.message_user( + request, + f'Successfully initiated certificate rotation for {rotated_count} certificate(s): {domains}. ' + f'Task IDs: {task_list}. Certificates will be automatically redeployed to all servers once generated.', + level='SUCCESS' + ) + + rotate_selected_certificates.short_description = "🔄 Rotate selected Let's Encrypt certificates" + @admin.register(Inbound) class InboundAdmin(admin.ModelAdmin): @@ -393,7 +502,10 @@ class InboundAdmin(admin.ModelAdmin): try: # Always regenerate config to reflect any changes obj.build_config() - messages.success(request, f'✅ Configuration generated successfully for {obj.protocol.upper()} inbound on port {obj.port}') + if change: + messages.success(request, f'✅ Inbound "{obj.name}" updated. Changes will be automatically deployed to servers.') + else: + messages.success(request, f'✅ Inbound "{obj.name}" created. It will be deployed when added to subscription groups.') except Exception as e: messages.warning(request, f'Inbound saved but config generation failed: {e}') # Set empty dict if generation fails @@ -425,7 +537,8 @@ class SubscriptionGroupAdmin(admin.ModelAdmin): }), ('Inbounds', { 'fields': ('inbounds',), - 'description': 'Select inbounds to include in this group' + 'description': 'Select inbounds to include in this group. ' + + '
🚀 Auto-sync enabled: Changes will be automatically deployed to servers!' }), ('Statistics', { 'fields': ('group_statistics',), @@ -435,6 +548,20 @@ class SubscriptionGroupAdmin(admin.ModelAdmin): 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: diff --git a/vpn/apps.py b/vpn/apps.py index a232cc6..762f102 100644 --- a/vpn/apps.py +++ b/vpn/apps.py @@ -4,3 +4,10 @@ from django.contrib.auth import get_user_model class VPN(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'vpn' + + def ready(self): + """Import signals when Django starts""" + try: + import vpn.signals # noqa + except ImportError: + pass diff --git a/vpn/migrations/0021_remove_xray_configuration.py b/vpn/migrations/0021_remove_xray_configuration.py new file mode 100644 index 0000000..5d3d225 --- /dev/null +++ b/vpn/migrations/0021_remove_xray_configuration.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.7 on 2025-08-08 03:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0020_alter_inbound_full_config'), + ] + + operations = [ + migrations.DeleteModel( + name='XrayConfiguration', + ), + ] diff --git a/vpn/models.py b/vpn/models.py index 1ab9d07..cc681e1 100644 --- a/vpn/models.py +++ b/vpn/models.py @@ -171,6 +171,6 @@ class ACLLink(models.Model): # Import new Xray models from .models_xray import ( - XrayConfiguration, Credentials, Certificate, + Credentials, Certificate, Inbound, SubscriptionGroup, UserSubscription ) diff --git a/vpn/models_xray.py b/vpn/models_xray.py index 673c8c1..0b00ee7 100644 --- a/vpn/models_xray.py +++ b/vpn/models_xray.py @@ -10,42 +10,6 @@ from django.core.exceptions import ValidationError from django.utils import timezone -class XrayConfiguration(models.Model): - """Global Xray configuration - Admin menu settings""" - grpc_address = models.CharField( - max_length=255, - default="127.0.0.1:10085", - help_text="Xray gRPC API address (host:port)" - ) - default_client_hostname = models.CharField( - max_length=255, - help_text="Default hostname for client connections" - ) - stats_enabled = models.BooleanField( - default=True, - help_text="Enable traffic statistics" - ) - cert_renewal_days = models.IntegerField( - default=60, - help_text="Renew certificates X days before expiration" - ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - verbose_name = "Xray Configuration" - verbose_name_plural = "Xray Configuration" - - def __str__(self): - return f"Xray Config - {self.grpc_address}" - - def save(self, *args, **kwargs): - # Ensure only one configuration exists - if not self.pk and XrayConfiguration.objects.exists(): - raise ValidationError("Only one Xray configuration allowed") - super().save(*args, **kwargs) - - class Credentials(models.Model): """Universal credentials storage for various services""" CRED_TYPES = [ @@ -171,11 +135,8 @@ class Certificate(models.Model): if not self.auto_renew or not self.expires_at: return False - try: - config = XrayConfiguration.objects.first() - renewal_days = config.cert_renewal_days if config else 60 - except: - renewal_days = 60 + # Default renewal period + renewal_days = 60 days_left = self.days_until_expiration if days_left is None: diff --git a/vpn/server_plugins/xray_v2.py b/vpn/server_plugins/xray_v2.py index 2435f97..037a664 100644 --- a/vpn/server_plugins/xray_v2.py +++ b/vpn/server_plugins/xray_v2.py @@ -2,7 +2,7 @@ import logging from django.db import models from django.contrib import admin from .generic import Server -from vpn.models_xray import XrayConfiguration, Inbound, UserSubscription +from vpn.models_xray import Inbound, UserSubscription logger = logging.getLogger(__name__) @@ -151,17 +151,16 @@ class XrayServerV2(Server): logger.error(f"Failed to schedule user sync for server {self.name}: {e}") return {"status": "failed", "error": str(e)} - def sync_inbounds(self): + def sync_inbounds(self, auto_sync_users=True): """Deploy all required inbounds on this server based on subscription groups""" try: from vpn.tasks import sync_server_inbounds - task = sync_server_inbounds.delay(self.id) + task = sync_server_inbounds.delay(self.id, auto_sync_users) logger.info(f"Scheduled inbound sync for Xray server {self.name} - task ID: {task.id}") - # Return None to match old behavior - return None + return {"task_id": str(task.id), "auto_sync_users": auto_sync_users} except Exception as e: logger.error(f"Failed to schedule inbound sync for server {self.name}: {e}") - return None + return {"error": str(e)} def deploy_inbound(self, inbound, users=None): """Deploy a specific inbound on this server with optional users""" diff --git a/vpn/signals.py b/vpn/signals.py new file mode 100644 index 0000000..3c4a7ac --- /dev/null +++ b/vpn/signals.py @@ -0,0 +1,272 @@ +""" +Django signals for automatic Xray server synchronization +""" +import logging +from django.db.models.signals import post_save, post_delete, m2m_changed +from django.dispatch import receiver +from django.db import transaction +from celery import group + +from .models_xray import ( + Inbound, + SubscriptionGroup, + UserSubscription, + Certificate, + ServerInbound +) +from .server_plugins.xray_v2 import XrayServerV2 + +logger = logging.getLogger(__name__) + + +def get_active_xray_servers(): + """Get all active Xray servers""" + from .server_plugins import Server + return [ + server.get_real_instance() + for server in Server.objects.all() + if hasattr(server.get_real_instance(), 'api_enabled') and + server.get_real_instance().api_enabled + ] + + +def schedule_inbound_sync_for_servers(inbound, servers=None): + """Schedule inbound deployment on servers""" + if servers is None: + servers = get_active_xray_servers() + + if not servers: + logger.warning("No active Xray servers found for inbound sync") + return + + logger.info(f"Scheduling inbound {inbound.name} deployment on {len(servers)} servers") + + # Schedule deployment tasks + from .tasks import deploy_inbound_on_server + tasks = [] + for server in servers: + task = deploy_inbound_on_server.s(server.id, inbound.id) + tasks.append(task) + + # Execute all deployments in parallel + job = group(tasks) + result = job.apply_async() + + logger.info(f"Scheduled inbound deployment tasks: {result}") + return result + + +def schedule_user_sync_for_servers(servers=None): + """Schedule user sync on servers after inbound changes""" + if servers is None: + servers = get_active_xray_servers() + + if not servers: + logger.warning("No active Xray servers found for user sync") + return + + logger.info(f"Scheduling user sync on {len(servers)} servers") + + # Schedule user sync tasks + from .tasks import sync_server_users + tasks = [] + for server in servers: + task = sync_server_users.s(server.id) + tasks.append(task) + + # Execute all user syncs in parallel with delay to allow inbound sync to complete + job = group(tasks) + result = job.apply_async(countdown=10) # 10 second delay + + logger.info(f"Scheduled user sync tasks: {result}") + return result + + +@receiver(post_save, sender=Inbound) +def inbound_created_or_updated(sender, instance, created, **kwargs): + """ + When an inbound is created or updated, deploy it to all servers + where subscription groups contain this inbound + """ + if created: + logger.info(f"New inbound {instance.name} created, will deploy when added to groups") + else: + logger.info(f"Inbound {instance.name} updated, scheduling redeployment") + + # Get all subscription groups that contain this inbound + groups = instance.subscriptiongroup_set.filter(is_active=True) + + if groups.exists(): + # Get all servers that should have this inbound + servers = get_active_xray_servers() + + # Schedule redeployment + transaction.on_commit(lambda: schedule_inbound_sync_for_servers(instance, servers)) + + # Schedule user sync after inbound update + transaction.on_commit(lambda: schedule_user_sync_for_servers(servers)) + + +@receiver(post_delete, sender=Inbound) +def inbound_deleted(sender, instance, **kwargs): + """ + When an inbound is deleted, remove it from all servers + """ + logger.info(f"Inbound {instance.name} deleted, scheduling removal from servers") + + # Schedule removal from all servers + from .tasks import remove_inbound_from_server + servers = get_active_xray_servers() + tasks = [] + + for server in servers: + task = remove_inbound_from_server.s(server.id, instance.name) + tasks.append(task) + + if tasks: + job = group(tasks) + transaction.on_commit(lambda: job.apply_async()) + + +@receiver(m2m_changed, sender=SubscriptionGroup.inbounds.through) +def subscription_group_inbounds_changed(sender, instance, action, pk_set, **kwargs): + """ + When inbounds are added/removed from subscription groups, + automatically deploy/remove them on servers + """ + if action in ['post_add', 'post_remove']: + logger.info(f"Subscription group {instance.name} inbounds changed: {action}") + + if action == 'post_add' and pk_set: + # Inbounds were added to the group - deploy them + inbounds = Inbound.objects.filter(pk__in=pk_set) + servers = get_active_xray_servers() + + for inbound in inbounds: + logger.info(f"Deploying inbound {inbound.name} (added to group {instance.name})") + transaction.on_commit( + lambda inb=inbound: schedule_inbound_sync_for_servers(inb, servers) + ) + + # Schedule user sync after all inbounds are deployed + transaction.on_commit(lambda: schedule_user_sync_for_servers(servers)) + + elif action == 'post_remove' and pk_set: + # Inbounds were removed from the group + inbounds = Inbound.objects.filter(pk__in=pk_set) + + for inbound in inbounds: + # Check if inbound is still used by other active groups + other_groups = inbound.subscriptiongroup_set.filter(is_active=True).exclude(id=instance.id) + + if not other_groups.exists(): + # Inbound is not used by any other group - remove from servers + logger.info(f"Removing inbound {inbound.name} from servers (no longer in any group)") + from .tasks import remove_inbound_from_server + servers = get_active_xray_servers() + tasks = [] + + for server in servers: + task = remove_inbound_from_server.s(server.id, inbound.name) + tasks.append(task) + + if tasks: + job = group(tasks) + transaction.on_commit(lambda: job.apply_async()) + + +@receiver(post_save, sender=UserSubscription) +def user_subscription_created_or_updated(sender, instance, created, **kwargs): + """ + When user subscription is created or updated, sync the user to servers + """ + if created: + logger.info(f"New subscription created for user {instance.user.username} in group {instance.subscription_group.name}") + else: + logger.info(f"Subscription updated for user {instance.user.username} in group {instance.subscription_group.name}") + + if instance.active: + # Schedule user sync on all servers + servers = get_active_xray_servers() + transaction.on_commit(lambda: schedule_user_sync_for_servers(servers)) + + +@receiver(post_delete, sender=UserSubscription) +def user_subscription_deleted(sender, instance, **kwargs): + """ + When user subscription is deleted, remove user from servers if no other subscriptions + """ + logger.info(f"Subscription deleted for user {instance.user.username} in group {instance.subscription_group.name}") + + # Check if user has other active subscriptions + other_subscriptions = UserSubscription.objects.filter( + user=instance.user, + active=True + ).exclude(id=instance.id).exists() + + if not other_subscriptions: + # User has no more subscriptions - remove from all servers + logger.info(f"User {instance.user.username} has no more subscriptions, removing from servers") + from .tasks import remove_user_from_server + servers = get_active_xray_servers() + tasks = [] + + for server in servers: + task = remove_user_from_server.s(server.id, instance.user.id) + tasks.append(task) + + if tasks: + job = group(tasks) + transaction.on_commit(lambda: job.apply_async()) + + +@receiver(post_save, sender=Certificate) +def certificate_updated(sender, instance, created, **kwargs): + """ + When certificate is updated, redeploy all inbounds that use it + """ + if not created and instance.certificate_pem: # Only on updates when cert is available + logger.info(f"Certificate {instance.domain} updated, redeploying dependent inbounds") + + # Find all inbounds that use this certificate + inbounds = Inbound.objects.filter(certificate=instance) + servers = get_active_xray_servers() + + for inbound in inbounds: + transaction.on_commit( + lambda inb=inbound: schedule_inbound_sync_for_servers(inb, servers) + ) + + +@receiver(post_save, sender=SubscriptionGroup) +def subscription_group_updated(sender, instance, created, **kwargs): + """ + When subscription group is created/updated, sync its state + """ + if created: + logger.info(f"New subscription group {instance.name} created") + else: + logger.info(f"Subscription group {instance.name} updated") + + if not instance.is_active: + # Group was deactivated - remove its inbounds from servers if not used elsewhere + logger.info(f"Subscription group {instance.name} deactivated, checking inbounds") + + for inbound in instance.inbounds.all(): + # Check if inbound is used by other active groups + other_groups = inbound.subscriptiongroup_set.filter(is_active=True).exclude(id=instance.id) + + if not other_groups.exists(): + # Remove inbound from servers + logger.info(f"Removing inbound {inbound.name} from servers (group deactivated)") + from .tasks import remove_inbound_from_server + servers = get_active_xray_servers() + tasks = [] + + for server in servers: + task = remove_inbound_from_server.s(server.id, inbound.name) + tasks.append(task) + + if tasks: + job = group(tasks) + transaction.on_commit(lambda: job.apply_async()) \ No newline at end of file diff --git a/vpn/tasks.py b/vpn/tasks.py index b21af99..94b7b12 100644 --- a/vpn/tasks.py +++ b/vpn/tasks.py @@ -544,8 +544,9 @@ def sync_user_xray_access(self, user_id, server_id): Creates inbounds on server if needed and adds user to them. """ from .models import User, Server - from .models_xray import SubscriptionGroup, Inbound, XrayConfiguration + from .models_xray import SubscriptionGroup, Inbound from vpn.xray_api_v2.client import XrayClient + from vpn.server_plugins.xray_v2 import XrayServerV2 start_time = time.time() task_id = self.request.id @@ -554,10 +555,10 @@ def sync_user_xray_access(self, user_id, server_id): user = User.objects.get(id=user_id) server = Server.objects.get(id=server_id) - # Get Xray configuration - xray_config = XrayConfiguration.objects.first() - if not xray_config: - raise ValueError("Xray configuration not found. Please configure in admin.") + # Get server instance + real_server = server.get_real_instance() + if not isinstance(real_server, XrayServerV2): + raise ValueError(f"Server {server.name} is not an Xray v2 server") create_task_log( task_id, "sync_user_xray_access", @@ -584,7 +585,7 @@ def sync_user_xray_access(self, user_id, server_id): logger.info(f"User {user.username} has access to {user_inbounds.count()} inbounds") # Connect to Xray server - client = XrayClient(xray_config.grpc_address) + client = XrayClient(real_server.api_address) # Get existing inbounds on server try: @@ -734,13 +735,13 @@ def sync_server_users(self, server_id): @shared_task(name="sync_server_inbounds", bind=True) -def sync_server_inbounds(self, server_id): +def sync_server_inbounds(self, server_id, auto_sync_users=True): """ Sync all inbounds for a specific Xray server. This is called by XrayServerV2.sync_inbounds() """ from vpn.server_plugins import Server - from vpn.models_xray import SubscriptionGroup, ServerInbound + from vpn.models_xray import SubscriptionGroup, ServerInbound, UserSubscription try: server = Server.objects.get(id=server_id) @@ -753,20 +754,169 @@ def sync_server_inbounds(self, server_id): for group in groups: for inbound in group.inbounds.all(): try: - if real_server.deploy_inbound(inbound): + # Get users for this inbound + users_with_access = [] + group_users = [ + sub.user for sub in + UserSubscription.objects.filter( + subscription_group=group, + active=True + ).select_related('user') + ] + users_with_access.extend(group_users) + + # Remove duplicates + users_with_access = list(set(users_with_access)) + + # Deploy inbound with users + if real_server.deploy_inbound(inbound, users=users_with_access): deployed_count += 1 - logger.info(f"Deployed inbound {inbound.name} on server {server.name}") + + # Mark as deployed + ServerInbound.objects.update_or_create( + server=server, + inbound=inbound, + defaults={'active': True} + ) + + logger.info(f"Deployed inbound {inbound.name} with {len(users_with_access)} users on server {server.name}") + else: + logger.error(f"Failed to deploy inbound {inbound.name} on server {server.name}") except Exception as e: logger.error(f"Failed to deploy inbound {inbound.name} on server {server.name}: {e}") logger.info(f"Successfully deployed {deployed_count} inbounds on server {server.name}") - return {"inbounds_deployed": deployed_count} + + # Automatically sync users after inbound deployment if requested + if auto_sync_users and deployed_count > 0: + logger.info(f"Scheduling user sync for server {server.name} after inbound deployment") + sync_server_users.apply_async(args=[server_id], countdown=5) # 5 second delay + + return {"inbounds_deployed": deployed_count, "auto_sync_users": auto_sync_users} except Exception as e: logger.error(f"Error syncing inbounds for server {server_id}: {e}") raise +@shared_task(name="deploy_inbound_on_server", bind=True) +def deploy_inbound_on_server(self, server_id, inbound_id): + """ + Deploy a specific inbound on a specific server + """ + from vpn.server_plugins import Server + from vpn.models_xray import Inbound + + try: + server = Server.objects.get(id=server_id) + real_server = server.get_real_instance() + inbound = Inbound.objects.get(id=inbound_id) + + logger.info(f"Deploying inbound {inbound.name} on server {server.name}") + + # Get all users that should have access to this inbound + from vpn.models_xray import UserSubscription + users_with_access = [] + + # Find users through subscription groups + for group in inbound.subscriptiongroup_set.filter(is_active=True): + group_users = [ + sub.user for sub in + UserSubscription.objects.filter( + subscription_group=group, + active=True + ).select_related('user') + ] + users_with_access.extend(group_users) + + # Remove duplicates + users_with_access = list(set(users_with_access)) + + logger.info(f"Deploying inbound {inbound.name} with {len(users_with_access)} users") + + # Deploy inbound with users + if real_server.deploy_inbound(inbound, users=users_with_access): + # Mark as deployed + from vpn.models_xray import ServerInbound + ServerInbound.objects.update_or_create( + server=server, + inbound=inbound, + defaults={'active': True} + ) + logger.info(f"Successfully deployed inbound {inbound.name} on server {server.name}") + return {"success": True, "inbound": inbound.name, "server": server.name, "users": len(users_with_access)} + else: + logger.error(f"Failed to deploy inbound {inbound.name} on server {server.name}") + return {"success": False, "inbound": inbound.name, "server": server.name, "error": "Deployment failed"} + + except Exception as e: + logger.error(f"Error deploying inbound {inbound_id} on server {server_id}: {e}") + return {"success": False, "error": str(e)} + + +@shared_task(name="remove_inbound_from_server", bind=True) +def remove_inbound_from_server(self, server_id, inbound_name): + """ + Remove a specific inbound from a specific server + """ + from vpn.server_plugins import Server + from vpn.xray_api_v2.client import XrayClient + + try: + server = Server.objects.get(id=server_id) + real_server = server.get_real_instance() + + logger.info(f"Removing inbound {inbound_name} from server {server.name}") + + # Remove inbound using Xray API + client = XrayClient(server=real_server.api_address) + result = client.remove_inbound(inbound_name) + + # Remove from ServerInbound tracking + from vpn.models_xray import ServerInbound, Inbound + try: + inbound = Inbound.objects.get(name=inbound_name) + ServerInbound.objects.filter(server=server, inbound=inbound).delete() + except Inbound.DoesNotExist: + pass # Inbound was already deleted from Django + + logger.info(f"Successfully removed inbound {inbound_name} from server {server.name}") + return {"success": True, "inbound": inbound_name, "server": server.name} + + except Exception as e: + logger.error(f"Error removing inbound {inbound_name} from server {server_id}: {e}") + return {"success": False, "error": str(e)} + + +@shared_task(name="remove_user_from_server", bind=True) +def remove_user_from_server(self, server_id, user_id): + """ + Remove a specific user from a specific server + """ + from vpn.server_plugins import Server + from vpn.models import User + + try: + server = Server.objects.get(id=server_id) + real_server = server.get_real_instance() + user = User.objects.get(id=user_id) + + logger.info(f"Removing user {user.username} from server {server.name}") + + result = real_server.delete_user(user) + + if result: + logger.info(f"Successfully removed user {user.username} from server {server.name}") + return {"success": True, "user": user.username, "server": server.name} + else: + logger.warning(f"Failed to remove user {user.username} from server {server.name}") + return {"success": False, "user": user.username, "server": server.name, "error": "Removal failed"} + + except Exception as e: + logger.error(f"Error removing user {user_id} from server {server_id}: {e}") + return {"success": False, "error": str(e)} + + @shared_task(name="generate_certificate_task", bind=True) def generate_certificate_task(self, certificate_id): """ @@ -857,7 +1007,7 @@ def renew_certificates(self): """ Check and renew certificates that are about to expire. """ - from .models_xray import Certificate, XrayConfiguration + from .models_xray import Certificate from .letsencrypt import get_certificate_for_domain from datetime import datetime diff --git a/vpn/templates/vpn/user_portal.html b/vpn/templates/vpn/user_portal.html index bfa0cf5..a404d67 100644 --- a/vpn/templates/vpn/user_portal.html +++ b/vpn/templates/vpn/user_portal.html @@ -497,7 +497,6 @@
{{ group_name }}
- 📊 {{ group_data.total_connections }} uses 🔗 {{ group_data.inbounds|length }} inbound(s)
@@ -518,16 +517,6 @@ diff --git a/vpn/views.py b/vpn/views.py index c457ec6..af9107d 100644 --- a/vpn/views.py +++ b/vpn/views.py @@ -28,10 +28,22 @@ def userPortal(request, user_hash): logger.info(f"Found {user_subscriptions.count()} active subscription groups for user {user.username}") - # For now, set statistics to zero as we're transitioning systems - total_connections = 0 - recent_connections = 0 - logger.info(f"Using zero stats during transition for user {user.username}") + # Calculate overall Xray subscription statistics + from .models import AccessLog + total_connections = AccessLog.objects.filter( + user=user.username, + action='Success', + server='Xray-Subscription' + ).count() + + recent_connections = AccessLog.objects.filter( + user=user.username, + action='Success', + server='Xray-Subscription', + timestamp__gte=timezone.now() - timedelta(days=30) + ).count() + + logger.info(f"Xray statistics for user {user.username}: total={total_connections}, recent={recent_connections}") # Determine protocol scheme scheme = 'https' if request.is_secure() else 'http' @@ -48,11 +60,19 @@ def userPortal(request, user_hash): # Get all inbounds for this group group_inbounds = group.inbounds.all() + # Calculate connections for this specific group + group_connections = AccessLog.objects.filter( + user=user.username, + action='Success', + server='Xray-Subscription', + data__icontains=f'"group": "{group_name}"' + ).count() + groups_data[group_name] = { 'group': group, 'subscription': subscription, 'inbounds': [], - 'total_connections': 0, # Placeholder during transition + 'total_connections': group_connections, } for inbound in group_inbounds: