Xray works.

This commit is contained in:
AB from home.homenet
2025-08-08 08:35:47 +03:00
parent 9363bd4db8
commit 042ce6bd3f
4 changed files with 193 additions and 68 deletions

View File

@@ -450,7 +450,7 @@ class InboundAdmin(admin.ModelAdmin):
"""Admin for inbound template management""" """Admin for inbound template management"""
list_display = ( list_display = (
'name', 'protocol', 'port', 'network', 'name', 'protocol', 'port', 'network',
'security', 'certificate_status', 'group_count' 'security', 'group_count'
) )
list_filter = ('protocol', 'network', 'security') list_filter = ('protocol', 'network', 'security')
search_fields = ('name',) search_fields = ('name',)
@@ -458,10 +458,10 @@ class InboundAdmin(admin.ModelAdmin):
fieldsets = ( fieldsets = (
('Basic Configuration', { ('Basic Configuration', {
'fields': ('name', 'protocol', 'port'), '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', { ('Transport & Security', {
'fields': ('network', 'security', 'certificate', 'listen_address') 'fields': ('network', 'security', 'listen_address')
}), }),
('Advanced Settings', { ('Advanced Settings', {
'fields': ('enable_sniffing', 'full_config_display'), 'fields': ('enable_sniffing', 'full_config_display'),
@@ -476,19 +476,6 @@ class InboundAdmin(admin.ModelAdmin):
readonly_fields = ('full_config_display', 'created_at', 'updated_at') 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('<span style="color: red;">❌ Expired</span>')
else:
return format_html('<span style="color: green;">✅ Valid</span>')
else:
return format_html('<span style="color: orange;">⚠️ No cert</span>')
return format_html('<span style="color: gray;">-</span>')
certificate_status.short_description = 'Cert Status'
def group_count(self, obj): def group_count(self, obj):
"""Number of groups this inbound belongs to""" """Number of groups this inbound belongs to"""
return obj.subscriptiongroup_set.count() 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 # ServerInbound admin is integrated into XrayServerV2 admin, not shown in main menu
class ServerInboundAdmin(admin.ModelAdmin): class ServerInboundAdmin(admin.ModelAdmin):
"""Admin for server-inbound deployment tracking""" """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') list_filter = ('active', 'inbound__protocol', 'deployed_at')
search_fields = ('server__name', 'inbound__name') search_fields = ('server__name', 'inbound__name')
date_hierarchy = 'deployed_at' date_hierarchy = 'deployed_at'
@@ -704,13 +691,54 @@ class ServerInboundAdmin(admin.ModelAdmin):
('Template Deployment', { ('Template Deployment', {
'fields': ('server', 'inbound', 'active') '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', { ('Timestamps', {
'fields': ('deployed_at', 'updated_at'), 'fields': ('deployed_at', 'updated_at'),
'classes': ('collapse',) '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('<span style="color: gray;">-</span>')
cert = obj.get_certificate()
if cert:
if cert.is_expired:
return format_html('<span style="color: red;">❌ {}</span>', cert.domain)
else:
return format_html('<span style="color: green;">✅ {}</span>', cert.domain)
else:
return format_html('<span style="color: orange;">⚠️ No cert</span>')
certificate_status.short_description = 'Certificate'
def certificate_info(self, obj):
"""Display certificate information and selection logic"""
html = '<div style="background: #f8f9fa; padding: 10px; border-radius: 4px;">'
if not obj.requires_certificate():
html += '<p><strong>Certificate not required</strong> for this protocol/security combination.</p>'
else:
cert = obj.get_certificate()
if obj.certificate:
html += f'<p><strong>✅ Explicit certificate:</strong> {obj.certificate.domain}</p>'
elif cert:
html += f'<p><strong>🔄 Auto-selected:</strong> {cert.domain} (matches server hostname)</p>'
else:
server_hostname = getattr(obj.server.get_real_instance(), 'client_hostname', 'N/A')
html += f'<p><strong>⚠️ No certificate found</strong></p>'
html += f'<p>Server hostname: <code>{server_hostname}</code></p>'
html += f'<p>Consider creating certificate for this domain or select one manually above.</p>'
html += '</div>'
return format_html(html)
certificate_info.short_description = 'Certificate Selection Info'
# Unified Subscriptions Admin with tabs # Unified Subscriptions Admin with tabs

View File

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

View File

@@ -193,13 +193,6 @@ class Inbound(models.Model):
default='none', default='none',
help_text="Security type" 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 configuration for Xray
full_config = models.JSONField( full_config = models.JSONField(
@@ -328,13 +321,7 @@ class Inbound(models.Model):
"alpn": ["h2", "http/1.1"] "alpn": ["h2", "http/1.1"]
} }
if self.certificate: # Certificate will be set during deployment based on ServerInbound configuration
tls_settings.update({
"certificates": [{
"certificateFile": f"/etc/xray/certs/{self.certificate.domain}.crt",
"keyFile": f"/etc/xray/certs/{self.certificate.domain}.key"
}]
})
stream_settings["tlsSettings"] = tls_settings stream_settings["tlsSettings"] = tls_settings
@@ -430,6 +417,15 @@ class ServerInbound(models.Model):
deployed_at = models.DateTimeField(auto_now_add=True) deployed_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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 # Store deployment-specific configuration if needed
deployment_config = models.JSONField(default=dict, blank=True, help_text="Server-specific deployment configuration") 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): def __str__(self):
status = "Active" if self.active else "Inactive" status = "Active" if self.active else "Inactive"
return f"{self.server.name} -> {self.inbound.name} ({status})" 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'

View File

@@ -162,7 +162,7 @@ class XrayServerV2(Server):
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 {"error": str(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""" """Deploy a specific inbound on this server with optional users"""
try: try:
from vpn.xray_api_v2.client import XrayClient from vpn.xray_api_v2.client import XrayClient
@@ -201,7 +201,7 @@ class XrayServerV2(Server):
continue continue
user_configs.append(user_config) 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 # Build proper inbound configuration based on protocol
if inbound.full_config: if inbound.full_config:
@@ -213,24 +213,37 @@ class XrayServerV2(Server):
if 'settings' not in inbound_config: if 'settings' not in inbound_config:
inbound_config['settings'] = {} inbound_config['settings'] = {}
inbound_config['settings']['clients'] = user_configs 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 # Get certificate from ServerInbound or auto-select
if inbound.certificate and inbound.certificate.certificate_pem: certificate = None
logger.info(f"Updating full_config with inline certificate for {inbound.certificate.domain}") 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 # Convert PEM to lines for Xray format
cert_lines = inbound.certificate.certificate_pem.strip().split('\n') cert_lines = certificate.certificate_pem.strip().split('\n')
key_lines = inbound.certificate.private_key_pem.strip().split('\n') key_lines = certificate.private_key_pem.strip().split('\n')
# Update streamSettings if it exists # Update streamSettings if it exists
if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]: 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"] = [{ inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{
"certificate": cert_lines, "certificate": cert_lines,
"key": key_lines, "key": key_lines,
"usage": "encipherment" "usage": "encipherment"
}] }]
logger.info("Updated existing tlsSettings with inline certificate") logger.debug("Updated existing tlsSettings with inline certificate and removed file paths")
else: else:
# Build full config based on protocol # Build full config based on protocol
inbound_config = { inbound_config = {
@@ -281,12 +294,16 @@ class XrayServerV2(Server):
"security": "tls" "security": "tls"
} }
# Trojan always requires TLS certificate # Get certificate for Trojan (always required)
if inbound.certificate and inbound.certificate.certificate_pem: certificate = None
logger.info(f"Using certificate for Trojan inbound on domain {inbound.certificate.domain}") 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 # Convert PEM to lines for Xray format
cert_lines = inbound.certificate.certificate_pem.strip().split('\n') cert_lines = certificate.certificate_pem.strip().split('\n')
key_lines = inbound.certificate.private_key_pem.strip().split('\n') key_lines = certificate.private_key_pem.strip().split('\n')
inbound_config["streamSettings"]["tlsSettings"] = { inbound_config["streamSettings"]["tlsSettings"] = {
"certificates": [{ "certificates": [{
@@ -307,12 +324,16 @@ class XrayServerV2(Server):
inbound_config["streamSettings"] = {} inbound_config["streamSettings"] = {}
inbound_config["streamSettings"]["security"] = "tls" inbound_config["streamSettings"]["security"] = "tls"
# Check if inbound has a certificate # Get certificate for TLS
if inbound.certificate and inbound.certificate.certificate_pem: certificate = None
logger.info(f"Using certificate for domain {inbound.certificate.domain}") 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 # Convert PEM to lines for Xray format
cert_lines = inbound.certificate.certificate_pem.strip().split('\n') cert_lines = certificate.certificate_pem.strip().split('\n')
key_lines = inbound.certificate.private_key_pem.strip().split('\n') key_lines = certificate.private_key_pem.strip().split('\n')
inbound_config["streamSettings"]["tlsSettings"] = { inbound_config["streamSettings"]["tlsSettings"] = {
"certificates": [{ "certificates": [{
@@ -327,7 +348,7 @@ class XrayServerV2(Server):
"certificates": [] "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 # Add inbound using the client's add_inbound method which handles wrapping
try: try:
@@ -360,14 +381,22 @@ class XrayServerV2(Server):
"""Add a user to a specific inbound on this server using inbound recreation approach""" """Add a user to a specific inbound on this server using inbound recreation approach"""
try: try:
from vpn.xray_api_v2.client import XrayClient from vpn.xray_api_v2.client import XrayClient
from vpn.models_xray import ServerInbound
import uuid import uuid
logger.info(f"Adding user {user.username} to inbound {inbound.name} using inbound recreation") logger.info(f"Adding user {user.username} to inbound {inbound.name} using inbound recreation")
client = XrayClient(server=self.api_address) 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 # Generate user UUID based on username and inbound
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}")) 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 # Build user config based on protocol
if inbound.protocol == 'vless': if inbound.protocol == 'vless':
@@ -406,8 +435,13 @@ class XrayServerV2(Server):
if not existing_inbound: if not existing_inbound:
logger.warning(f"Inbound {inbound.name} not found on server, deploying it first") 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 # 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}") logger.error(f"Failed to deploy inbound {inbound.name}")
return False return False
# Get the inbound config we just created # Get the inbound config we just created
@@ -439,11 +473,22 @@ class XrayServerV2(Server):
inbound_config['settings']['clients'] = existing_users inbound_config['settings']['clients'] = existing_users
# Handle certificate embedding if needed # Handle certificate embedding if needed
if inbound.certificate and inbound.certificate.certificate_pem: certificate = None
cert_lines = inbound.certificate.certificate_pem.strip().split('\n') if server_inbound:
key_lines = inbound.certificate.private_key_pem.strip().split('\n') 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"]: 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"] = [{ inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{
"certificate": cert_lines, "certificate": cert_lines,
"key": key_lines, "key": key_lines,
@@ -611,19 +656,16 @@ class XrayServerV2(Server):
# Check if inbound exists on server # Check if inbound exists on server
if inbound.name not in existing_inbound_tags: if inbound.name not in existing_inbound_tags:
logger.info(f"Inbound {inbound.name} doesn't exist on server, creating with user") 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 # 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}") logger.info(f"Successfully created inbound {inbound.name} with user {user.username}")
added_count += 1 added_count += 1
existing_inbound_tags.add(inbound.name) 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: else:
logger.error(f"Failed to create inbound {inbound.name} with user") logger.error(f"Failed to create inbound {inbound.name} with user")
continue continue
@@ -685,9 +727,17 @@ class ServerInboundInline(admin.TabularInline):
from vpn.models_xray import ServerInbound from vpn.models_xray import ServerInbound
model = ServerInbound model = ServerInbound
extra = 0 extra = 0
fields = ('inbound', 'active') fields = ('inbound', 'certificate', 'active')
verbose_name = "Inbound Template" verbose_name = "Inbound Template"
verbose_name_plural = "Inbound Templates" 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): class XrayServerV2Admin(admin.ModelAdmin):
@@ -697,6 +747,10 @@ class XrayServerV2Admin(admin.ModelAdmin):
readonly_fields = ['server_type', 'registration_date'] readonly_fields = ['server_type', 'registration_date']
inlines = [ServerInboundInline] inlines = [ServerInboundInline]
def has_module_permission(self, request):
"""Hide this model from the main admin index"""
return False
fieldsets = [ fieldsets = [
('Basic Information', { ('Basic Information', {
'fields': ('name', 'comment', 'server_type') 'fields': ('name', 'comment', 'server_type')