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')