From 042ce6bd3fee5a7907ee00e3c9128a48dfd05ea0 Mon Sep 17 00:00:00 2001 From: "AB from home.homenet" Date: Fri, 8 Aug 2025 08:35:47 +0300 Subject: [PATCH] Xray works. --- vpn/admin_xray.py | 64 ++++++--- .../0024_add_certificate_to_serverinbound.py | 23 ++++ vpn/models_xray.py | 50 ++++--- vpn/server_plugins/xray_v2.py | 124 +++++++++++++----- 4 files changed, 193 insertions(+), 68 deletions(-) create mode 100644 vpn/migrations/0024_add_certificate_to_serverinbound.py diff --git a/vpn/admin_xray.py b/vpn/admin_xray.py index 5cd40d4..fbc85df 100644 --- a/vpn/admin_xray.py +++ b/vpn/admin_xray.py @@ -450,7 +450,7 @@ class InboundAdmin(admin.ModelAdmin): """Admin for inbound template management""" list_display = ( 'name', 'protocol', 'port', 'network', - 'security', 'certificate_status', 'group_count' + 'security', 'group_count' ) list_filter = ('protocol', 'network', 'security') search_fields = ('name',) @@ -458,10 +458,10 @@ class InboundAdmin(admin.ModelAdmin): fieldsets = ( ('Basic Configuration', { 'fields': ('name', 'protocol', 'port'), - 'description': 'Domain will be taken from server client_hostname when deployed' + 'description': 'Certificates are configured per-server in Server admin → Inbound Templates tab' }), ('Transport & Security', { - 'fields': ('network', 'security', 'certificate', 'listen_address') + 'fields': ('network', 'security', 'listen_address') }), ('Advanced Settings', { 'fields': ('enable_sniffing', 'full_config_display'), @@ -476,19 +476,6 @@ class InboundAdmin(admin.ModelAdmin): readonly_fields = ('full_config_display', 'created_at', 'updated_at') - def certificate_status(self, obj): - """Display certificate status""" - if obj.security == 'tls': - if obj.certificate: - if obj.certificate.is_expired: - return format_html('❌ Expired') - else: - return format_html('✅ Valid') - else: - return format_html('⚠️ No cert') - return format_html('-') - certificate_status.short_description = 'Cert Status' - def group_count(self, obj): """Number of groups this inbound belongs to""" return obj.subscriptiongroup_set.count() @@ -695,7 +682,7 @@ class UserSubscriptionAdmin(admin.ModelAdmin): # ServerInbound admin is integrated into XrayServerV2 admin, not shown in main menu class ServerInboundAdmin(admin.ModelAdmin): """Admin for server-inbound deployment tracking""" - list_display = ('server', 'inbound', 'active', 'deployed_at', 'updated_at') + list_display = ('server', 'inbound', 'certificate_status', 'active', 'deployed_at', 'updated_at') list_filter = ('active', 'inbound__protocol', 'deployed_at') search_fields = ('server__name', 'inbound__name') date_hierarchy = 'deployed_at' @@ -704,13 +691,54 @@ class ServerInboundAdmin(admin.ModelAdmin): ('Template Deployment', { 'fields': ('server', 'inbound', 'active') }), + ('Certificate Configuration', { + 'fields': ('certificate', 'certificate_info'), + 'description': 'Certificate for TLS. If not specified, will be auto-selected by server hostname.' + }), ('Timestamps', { 'fields': ('deployed_at', 'updated_at'), 'classes': ('collapse',) }) ) - readonly_fields = ('deployed_at', 'updated_at') + readonly_fields = ('deployed_at', 'updated_at', 'certificate_info') + + def certificate_status(self, obj): + """Display certificate status""" + if not obj.requires_certificate(): + return format_html('-') + + cert = obj.get_certificate() + if cert: + if cert.is_expired: + return format_html('❌ {}', cert.domain) + else: + return format_html('✅ {}', cert.domain) + else: + return format_html('⚠️ No cert') + certificate_status.short_description = 'Certificate' + + def certificate_info(self, obj): + """Display certificate information and selection logic""" + html = '
' + + if not obj.requires_certificate(): + html += '

Certificate not required for this protocol/security combination.

' + else: + cert = obj.get_certificate() + if obj.certificate: + html += f'

✅ Explicit certificate: {obj.certificate.domain}

' + elif cert: + html += f'

🔄 Auto-selected: {cert.domain} (matches server hostname)

' + else: + server_hostname = getattr(obj.server.get_real_instance(), 'client_hostname', 'N/A') + html += f'

⚠️ No certificate found

' + html += f'

Server hostname: {server_hostname}

' + html += f'

Consider creating certificate for this domain or select one manually above.

' + + html += '
' + return format_html(html) + certificate_info.short_description = 'Certificate Selection Info' # Unified Subscriptions Admin with tabs diff --git a/vpn/migrations/0024_add_certificate_to_serverinbound.py b/vpn/migrations/0024_add_certificate_to_serverinbound.py new file mode 100644 index 0000000..9711da7 --- /dev/null +++ b/vpn/migrations/0024_add_certificate_to_serverinbound.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2025-08-08 05:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0023_alter_subscriptiongroup_options'), + ] + + operations = [ + migrations.RemoveField( + model_name='inbound', + name='certificate', + ), + migrations.AddField( + model_name='serverinbound', + name='certificate', + field=models.ForeignKey(blank=True, help_text='Certificate for TLS on this specific server (overrides automatic selection)', null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.certificate'), + ), + ] diff --git a/vpn/models_xray.py b/vpn/models_xray.py index 272e590..ca21a9d 100644 --- a/vpn/models_xray.py +++ b/vpn/models_xray.py @@ -193,13 +193,6 @@ class Inbound(models.Model): default='none', help_text="Security type" ) - certificate = models.ForeignKey( - Certificate, - null=True, - blank=True, - on_delete=models.SET_NULL, - help_text="Certificate for TLS" - ) # Full configuration for Xray full_config = models.JSONField( @@ -328,13 +321,7 @@ class Inbound(models.Model): "alpn": ["h2", "http/1.1"] } - if self.certificate: - tls_settings.update({ - "certificates": [{ - "certificateFile": f"/etc/xray/certs/{self.certificate.domain}.crt", - "keyFile": f"/etc/xray/certs/{self.certificate.domain}.key" - }] - }) + # Certificate will be set during deployment based on ServerInbound configuration stream_settings["tlsSettings"] = tls_settings @@ -430,6 +417,15 @@ class ServerInbound(models.Model): deployed_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Certificate for TLS on this specific server deployment + certificate = models.ForeignKey( + Certificate, + null=True, + blank=True, + on_delete=models.SET_NULL, + help_text="Certificate for TLS on this specific server (overrides automatic selection)" + ) + # Store deployment-specific configuration if needed deployment_config = models.JSONField(default=dict, blank=True, help_text="Server-specific deployment configuration") @@ -441,4 +437,28 @@ class ServerInbound(models.Model): def __str__(self): status = "Active" if self.active else "Inactive" - return f"{self.server.name} -> {self.inbound.name} ({status})" \ No newline at end of file + return f"{self.server.name} -> {self.inbound.name} ({status})" + + def get_certificate(self): + """Get certificate for this deployment with fallback logic""" + # 1. Use explicitly set certificate + if self.certificate: + return self.certificate + + # 2. Try to find certificate by server's client_hostname + if hasattr(self.server.get_real_instance(), 'client_hostname'): + server_hostname = self.server.get_real_instance().client_hostname + try: + return Certificate.objects.get(domain=server_hostname, cert_type='letsencrypt') + except Certificate.DoesNotExist: + try: + return Certificate.objects.get(domain=server_hostname) + except Certificate.DoesNotExist: + pass + + # 3. No certificate found + return None + + def requires_certificate(self): + """Check if this inbound requires a certificate""" + return self.inbound.security in ['tls'] or self.inbound.protocol == 'trojan' \ No newline at end of file diff --git a/vpn/server_plugins/xray_v2.py b/vpn/server_plugins/xray_v2.py index a54bb63..bfc6c39 100644 --- a/vpn/server_plugins/xray_v2.py +++ b/vpn/server_plugins/xray_v2.py @@ -162,7 +162,7 @@ class XrayServerV2(Server): logger.error(f"Failed to schedule inbound sync for server {self.name}: {e}") return {"error": str(e)} - def deploy_inbound(self, inbound, users=None): + def deploy_inbound(self, inbound, users=None, server_inbound=None): """Deploy a specific inbound on this server with optional users""" try: from vpn.xray_api_v2.client import XrayClient @@ -201,7 +201,7 @@ class XrayServerV2(Server): continue user_configs.append(user_config) - logger.info(f"Added user {user.username} to inbound config") + logger.debug(f"Added user {user.username} to inbound config") # Build proper inbound configuration based on protocol if inbound.full_config: @@ -213,24 +213,37 @@ class XrayServerV2(Server): if 'settings' not in inbound_config: inbound_config['settings'] = {} inbound_config['settings']['clients'] = user_configs - logger.info(f"Added {len(user_configs)} users to full_config") + logger.debug(f"Added {len(user_configs)} users to full_config") - # If inbound has a certificate, update the config to use inline certificates - if inbound.certificate and inbound.certificate.certificate_pem: - logger.info(f"Updating full_config with inline certificate for {inbound.certificate.domain}") + # Get certificate from ServerInbound or auto-select + certificate = None + if server_inbound: + certificate = server_inbound.get_certificate() + + # If certificate found, update the config to use inline certificates + if certificate and certificate.certificate_pem: + logger.info(f"Updating full_config with inline certificate for {certificate.domain}") # Convert PEM to lines for Xray format - cert_lines = inbound.certificate.certificate_pem.strip().split('\n') - key_lines = inbound.certificate.private_key_pem.strip().split('\n') + cert_lines = certificate.certificate_pem.strip().split('\n') + key_lines = certificate.private_key_pem.strip().split('\n') # Update streamSettings if it exists if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]: + # Remove any existing certificate file paths + tls_settings = inbound_config["streamSettings"]["tlsSettings"] + if "certificateFile" in tls_settings: + del tls_settings["certificateFile"] + if "keyFile" in tls_settings: + del tls_settings["keyFile"] + + # Set inline certificates inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{ "certificate": cert_lines, "key": key_lines, "usage": "encipherment" }] - logger.info("Updated existing tlsSettings with inline certificate") + logger.debug("Updated existing tlsSettings with inline certificate and removed file paths") else: # Build full config based on protocol inbound_config = { @@ -281,12 +294,16 @@ class XrayServerV2(Server): "security": "tls" } - # Trojan always requires TLS certificate - if inbound.certificate and inbound.certificate.certificate_pem: - logger.info(f"Using certificate for Trojan inbound on domain {inbound.certificate.domain}") + # Get certificate for Trojan (always required) + certificate = None + if server_inbound: + certificate = server_inbound.get_certificate() + + if certificate and certificate.certificate_pem: + logger.info(f"Using certificate for Trojan inbound on domain {certificate.domain}") # Convert PEM to lines for Xray format - cert_lines = inbound.certificate.certificate_pem.strip().split('\n') - key_lines = inbound.certificate.private_key_pem.strip().split('\n') + cert_lines = certificate.certificate_pem.strip().split('\n') + key_lines = certificate.private_key_pem.strip().split('\n') inbound_config["streamSettings"]["tlsSettings"] = { "certificates": [{ @@ -307,12 +324,16 @@ class XrayServerV2(Server): inbound_config["streamSettings"] = {} inbound_config["streamSettings"]["security"] = "tls" - # Check if inbound has a certificate - if inbound.certificate and inbound.certificate.certificate_pem: - logger.info(f"Using certificate for domain {inbound.certificate.domain}") + # Get certificate for TLS + certificate = None + if server_inbound: + certificate = server_inbound.get_certificate() + + if certificate and certificate.certificate_pem: + logger.info(f"Using certificate for domain {certificate.domain}") # Convert PEM to lines for Xray format - cert_lines = inbound.certificate.certificate_pem.strip().split('\n') - key_lines = inbound.certificate.private_key_pem.strip().split('\n') + cert_lines = certificate.certificate_pem.strip().split('\n') + key_lines = certificate.private_key_pem.strip().split('\n') inbound_config["streamSettings"]["tlsSettings"] = { "certificates": [{ @@ -327,7 +348,7 @@ class XrayServerV2(Server): "certificates": [] } - logger.info(f"Inbound config: {inbound_config}") + logger.debug(f"Inbound config for {inbound.name}: {len(str(inbound_config))} chars") # Add inbound using the client's add_inbound method which handles wrapping try: @@ -360,14 +381,22 @@ class XrayServerV2(Server): """Add a user to a specific inbound on this server using inbound recreation approach""" try: from vpn.xray_api_v2.client import XrayClient + from vpn.models_xray import ServerInbound import uuid logger.info(f"Adding user {user.username} to inbound {inbound.name} using inbound recreation") client = XrayClient(server=self.api_address) + # Get ServerInbound object for certificate access + try: + server_inbound = ServerInbound.objects.get(server=self, inbound=inbound) + except ServerInbound.DoesNotExist: + logger.warning(f"ServerInbound not found for {self.name} -> {inbound.name}, creating one") + server_inbound = ServerInbound.objects.create(server=self, inbound=inbound, active=True) + # Generate user UUID based on username and inbound user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}")) - logger.info(f"Generated UUID for user {user.username}: {user_uuid}") + logger.debug(f"Generated UUID for user {user.username}: {user_uuid}") # Build user config based on protocol if inbound.protocol == 'vless': @@ -406,8 +435,13 @@ class XrayServerV2(Server): if not existing_inbound: logger.warning(f"Inbound {inbound.name} not found on server, deploying it first") + # Get or create ServerInbound for certificate access + from vpn.models_xray import ServerInbound + server_inbound_obj, created = ServerInbound.objects.get_or_create( + server=self, inbound=inbound, defaults={'active': True} + ) # Deploy the inbound if it doesn't exist - if not self.deploy_inbound(inbound): + if not self.deploy_inbound(inbound, server_inbound=server_inbound_obj): logger.error(f"Failed to deploy inbound {inbound.name}") return False # Get the inbound config we just created @@ -439,11 +473,22 @@ class XrayServerV2(Server): inbound_config['settings']['clients'] = existing_users # Handle certificate embedding if needed - if inbound.certificate and inbound.certificate.certificate_pem: - cert_lines = inbound.certificate.certificate_pem.strip().split('\n') - key_lines = inbound.certificate.private_key_pem.strip().split('\n') + certificate = None + if server_inbound: + certificate = server_inbound.get_certificate() + + if certificate and certificate.certificate_pem: + cert_lines = certificate.certificate_pem.strip().split('\n') + key_lines = certificate.private_key_pem.strip().split('\n') if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]: + # Remove any existing certificate file paths + tls_settings = inbound_config["streamSettings"]["tlsSettings"] + if "certificateFile" in tls_settings: + del tls_settings["certificateFile"] + if "keyFile" in tls_settings: + del tls_settings["keyFile"] + inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{ "certificate": cert_lines, "key": key_lines, @@ -611,19 +656,16 @@ class XrayServerV2(Server): # Check if inbound exists on server if inbound.name not in existing_inbound_tags: logger.info(f"Inbound {inbound.name} doesn't exist on server, creating with user") + # Get or create ServerInbound for certificate access + from vpn.models_xray import ServerInbound + server_inbound_obj, created = ServerInbound.objects.get_or_create( + server=self, inbound=inbound, defaults={'active': True} + ) # Create the inbound with the user directly - if self.deploy_inbound(inbound, users=[user]): + if self.deploy_inbound(inbound, users=[user], server_inbound=server_inbound_obj): logger.info(f"Successfully created inbound {inbound.name} with user {user.username}") added_count += 1 existing_inbound_tags.add(inbound.name) - - # Mark as deployed on this server - from vpn.models_xray import ServerInbound - ServerInbound.objects.update_or_create( - server=self, - inbound=inbound, - defaults={'active': True} - ) else: logger.error(f"Failed to create inbound {inbound.name} with user") continue @@ -685,9 +727,17 @@ class ServerInboundInline(admin.TabularInline): from vpn.models_xray import ServerInbound model = ServerInbound extra = 0 - fields = ('inbound', 'active') + fields = ('inbound', 'certificate', 'active') verbose_name = "Inbound Template" verbose_name_plural = "Inbound Templates" + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + """Filter certificates for inbound selection""" + if db_field.name == 'certificate': + from vpn.models_xray import Certificate + kwargs['queryset'] = Certificate.objects.filter(cert_type__in=['letsencrypt', 'custom']) + kwargs['empty_label'] = "Auto-select by server hostname" + return super().formfield_for_foreignkey(db_field, request, **kwargs) class XrayServerV2Admin(admin.ModelAdmin): @@ -697,6 +747,10 @@ class XrayServerV2Admin(admin.ModelAdmin): readonly_fields = ['server_type', 'registration_date'] inlines = [ServerInboundInline] + def has_module_permission(self, request): + """Hide this model from the main admin index""" + return False + fieldsets = [ ('Basic Information', { 'fields': ('name', 'comment', 'server_type')