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'Subject: | {cert.subject.rfc4514_string()} |
'
+ html += f'Issuer: | {cert.issuer.rfc4514_string()} |
'
+ html += f'Serial Number: | {cert.serial_number} |
'
+ # 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'Valid From: | {valid_from} |
'
+ html += f'Valid Until: | {valid_until} |
'
+
+ # 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'Status: | {status} |
'
+
+ # Extensions
+ try:
+ san = cert.extensions.get_extension_for_oid(x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
+ domains = [name.value for name in san.value]
+ html += f'Domains: | {", ".join(domains)} |
'
+ except:
+ # No SAN extension or other error
+ pass
+
+ html += '
'
+
+ 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 @@
- {% if group_data.inbounds|length > 1 %}
- ✨ {{ group_data.inbounds|length }} protocols included
- {% else %}
- ✨ {{ group_data.inbounds.0.protocol|upper }} protocol
- {% endif %}
- 📅
- {% for inbound_data in group_data.inbounds %}
- {{ inbound_data.protocol|upper }}{% if not forloop.last %}, {% endif %}
- {% endfor %}
-
🔗 {{ group_data.inbounds|length }} inbound(s)
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: