Xray works. fixed certs.

This commit is contained in:
AB from home.homenet
2025-08-08 06:50:04 +03:00
parent 787432cbcf
commit fe56811b33
10 changed files with 661 additions and 120 deletions

View File

@@ -13,33 +13,15 @@ from django.urls import path, reverse
from django.http import JsonResponse, HttpResponseRedirect from django.http import JsonResponse, HttpResponseRedirect
from .models_xray import ( from .models_xray import (
XrayConfiguration, Credentials, Certificate, Credentials, Certificate,
Inbound, SubscriptionGroup, UserSubscription, ServerInbound 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): # Credentials admin available through direct URL but not in main menu
# Only allow one configuration
return not XrayConfiguration.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
@admin.register(Credentials)
class CredentialsAdmin(admin.ModelAdmin): 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_display = ('name', 'cred_type', 'description', 'created_at')
list_filter = ('cred_type',) list_filter = ('cred_type',)
search_fields = ('name', 'description') search_fields = ('name', 'description')
@@ -50,7 +32,7 @@ class CredentialsAdmin(admin.ModelAdmin):
}), }),
('Credentials Data', { ('Credentials Data', {
'fields': ('credentials_help', 'credentials'), 'fields': ('credentials_help', 'credentials'),
'description': 'Enter credentials as JSON. Example: {"api_token": "your_token", "email": "your_email"}' 'description': 'Enter credentials as JSON'
}), }),
('Preview', { ('Preview', {
'fields': ('credentials_display',), 'fields': ('credentials_display',),
@@ -64,27 +46,23 @@ class CredentialsAdmin(admin.ModelAdmin):
readonly_fields = ('credentials_display', 'credentials_help', 'created_at', 'updated_at') readonly_fields = ('credentials_display', 'credentials_help', 'created_at', 'updated_at')
# Add JSON widget for better formatting
formfield_overrides = { 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): def credentials_help(self, obj):
"""Help text and examples for credentials field""" """Display help for different credential formats"""
examples = { examples = {
'cloudflare': { 'cloudflare': {
'api_token': 'your_cloudflare_api_token', 'api_token': 'your_cloudflare_api_token_here'
'email': 'your_email@example.com'
}, },
'dns_provider': { 'digitalocean': {
'api_key': 'your_dns_api_key', 'token': 'your_digitalocean_token_here'
'secret': 'your_secret'
}, },
'email': { 'aws_route53': {
'smtp_host': 'smtp.example.com', 'access_key_id': 'your_access_key_id',
'smtp_port': 587, 'secret_access_key': 'your_secret_access_key',
'username': 'your_email', 'region': 'us-east-1'
'password': 'your_password'
} }
} }
@@ -122,6 +100,9 @@ class CredentialsAdmin(admin.ModelAdmin):
return '-' return '-'
credentials_display.short_description = 'Credentials (Preview)' 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) @admin.register(Certificate)
class CertificateAdmin(admin.ModelAdmin): class CertificateAdmin(admin.ModelAdmin):
@@ -132,20 +113,25 @@ class CertificateAdmin(admin.ModelAdmin):
) )
list_filter = ('cert_type', 'auto_renew') list_filter = ('cert_type', 'auto_renew')
search_fields = ('domain',) search_fields = ('domain',)
actions = ['rotate_selected_certificates']
fieldsets = ( fieldsets = (
('Certificate Request', { ('Certificate Request', {
'fields': ('domain', 'cert_type', 'acme_email', 'credentials', 'auto_renew'), 'fields': ('domain', 'cert_type', 'acme_email', 'auto_renew'),
'description': 'For Let\'s Encrypt certificates, provide email for ACME registration and select credentials with Cloudflare API token.' 'description': 'For Let\'s Encrypt certificates, provide email for ACME registration and select/create credentials below.'
}), }),
('Certificate Status', { ('API Credentials', {
'fields': ('generation_help', 'status_display', 'expires_at'), 'fields': ('credentials',),
'description': 'Select API credentials for automatic Let\'s Encrypt certificate generation'
}),
('Certificate Generation Status', {
'fields': ('generation_help',),
'classes': ('wide',) 'classes': ('wide',)
}), }),
('Certificate Data', { ('Certificate Data', {
'fields': ('certificate_preview', 'certificate_pem', 'private_key_pem'), 'fields': ('certificate_info', 'certificate_pem', 'private_key_pem'),
'classes': ('collapse',), 'classes': ('collapse',),
'description': 'Certificate data (auto-generated for Let\'s Encrypt)' 'description': 'Detailed certificate information'
}), }),
('Renewal Settings', { ('Renewal Settings', {
'fields': ('last_renewed',), 'fields': ('last_renewed',),
@@ -158,7 +144,7 @@ class CertificateAdmin(admin.ModelAdmin):
) )
readonly_fields = ( readonly_fields = (
'certificate_preview', 'status_display', 'generation_help', 'certificate_info', 'status_display', 'generation_help',
'expires_at', 'last_renewed', 'created_at', 'updated_at' 'expires_at', 'last_renewed', 'created_at', 'updated_at'
) )
@@ -329,6 +315,129 @@ class CertificateAdmin(admin.ModelAdmin):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.error(f'Certificate generation failed for {cert_obj.domain}: {e}', exc_info=True) 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 = '<div style="background: #f8f9fa; padding: 15px; border-radius: 4px;">'
# 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 += '<h4>📜 Certificate Information</h4>'
html += '<table style="width: 100%; font-size: 12px;">'
html += f'<tr><td><strong>Subject:</strong></td><td>{cert.subject.rfc4514_string()}</td></tr>'
html += f'<tr><td><strong>Issuer:</strong></td><td>{cert.issuer.rfc4514_string()}</td></tr>'
html += f'<tr><td><strong>Serial Number:</strong></td><td>{cert.serial_number}</td></tr>'
# 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'<tr><td><strong>Valid From:</strong></td><td>{valid_from}</td></tr>'
html += f'<tr><td><strong>Valid Until:</strong></td><td>{valid_until}</td></tr>'
# 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'<span style="color: red;">❌ Expired {abs(days_until_expiry)} days ago</span>'
elif days_until_expiry < 30:
status = f'<span style="color: orange;">⚠️ Expires in {days_until_expiry} days</span>'
else:
status = f'<span style="color: green;">✅ Valid for {days_until_expiry} days</span>'
html += f'<tr><td><strong>Status:</strong></td><td>{status}</td></tr>'
# Extensions
try:
san = cert.extensions.get_extension_for_oid(x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
domains = [name.value for name in san.value]
html += f'<tr><td><strong>Domains:</strong></td><td>{", ".join(domains)}</td></tr>'
except:
# No SAN extension or other error
pass
html += '</table>'
except ImportError:
html += '<p>⚠️ Install cryptography package to see detailed certificate information</p>'
except Exception as e:
html += f'<p>❌ Error parsing certificate: {e}</p>'
html += '</div>'
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) @admin.register(Inbound)
class InboundAdmin(admin.ModelAdmin): class InboundAdmin(admin.ModelAdmin):
@@ -393,7 +502,10 @@ class InboundAdmin(admin.ModelAdmin):
try: try:
# Always regenerate config to reflect any changes # Always regenerate config to reflect any changes
obj.build_config() 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: except Exception as e:
messages.warning(request, f'Inbound saved but config generation failed: {e}') messages.warning(request, f'Inbound saved but config generation failed: {e}')
# Set empty dict if generation fails # Set empty dict if generation fails
@@ -425,7 +537,8 @@ class SubscriptionGroupAdmin(admin.ModelAdmin):
}), }),
('Inbounds', { ('Inbounds', {
'fields': ('inbounds',), 'fields': ('inbounds',),
'description': 'Select inbounds to include in this group' 'description': 'Select inbounds to include in this group. ' +
'<br><strong>🚀 Auto-sync enabled:</strong> Changes will be automatically deployed to servers!'
}), }),
('Statistics', { ('Statistics', {
'fields': ('group_statistics',), 'fields': ('group_statistics',),
@@ -435,6 +548,20 @@ class SubscriptionGroupAdmin(admin.ModelAdmin):
readonly_fields = ('group_statistics',) 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): def group_statistics(self, obj):
"""Display group statistics""" """Display group statistics"""
if obj.pk: if obj.pk:

View File

@@ -4,3 +4,10 @@ from django.contrib.auth import get_user_model
class VPN(AppConfig): class VPN(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'vpn' name = 'vpn'
def ready(self):
"""Import signals when Django starts"""
try:
import vpn.signals # noqa
except ImportError:
pass

View File

@@ -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',
),
]

View File

@@ -171,6 +171,6 @@ class ACLLink(models.Model):
# Import new Xray models # Import new Xray models
from .models_xray import ( from .models_xray import (
XrayConfiguration, Credentials, Certificate, Credentials, Certificate,
Inbound, SubscriptionGroup, UserSubscription Inbound, SubscriptionGroup, UserSubscription
) )

View File

@@ -10,42 +10,6 @@ from django.core.exceptions import ValidationError
from django.utils import timezone 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): class Credentials(models.Model):
"""Universal credentials storage for various services""" """Universal credentials storage for various services"""
CRED_TYPES = [ CRED_TYPES = [
@@ -171,11 +135,8 @@ class Certificate(models.Model):
if not self.auto_renew or not self.expires_at: if not self.auto_renew or not self.expires_at:
return False return False
try: # Default renewal period
config = XrayConfiguration.objects.first() renewal_days = 60
renewal_days = config.cert_renewal_days if config else 60
except:
renewal_days = 60
days_left = self.days_until_expiration days_left = self.days_until_expiration
if days_left is None: if days_left is None:

View File

@@ -2,7 +2,7 @@ import logging
from django.db import models from django.db import models
from django.contrib import admin from django.contrib import admin
from .generic import Server from .generic import Server
from vpn.models_xray import XrayConfiguration, Inbound, UserSubscription from vpn.models_xray import Inbound, UserSubscription
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -151,17 +151,16 @@ class XrayServerV2(Server):
logger.error(f"Failed to schedule user sync for server {self.name}: {e}") logger.error(f"Failed to schedule user sync for server {self.name}: {e}")
return {"status": "failed", "error": str(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""" """Deploy all required inbounds on this server based on subscription groups"""
try: try:
from vpn.tasks import sync_server_inbounds 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}") logger.info(f"Scheduled inbound sync for Xray server {self.name} - task ID: {task.id}")
# Return None to match old behavior return {"task_id": str(task.id), "auto_sync_users": auto_sync_users}
return None
except Exception as e: except Exception as e:
logger.error(f"Failed to schedule inbound sync for server {self.name}: {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): def deploy_inbound(self, inbound, users=None):
"""Deploy a specific inbound on this server with optional users""" """Deploy a specific inbound on this server with optional users"""

272
vpn/signals.py Normal file
View File

@@ -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())

View File

@@ -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. Creates inbounds on server if needed and adds user to them.
""" """
from .models import User, Server 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.xray_api_v2.client import XrayClient
from vpn.server_plugins.xray_v2 import XrayServerV2
start_time = time.time() start_time = time.time()
task_id = self.request.id 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) user = User.objects.get(id=user_id)
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
# Get Xray configuration # Get server instance
xray_config = XrayConfiguration.objects.first() real_server = server.get_real_instance()
if not xray_config: if not isinstance(real_server, XrayServerV2):
raise ValueError("Xray configuration not found. Please configure in admin.") raise ValueError(f"Server {server.name} is not an Xray v2 server")
create_task_log( create_task_log(
task_id, "sync_user_xray_access", 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") logger.info(f"User {user.username} has access to {user_inbounds.count()} inbounds")
# Connect to Xray server # Connect to Xray server
client = XrayClient(xray_config.grpc_address) client = XrayClient(real_server.api_address)
# Get existing inbounds on server # Get existing inbounds on server
try: try:
@@ -734,13 +735,13 @@ def sync_server_users(self, server_id):
@shared_task(name="sync_server_inbounds", bind=True) @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. Sync all inbounds for a specific Xray server.
This is called by XrayServerV2.sync_inbounds() This is called by XrayServerV2.sync_inbounds()
""" """
from vpn.server_plugins import Server from vpn.server_plugins import Server
from vpn.models_xray import SubscriptionGroup, ServerInbound from vpn.models_xray import SubscriptionGroup, ServerInbound, UserSubscription
try: try:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
@@ -753,20 +754,169 @@ def sync_server_inbounds(self, server_id):
for group in groups: for group in groups:
for inbound in group.inbounds.all(): for inbound in group.inbounds.all():
try: 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 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: except Exception as e:
logger.error(f"Failed to deploy inbound {inbound.name} on server {server.name}: {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}") 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: except Exception as e:
logger.error(f"Error syncing inbounds for server {server_id}: {e}") logger.error(f"Error syncing inbounds for server {server_id}: {e}")
raise 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) @shared_task(name="generate_certificate_task", bind=True)
def generate_certificate_task(self, certificate_id): def generate_certificate_task(self, certificate_id):
""" """
@@ -857,7 +1007,7 @@ def renew_certificates(self):
""" """
Check and renew certificates that are about to expire. 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 .letsencrypt import get_certificate_for_domain
from datetime import datetime from datetime import datetime

View File

@@ -497,7 +497,6 @@
<div class="server-info"> <div class="server-info">
<div class="server-name">{{ group_name }}</div> <div class="server-name">{{ group_name }}</div>
<div class="server-stats"> <div class="server-stats">
<span class="connection-count">📊 {{ group_data.total_connections }} uses</span>
<span class="connection-count">🔗 {{ group_data.inbounds|length }} inbound(s)</span> <span class="connection-count">🔗 {{ group_data.inbounds|length }} inbound(s)</span>
</div> </div>
</div> </div>
@@ -518,16 +517,6 @@
<div class="link-info"> <div class="link-info">
<div class="link-comment">🚀 {{ group_name }} Subscription</div> <div class="link-comment">🚀 {{ group_name }} Subscription</div>
<div class="link-stats"> <div class="link-stats">
{% if group_data.inbounds|length > 1 %}
<span class="usage-count">✨ {{ group_data.inbounds|length }} protocols included</span>
{% else %}
<span class="usage-count">✨ {{ group_data.inbounds.0.protocol|upper }} protocol</span>
{% endif %}
<span class="recent-count">📅
{% for inbound_data in group_data.inbounds %}
{{ inbound_data.protocol|upper }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</span>
<span class="last-used">🔗 {{ group_data.inbounds|length }} inbound(s)</span> <span class="last-used">🔗 {{ group_data.inbounds|length }} inbound(s)</span>
</div> </div>
</div> </div>

View File

@@ -28,10 +28,22 @@ def userPortal(request, user_hash):
logger.info(f"Found {user_subscriptions.count()} active subscription groups for user {user.username}") 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 # Calculate overall Xray subscription statistics
total_connections = 0 from .models import AccessLog
recent_connections = 0 total_connections = AccessLog.objects.filter(
logger.info(f"Using zero stats during transition for user {user.username}") 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 # Determine protocol scheme
scheme = 'https' if request.is_secure() else 'http' scheme = 'https' if request.is_secure() else 'http'
@@ -48,11 +60,19 @@ def userPortal(request, user_hash):
# Get all inbounds for this group # Get all inbounds for this group
group_inbounds = group.inbounds.all() 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] = { groups_data[group_name] = {
'group': group, 'group': group,
'subscription': subscription, 'subscription': subscription,
'inbounds': [], 'inbounds': [],
'total_connections': 0, # Placeholder during transition 'total_connections': group_connections,
} }
for inbound in group_inbounds: for inbound in group_inbounds: