mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Xray works. fixed certs.
This commit is contained in:
@@ -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:
|
||||||
|
@@ -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
|
||||||
|
16
vpn/migrations/0021_remove_xray_configuration.py
Normal file
16
vpn/migrations/0021_remove_xray_configuration.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
@@ -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
|
||||||
)
|
)
|
||||||
|
@@ -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:
|
||||||
|
@@ -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
272
vpn/signals.py
Normal 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())
|
174
vpn/tasks.py
174
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.
|
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
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
|
30
vpn/views.py
30
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}")
|
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:
|
||||||
|
Reference in New Issue
Block a user