mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Xray works
This commit is contained in:
@@ -23,7 +23,7 @@ urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('ss/<path:link>', shadowsocks, name='shadowsocks'),
|
||||
path('dynamic/<path:link>', shadowsocks, name='shadowsocks'),
|
||||
path('xray/<path:link>', xray_subscription, name='xray_subscription'),
|
||||
path('xray/<str:user_hash>', xray_subscription, name='xray_subscription'),
|
||||
path('stat/<path:user_hash>', userFrontend, name='userFrontend'),
|
||||
path('u/<path:user_hash>', userPortal, name='userPortal'),
|
||||
path('', RedirectView.as_view(url='/admin/', permanent=False)),
|
||||
|
@@ -16,3 +16,6 @@ psycopg2-binary==2.9.10
|
||||
setuptools==75.2.0
|
||||
shortuuid==1.0.13
|
||||
cryptography==45.0.5
|
||||
acme>=2.0.0
|
||||
cloudflare>=4.3.1
|
||||
josepy>=2.0.0
|
||||
|
111
vpn/admin.py
111
vpn/admin.py
@@ -25,10 +25,13 @@ from .server_plugins import (
|
||||
WireguardServerAdmin,
|
||||
OutlineServer,
|
||||
OutlineServerAdmin,
|
||||
XrayCoreServer,
|
||||
XrayCoreServerAdmin,
|
||||
XrayInbound,
|
||||
XrayClient)
|
||||
XrayServerV2,
|
||||
XrayServerV2Admin)
|
||||
|
||||
# Import new Xray admin configuration
|
||||
from .admin_xray import add_subscription_management_to_user
|
||||
|
||||
# This will be registered at the end of the file
|
||||
|
||||
|
||||
@admin.register(TaskExecutionLog)
|
||||
@@ -250,11 +253,11 @@ class LastAccessFilter(admin.SimpleListFilter):
|
||||
@admin.register(Server)
|
||||
class ServerAdmin(PolymorphicParentModelAdmin):
|
||||
base_model = Server
|
||||
child_models = (OutlineServer, WireguardServer, XrayCoreServer)
|
||||
child_models = (OutlineServer, WireguardServer, XrayServerV2)
|
||||
list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'server_status_compact', 'registration_date')
|
||||
search_fields = ('name', 'comment')
|
||||
list_filter = ('server_type', )
|
||||
actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers']
|
||||
actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers', 'sync_xray_inbounds', 'check_status']
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
@@ -495,7 +498,7 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
||||
|
||||
# Check server status based on type
|
||||
from vpn.server_plugins.outline import OutlineServer
|
||||
from vpn.server_plugins.xray_core import XrayCoreServer
|
||||
# Old xray_core module removed - skip this server type
|
||||
|
||||
if isinstance(real_server, OutlineServer):
|
||||
try:
|
||||
@@ -524,41 +527,54 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
||||
'message': f'Connection error: {str(e)[:100]}'
|
||||
})
|
||||
|
||||
elif isinstance(real_server, XrayCoreServer):
|
||||
elif isinstance(real_server, XrayServerV2):
|
||||
try:
|
||||
logger.info(f"Checking Xray server: {server.name}")
|
||||
# Try to get server status from Xray
|
||||
logger.info(f"Checking Xray v2 server: {server.name}")
|
||||
# Get server status from new Xray implementation
|
||||
status = real_server.get_server_status()
|
||||
if status and isinstance(status, dict):
|
||||
if status.get('status') == 'online' or 'version' in status:
|
||||
inbounds_count = real_server.inbounds.count()
|
||||
clients_count = sum(inbound.clients.count() for inbound in real_server.inbounds.all())
|
||||
message = f'Server is online. Inbounds: {inbounds_count}, Clients: {clients_count}'
|
||||
if 'version' in status:
|
||||
message += f', Version: {status["version"]}'
|
||||
if status.get('accessible', False):
|
||||
message = f'✅ Server is {status.get("status", "accessible")}. '
|
||||
message += f'Host: {status.get("client_hostname", "N/A")}, '
|
||||
message += f'API: {status.get("api_address", "N/A")}'
|
||||
|
||||
logger.info(f"Xray server {server.name} is online: {message}")
|
||||
if status.get('api_connected'):
|
||||
message += ' (Connected)'
|
||||
# Add stats if available
|
||||
api_stats = status.get('api_stats', {})
|
||||
if api_stats and isinstance(api_stats, dict):
|
||||
if 'connection' in api_stats:
|
||||
message += f', Stats: {api_stats.get("connection", "ok")}'
|
||||
if api_stats.get('library') == 'not_available':
|
||||
message += ' [Basic check only]'
|
||||
elif status.get('api_error'):
|
||||
message += f' ({status.get("api_error")})'
|
||||
|
||||
message += f', Inbounds: {status.get("total_inbounds", 0)}'
|
||||
|
||||
logger.info(f"Xray v2 server {server.name} status: {message}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'online',
|
||||
'message': message
|
||||
})
|
||||
else:
|
||||
logger.warning(f"Xray server {server.name} returned status: {status}")
|
||||
error_msg = status.get('error') or status.get('api_error', 'Unknown error')
|
||||
logger.warning(f"Xray v2 server {server.name} not accessible: {error_msg}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'offline',
|
||||
'message': f'Server status: {status.get("message", "Unknown error")}'
|
||||
'message': f'❌ Server not accessible: {error_msg}'
|
||||
})
|
||||
else:
|
||||
logger.warning(f"Xray server {server.name} returned no status")
|
||||
logger.warning(f"Xray v2 server {server.name} returned invalid status")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'offline',
|
||||
'message': 'Server not responding'
|
||||
'message': 'Invalid server response'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking Xray server {server.name}: {e}")
|
||||
logger.error(f"Error checking Xray v2 server {server.name}: {e}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'error',
|
||||
@@ -723,6 +739,38 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
||||
)
|
||||
|
||||
sync_all_selected_servers.short_description = "🔄 Sync all users on selected servers"
|
||||
|
||||
def check_status(self, request, queryset):
|
||||
"""Check status for selected servers"""
|
||||
for server in queryset:
|
||||
try:
|
||||
status = server.get_server_status()
|
||||
msg = f"{server.name}: {status.get('accessible', 'Unknown')} - {status.get('status', 'N/A')}"
|
||||
self.message_user(request, msg, level=messages.INFO)
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error checking {server.name}: {e}", level=messages.ERROR)
|
||||
check_status.short_description = "📊 Check server status"
|
||||
|
||||
def sync_xray_inbounds(self, request, queryset):
|
||||
"""Sync inbounds for selected servers (Xray v2 only)"""
|
||||
from .server_plugins.xray_v2 import XrayServerV2
|
||||
synced_count = 0
|
||||
|
||||
for server in queryset:
|
||||
try:
|
||||
real_server = server.get_real_instance()
|
||||
if isinstance(real_server, XrayServerV2):
|
||||
real_server.sync_inbounds()
|
||||
synced_count += 1
|
||||
self.message_user(request, f"Scheduled inbound sync for {server.name}", level=messages.SUCCESS)
|
||||
else:
|
||||
self.message_user(request, f"{server.name} is not an Xray v2 server", level=messages.WARNING)
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error syncing inbounds for {server.name}: {e}", level=messages.ERROR)
|
||||
|
||||
if synced_count > 0:
|
||||
self.message_user(request, f"Scheduled inbound sync for {synced_count} server(s)", level=messages.SUCCESS)
|
||||
sync_xray_inbounds.short_description = "🔧 Sync Xray inbounds"
|
||||
|
||||
@admin.display(description='Server', ordering='name')
|
||||
def name_with_icon(self, obj):
|
||||
@@ -731,6 +779,7 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
||||
'outline': '🔵',
|
||||
'wireguard': '🟢',
|
||||
'xray_core': '🟣',
|
||||
'xray_v2': '🟡',
|
||||
}
|
||||
icon = icons.get(obj.server_type, '')
|
||||
name_part = f"{icon} {obj.name}" if icon else obj.name
|
||||
@@ -859,15 +908,15 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
||||
"""Dispatch sync to appropriate server type."""
|
||||
from django.shortcuts import redirect, get_object_or_404
|
||||
from django.contrib import messages
|
||||
from vpn.server_plugins import XrayCoreServer
|
||||
# XrayCoreServer removed - using XrayServerV2 now
|
||||
|
||||
try:
|
||||
server = get_object_or_404(Server, pk=object_id)
|
||||
real_server = server.get_real_instance()
|
||||
|
||||
# Handle XrayCoreServer
|
||||
if isinstance(real_server, XrayCoreServer):
|
||||
return redirect(f'/admin/vpn/xraycoreserver/{real_server.pk}/sync/')
|
||||
# Handle XrayServerV2
|
||||
if isinstance(real_server, XrayServerV2):
|
||||
return redirect(f'/admin/vpn/xrayserverv2/{real_server.pk}/sync/')
|
||||
|
||||
# Fallback for other server types
|
||||
else:
|
||||
@@ -1834,3 +1883,13 @@ try:
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Register XrayServerV2 admin
|
||||
admin.site.register(XrayServerV2, XrayServerV2Admin)
|
||||
|
||||
# Add subscription management to User admin
|
||||
from django.contrib.admin import site
|
||||
for model, admin_instance in site._registry.items():
|
||||
if model.__name__ == 'User' and hasattr(admin_instance, 'fieldsets'):
|
||||
add_subscription_management_to_user(admin_instance.__class__)
|
||||
break
|
||||
|
595
vpn/admin_xray.py
Normal file
595
vpn/admin_xray.py
Normal file
@@ -0,0 +1,595 @@
|
||||
"""
|
||||
Admin interface for new Xray models.
|
||||
"""
|
||||
|
||||
import json
|
||||
from django.contrib import admin, messages
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.db import models
|
||||
from django.forms import CheckboxSelectMultiple, Textarea
|
||||
from django.shortcuts import render, redirect
|
||||
from django.urls import path, reverse
|
||||
from django.http import JsonResponse, HttpResponseRedirect
|
||||
|
||||
from .models_xray import (
|
||||
XrayConfiguration, Credentials, Certificate,
|
||||
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):
|
||||
# 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):
|
||||
"""Admin for credentials management"""
|
||||
list_display = ('name', 'cred_type', 'description', 'created_at')
|
||||
list_filter = ('cred_type',)
|
||||
search_fields = ('name', 'description')
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {
|
||||
'fields': ('name', 'cred_type', 'description')
|
||||
}),
|
||||
('Credentials Data', {
|
||||
'fields': ('credentials_help', 'credentials'),
|
||||
'description': 'Enter credentials as JSON. Example: {"api_token": "your_token", "email": "your_email"}'
|
||||
}),
|
||||
('Preview', {
|
||||
'fields': ('credentials_display',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
readonly_fields = ('credentials_display', 'credentials_help', 'created_at', 'updated_at')
|
||||
|
||||
# Add JSON widget for better formatting
|
||||
formfield_overrides = {
|
||||
models.JSONField: {'widget': Textarea(attrs={'rows': 10, 'cols': 80, 'class': 'vLargeTextField'})},
|
||||
}
|
||||
|
||||
def credentials_help(self, obj):
|
||||
"""Help text and examples for credentials field"""
|
||||
examples = {
|
||||
'cloudflare': {
|
||||
'api_token': 'your_cloudflare_api_token',
|
||||
'email': 'your_email@example.com'
|
||||
},
|
||||
'dns_provider': {
|
||||
'api_key': 'your_dns_api_key',
|
||||
'secret': 'your_secret'
|
||||
},
|
||||
'email': {
|
||||
'smtp_host': 'smtp.example.com',
|
||||
'smtp_port': 587,
|
||||
'username': 'your_email',
|
||||
'password': 'your_password'
|
||||
}
|
||||
}
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 4px; margin-bottom: 10px;">'
|
||||
html += '<h4>JSON Examples:</h4>'
|
||||
|
||||
for cred_type, example in examples.items():
|
||||
html += '<div style="margin: 10px 0;">'
|
||||
html += '<strong>' + cred_type.title() + ':</strong>'
|
||||
json_str = json.dumps(example, indent=2)
|
||||
html += '<pre style="background: white; padding: 8px; border-radius: 3px; font-size: 12px;">' + json_str + '</pre>'
|
||||
html += '</div>'
|
||||
|
||||
html += '<p><strong>Note:</strong> Make sure your JSON is valid. Use double quotes for strings.</p>'
|
||||
html += '</div>'
|
||||
|
||||
return mark_safe(html)
|
||||
credentials_help.short_description = 'Credentials Format Help'
|
||||
|
||||
def credentials_display(self, obj):
|
||||
"""Display credentials in a safe format"""
|
||||
if obj.credentials:
|
||||
# Hide sensitive values
|
||||
safe_creds = {}
|
||||
for key, value in obj.credentials.items():
|
||||
if any(sensitive in key.lower() for sensitive in ['token', 'key', 'password', 'secret']):
|
||||
safe_creds[key] = '*' * 8
|
||||
else:
|
||||
safe_creds[key] = value
|
||||
|
||||
return format_html(
|
||||
'<pre style="background: #f4f4f4; padding: 10px; border-radius: 4px;">{}</pre>',
|
||||
json.dumps(safe_creds, indent=2)
|
||||
)
|
||||
return '-'
|
||||
credentials_display.short_description = 'Credentials (Preview)'
|
||||
|
||||
|
||||
@admin.register(Certificate)
|
||||
class CertificateAdmin(admin.ModelAdmin):
|
||||
"""Admin for certificate management"""
|
||||
list_display = (
|
||||
'domain', 'cert_type', 'status_display',
|
||||
'expires_at', 'auto_renew', 'action_buttons'
|
||||
)
|
||||
list_filter = ('cert_type', 'auto_renew')
|
||||
search_fields = ('domain',)
|
||||
|
||||
fieldsets = (
|
||||
('Certificate Request', {
|
||||
'fields': ('domain', 'cert_type', 'acme_email', 'credentials', 'auto_renew'),
|
||||
'description': 'For Let\'s Encrypt certificates, provide email for ACME registration and select credentials with Cloudflare API token.'
|
||||
}),
|
||||
('Certificate Status', {
|
||||
'fields': ('generation_help', 'status_display', 'expires_at'),
|
||||
'classes': ('wide',)
|
||||
}),
|
||||
('Certificate Data', {
|
||||
'fields': ('certificate_preview', 'certificate_pem', 'private_key_pem'),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Certificate data (auto-generated for Let\'s Encrypt)'
|
||||
}),
|
||||
('Renewal Settings', {
|
||||
'fields': ('last_renewed',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
readonly_fields = (
|
||||
'certificate_preview', 'status_display', 'generation_help',
|
||||
'expires_at', 'last_renewed', 'created_at', 'updated_at'
|
||||
)
|
||||
|
||||
def generation_help(self, obj):
|
||||
"""Show help text for certificate generation"""
|
||||
if not obj.pk:
|
||||
return mark_safe('<div style="background: #e3f2fd; padding: 10px; border-radius: 4px;">'
|
||||
'<p><strong>How it works:</strong></p>'
|
||||
'<ol>'
|
||||
'<li>Fill in the domain name</li>'
|
||||
'<li>Select certificate type (Let\'s Encrypt recommended)</li>'
|
||||
'<li>For Let\'s Encrypt: provide email for ACME account registration</li>'
|
||||
'<li>Select credentials with Cloudflare API token</li>'
|
||||
'<li>Save - certificate will be generated automatically</li>'
|
||||
'</ol>'
|
||||
'</div>')
|
||||
|
||||
if obj.cert_type == 'letsencrypt' and not obj.certificate_pem:
|
||||
return mark_safe('<div style="background: #fff3e0; padding: 10px; border-radius: 4px;">'
|
||||
'<p><strong>⏳ Certificate not generated yet</strong></p>'
|
||||
'<p>Certificate will be generated automatically using Let\'s Encrypt DNS-01 challenge.</p>'
|
||||
'</div>')
|
||||
|
||||
if obj.certificate_pem:
|
||||
days = obj.days_until_expiration if obj.days_until_expiration is not None else 'Unknown'
|
||||
return mark_safe('<div style="background: #e8f5e8; padding: 10px; border-radius: 4px;">'
|
||||
'<p><strong>✅ Certificate generated successfully</strong></p>'
|
||||
f'<p>Expires: {obj.expires_at}</p>'
|
||||
f'<p>Days remaining: {days}</p>'
|
||||
'</div>')
|
||||
|
||||
return '-'
|
||||
generation_help.short_description = 'Certificate Generation Status'
|
||||
|
||||
def status_display(self, obj):
|
||||
"""Display certificate status"""
|
||||
if obj.is_expired:
|
||||
return format_html(
|
||||
'<span style="color: red;">❌ Expired</span>'
|
||||
)
|
||||
elif obj.needs_renewal:
|
||||
return format_html(
|
||||
'<span style="color: orange;">⚠️ Needs renewal ({} days)</span>',
|
||||
obj.days_until_expiration
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="color: green;">✅ Valid ({} days)</span>',
|
||||
obj.days_until_expiration
|
||||
)
|
||||
status_display.short_description = 'Status'
|
||||
|
||||
def certificate_preview(self, obj):
|
||||
"""Preview certificate info"""
|
||||
if obj.certificate_pem:
|
||||
lines = obj.certificate_pem.strip().split('\n')
|
||||
preview = '\n'.join(lines[:5] + ['...'] + lines[-3:])
|
||||
return format_html(
|
||||
'<pre style="background: #f4f4f4; padding: 10px; font-family: monospace; font-size: 12px;">{}</pre>',
|
||||
preview
|
||||
)
|
||||
return '-'
|
||||
certificate_preview.short_description = 'Certificate Preview'
|
||||
|
||||
def action_buttons(self, obj):
|
||||
"""Action buttons for certificate"""
|
||||
buttons = []
|
||||
|
||||
if obj.needs_renewal and obj.auto_renew:
|
||||
renew_url = reverse('admin:certificate_renew', args=[obj.pk])
|
||||
buttons.append(
|
||||
f'<a href="{renew_url}" class="button" style="background: #ff9800;">🔄 Renew Now</a>'
|
||||
)
|
||||
|
||||
if obj.cert_type == 'self_signed':
|
||||
regenerate_url = reverse('admin:certificate_regenerate', args=[obj.pk])
|
||||
buttons.append(
|
||||
f'<a href="{regenerate_url}" class="button">🔄 Regenerate</a>'
|
||||
)
|
||||
|
||||
return format_html(' '.join(buttons)) if buttons else '-'
|
||||
action_buttons.short_description = 'Actions'
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('<int:cert_id>/renew/',
|
||||
self.admin_site.admin_view(self.renew_certificate_view),
|
||||
name='certificate_renew'),
|
||||
path('<int:cert_id>/regenerate/',
|
||||
self.admin_site.admin_view(self.regenerate_certificate_view),
|
||||
name='certificate_regenerate'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def renew_certificate_view(self, request, cert_id):
|
||||
"""Renew Let's Encrypt certificate"""
|
||||
try:
|
||||
cert = Certificate.objects.get(pk=cert_id)
|
||||
# TODO: Implement renewal logic
|
||||
messages.success(request, f'Certificate for {cert.domain} renewed successfully!')
|
||||
except Exception as e:
|
||||
messages.error(request, f'Failed to renew certificate: {e}')
|
||||
|
||||
return redirect('admin:vpn_certificate_change', cert_id)
|
||||
|
||||
def regenerate_certificate_view(self, request, cert_id):
|
||||
"""Regenerate self-signed certificate"""
|
||||
try:
|
||||
cert = Certificate.objects.get(pk=cert_id)
|
||||
# TODO: Implement regeneration logic
|
||||
messages.success(request, f'Certificate for {cert.domain} regenerated successfully!')
|
||||
except Exception as e:
|
||||
messages.error(request, f'Failed to regenerate certificate: {e}')
|
||||
|
||||
return redirect('admin:vpn_certificate_change', cert_id)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Auto-generate certificate for Let's Encrypt after saving"""
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
# Auto-generate Let's Encrypt certificate if needed
|
||||
if obj.cert_type == 'letsencrypt' and not obj.certificate_pem:
|
||||
try:
|
||||
self.generate_letsencrypt_certificate(obj, request)
|
||||
except Exception as e:
|
||||
messages.warning(request, f'Certificate saved but auto-generation failed: {e}')
|
||||
|
||||
def generate_letsencrypt_certificate(self, cert_obj, request):
|
||||
"""Generate Let's Encrypt certificate using DNS-01 challenge"""
|
||||
if not cert_obj.credentials:
|
||||
messages.error(request, 'Credentials required for Let\'s Encrypt certificate generation')
|
||||
return
|
||||
|
||||
if not cert_obj.acme_email:
|
||||
messages.error(request, 'ACME email address required for Let\'s Encrypt certificate generation')
|
||||
return
|
||||
|
||||
try:
|
||||
from vpn.letsencrypt.letsencrypt_dns import get_certificate_for_domain
|
||||
from datetime import datetime, timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
# Get Cloudflare credentials
|
||||
api_token = cert_obj.credentials.get_credential('api_token')
|
||||
|
||||
if not api_token:
|
||||
messages.error(request, 'Cloudflare API token not found in credentials')
|
||||
return
|
||||
|
||||
messages.info(request, f'🔄 Generating Let\'s Encrypt certificate for {cert_obj.domain} using {cert_obj.acme_email}...')
|
||||
|
||||
# Schedule certificate generation via Celery
|
||||
from vpn.tasks import generate_certificate_task
|
||||
task = generate_certificate_task.delay(cert_obj.id)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f'🔄 Certificate generation scheduled for {cert_obj.domain}. Task ID: {task.id}'
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
messages.warning(request, 'Let\'s Encrypt DNS challenge library not available')
|
||||
except Exception as e:
|
||||
messages.error(request, f'Failed to generate certificate: {str(e)}')
|
||||
# Log the full error for debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Certificate generation failed for {cert_obj.domain}: {e}', exc_info=True)
|
||||
|
||||
|
||||
@admin.register(Inbound)
|
||||
class InboundAdmin(admin.ModelAdmin):
|
||||
"""Admin for inbound management"""
|
||||
list_display = (
|
||||
'name', 'protocol', 'port', 'network',
|
||||
'security', 'certificate_status', 'group_count'
|
||||
)
|
||||
list_filter = ('protocol', 'network', 'security')
|
||||
search_fields = ('name', 'domain')
|
||||
|
||||
fieldsets = (
|
||||
('Basic Configuration', {
|
||||
'fields': ('name', 'protocol', 'port', 'domain')
|
||||
}),
|
||||
('Transport & Security', {
|
||||
'fields': ('network', 'security', 'certificate', 'listen_address')
|
||||
}),
|
||||
('Advanced Settings', {
|
||||
'fields': ('enable_sniffing', 'full_config_display'),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Configuration is auto-generated based on basic settings above'
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
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):
|
||||
"""Number of groups this inbound belongs to"""
|
||||
return obj.subscriptiongroup_set.count()
|
||||
group_count.short_description = 'Groups'
|
||||
|
||||
def full_config_display(self, obj):
|
||||
"""Display full config in formatted JSON"""
|
||||
if obj.full_config:
|
||||
return format_html(
|
||||
'<pre style="background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 400px; overflow-y: auto;">{}</pre>',
|
||||
json.dumps(obj.full_config, indent=2)
|
||||
)
|
||||
return 'Not generated yet'
|
||||
full_config_display.short_description = 'Configuration Preview'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Generate config on save"""
|
||||
try:
|
||||
# Always regenerate config to reflect any changes
|
||||
obj.build_config()
|
||||
messages.success(request, f'✅ Configuration generated successfully for {obj.protocol.upper()} inbound on port {obj.port}')
|
||||
except Exception as e:
|
||||
messages.warning(request, f'Inbound saved but config generation failed: {e}')
|
||||
# Set empty dict if generation fails
|
||||
if not obj.full_config:
|
||||
obj.full_config = {}
|
||||
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class InboundInline(admin.TabularInline):
|
||||
"""Inline for inbounds in subscription groups"""
|
||||
model = SubscriptionGroup.inbounds.through
|
||||
extra = 1
|
||||
verbose_name = "Inbound"
|
||||
verbose_name_plural = "Inbounds in this group"
|
||||
|
||||
|
||||
@admin.register(SubscriptionGroup)
|
||||
class SubscriptionGroupAdmin(admin.ModelAdmin):
|
||||
"""Admin for subscription groups"""
|
||||
list_display = ('name', 'is_active', 'inbound_count', 'user_count', 'created_at')
|
||||
list_filter = ('is_active',)
|
||||
search_fields = ('name', 'description')
|
||||
filter_horizontal = ('inbounds',)
|
||||
|
||||
fieldsets = (
|
||||
('Group Information', {
|
||||
'fields': ('name', 'description', 'is_active')
|
||||
}),
|
||||
('Inbounds', {
|
||||
'fields': ('inbounds',),
|
||||
'description': 'Select inbounds to include in this group'
|
||||
}),
|
||||
('Statistics', {
|
||||
'fields': ('group_statistics',),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
readonly_fields = ('group_statistics',)
|
||||
|
||||
def group_statistics(self, obj):
|
||||
"""Display group statistics"""
|
||||
if obj.pk:
|
||||
stats = {
|
||||
'Total Inbounds': obj.inbound_count,
|
||||
'Active Users': obj.user_count,
|
||||
'Protocols': list(obj.inbounds.values_list('protocol', flat=True).distinct()),
|
||||
'Ports': list(obj.inbounds.values_list('port', flat=True).distinct())
|
||||
}
|
||||
|
||||
html = '<div style="background: #f4f4f4; padding: 10px; border-radius: 4px;">'
|
||||
for key, value in stats.items():
|
||||
if isinstance(value, list):
|
||||
value = ', '.join(map(str, value))
|
||||
html += f'<div><strong>{key}:</strong> {value}</div>'
|
||||
html += '</div>'
|
||||
|
||||
return format_html(html)
|
||||
return 'Save to see statistics'
|
||||
group_statistics.short_description = 'Group Statistics'
|
||||
|
||||
|
||||
class UserSubscriptionInline(admin.TabularInline):
|
||||
"""Inline for user subscriptions"""
|
||||
model = UserSubscription
|
||||
extra = 0
|
||||
fields = ('subscription_group', 'active', 'created_at')
|
||||
readonly_fields = ('created_at',)
|
||||
verbose_name = "Subscription Group"
|
||||
verbose_name_plural = "User's Subscription Groups"
|
||||
|
||||
|
||||
# Extension for User admin
|
||||
def add_subscription_management_to_user(UserAdmin):
|
||||
"""Add subscription management to existing User admin"""
|
||||
|
||||
# Add inline
|
||||
if hasattr(UserAdmin, 'inlines'):
|
||||
UserAdmin.inlines = list(UserAdmin.inlines) + [UserSubscriptionInline]
|
||||
else:
|
||||
UserAdmin.inlines = [UserSubscriptionInline]
|
||||
|
||||
# Add custom fields to fieldsets
|
||||
original_fieldsets = list(UserAdmin.fieldsets)
|
||||
|
||||
# Find where to insert our fieldset
|
||||
insert_index = len(original_fieldsets)
|
||||
for i, (title, fields_dict) in enumerate(original_fieldsets):
|
||||
if title and 'Statistics' in title:
|
||||
insert_index = i + 1
|
||||
break
|
||||
|
||||
# Insert our fieldset
|
||||
subscription_fieldset = (
|
||||
'Xray Subscriptions', {
|
||||
'fields': ('subscription_groups_widget',),
|
||||
'classes': ('wide',)
|
||||
}
|
||||
)
|
||||
original_fieldsets.insert(insert_index, subscription_fieldset)
|
||||
UserAdmin.fieldsets = tuple(original_fieldsets)
|
||||
|
||||
# Add readonly field
|
||||
if hasattr(UserAdmin, 'readonly_fields'):
|
||||
UserAdmin.readonly_fields = list(UserAdmin.readonly_fields) + ['subscription_groups_widget']
|
||||
else:
|
||||
UserAdmin.readonly_fields = ['subscription_groups_widget']
|
||||
|
||||
# Add method for displaying subscription groups
|
||||
def subscription_groups_widget(self, obj):
|
||||
"""Display subscription groups management widget"""
|
||||
if not obj or not obj.pk:
|
||||
return mark_safe('<div style="color: #6c757d;">Save user first to manage subscriptions</div>')
|
||||
|
||||
# Get all groups and user's current subscriptions
|
||||
all_groups = SubscriptionGroup.objects.filter(is_active=True)
|
||||
user_groups = obj.xray_subscriptions.filter(active=True).values_list('subscription_group_id', flat=True)
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 4px;">'
|
||||
html += '<h4 style="margin-top: 0;">Available Subscription Groups:</h4>'
|
||||
|
||||
if all_groups:
|
||||
html += '<div style="display: grid; gap: 10px;">'
|
||||
for group in all_groups:
|
||||
checked = 'checked' if group.id in user_groups else ''
|
||||
status = '✅' if group.id in user_groups else '⬜'
|
||||
|
||||
html += f'''
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 8px; background: white; border-radius: 4px;">
|
||||
<span style="font-size: 18px;">{status}</span>
|
||||
<label style="flex: 1; cursor: pointer;">
|
||||
<strong>{group.name}</strong>
|
||||
{f' - {group.description}' if group.description else ''}
|
||||
<small style="color: #6c757d;"> ({group.inbound_count} inbounds)</small>
|
||||
</label>
|
||||
</div>
|
||||
'''
|
||||
html += '</div>'
|
||||
html += '<div style="margin-top: 10px; color: #6c757d; font-size: 12px;">'
|
||||
html += 'ℹ️ Use the inline form below to manage subscriptions'
|
||||
html += '</div>'
|
||||
else:
|
||||
html += '<div style="color: #6c757d;">No active subscription groups available</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
subscription_groups_widget.short_description = 'Subscription Groups Overview'
|
||||
UserAdmin.subscription_groups_widget = subscription_groups_widget
|
||||
|
||||
|
||||
# Register admin for UserSubscription (if needed separately)
|
||||
@admin.register(UserSubscription)
|
||||
class UserSubscriptionAdmin(admin.ModelAdmin):
|
||||
"""Standalone admin for user subscriptions"""
|
||||
list_display = ('user', 'subscription_group', 'active', 'created_at')
|
||||
list_filter = ('active', 'subscription_group')
|
||||
search_fields = ('user__username', 'subscription_group__name')
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Prefer managing through User admin
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(ServerInbound)
|
||||
class ServerInboundAdmin(admin.ModelAdmin):
|
||||
"""Admin for server-inbound deployment tracking"""
|
||||
list_display = ('server', 'inbound', 'active', 'deployed_at', 'updated_at')
|
||||
list_filter = ('active', 'inbound__protocol', 'deployed_at')
|
||||
search_fields = ('server__name', 'inbound__name')
|
||||
date_hierarchy = 'deployed_at'
|
||||
|
||||
fieldsets = (
|
||||
('Deployment', {
|
||||
'fields': ('server', 'inbound', 'active')
|
||||
}),
|
||||
('Configuration', {
|
||||
'fields': ('deployment_config_display', 'deployment_config'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('deployed_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
readonly_fields = ('deployment_config_display', 'deployed_at', 'updated_at')
|
||||
|
||||
def deployment_config_display(self, obj):
|
||||
"""Display deployment config in formatted JSON"""
|
||||
if obj.deployment_config:
|
||||
return format_html(
|
||||
'<pre style="background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;">{}</pre>',
|
||||
json.dumps(obj.deployment_config, indent=2)
|
||||
)
|
||||
return 'No additional deployment configuration'
|
||||
deployment_config_display.short_description = 'Deployment Config Preview'
|
13
vpn/letsencrypt/__init__.py
Normal file
13
vpn/letsencrypt/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Let's Encrypt DNS Challenge Library for OutFleet"""
|
||||
|
||||
from .letsencrypt_dns import (
|
||||
AcmeDnsChallenge,
|
||||
get_certificate,
|
||||
get_certificate_for_domain
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'AcmeDnsChallenge',
|
||||
'get_certificate',
|
||||
'get_certificate_for_domain'
|
||||
]
|
403
vpn/letsencrypt/letsencrypt_dns.py
Normal file
403
vpn/letsencrypt/letsencrypt_dns.py
Normal file
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Let's Encrypt/ZeroSSL Certificate Library with Cloudflare DNS Challenge
|
||||
Generate publicly trusted SSL certificates using ACME DNS-01 challenge
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from acme import client, messages, challenges, errors
|
||||
from acme.client import ClientV2
|
||||
import josepy as jose
|
||||
from cloudflare import Cloudflare
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AcmeDnsChallenge:
|
||||
"""ACME DNS-01 Challenge handler with Cloudflare API"""
|
||||
|
||||
def __init__(self, cloudflare_token: str, acme_directory: str = None):
|
||||
"""
|
||||
Initialize ACME DNS challenge handler
|
||||
|
||||
Args:
|
||||
cloudflare_token: Cloudflare API token with DNS edit permissions
|
||||
acme_directory: ACME directory URL (defaults to Let's Encrypt production)
|
||||
"""
|
||||
self.cf_token = cloudflare_token
|
||||
self.cf = Cloudflare(api_token=cloudflare_token)
|
||||
|
||||
# ACME directory URLs
|
||||
self.acme_directories = {
|
||||
'letsencrypt': 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
'letsencrypt_staging': 'https://acme-staging-v02.api.letsencrypt.org/directory',
|
||||
'zerossl': 'https://acme.zerossl.com/v2/DV90'
|
||||
}
|
||||
|
||||
self.acme_directory = acme_directory or self.acme_directories['letsencrypt']
|
||||
self.acme_client = None
|
||||
self.account_key = None
|
||||
|
||||
def _generate_account_key(self) -> jose.JWKRSA:
|
||||
"""Generate RSA private key for ACME account"""
|
||||
# Generate cryptography key first
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048
|
||||
)
|
||||
# Convert to josepy format for ACME
|
||||
return jose.JWKRSA(key=private_key)
|
||||
|
||||
def _get_zone_id(self, domain: str) -> str:
|
||||
"""Get Cloudflare zone ID for domain"""
|
||||
try:
|
||||
# Get base domain (remove subdomains)
|
||||
parts = domain.split('.')
|
||||
if len(parts) >= 2:
|
||||
base_domain = '.'.join(parts[-2:])
|
||||
else:
|
||||
base_domain = domain
|
||||
|
||||
zones = self.cf.zones.list(name=base_domain)
|
||||
if not zones.result:
|
||||
raise ValueError(f"Domain {base_domain} not found in Cloudflare")
|
||||
|
||||
return zones.result[0].id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get zone ID for {domain}: {e}")
|
||||
raise
|
||||
|
||||
def _create_dns_record(self, domain: str, name: str, content: str) -> str:
|
||||
"""Create DNS TXT record for ACME challenge"""
|
||||
try:
|
||||
zone_id = self._get_zone_id(domain)
|
||||
|
||||
result = self.cf.dns.records.create(
|
||||
zone_id=zone_id,
|
||||
name=name,
|
||||
type='TXT',
|
||||
content=content,
|
||||
ttl=60 # 1 minute TTL for faster propagation
|
||||
)
|
||||
logger.info(f"Created DNS record: {name} = {content}")
|
||||
return result.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create DNS record {name}: {e}")
|
||||
raise
|
||||
|
||||
def _delete_dns_record(self, domain: str, record_id: str):
|
||||
"""Delete DNS TXT record"""
|
||||
try:
|
||||
zone_id = self._get_zone_id(domain)
|
||||
self.cf.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
|
||||
logger.info(f"Deleted DNS record: {record_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete DNS record {record_id}: {e}")
|
||||
|
||||
def _wait_for_dns_propagation(self, record_name: str, expected_value: str, wait_time: int = 20):
|
||||
"""Wait for DNS record to propagate - no local checks, just wait"""
|
||||
logger.info(f"Waiting {wait_time} seconds for DNS propagation of {record_name}...")
|
||||
logger.info(f"Record value: {expected_value}")
|
||||
logger.info("(No local DNS checks - Let's Encrypt servers will verify)")
|
||||
|
||||
time.sleep(wait_time)
|
||||
|
||||
logger.info("DNS propagation wait completed - proceeding with challenge")
|
||||
return True
|
||||
|
||||
def create_acme_client(self, email: str, accept_tos: bool = True) -> ClientV2:
|
||||
"""Create and register ACME client"""
|
||||
if self.acme_client:
|
||||
return self.acme_client
|
||||
|
||||
try:
|
||||
logger.info("Generating ACME account key...")
|
||||
# Generate account key
|
||||
self.account_key = self._generate_account_key()
|
||||
logger.info("Account key generated successfully")
|
||||
|
||||
logger.info(f"Connecting to ACME directory: {self.acme_directory}")
|
||||
# Create ACME client
|
||||
net = client.ClientNetwork(self.account_key, user_agent='letsencrypt-dns-lib/1.0')
|
||||
logger.info("Getting ACME directory...")
|
||||
directory_response = net.get(self.acme_directory)
|
||||
logger.info(f"Directory response status: {directory_response.status_code}")
|
||||
directory = messages.Directory.from_json(directory_response.json())
|
||||
logger.info("ACME directory loaded successfully")
|
||||
|
||||
self.acme_client = ClientV2(directory, net=net)
|
||||
logger.info("ACME client created successfully")
|
||||
|
||||
# Register account
|
||||
logger.info(f"Registering ACME account for email: {email}")
|
||||
try:
|
||||
registration = messages.NewRegistration.from_data(
|
||||
email=email,
|
||||
terms_of_service_agreed=accept_tos
|
||||
)
|
||||
logger.info("Sending account registration...")
|
||||
account = self.acme_client.new_account(registration)
|
||||
logger.info(f"ACME account registered: {account.uri}")
|
||||
|
||||
except errors.ConflictError as e:
|
||||
logger.info(f"Account already exists (ConflictError): {e}")
|
||||
# Account already exists
|
||||
account = self.acme_client.query_registration(messages.NewRegistration())
|
||||
logger.info("Using existing ACME account")
|
||||
except Exception as reg_e:
|
||||
logger.error(f"Account registration failed: {reg_e}")
|
||||
logger.error(f"Registration error type: {type(reg_e).__name__}")
|
||||
raise
|
||||
|
||||
return self.acme_client
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ACME client: {e}")
|
||||
logger.error(f"Error type: {type(e).__name__}")
|
||||
import traceback
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
def request_certificate(self, domains: List[str], email: str,
|
||||
key_size: int = 2048) -> Tuple[str, str]:
|
||||
"""
|
||||
Request certificate using DNS-01 challenge
|
||||
|
||||
Args:
|
||||
domains: List of domain names for certificate
|
||||
email: Email for ACME account registration
|
||||
key_size: RSA key size for certificate
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
logger.info(f"Requesting certificate for domains: {domains}")
|
||||
|
||||
try:
|
||||
# Create ACME client
|
||||
logger.info("Creating ACME client...")
|
||||
acme_client = self.create_acme_client(email)
|
||||
logger.info("ACME client created successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ACME client: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
try:
|
||||
# Generate private key for certificate
|
||||
logger.info(f"Generating {key_size}-bit RSA private key...")
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_size
|
||||
)
|
||||
logger.info("Private key generated successfully")
|
||||
|
||||
# Create CSR
|
||||
logger.info(f"Creating CSR for domains: {domains}")
|
||||
csr_obj = x509.CertificateSigningRequestBuilder().subject_name(
|
||||
x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, domains[0])
|
||||
])
|
||||
).add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.DNSName(domain) for domain in domains
|
||||
]),
|
||||
critical=False
|
||||
).sign(private_key, hashes.SHA256())
|
||||
|
||||
# Convert CSR to PEM format for ACME
|
||||
csr_pem = csr_obj.public_bytes(serialization.Encoding.PEM)
|
||||
logger.info("CSR created successfully")
|
||||
|
||||
# Request certificate
|
||||
logger.info("Requesting certificate order from ACME...")
|
||||
order = acme_client.new_order(csr_pem)
|
||||
logger.info(f"Created ACME order: {order.uri}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed during CSR/order creation: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
# Process challenges - collect all challenges first, then create DNS records
|
||||
dns_records = []
|
||||
challenges_to_answer = []
|
||||
|
||||
try:
|
||||
# First pass: collect all challenges and create DNS records
|
||||
for authorization in order.authorizations:
|
||||
domain = authorization.body.identifier.value
|
||||
logger.info(f"Processing authorization for: {domain}")
|
||||
|
||||
# Find DNS-01 challenge
|
||||
dns_challenge = None
|
||||
for challenge in authorization.body.challenges:
|
||||
if isinstance(challenge.chall, challenges.DNS01):
|
||||
dns_challenge = challenge
|
||||
break
|
||||
|
||||
if not dns_challenge:
|
||||
raise ValueError(f"No DNS-01 challenge found for {domain}")
|
||||
|
||||
# Calculate challenge response
|
||||
response, validation = dns_challenge.response_and_validation(acme_client.net.key)
|
||||
|
||||
# For wildcard domains, use base domain for DNS record
|
||||
if domain.startswith('*.'):
|
||||
dns_domain = domain[2:] # Remove *. prefix
|
||||
else:
|
||||
dns_domain = domain
|
||||
|
||||
# Create DNS record
|
||||
record_name = f"_acme-challenge.{dns_domain}"
|
||||
|
||||
# Check if we already created this DNS record
|
||||
existing_record = None
|
||||
for existing_domain, existing_id, existing_validation in dns_records:
|
||||
if existing_domain == dns_domain:
|
||||
existing_record = (existing_domain, existing_id, existing_validation)
|
||||
break
|
||||
|
||||
if existing_record:
|
||||
logger.info(f"DNS record already exists for {dns_domain}, reusing...")
|
||||
record_id = existing_record[1]
|
||||
# Verify the validation value matches
|
||||
if existing_record[2] != validation:
|
||||
logger.warning(f"Validation values differ for {dns_domain}! This may cause issues.")
|
||||
else:
|
||||
logger.info(f"Creating DNS record for {dns_domain}...")
|
||||
record_id = self._create_dns_record(dns_domain, record_name, validation)
|
||||
dns_records.append((dns_domain, record_id, validation))
|
||||
|
||||
# Store challenge to answer later
|
||||
challenges_to_answer.append((dns_challenge, response, domain, dns_domain))
|
||||
|
||||
# Wait for DNS propagation once for all records
|
||||
if dns_records:
|
||||
logger.info(f"Waiting for DNS propagation for {len(dns_records)} DNS records...")
|
||||
for dns_domain, record_id, validation in dns_records:
|
||||
record_name = f"_acme-challenge.{dns_domain}"
|
||||
self._wait_for_dns_propagation(record_name, validation)
|
||||
|
||||
# Second pass: answer all challenges
|
||||
for dns_challenge, response, domain, dns_domain in challenges_to_answer:
|
||||
logger.info(f"Responding to DNS challenge for {domain}...")
|
||||
challenge_response = acme_client.answer_challenge(dns_challenge, response)
|
||||
logger.info(f"Challenge response sent for {domain}")
|
||||
|
||||
# Finalize order
|
||||
logger.info("Finalizing certificate order...")
|
||||
order = acme_client.poll_and_finalize(order)
|
||||
|
||||
# Get certificate
|
||||
certificate_pem = order.fullchain_pem
|
||||
private_key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
).decode('utf-8')
|
||||
|
||||
logger.info("Certificate obtained successfully!")
|
||||
return certificate_pem, private_key_pem
|
||||
|
||||
finally:
|
||||
# Clean up DNS records
|
||||
for dns_domain, record_id, validation in dns_records:
|
||||
try:
|
||||
self._delete_dns_record(dns_domain, record_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup DNS record for {dns_domain}: {e}")
|
||||
|
||||
def get_certificate(domains: List[str], email: str, cloudflare_token: str,
|
||||
provider: str = 'letsencrypt', staging: bool = False) -> Tuple[str, str]:
|
||||
"""
|
||||
Simple function to get Let's Encrypt/ZeroSSL certificate
|
||||
|
||||
Args:
|
||||
domains: List of domains for certificate
|
||||
email: Email for ACME registration
|
||||
cloudflare_token: Cloudflare API token
|
||||
provider: 'letsencrypt' or 'zerossl'
|
||||
staging: Use staging environment (for testing)
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
# Select ACME directory
|
||||
acme_dns = AcmeDnsChallenge(cloudflare_token)
|
||||
|
||||
if provider == 'letsencrypt':
|
||||
if staging:
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt_staging']
|
||||
else:
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt']
|
||||
elif provider == 'zerossl':
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['zerossl']
|
||||
else:
|
||||
raise ValueError("Provider must be 'letsencrypt' or 'zerossl'")
|
||||
|
||||
return acme_dns.request_certificate(domains, email)
|
||||
|
||||
def get_certificate_for_domain(domain: str, email: str, cloudflare_token: str,
|
||||
include_wildcard: bool = False, **kwargs) -> Tuple[str, str]:
|
||||
"""
|
||||
Helper function to get certificate for single domain (compatible with Cloudflare cert lib)
|
||||
|
||||
Args:
|
||||
domain: Primary domain
|
||||
email: Email for ACME registration
|
||||
cloudflare_token: Cloudflare API token
|
||||
include_wildcard: Include wildcard subdomain
|
||||
**kwargs: Additional arguments (provider, staging)
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
domains = [domain]
|
||||
if include_wildcard:
|
||||
domains.append(f"*.{domain}")
|
||||
|
||||
return get_certificate(domains, email, cloudflare_token, **kwargs)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
import sys
|
||||
|
||||
if len(sys.argv) != 4:
|
||||
print("Usage: python letsencrypt_dns.py <domain> <email> <cloudflare_token>")
|
||||
sys.exit(1)
|
||||
|
||||
domain, email, token = sys.argv[1:4]
|
||||
|
||||
try:
|
||||
cert_pem, key_pem = get_certificate_for_domain(
|
||||
domain=domain,
|
||||
email=email,
|
||||
cloudflare_token=token,
|
||||
include_wildcard=True,
|
||||
staging=True # Use staging for testing
|
||||
)
|
||||
|
||||
print(f"Certificate obtained for {domain}")
|
||||
print(f"Certificate length: {len(cert_pem)} bytes")
|
||||
print(f"Private key length: {len(key_pem)} bytes")
|
||||
|
||||
# Save to files
|
||||
with open(f"{domain}.crt", 'w') as f:
|
||||
f.write(cert_pem)
|
||||
with open(f"{domain}.key", 'w') as f:
|
||||
f.write(key_pem)
|
||||
|
||||
print(f"Saved: {domain}.crt, {domain}.key")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
32
vpn/migrations/0015_remove_old_xray_models.py
Normal file
32
vpn/migrations/0015_remove_old_xray_models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated manually to properly remove old Xray models
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0014_alter_xraycoreserver_client_hostname_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Remove unique_together first to avoid field reference issues
|
||||
migrations.AlterUniqueTogether(
|
||||
name='xrayinbound',
|
||||
unique_together=None,
|
||||
),
|
||||
|
||||
# Remove old models completely
|
||||
migrations.DeleteModel(
|
||||
name='XrayClient',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='XrayInbound',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='XrayInboundServer',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='XrayCoreServer',
|
||||
),
|
||||
]
|
127
vpn/migrations/0016_add_new_xray_models.py
Normal file
127
vpn/migrations/0016_add_new_xray_models.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# Generated manually to add new Xray models
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0015_remove_old_xray_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayConfiguration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('grpc_address', models.CharField(default='127.0.0.1:10085', help_text='Xray gRPC API address (host:port)', max_length=255)),
|
||||
('default_client_hostname', models.CharField(help_text='Default hostname for client connections', max_length=255)),
|
||||
('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)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Configuration',
|
||||
'verbose_name_plural': 'Xray Configuration',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Credentials',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Descriptive name for these credentials', max_length=100, unique=True)),
|
||||
('cred_type', models.CharField(choices=[('cloudflare', 'Cloudflare API'), ('dns_provider', 'DNS Provider'), ('email', 'Email SMTP'), ('other', 'Other')], help_text='Type of credentials', max_length=20)),
|
||||
('credentials', models.JSONField(help_text="Credentials data (e.g., {'api_token': '...', 'email': '...'})")),
|
||||
('description', models.TextField(blank=True, help_text='Description of what these credentials are used for')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Credentials',
|
||||
'verbose_name_plural': 'Credentials',
|
||||
'ordering': ['cred_type', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Certificate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('domain', models.CharField(help_text='Domain name for this certificate', max_length=255, unique=True)),
|
||||
('certificate_pem', models.TextField(help_text='Certificate in PEM format')),
|
||||
('private_key_pem', models.TextField(help_text='Private key in PEM format')),
|
||||
('cert_type', models.CharField(choices=[('self_signed', 'Self-Signed'), ('letsencrypt', "Let's Encrypt"), ('custom', 'Custom')], help_text='Type of certificate', max_length=20)),
|
||||
('expires_at', models.DateTimeField(help_text='Certificate expiration date')),
|
||||
('auto_renew', models.BooleanField(default=True, help_text='Automatically renew certificate before expiration')),
|
||||
('last_renewed', models.DateTimeField(blank=True, help_text='Last renewal timestamp', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('credentials', models.ForeignKey(blank=True, help_text="Credentials for Let's Encrypt (Cloudflare API)", null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.credentials')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Certificate',
|
||||
'verbose_name_plural': 'Certificates',
|
||||
'ordering': ['domain'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Inbound',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Unique identifier for this inbound', max_length=100, unique=True)),
|
||||
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], help_text='Protocol type', max_length=20)),
|
||||
('port', models.IntegerField(help_text='Port to listen on')),
|
||||
('network', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('grpc', 'gRPC'), ('http', 'HTTP/2'), ('quic', 'QUIC')], default='tcp', help_text='Transport protocol', max_length=20)),
|
||||
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY')], default='none', help_text='Security type', max_length=20)),
|
||||
('domain', models.CharField(blank=True, help_text='Client connection domain', max_length=255)),
|
||||
('full_config', models.JSONField(default=dict, help_text='Complete configuration for creating inbound on server')),
|
||||
('listen_address', models.CharField(default='0.0.0.0', help_text='IP address to listen on', max_length=45)),
|
||||
('enable_sniffing', models.BooleanField(default=True, help_text='Enable protocol sniffing')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('certificate', models.ForeignKey(blank=True, help_text='Certificate for TLS', null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.certificate')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Inbound',
|
||||
'verbose_name_plural': 'Inbounds',
|
||||
'ordering': ['protocol', 'port'],
|
||||
'unique_together': {('port', 'listen_address')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubscriptionGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="Group name (e.g., 'VLESS Premium', 'VMess Basic')", max_length=100, unique=True)),
|
||||
('description', models.TextField(blank=True, help_text='Description of this subscription group')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this group is active')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('inbounds', models.ManyToManyField(blank=True, help_text='Inbounds included in this group', to='vpn.inbound')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Subscription Group',
|
||||
'verbose_name_plural': 'Subscription Groups',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserSubscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('active', models.BooleanField(default=True, help_text='Whether this subscription is active')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('subscription_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vpn.subscriptiongroup')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='xray_subscriptions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User Subscription',
|
||||
'verbose_name_plural': 'User Subscriptions',
|
||||
'ordering': ['user__username', 'subscription_group__name'],
|
||||
'unique_together': {('user', 'subscription_group')},
|
||||
},
|
||||
),
|
||||
]
|
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 13:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0016_add_new_xray_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayServerV2',
|
||||
fields=[
|
||||
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
|
||||
('client_hostname', models.CharField(help_text='Client connection hostname (what users see in their configs)', max_length=255)),
|
||||
('api_address', models.CharField(default='127.0.0.1:10085', help_text='Xray gRPC API address for management', max_length=255)),
|
||||
('api_enabled', models.BooleanField(default=True, help_text='Enable gRPC API for user management')),
|
||||
('stats_enabled', models.BooleanField(default=True, help_text='Enable traffic statistics collection')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Server v2',
|
||||
'verbose_name_plural': 'Xray Servers v2',
|
||||
},
|
||||
bases=('vpn.server',),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='server',
|
||||
name='server_type',
|
||||
field=models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard'), ('xray_core', 'Xray Core'), ('xray_v2', 'Xray Server v2')], editable=False, max_length=50),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ServerInbound',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('active', models.BooleanField(default=True, help_text='Whether this inbound is active on the server')),
|
||||
('deployed_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('deployment_config', models.JSONField(blank=True, default=dict, help_text='Server-specific deployment configuration')),
|
||||
('inbound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployed_servers', to='vpn.inbound')),
|
||||
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployed_inbounds', to='vpn.server')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Server Inbound Deployment',
|
||||
'verbose_name_plural': 'Server Inbound Deployments',
|
||||
'ordering': ['server__name', 'inbound__name'],
|
||||
'unique_together': {('server', 'inbound')},
|
||||
},
|
||||
),
|
||||
]
|
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 14:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0017_xrayserverv2_alter_server_server_type_serverinbound'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='certificate',
|
||||
name='certificate_pem',
|
||||
field=models.TextField(blank=True, help_text="Certificate in PEM format (auto-generated for Let's Encrypt)"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='certificate',
|
||||
name='expires_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Certificate expiration date (auto-filled after generation)', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='certificate',
|
||||
name='private_key_pem',
|
||||
field=models.TextField(blank=True, help_text="Private key in PEM format (auto-generated for Let's Encrypt)"),
|
||||
),
|
||||
]
|
18
vpn/migrations/0019_certificate_acme_email.py
Normal file
18
vpn/migrations/0019_certificate_acme_email.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 14:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0018_alter_certificate_certificate_pem_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='certificate',
|
||||
name='acme_email',
|
||||
field=models.EmailField(blank=True, help_text="Email address for ACME account registration (required for Let's Encrypt)", max_length=254),
|
||||
),
|
||||
]
|
18
vpn/migrations/0020_alter_inbound_full_config.py
Normal file
18
vpn/migrations/0020_alter_inbound_full_config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 15:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0019_certificate_acme_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='inbound',
|
||||
name='full_config',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Complete configuration for creating inbound on server (auto-generated if empty)'),
|
||||
),
|
||||
]
|
@@ -167,3 +167,10 @@ class ACLLink(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.link
|
||||
|
||||
|
||||
# Import new Xray models
|
||||
from .models_xray import (
|
||||
XrayConfiguration, Credentials, Certificate,
|
||||
Inbound, SubscriptionGroup, UserSubscription
|
||||
)
|
||||
|
488
vpn/models_xray.py
Normal file
488
vpn/models_xray.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
New Xray models for flexible inbound and subscription management.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
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):
|
||||
"""Universal credentials storage for various services"""
|
||||
CRED_TYPES = [
|
||||
('cloudflare', 'Cloudflare API'),
|
||||
('dns_provider', 'DNS Provider'),
|
||||
('email', 'Email SMTP'),
|
||||
('other', 'Other')
|
||||
]
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Descriptive name for these credentials"
|
||||
)
|
||||
cred_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=CRED_TYPES,
|
||||
help_text="Type of credentials"
|
||||
)
|
||||
credentials = models.JSONField(
|
||||
help_text="Credentials data (e.g., {'api_token': '...', 'email': '...'})"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of what these credentials are used for"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Credentials"
|
||||
verbose_name_plural = "Credentials"
|
||||
ordering = ['cred_type', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_cred_type_display()})"
|
||||
|
||||
def get_credential(self, key: str, default=None):
|
||||
"""Safely get credential value"""
|
||||
return self.credentials.get(key, default)
|
||||
|
||||
|
||||
class Certificate(models.Model):
|
||||
"""SSL/TLS Certificate management"""
|
||||
CERT_TYPES = [
|
||||
('self_signed', 'Self-Signed'),
|
||||
('letsencrypt', "Let's Encrypt"),
|
||||
('custom', 'Custom')
|
||||
]
|
||||
|
||||
domain = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
help_text="Domain name for this certificate"
|
||||
)
|
||||
certificate_pem = models.TextField(
|
||||
blank=True,
|
||||
help_text="Certificate in PEM format (auto-generated for Let's Encrypt)"
|
||||
)
|
||||
private_key_pem = models.TextField(
|
||||
blank=True,
|
||||
help_text="Private key in PEM format (auto-generated for Let's Encrypt)"
|
||||
)
|
||||
cert_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=CERT_TYPES,
|
||||
help_text="Type of certificate"
|
||||
)
|
||||
expires_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Certificate expiration date (auto-filled after generation)"
|
||||
)
|
||||
credentials = models.ForeignKey(
|
||||
Credentials,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text="Credentials for Let's Encrypt (Cloudflare API)"
|
||||
)
|
||||
acme_email = models.EmailField(
|
||||
blank=True,
|
||||
help_text="Email address for ACME account registration (required for Let's Encrypt)"
|
||||
)
|
||||
auto_renew = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Automatically renew certificate before expiration"
|
||||
)
|
||||
last_renewed = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Last renewal timestamp"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Certificate"
|
||||
verbose_name_plural = "Certificates"
|
||||
ordering = ['domain']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.domain} ({self.get_cert_type_display()})"
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""Check if certificate is expired"""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
@property
|
||||
def days_until_expiration(self):
|
||||
"""Days until certificate expires"""
|
||||
if not self.expires_at:
|
||||
return None
|
||||
delta = self.expires_at - timezone.now()
|
||||
return delta.days
|
||||
|
||||
@property
|
||||
def needs_renewal(self):
|
||||
"""Check if certificate needs renewal"""
|
||||
if not self.auto_renew or not self.expires_at:
|
||||
return False
|
||||
|
||||
try:
|
||||
config = XrayConfiguration.objects.first()
|
||||
renewal_days = config.cert_renewal_days if config else 60
|
||||
except:
|
||||
renewal_days = 60
|
||||
|
||||
days_left = self.days_until_expiration
|
||||
if days_left is None:
|
||||
return False
|
||||
|
||||
return days_left <= renewal_days
|
||||
|
||||
|
||||
class Inbound(models.Model):
|
||||
"""Independent inbound configuration"""
|
||||
PROTOCOLS = [
|
||||
('vless', 'VLESS'),
|
||||
('vmess', 'VMess'),
|
||||
('trojan', 'Trojan'),
|
||||
('shadowsocks', 'Shadowsocks')
|
||||
]
|
||||
|
||||
NETWORKS = [
|
||||
('tcp', 'TCP'),
|
||||
('ws', 'WebSocket'),
|
||||
('grpc', 'gRPC'),
|
||||
('http', 'HTTP/2'),
|
||||
('quic', 'QUIC')
|
||||
]
|
||||
|
||||
SECURITIES = [
|
||||
('none', 'None'),
|
||||
('tls', 'TLS'),
|
||||
('reality', 'REALITY')
|
||||
]
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Unique identifier for this inbound"
|
||||
)
|
||||
protocol = models.CharField(
|
||||
max_length=20,
|
||||
choices=PROTOCOLS,
|
||||
help_text="Protocol type"
|
||||
)
|
||||
port = models.IntegerField(
|
||||
help_text="Port to listen on"
|
||||
)
|
||||
network = models.CharField(
|
||||
max_length=20,
|
||||
choices=NETWORKS,
|
||||
default='tcp',
|
||||
help_text="Transport protocol"
|
||||
)
|
||||
security = models.CharField(
|
||||
max_length=20,
|
||||
choices=SECURITIES,
|
||||
default='none',
|
||||
help_text="Security type"
|
||||
)
|
||||
certificate = models.ForeignKey(
|
||||
Certificate,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text="Certificate for TLS"
|
||||
)
|
||||
domain = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Client connection domain"
|
||||
)
|
||||
|
||||
# Full configuration for Xray
|
||||
full_config = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Complete configuration for creating inbound on server (auto-generated if empty)"
|
||||
)
|
||||
|
||||
# Additional settings
|
||||
listen_address = models.CharField(
|
||||
max_length=45,
|
||||
default="0.0.0.0",
|
||||
help_text="IP address to listen on"
|
||||
)
|
||||
enable_sniffing = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Enable protocol sniffing"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Inbound"
|
||||
verbose_name_plural = "Inbounds"
|
||||
ordering = ['protocol', 'port']
|
||||
unique_together = [['port', 'listen_address']]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.protocol.upper()}:{self.port})"
|
||||
|
||||
def generate_tag(self):
|
||||
"""Generate unique tag for inbound"""
|
||||
return f"{self.protocol}-{self.port}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
def build_config(self):
|
||||
"""Build full configuration for Xray"""
|
||||
try:
|
||||
# Build basic Xray inbound configuration
|
||||
config = {
|
||||
"tag": self.name,
|
||||
"port": self.port,
|
||||
"listen": self.listen_address,
|
||||
"protocol": self.protocol,
|
||||
"settings": self._build_protocol_settings(),
|
||||
"streamSettings": self._build_stream_settings(),
|
||||
"sniffing": {
|
||||
"enabled": self.enable_sniffing,
|
||||
"destOverride": ["http", "tls"]
|
||||
} if self.enable_sniffing else {}
|
||||
}
|
||||
|
||||
# Store the built config
|
||||
self.full_config = config
|
||||
return self.full_config
|
||||
|
||||
except Exception as e:
|
||||
# Fallback to basic config if detailed build fails
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Failed to build detailed config for {self.name}: {e}")
|
||||
|
||||
self.full_config = {
|
||||
"tag": self.name,
|
||||
"port": self.port,
|
||||
"listen": self.listen_address,
|
||||
"protocol": self.protocol,
|
||||
"settings": {},
|
||||
"streamSettings": {}
|
||||
}
|
||||
return self.full_config
|
||||
|
||||
def _build_protocol_settings(self):
|
||||
"""Build protocol-specific settings"""
|
||||
settings = {}
|
||||
|
||||
if self.protocol == 'vless':
|
||||
settings = {
|
||||
"clients": [], # Will be populated when users are added
|
||||
"decryption": "none"
|
||||
}
|
||||
elif self.protocol == 'vmess':
|
||||
settings = {
|
||||
"clients": [] # Will be populated when users are added
|
||||
}
|
||||
elif self.protocol == 'trojan':
|
||||
settings = {
|
||||
"clients": [] # Will be populated when users are added
|
||||
}
|
||||
elif self.protocol == 'shadowsocks':
|
||||
settings = {
|
||||
"method": "aes-128-gcm", # Default method
|
||||
"password": "", # Will be set when configured
|
||||
"network": "tcp,udp"
|
||||
}
|
||||
|
||||
return settings
|
||||
|
||||
def _build_stream_settings(self):
|
||||
"""Build stream transport settings"""
|
||||
stream_settings = {
|
||||
"network": self.network
|
||||
}
|
||||
|
||||
# Add network-specific settings
|
||||
if self.network == "ws":
|
||||
stream_settings["wsSettings"] = {
|
||||
"path": f"/{self.name}",
|
||||
"headers": {}
|
||||
}
|
||||
elif self.network == "grpc":
|
||||
stream_settings["grpcSettings"] = {
|
||||
"serviceName": self.name
|
||||
}
|
||||
elif self.network == "http":
|
||||
stream_settings["httpSettings"] = {
|
||||
"path": f"/{self.name}",
|
||||
"host": [self.domain] if self.domain else []
|
||||
}
|
||||
|
||||
# Add security settings
|
||||
if self.security == "tls":
|
||||
stream_settings["security"] = "tls"
|
||||
tls_settings = {
|
||||
"serverName": self.domain or "localhost",
|
||||
"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"
|
||||
}]
|
||||
})
|
||||
|
||||
stream_settings["tlsSettings"] = tls_settings
|
||||
|
||||
elif self.security == "reality":
|
||||
stream_settings["security"] = "reality"
|
||||
# Reality settings would be configured here
|
||||
stream_settings["realitySettings"] = {
|
||||
"dest": self.domain or "example.com:443",
|
||||
"serverNames": [self.domain] if self.domain else ["example.com"],
|
||||
"privateKey": "", # Would be generated
|
||||
"shortIds": [""] # Would be generated
|
||||
}
|
||||
|
||||
return stream_settings
|
||||
|
||||
|
||||
class SubscriptionGroup(models.Model):
|
||||
"""Groups of inbounds for subscription management"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Group name (e.g., 'VLESS Premium', 'VMess Basic')"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of this subscription group"
|
||||
)
|
||||
inbounds = models.ManyToManyField(
|
||||
Inbound,
|
||||
blank=True,
|
||||
help_text="Inbounds included in this group"
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this group is active"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Subscription Group"
|
||||
verbose_name_plural = "Subscription Groups"
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def inbound_count(self):
|
||||
"""Number of inbounds in this group"""
|
||||
return self.inbounds.count()
|
||||
|
||||
@property
|
||||
def user_count(self):
|
||||
"""Number of users subscribed to this group"""
|
||||
return self.usersubscription_set.filter(active=True).count()
|
||||
|
||||
|
||||
class UserSubscription(models.Model):
|
||||
"""User subscriptions to groups"""
|
||||
user = models.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='xray_subscriptions'
|
||||
)
|
||||
subscription_group = models.ForeignKey(
|
||||
SubscriptionGroup,
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this subscription is active"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "User Subscription"
|
||||
verbose_name_plural = "User Subscriptions"
|
||||
unique_together = ['user', 'subscription_group']
|
||||
ordering = ['user__username', 'subscription_group__name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.subscription_group.name}"
|
||||
|
||||
|
||||
class ServerInbound(models.Model):
|
||||
"""Many-to-many relationship between servers and inbounds to track deployment"""
|
||||
server = models.ForeignKey('Server', on_delete=models.CASCADE, related_name='deployed_inbounds')
|
||||
inbound = models.ForeignKey(Inbound, on_delete=models.CASCADE, related_name='deployed_servers')
|
||||
active = models.BooleanField(default=True, help_text="Whether this inbound is active on the server")
|
||||
|
||||
deployed_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Store deployment-specific configuration if needed
|
||||
deployment_config = models.JSONField(default=dict, blank=True, help_text="Server-specific deployment configuration")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Server Inbound Deployment"
|
||||
verbose_name_plural = "Server Inbound Deployments"
|
||||
ordering = ['server__name', 'inbound__name']
|
||||
unique_together = [('server', 'inbound')]
|
||||
|
||||
def __str__(self):
|
||||
status = "Active" if self.active else "Inactive"
|
||||
return f"{self.server.name} -> {self.inbound.name} ({status})"
|
@@ -1,5 +1,5 @@
|
||||
from .generic import Server
|
||||
from .outline import OutlineServer, OutlineServerAdmin
|
||||
from .wireguard import WireguardServer, WireguardServerAdmin
|
||||
from .xray_core import XrayCoreServer, XrayCoreServerAdmin, XrayInbound, XrayClient, XrayInboundServer, XrayInboundServerAdmin
|
||||
from .xray_v2 import XrayServerV2, XrayServerV2Admin
|
||||
from .urls import urlpatterns
|
@@ -7,6 +7,7 @@ class Server(PolymorphicModel):
|
||||
('Outline', 'Outline'),
|
||||
('Wireguard', 'Wireguard'),
|
||||
('xray_core', 'Xray Core'),
|
||||
('xray_v2', 'Xray Server v2'),
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=100, help_text="Server name")
|
||||
|
@@ -1,6 +1,7 @@
|
||||
from django.urls import path
|
||||
from vpn.views import shadowsocks
|
||||
from vpn.views import shadowsocks, xray_subscription
|
||||
|
||||
urlpatterns = [
|
||||
path('ss/<str:hash_value>/', shadowsocks, name='shadowsocks'),
|
||||
path('xray/<str:user_hash>/', xray_subscription, name='xray_subscription'),
|
||||
]
|
File diff suppressed because it is too large
Load Diff
726
vpn/server_plugins/xray_v2.py
Normal file
726
vpn/server_plugins/xray_v2.py
Normal file
@@ -0,0 +1,726 @@
|
||||
import logging
|
||||
from django.db import models
|
||||
from django.contrib import admin
|
||||
from .generic import Server
|
||||
from vpn.models_xray import XrayConfiguration, Inbound, UserSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class XrayServerV2(Server):
|
||||
"""
|
||||
New Xray server that works with subscription groups and inbounds.
|
||||
This server can host multiple inbounds and users access them through subscription groups.
|
||||
"""
|
||||
client_hostname = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Client connection hostname (what users see in their configs)"
|
||||
)
|
||||
api_address = models.CharField(
|
||||
max_length=255,
|
||||
default="127.0.0.1:10085",
|
||||
help_text="Xray gRPC API address for management"
|
||||
)
|
||||
api_enabled = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Enable gRPC API for user management"
|
||||
)
|
||||
stats_enabled = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Enable traffic statistics collection"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Xray Server v2"
|
||||
verbose_name_plural = "Xray Servers v2"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.server_type:
|
||||
self.server_type = 'xray_v2'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_server_status(self):
|
||||
"""Get server status including active inbounds"""
|
||||
try:
|
||||
# Get basic server information
|
||||
active_inbounds = self.get_active_inbounds()
|
||||
|
||||
# Try to connect to Xray API if enabled
|
||||
api_status = False
|
||||
api_error = None
|
||||
api_stats = {}
|
||||
|
||||
if self.api_enabled:
|
||||
try:
|
||||
# Try different methods to check server status
|
||||
import socket
|
||||
import json
|
||||
|
||||
# Parse API address
|
||||
host, port = self.api_address.split(':')
|
||||
port = int(port)
|
||||
|
||||
# Test basic connection
|
||||
logger.info(f"Testing connection to Xray API at {host}:{port}")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
result = sock.connect_ex((host, port))
|
||||
sock.close()
|
||||
|
||||
if result == 0:
|
||||
api_status = True
|
||||
logger.info(f"Successfully connected to Xray API at {self.api_address}")
|
||||
|
||||
# Try to get stats if library is available
|
||||
try:
|
||||
from vpn.xray_api_v2.server_manager import ServerManager
|
||||
manager = ServerManager(self.api_address)
|
||||
api_stats = manager.get_server_stats()
|
||||
logger.info(f"Got server stats: {api_stats}")
|
||||
except ImportError:
|
||||
logger.info("Xray API v2 library not available, but connection successful")
|
||||
api_stats = {"connection": "ok", "library": "not_available"}
|
||||
except Exception as stats_e:
|
||||
logger.warning(f"Connection OK but stats failed: {stats_e}")
|
||||
api_stats = {"connection": "ok", "stats_error": str(stats_e)}
|
||||
else:
|
||||
api_error = f"Connection failed to {host}:{port}"
|
||||
logger.warning(f"Failed to connect to Xray API at {self.api_address}: {api_error}")
|
||||
|
||||
except Exception as e:
|
||||
api_error = f"Connection test failed: {str(e)}"
|
||||
logger.warning(f"Failed to test connection to Xray API for server {self.name}: {e}")
|
||||
else:
|
||||
api_error = "API disabled in server settings"
|
||||
logger.info(f"API disabled for server {self.name}")
|
||||
|
||||
# Build status response
|
||||
status = {
|
||||
'server_name': self.name,
|
||||
'server_type': 'Xray Server v2',
|
||||
'client_hostname': self.client_hostname,
|
||||
'api_address': self.api_address,
|
||||
'api_enabled': self.api_enabled,
|
||||
'api_connected': api_status,
|
||||
'api_error': api_error,
|
||||
'api_stats': api_stats,
|
||||
'stats_enabled': self.stats_enabled,
|
||||
'total_inbounds': active_inbounds.count(),
|
||||
'inbound_ports': [], # Will be populated when ServerInbound model is fully implemented
|
||||
'accessible': api_status if self.api_enabled else True, # Consider accessible if API disabled
|
||||
'status': 'Connected' if api_status else 'API Issue' if self.api_enabled else 'No API Check'
|
||||
}
|
||||
|
||||
logger.info(f"Server status for {self.name}: {status['status']}")
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get status for Xray server {self.name}: {e}")
|
||||
return {
|
||||
'error': str(e),
|
||||
'server_name': self.name,
|
||||
'server_type': 'Xray Server v2',
|
||||
'accessible': False,
|
||||
'status': 'Error'
|
||||
}
|
||||
|
||||
def get_active_inbounds(self):
|
||||
"""Get all inbounds that are deployed on this server"""
|
||||
try:
|
||||
from vpn.models_xray import ServerInbound
|
||||
return ServerInbound.objects.filter(server=self, active=True).select_related('inbound')
|
||||
except ImportError:
|
||||
# ServerInbound model doesn't exist yet, return empty queryset
|
||||
from django.db.models import QuerySet
|
||||
from vpn.models_xray import Inbound
|
||||
return Inbound.objects.none()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting active inbounds for server {self.name}: {e}")
|
||||
from vpn.models_xray import Inbound
|
||||
return Inbound.objects.none()
|
||||
|
||||
def sync_users(self):
|
||||
"""Sync all users who have subscription groups containing inbounds on this server"""
|
||||
try:
|
||||
from vpn.tasks import sync_server_users
|
||||
task = sync_server_users.delay(self.id)
|
||||
logger.info(f"Scheduled user sync for Xray server {self.name} - task ID: {task.id}")
|
||||
# Return success to indicate task was scheduled
|
||||
return {"status": "scheduled", "task_id": str(task.id)}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule user sync for server {self.name}: {e}")
|
||||
return {"status": "failed", "error": str(e)}
|
||||
|
||||
def sync_inbounds(self):
|
||||
"""Deploy all required inbounds on this server based on subscription groups"""
|
||||
try:
|
||||
from vpn.tasks import sync_server_inbounds
|
||||
task = sync_server_inbounds.delay(self.id)
|
||||
logger.info(f"Scheduled inbound sync for Xray server {self.name} - task ID: {task.id}")
|
||||
# Return None to match old behavior
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule inbound sync for server {self.name}: {e}")
|
||||
return None
|
||||
|
||||
def deploy_inbound(self, inbound, users=None):
|
||||
"""Deploy a specific inbound on this server with optional users"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
import uuid
|
||||
|
||||
logger.info(f"Deploying inbound {inbound.name} with protocol {inbound.protocol} on port {inbound.port}")
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# Build user configs if users are provided
|
||||
user_configs = []
|
||||
if users:
|
||||
for user in users:
|
||||
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
|
||||
|
||||
if inbound.protocol == 'vless':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0,
|
||||
"alterId": 0
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"password": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Unsupported protocol {inbound.protocol} for user {user.username}")
|
||||
continue
|
||||
|
||||
user_configs.append(user_config)
|
||||
logger.info(f"Added user {user.username} to inbound config")
|
||||
|
||||
# Build proper inbound configuration based on protocol
|
||||
if inbound.full_config:
|
||||
inbound_config = inbound.full_config.copy() # Make a copy to modify
|
||||
logger.info(f"Using existing full_config for inbound {inbound.name}")
|
||||
|
||||
# Add users to the config if provided
|
||||
if user_configs:
|
||||
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")
|
||||
|
||||
# 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}")
|
||||
|
||||
# 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')
|
||||
|
||||
# Update streamSettings if it exists
|
||||
if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]:
|
||||
inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
logger.info("Updated existing tlsSettings with inline certificate")
|
||||
else:
|
||||
# Build full config based on protocol
|
||||
inbound_config = {
|
||||
"tag": inbound.name,
|
||||
"port": inbound.port,
|
||||
"protocol": inbound.protocol,
|
||||
"listen": inbound.listen_address or "0.0.0.0",
|
||||
}
|
||||
|
||||
# Add protocol-specific settings
|
||||
if inbound.protocol == 'vless':
|
||||
inbound_config["settings"] = {
|
||||
"clients": user_configs, # Add users during creation
|
||||
"decryption": "none"
|
||||
}
|
||||
if inbound.network == 'ws':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "ws",
|
||||
"wsSettings": {
|
||||
"path": f"/{inbound.name}"
|
||||
}
|
||||
}
|
||||
elif inbound.network == 'tcp':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "tcp"
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
inbound_config["settings"] = {
|
||||
"clients": user_configs # Add users during creation
|
||||
}
|
||||
if inbound.network == 'ws':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "ws",
|
||||
"wsSettings": {
|
||||
"path": f"/{inbound.name}"
|
||||
}
|
||||
}
|
||||
elif inbound.network == 'tcp':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "tcp"
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
inbound_config["settings"] = {
|
||||
"clients": user_configs # Add users during creation
|
||||
}
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "tcp",
|
||||
"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}")
|
||||
# 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')
|
||||
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
}
|
||||
else:
|
||||
logger.error(f"Trojan protocol requires certificate, but none found for inbound {inbound.name}!")
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": []
|
||||
}
|
||||
|
||||
# Add TLS if specified
|
||||
if inbound.security == 'tls' and inbound.protocol != 'trojan':
|
||||
if "streamSettings" not in inbound_config:
|
||||
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}")
|
||||
# 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')
|
||||
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
}
|
||||
else:
|
||||
logger.warning(f"No certificate found for inbound {inbound.name}, TLS will not work!")
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": []
|
||||
}
|
||||
|
||||
logger.info(f"Inbound config: {inbound_config}")
|
||||
|
||||
# Add inbound using the client's add_inbound method which handles wrapping
|
||||
try:
|
||||
result = client.add_inbound(inbound_config)
|
||||
logger.info(f"Deploy inbound result: {result}")
|
||||
|
||||
# Check if command was successful
|
||||
if result is not None and not (isinstance(result, dict) and 'error' in result):
|
||||
# Mark as deployed on this server
|
||||
from vpn.models_xray import ServerInbound
|
||||
ServerInbound.objects.update_or_create(
|
||||
server=self,
|
||||
inbound=inbound,
|
||||
defaults={'active': True}
|
||||
)
|
||||
logger.info(f"Successfully deployed inbound {inbound.name} on server {self.name}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to deploy inbound {inbound.name} on server {self.name}. Result: {result}")
|
||||
return False
|
||||
except Exception as cmd_error:
|
||||
logger.error(f"Command execution error: {cmd_error}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deploying inbound {inbound.name} on server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def add_user_to_inbound(self, user, inbound):
|
||||
"""Add a user to a specific inbound on this server using inbound recreation approach"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
import uuid
|
||||
|
||||
logger.info(f"Adding user {user.username} to inbound {inbound.name} using inbound recreation")
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# 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}")
|
||||
|
||||
# Build user config based on protocol
|
||||
if inbound.protocol == 'vless':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0,
|
||||
"alterId": 0
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"password": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
else:
|
||||
logger.error(f"Unsupported protocol: {inbound.protocol}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# First, get existing inbound to check for other users
|
||||
existing_result = client.execute_command('lsi')
|
||||
existing_inbound = None
|
||||
|
||||
if existing_result and 'inbounds' in existing_result:
|
||||
for ib in existing_result['inbounds']:
|
||||
if ib.get('tag') == inbound.name:
|
||||
existing_inbound = ib
|
||||
break
|
||||
|
||||
if not existing_inbound:
|
||||
logger.warning(f"Inbound {inbound.name} not found on server, deploying it first")
|
||||
# Deploy the inbound if it doesn't exist
|
||||
if not self.deploy_inbound(inbound):
|
||||
logger.error(f"Failed to deploy inbound {inbound.name}")
|
||||
return False
|
||||
# Get the inbound config we just created
|
||||
existing_inbound = {"settings": {"clients": []}}
|
||||
|
||||
# Get existing users from the inbound
|
||||
existing_users = existing_inbound.get('settings', {}).get('clients', [])
|
||||
logger.info(f"Found {len(existing_users)} existing users in inbound {inbound.name}")
|
||||
|
||||
# Check if user already exists
|
||||
for existing_user in existing_users:
|
||||
if existing_user.get('email') == f"{user.username}@{self.name}":
|
||||
logger.info(f"User {user.username} already exists in inbound {inbound.name}")
|
||||
return True
|
||||
|
||||
# Add new user to existing users list
|
||||
existing_users.append(user_config)
|
||||
logger.info(f"Creating new inbound with {len(existing_users)} users including {user.username}")
|
||||
|
||||
# Remove the old inbound
|
||||
logger.info(f"Removing old inbound {inbound.name}")
|
||||
client.remove_inbound(inbound.name)
|
||||
|
||||
# Recreate inbound with updated user list
|
||||
if inbound.full_config:
|
||||
inbound_config = inbound.full_config.copy()
|
||||
if 'settings' not in inbound_config:
|
||||
inbound_config['settings'] = {}
|
||||
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')
|
||||
|
||||
if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]:
|
||||
inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
else:
|
||||
# Build config from scratch with the users
|
||||
inbound_config = {
|
||||
"tag": inbound.name,
|
||||
"port": inbound.port,
|
||||
"protocol": inbound.protocol,
|
||||
"listen": inbound.listen_address or "0.0.0.0",
|
||||
"settings": {}
|
||||
}
|
||||
|
||||
if inbound.protocol in ['vless', 'vmess']:
|
||||
inbound_config["settings"]["clients"] = existing_users
|
||||
if inbound.protocol == 'vless':
|
||||
inbound_config["settings"]["decryption"] = "none"
|
||||
elif inbound.protocol == 'trojan':
|
||||
inbound_config["settings"]["clients"] = existing_users
|
||||
|
||||
logger.info(f"Deploying updated inbound with users: {[u.get('email') for u in existing_users]}")
|
||||
result = client.add_inbound(inbound_config)
|
||||
|
||||
if result is not None and not (isinstance(result, dict) and 'error' in result):
|
||||
logger.info(f"Successfully added user {user.username} to inbound {inbound.name} via inbound recreation")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to recreate inbound {inbound.name} with user. Result: {result}")
|
||||
return False
|
||||
|
||||
except Exception as cmd_error:
|
||||
logger.error(f"Error during inbound recreation: {cmd_error}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding user {user.username} to inbound {inbound.name} on server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def remove_user_from_inbound(self, user, inbound):
|
||||
"""Remove a user from a specific inbound on this server"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# Remove user using the client's remove_users method
|
||||
user_email = f"{user.username}@{self.name}"
|
||||
logger.info(f"Removing user {user_email} from inbound {inbound.name}")
|
||||
|
||||
result = client.remove_users(inbound.name, user_email)
|
||||
logger.info(f"Remove user result: {result}")
|
||||
|
||||
if result is not None and not (isinstance(result, dict) and 'error' in result):
|
||||
logger.info(f"Successfully removed user {user.username} from inbound {inbound.name} on server {self.name}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to remove user {user.username} from inbound {inbound.name} on server {self.name}. Result: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing user {user.username} from inbound {inbound.name} on server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def get_user_configs(self, user):
|
||||
"""Generate all connection configs for a user on this server"""
|
||||
configs = []
|
||||
|
||||
try:
|
||||
# Get all subscription groups for this user
|
||||
user_subscriptions = UserSubscription.objects.filter(
|
||||
user=user,
|
||||
active=True,
|
||||
subscription_group__is_active=True
|
||||
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
|
||||
|
||||
for subscription in user_subscriptions:
|
||||
group = subscription.subscription_group
|
||||
|
||||
# Check which inbounds from this group are active on this server
|
||||
active_inbounds = self.get_active_inbounds().filter(
|
||||
inbound__in=group.inbounds.all()
|
||||
)
|
||||
|
||||
for server_inbound in active_inbounds:
|
||||
inbound = server_inbound.inbound
|
||||
|
||||
try:
|
||||
# Generate connection string directly
|
||||
from vpn.views import generate_xray_connection_string
|
||||
connection_string = generate_xray_connection_string(user, inbound)
|
||||
|
||||
if connection_string:
|
||||
configs.append({
|
||||
'protocol': inbound.protocol,
|
||||
'inbound_name': inbound.name,
|
||||
'group_name': group.name,
|
||||
'connection_string': connection_string,
|
||||
'port': inbound.port,
|
||||
'network': inbound.network,
|
||||
'security': inbound.security
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate config for user {user.username} on inbound {inbound.name}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Generated {len(configs)} configs for user {user.username} on server {self.name}")
|
||||
return configs
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating user configs for {user.username} on server {self.name}: {e}")
|
||||
return []
|
||||
|
||||
def sync(self):
|
||||
"""Sync server configuration and users"""
|
||||
try:
|
||||
self.sync_inbounds()
|
||||
self.sync_users()
|
||||
logger.info(f"Full sync completed for server {self.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Sync failed for server {self.name}: {e}")
|
||||
|
||||
def add_user(self, user, **kwargs):
|
||||
"""Add user to server - implemented through subscription groups"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# Users are added through subscription groups in the new architecture
|
||||
subscriptions = user.xray_subscriptions.filter(active=True)
|
||||
added_count = 0
|
||||
|
||||
logger.info(f"User {user.username} has {subscriptions.count()} active subscriptions")
|
||||
|
||||
if subscriptions.count() == 0:
|
||||
logger.warning(f"User {user.username} has no active Xray subscriptions - cannot add to server")
|
||||
return False
|
||||
|
||||
# Get all inbounds that this user should have access to
|
||||
inbounds_to_process = []
|
||||
for subscription in subscriptions:
|
||||
logger.info(f"Processing subscription group: {subscription.subscription_group.name}")
|
||||
for inbound in subscription.subscription_group.inbounds.all():
|
||||
if inbound not in inbounds_to_process:
|
||||
inbounds_to_process.append(inbound)
|
||||
logger.info(f"Added inbound {inbound.name} to processing list")
|
||||
|
||||
# Get existing inbounds on server
|
||||
try:
|
||||
existing_result = client.execute_command('lsi') # List inbounds
|
||||
existing_inbound_tags = set()
|
||||
if existing_result and 'inbounds' in existing_result:
|
||||
existing_inbound_tags = {ib.get('tag') for ib in existing_result['inbounds'] if ib.get('tag')}
|
||||
logger.info(f"Existing inbound tags on server: {existing_inbound_tags}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to list inbounds: {e}")
|
||||
existing_inbound_tags = set()
|
||||
|
||||
# Process each inbound
|
||||
for inbound in inbounds_to_process:
|
||||
logger.info(f"Processing inbound: {inbound.name} (protocol: {inbound.protocol})")
|
||||
|
||||
# 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")
|
||||
# Create the inbound with the user directly
|
||||
if self.deploy_inbound(inbound, users=[user]):
|
||||
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
|
||||
else:
|
||||
# Inbound exists, add user using recreation approach
|
||||
logger.info(f"Inbound {inbound.name} exists, adding user via recreation")
|
||||
if self.add_user_to_inbound(user, inbound):
|
||||
added_count += 1
|
||||
logger.info(f"Successfully added user {user.username} to existing inbound {inbound.name}")
|
||||
else:
|
||||
logger.error(f"Failed to add user {user.username} to existing inbound {inbound.name}")
|
||||
|
||||
logger.info(f"Added user {user.username} to {added_count} inbounds on server {self.name}")
|
||||
return added_count > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add user {user.username} to server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def get_user(self, user, raw=False):
|
||||
"""Get user configurations from server"""
|
||||
try:
|
||||
configs = self.get_user_configs(user)
|
||||
if raw:
|
||||
return {
|
||||
'configs': configs,
|
||||
'total_configs': len(configs)
|
||||
}
|
||||
return configs
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user {user.username} from server {self.name}: {e}")
|
||||
return [] if not raw else {'error': str(e)}
|
||||
|
||||
def delete_user(self, user):
|
||||
"""Remove user from server"""
|
||||
try:
|
||||
removed_count = 0
|
||||
subscriptions = user.xray_subscriptions.filter(active=True)
|
||||
|
||||
for subscription in subscriptions:
|
||||
for inbound in subscription.subscription_group.inbounds.all():
|
||||
if self.remove_user_from_inbound(user, inbound):
|
||||
removed_count += 1
|
||||
|
||||
logger.info(f"Removed user {user.username} from {removed_count} inbounds on server {self.name}")
|
||||
return removed_count > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove user {user.username} from server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
return f"Xray Server v2: {self.name}"
|
||||
|
||||
|
||||
class XrayServerV2Admin(admin.ModelAdmin):
|
||||
list_display = ['name', 'client_hostname', 'api_address', 'api_enabled', 'stats_enabled', 'registration_date']
|
||||
list_filter = ['api_enabled', 'stats_enabled', 'registration_date']
|
||||
search_fields = ['name', 'client_hostname', 'comment']
|
||||
readonly_fields = ['server_type', 'registration_date']
|
||||
|
||||
fieldsets = [
|
||||
('Basic Information', {
|
||||
'fields': ('name', 'comment', 'server_type')
|
||||
}),
|
||||
('Connection Settings', {
|
||||
'fields': ('client_hostname', 'api_address')
|
||||
}),
|
||||
('Features', {
|
||||
'fields': ('api_enabled', 'stats_enabled')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('registration_date',),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
]
|
||||
|
||||
actions = ['sync_users', 'sync_inbounds', 'get_status']
|
||||
|
||||
def sync_users(self, request, queryset):
|
||||
for server in queryset:
|
||||
server.sync_users()
|
||||
self.message_user(request, f"Scheduled user sync for {queryset.count()} servers")
|
||||
sync_users.short_description = "Sync users for selected servers"
|
||||
|
||||
def sync_inbounds(self, request, queryset):
|
||||
for server in queryset:
|
||||
server.sync_inbounds()
|
||||
self.message_user(request, f"Scheduled inbound sync for {queryset.count()} servers")
|
||||
sync_inbounds.short_description = "Sync inbounds for selected servers"
|
||||
|
||||
def get_status(self, request, queryset):
|
||||
statuses = []
|
||||
for server in queryset:
|
||||
status = server.get_server_status()
|
||||
statuses.append(f"{server.name}: {status.get('accessible', 'Unknown')}")
|
||||
self.message_user(request, f"Server statuses: {', '.join(statuses)}")
|
||||
get_status.short_description = "Check status of selected servers"
|
479
vpn/tasks.py
479
vpn/tasks.py
@@ -54,7 +54,7 @@ def cleanup_task_logs():
|
||||
def sync_xray_inbounds(self, server_id):
|
||||
"""Stage 1: Sync inbounds for Xray server."""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.server_plugins.xray_core import XrayCoreServer
|
||||
from vpn.server_plugins.xray_v2 import XrayServerV2
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
@@ -63,7 +63,7 @@ def sync_xray_inbounds(self, server_id):
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
|
||||
if not isinstance(server.get_real_instance(), XrayCoreServer):
|
||||
if not isinstance(server.get_real_instance(), XrayServerV2):
|
||||
error_message = f"Server {server.name} is not an Xray server"
|
||||
logger.error(error_message)
|
||||
create_task_log(task_id, "sync_xray_inbounds", "Wrong server type", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
@@ -105,7 +105,7 @@ def sync_xray_inbounds(self, server_id):
|
||||
def sync_xray_users(self, server_id):
|
||||
"""Stage 2: Sync users for Xray server."""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.server_plugins.xray_core import XrayCoreServer
|
||||
from vpn.server_plugins.xray_v2 import XrayServerV2
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
@@ -114,7 +114,7 @@ def sync_xray_users(self, server_id):
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
|
||||
if not isinstance(server.get_real_instance(), XrayCoreServer):
|
||||
if not isinstance(server.get_real_instance(), XrayServerV2):
|
||||
error_message = f"Server {server.name} is not an Xray server"
|
||||
logger.error(error_message)
|
||||
create_task_log(task_id, "sync_xray_users", "Wrong server type", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
@@ -247,45 +247,13 @@ def sync_users(self, server_id):
|
||||
|
||||
create_task_log(task_id, "sync_all_users_on_server", f"Found {user_count} users to sync", 'STARTED', server=server, message=f"Users: {user_list}")
|
||||
|
||||
# For Xray servers, use separate staged sync tasks
|
||||
from vpn.server_plugins.xray_core import XrayCoreServer
|
||||
if isinstance(server.get_real_instance(), XrayCoreServer):
|
||||
logger.info(f"Performing staged sync for Xray server {server.name}")
|
||||
try:
|
||||
# Stage 1: Sync inbounds first
|
||||
logger.info(f"Stage 1: Syncing inbounds for {server.name}")
|
||||
inbound_task = sync_xray_inbounds.apply_async(args=[server.id])
|
||||
inbound_result = inbound_task.get() # Wait for completion
|
||||
logger.info(f"Inbound sync result for {server.name}: {inbound_result}")
|
||||
|
||||
if "error" in inbound_result:
|
||||
logger.error(f"Inbound sync failed, skipping user sync: {inbound_result['error']}")
|
||||
sync_result = inbound_result
|
||||
else:
|
||||
# Stage 2: Sync users after inbounds are ready
|
||||
logger.info(f"Stage 2: Syncing users for {server.name}")
|
||||
user_task = sync_xray_users.apply_async(args=[server.id])
|
||||
user_result = user_task.get() # Wait for completion
|
||||
logger.info(f"User sync result for {server.name}: {user_result}")
|
||||
|
||||
# Combine results
|
||||
if "error" in user_result:
|
||||
sync_result = {
|
||||
"status": "Staged sync partially failed",
|
||||
"inbounds": inbound_result.get("inbounds", []),
|
||||
"users": f"User sync failed: {user_result['error']}"
|
||||
}
|
||||
else:
|
||||
sync_result = {
|
||||
"status": "Staged sync completed successfully",
|
||||
"inbounds": inbound_result.get("inbounds", []),
|
||||
"users": f"Added {user_result.get('users_added', 0)} users across all inbounds"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Staged sync failed for Xray server {server.name}: {e}")
|
||||
# Fallback to regular user sync only
|
||||
sync_result = server.sync_users()
|
||||
# For Xray servers, use the new sync methods
|
||||
from vpn.server_plugins.xray_v2 import XrayServerV2
|
||||
if isinstance(server.get_real_instance(), XrayServerV2):
|
||||
logger.info(f"Using XrayServerV2 sync for server {server.name}")
|
||||
# Just call the sync method which schedules tasks asynchronously
|
||||
sync_result = server.sync_users()
|
||||
logger.info(f"Scheduled async sync for Xray server {server.name}")
|
||||
else:
|
||||
# For non-Xray servers, just sync users
|
||||
sync_result = server.sync_users()
|
||||
@@ -566,4 +534,427 @@ def sync_user(self, user_id, server_id):
|
||||
if errors:
|
||||
raise TaskFailedException(message=f"Errors during task: {errors}")
|
||||
|
||||
return result
|
||||
return result
|
||||
|
||||
|
||||
@shared_task(name="sync_user_xray_access", bind=True)
|
||||
def sync_user_xray_access(self, user_id, server_id):
|
||||
"""
|
||||
Sync user's Xray access based on subscription groups.
|
||||
Creates inbounds on server if needed and adds user to them.
|
||||
"""
|
||||
from .models import User, Server
|
||||
from .models_xray import SubscriptionGroup, Inbound, XrayConfiguration
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
server = Server.objects.get(id=server_id)
|
||||
|
||||
# Get Xray configuration
|
||||
xray_config = XrayConfiguration.objects.first()
|
||||
if not xray_config:
|
||||
raise ValueError("Xray configuration not found. Please configure in admin.")
|
||||
|
||||
create_task_log(
|
||||
task_id, "sync_user_xray_access",
|
||||
f"Starting Xray sync for {user.username} on {server.name}",
|
||||
'STARTED', server=server, user=user
|
||||
)
|
||||
|
||||
# Get user's active subscription groups
|
||||
user_groups = SubscriptionGroup.objects.filter(
|
||||
usersubscription__user=user,
|
||||
usersubscription__active=True,
|
||||
is_active=True
|
||||
).prefetch_related('inbounds')
|
||||
|
||||
if not user_groups.exists():
|
||||
logger.info(f"User {user.username} has no active subscriptions")
|
||||
return {"status": "No active subscriptions"}
|
||||
|
||||
# Collect all inbounds from user's groups
|
||||
user_inbounds = Inbound.objects.filter(
|
||||
subscriptiongroup__in=user_groups
|
||||
).distinct()
|
||||
|
||||
logger.info(f"User {user.username} has access to {user_inbounds.count()} inbounds")
|
||||
|
||||
# Connect to Xray server
|
||||
client = XrayClient(xray_config.grpc_address)
|
||||
|
||||
# Get existing inbounds on server
|
||||
try:
|
||||
existing_result = client.execute_command('lsi') # List inbounds
|
||||
existing_inbounds = existing_result.get('inbounds', []) if existing_result else []
|
||||
existing_tags = {ib.get('tag') for ib in existing_inbounds if ib.get('tag')}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to list existing inbounds: {e}")
|
||||
existing_tags = set()
|
||||
|
||||
results = {
|
||||
'inbounds_created': [],
|
||||
'users_added': [],
|
||||
'errors': []
|
||||
}
|
||||
|
||||
# Process each inbound
|
||||
for inbound in user_inbounds:
|
||||
try:
|
||||
# Check if inbound exists on server
|
||||
if inbound.name not in existing_tags:
|
||||
logger.info(f"Creating inbound {inbound.name} on server")
|
||||
|
||||
# Build inbound configuration
|
||||
if not inbound.full_config:
|
||||
inbound.build_config()
|
||||
inbound.save()
|
||||
|
||||
# Add inbound to server
|
||||
client.execute_command('adi', json_files=[inbound.full_config])
|
||||
results['inbounds_created'].append(inbound.name)
|
||||
|
||||
# Add user to inbound
|
||||
logger.info(f"Adding user {user.username} to inbound {inbound.name}")
|
||||
|
||||
# Create user config based on protocol
|
||||
import uuid
|
||||
|
||||
# Generate user UUID based on username and inbound
|
||||
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
|
||||
|
||||
if inbound.protocol == 'vless':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{server.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{server.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0,
|
||||
"alterId": 0
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{server.name}",
|
||||
"password": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Unsupported protocol: {inbound.protocol}")
|
||||
continue
|
||||
|
||||
# Add user to inbound
|
||||
add_request = {
|
||||
"inboundTag": inbound.name,
|
||||
"user": user_config
|
||||
}
|
||||
|
||||
client.execute_command('adu', json_files=[add_request])
|
||||
results['users_added'].append(f"{user.username} -> {inbound.name}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing inbound {inbound.name}: {e}"
|
||||
logger.error(error_msg)
|
||||
results['errors'].append(error_msg)
|
||||
|
||||
# Log results
|
||||
success_msg = (
|
||||
f"Xray sync completed for {user.username}: "
|
||||
f"Created {len(results['inbounds_created'])} inbounds, "
|
||||
f"Added user to {len(results['users_added'])} inbounds"
|
||||
)
|
||||
|
||||
create_task_log(
|
||||
task_id, "sync_user_xray_access",
|
||||
"Xray sync completed", 'SUCCESS',
|
||||
server=server, user=user,
|
||||
message=success_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error in Xray sync: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
create_task_log(
|
||||
task_id, "sync_user_xray_access",
|
||||
"Xray sync failed", 'FAILURE',
|
||||
message=error_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(name="sync_server_users", bind=True)
|
||||
def sync_server_users(self, server_id):
|
||||
"""
|
||||
Sync all users for a specific Xray server.
|
||||
This is called by XrayServerV2.sync_users()
|
||||
"""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.models import User, ACL
|
||||
from vpn.models_xray import UserSubscription
|
||||
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
real_server = server.get_real_instance()
|
||||
|
||||
# Get all users who should have access to this server
|
||||
# For Xray v2, users access through subscription groups
|
||||
users_to_sync = User.objects.filter(
|
||||
xray_subscriptions__active=True,
|
||||
xray_subscriptions__subscription_group__is_active=True
|
||||
).distinct()
|
||||
|
||||
logger.info(f"Syncing {users_to_sync.count()} users for Xray server {server.name}")
|
||||
|
||||
added_count = 0
|
||||
for user in users_to_sync:
|
||||
try:
|
||||
if real_server.add_user(user):
|
||||
added_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync user {user.username} on server {server.name}: {e}")
|
||||
|
||||
logger.info(f"Successfully synced {added_count} users for server {server.name}")
|
||||
return {"users_added": added_count, "total_users": users_to_sync.count()}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing users for server {server_id}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(name="sync_server_inbounds", bind=True)
|
||||
def sync_server_inbounds(self, server_id):
|
||||
"""
|
||||
Sync all inbounds for a specific Xray server.
|
||||
This is called by XrayServerV2.sync_inbounds()
|
||||
"""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.models_xray import SubscriptionGroup, ServerInbound
|
||||
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
real_server = server.get_real_instance()
|
||||
|
||||
# Get all subscription groups
|
||||
groups = SubscriptionGroup.objects.filter(is_active=True).prefetch_related('inbounds')
|
||||
|
||||
deployed_count = 0
|
||||
for group in groups:
|
||||
for inbound in group.inbounds.all():
|
||||
try:
|
||||
if real_server.deploy_inbound(inbound):
|
||||
deployed_count += 1
|
||||
logger.info(f"Deployed inbound {inbound.name} on server {server.name}")
|
||||
except Exception as 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}")
|
||||
return {"inbounds_deployed": deployed_count}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing inbounds for server {server_id}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(name="generate_certificate_task", bind=True)
|
||||
def generate_certificate_task(self, certificate_id):
|
||||
"""
|
||||
Generate Let's Encrypt certificate for a domain
|
||||
"""
|
||||
from .models_xray import Certificate
|
||||
from vpn.letsencrypt.letsencrypt_dns import get_certificate_for_domain
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
|
||||
try:
|
||||
cert = Certificate.objects.get(id=certificate_id)
|
||||
|
||||
create_task_log(
|
||||
task_id, "generate_certificate_task",
|
||||
f"Starting certificate generation for {cert.domain}",
|
||||
'STARTED'
|
||||
)
|
||||
|
||||
# Check if we have credentials
|
||||
if not cert.credentials:
|
||||
raise ValueError(f"No credentials configured for {cert.domain}")
|
||||
|
||||
# Get Cloudflare token from credentials
|
||||
cf_token = cert.credentials.get_credential('api_token')
|
||||
if not cf_token:
|
||||
raise ValueError(f"No Cloudflare API token found for {cert.domain}")
|
||||
|
||||
logger.info(f"Generating certificate for {cert.domain} using email {cert.acme_email}")
|
||||
|
||||
# Request certificate using the library function
|
||||
cert_pem, key_pem = get_certificate_for_domain(
|
||||
domain=cert.domain,
|
||||
email=cert.acme_email,
|
||||
cloudflare_token=cf_token,
|
||||
staging=False # Production certificate
|
||||
)
|
||||
|
||||
# Update certificate object
|
||||
cert.certificate_pem = cert_pem
|
||||
cert.private_key_pem = key_pem
|
||||
cert.expires_at = timezone.now() + timedelta(days=90) # Let's Encrypt certs are valid for 90 days
|
||||
cert.last_renewed = timezone.now()
|
||||
cert.save()
|
||||
|
||||
success_msg = f"Certificate for {cert.domain} generated successfully"
|
||||
logger.info(success_msg)
|
||||
|
||||
create_task_log(
|
||||
task_id, "generate_certificate_task",
|
||||
"Certificate generated", 'SUCCESS',
|
||||
message=success_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
return {"status": "success", "domain": cert.domain}
|
||||
|
||||
except Certificate.DoesNotExist:
|
||||
error_msg = f"Certificate with id {certificate_id} not found"
|
||||
logger.error(error_msg)
|
||||
|
||||
create_task_log(
|
||||
task_id, "generate_certificate_task",
|
||||
"Certificate not found", 'FAILURE',
|
||||
message=error_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to generate certificate: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
create_task_log(
|
||||
task_id, "generate_certificate_task",
|
||||
"Certificate generation failed", 'FAILURE',
|
||||
message=error_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(name="renew_certificates", bind=True)
|
||||
def renew_certificates(self):
|
||||
"""
|
||||
Check and renew certificates that are about to expire.
|
||||
"""
|
||||
from .models_xray import Certificate, XrayConfiguration
|
||||
from .letsencrypt import get_certificate_for_domain
|
||||
from datetime import datetime
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
|
||||
create_task_log(task_id, "renew_certificates", "Starting certificate renewal check", 'STARTED')
|
||||
|
||||
try:
|
||||
# Get certificates that need renewal
|
||||
certs_to_renew = Certificate.objects.filter(
|
||||
auto_renew=True,
|
||||
cert_type='letsencrypt'
|
||||
)
|
||||
|
||||
renewed_count = 0
|
||||
errors = []
|
||||
|
||||
for cert in certs_to_renew:
|
||||
if not cert.needs_renewal:
|
||||
continue
|
||||
|
||||
try:
|
||||
logger.info(f"Renewing certificate for {cert.domain}")
|
||||
|
||||
# Check if we have credentials
|
||||
if not cert.credentials:
|
||||
logger.warning(f"No credentials configured for {cert.domain}")
|
||||
continue
|
||||
|
||||
# Get Cloudflare token from credentials
|
||||
cf_token = cert.credentials.get_credential('api_token')
|
||||
cf_email = cert.credentials.get_credential('email', 'admin@example.com')
|
||||
|
||||
if not cf_token:
|
||||
logger.error(f"No Cloudflare API token found for {cert.domain}")
|
||||
continue
|
||||
|
||||
# Renew certificate
|
||||
cert_pem, key_pem = get_certificate_for_domain(
|
||||
domain=cert.domain,
|
||||
email=cf_email,
|
||||
cloudflare_token=cf_token,
|
||||
staging=False # Production certificate
|
||||
)
|
||||
|
||||
# Update certificate
|
||||
cert.certificate_pem = cert_pem
|
||||
cert.private_key_pem = key_pem
|
||||
cert.last_renewed = datetime.now()
|
||||
cert.expires_at = datetime.now() + timedelta(days=90) # Let's Encrypt certs are valid for 90 days
|
||||
cert.save()
|
||||
|
||||
renewed_count += 1
|
||||
logger.info(f"Successfully renewed certificate for {cert.domain}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to renew certificate for {cert.domain}: {e}"
|
||||
logger.error(error_msg)
|
||||
errors.append(error_msg)
|
||||
|
||||
# Summary
|
||||
if renewed_count > 0 or errors:
|
||||
summary = f"Renewed {renewed_count} certificates"
|
||||
if errors:
|
||||
summary += f", {len(errors)} errors"
|
||||
|
||||
create_task_log(
|
||||
task_id, "renew_certificates",
|
||||
"Certificate renewal completed",
|
||||
'SUCCESS' if not errors else 'PARTIAL',
|
||||
message=summary,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
else:
|
||||
create_task_log(
|
||||
task_id, "renew_certificates",
|
||||
"No certificates need renewal",
|
||||
'SUCCESS',
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
return {
|
||||
'renewed': renewed_count,
|
||||
'errors': errors
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Certificate renewal task failed: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
create_task_log(
|
||||
task_id, "renew_certificates",
|
||||
"Certificate renewal failed",
|
||||
'FAILURE',
|
||||
message=error_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
raise
|
@@ -450,12 +450,12 @@
|
||||
<div class="subtitle">Welcome back, <strong>{{ user.username }}</strong></div>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ total_servers }}</span>
|
||||
<span class="stat-label">Available Servers</span>
|
||||
<span class="stat-number">{{ total_groups }}</span>
|
||||
<span class="stat-label">Subscription Groups</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ total_links }}</span>
|
||||
<span class="stat-label">Active Links</span>
|
||||
<span class="stat-number">{{ total_inbounds }}</span>
|
||||
<span class="stat-label">Available Inbounds</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ total_connections }}</span>
|
||||
@@ -475,87 +475,136 @@
|
||||
</div>
|
||||
|
||||
<!-- Xray Subscription Link -->
|
||||
{% if has_xray_servers and user_links %}
|
||||
{% if has_xray_access %}
|
||||
<div class="xray-subscription" style="margin-top: 20px; padding: 15px; background: rgba(148, 163, 184, 0.1); border: 1px solid rgba(148, 163, 184, 0.3); border-radius: 12px;">
|
||||
<h3 style="color: #94a3b8; margin-bottom: 10px; font-size: 1.1rem;">🚀 Xray Universal Subscription</h3>
|
||||
<p style="color: #64748b; font-size: 0.9rem; margin-bottom: 10px;">
|
||||
One link for all your Xray protocols (VLESS, VMess, Trojan)
|
||||
</p>
|
||||
<div class="link-url" style="margin-bottom: 0;">
|
||||
{% url 'xray_subscription' user_links.0.link as xray_url %}{{ force_scheme|default:request.scheme }}://{{ request.get_host }}{{ xray_url }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ force_scheme|default:request.scheme }}://{{ request.get_host }}{{ xray_url }}')">Copy</button>
|
||||
{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if servers_data %}
|
||||
{% if groups_data %}
|
||||
<div class="servers-grid">
|
||||
{% for server_name, server_data in servers_data.items %}
|
||||
{% for group_name, group_data in groups_data.items %}
|
||||
<div class="server-card">
|
||||
<div class="server-header">
|
||||
<div class="server-info">
|
||||
<div class="server-name">{{ server_name }}</div>
|
||||
<div class="server-name">{{ group_name }}</div>
|
||||
<div class="server-stats">
|
||||
<span class="connection-count">📊 {{ server_data.total_connections }} uses</span>
|
||||
<span class="connection-count">📊 {{ group_data.total_connections }} uses</span>
|
||||
<span class="connection-count">🔗 {{ group_data.inbounds|length }} inbound(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-type">{{ server_data.server_type }}</div>
|
||||
<div class="server-type">Xray Group</div>
|
||||
</div>
|
||||
|
||||
<div class="server-status">
|
||||
{% if server_data.accessible %}
|
||||
<div class="status-indicator status-online">
|
||||
<div class="status-dot"></div>
|
||||
Online & Ready
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="status-indicator status-offline">
|
||||
<div class="status-dot"></div>
|
||||
Connection Issues
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="status-indicator status-online">
|
||||
<div class="status-dot"></div>
|
||||
Active Subscription
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual Subscription Link for this Group -->
|
||||
<div class="links-container">
|
||||
{% for link_data in server_data.links %}
|
||||
<div class="link-item">
|
||||
<div class="link-header">
|
||||
<div class="link-info">
|
||||
<div class="link-comment">📱 {{ link_data.comment }}</div>
|
||||
<div class="link-comment">🚀 {{ group_name }} Subscription</div>
|
||||
<div class="link-stats">
|
||||
<span class="usage-count">✨ {{ link_data.connections }} uses</span>
|
||||
<span class="recent-count">📅 {{ link_data.recent_connections }} last 30 days</span>
|
||||
<span class="last-used">🕒 {{ link_data.last_access_display }}</span>
|
||||
{% 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-chart" data-usage="{{ link_data.daily_usage|join:',' }}" data-max="{{ link_data.max_daily }}">
|
||||
<div class="chart-title">30-day activity</div>
|
||||
<div class="chart-bars">
|
||||
{% for day_usage in link_data.daily_usage %}
|
||||
<div class="chart-bar" data-height="{{ day_usage }}" data-max="{{ link_data.max_daily }}"></div>
|
||||
<div class="usage-chart">
|
||||
<div class="chart-title">Protocols</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px; align-items: center;">
|
||||
{% for inbound_data in group_data.inbounds %}
|
||||
<div style="background: rgba(74, 222, 128, 0.2); padding: 3px 8px; border-radius: 8px; font-size: 0.7rem; color: #4ade80;">
|
||||
{{ inbound_data.protocol|upper }}:{{ inbound_data.port }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="link-url">
|
||||
{{ link_data.url }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ link_data.url }}')">Copy</button>
|
||||
{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}?group={{ group_name }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}?group={{ group_name }}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-servers">
|
||||
<h3>No VPN Access Available</h3>
|
||||
<p>You don't have access to any VPN servers yet. Please contact your administrator.</p>
|
||||
<h3>No Xray Subscriptions Available</h3>
|
||||
<p>You don't have access to any subscription groups yet. Please contact your administrator.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Show old ACL links for backwards compatibility -->
|
||||
{% if has_old_links %}
|
||||
<h2 style="color: #9ca3af; margin: 40px 0 20px 0; text-align: center;">Legacy Shadowsocks Access</h2>
|
||||
<div class="servers-grid">
|
||||
{% for acl_link in acl_links %}
|
||||
<div class="server-card" style="opacity: 0.7;">
|
||||
<div class="server-header">
|
||||
<div class="server-info">
|
||||
<div class="server-name">{{ acl_link.acl.server.name }}</div>
|
||||
<div class="server-stats">
|
||||
<span class="connection-count">📊 Legacy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-type">Shadowsocks</div>
|
||||
</div>
|
||||
|
||||
<div class="server-status">
|
||||
<div class="status-indicator status-online">
|
||||
<div class="status-dot"></div>
|
||||
Legacy Access
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links-container">
|
||||
<div class="link-item">
|
||||
<div class="link-header">
|
||||
<div class="link-info">
|
||||
<div class="link-comment">📱 {{ acl_link.comment }}</div>
|
||||
<div class="link-stats">
|
||||
<span class="last-used">🕒 {{ acl_link.last_access_time|default:"Never used" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="link-url">
|
||||
{{ external_address }}/ss/{{ acl_link.link }}#{{ acl_link.acl.server.name }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ external_address }}/ss/{{ acl_link.link }}#{{ acl_link.acl.server.name }}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://github.com/house-of-vanity/OutFleet" target="_blank">OutFleet VPN Manager</a></p>
|
||||
<p>Keep this link secure and don't share it with others</p>
|
||||
|
536
vpn/views.py
536
vpn/views.py
@@ -1,6 +1,7 @@
|
||||
def userPortal(request, user_hash):
|
||||
"""HTML portal for user to view their VPN access links and server information"""
|
||||
from .models import User, ACLLink, UserStatistics, AccessLog
|
||||
"""HTML portal for user to view their VPN access links and subscription groups"""
|
||||
from .models import User
|
||||
from .models_xray import UserSubscription, SubscriptionGroup, Inbound
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
@@ -18,159 +19,137 @@ def userPortal(request, user_hash):
|
||||
}, status=403)
|
||||
|
||||
try:
|
||||
# Get all ACL links for the user with server information
|
||||
acl_links = ACLLink.objects.filter(acl__user=user).select_related('acl__server', 'acl')
|
||||
logger.info(f"Found {acl_links.count()} ACL links for user {user.username}")
|
||||
# Get all active subscription groups for the user
|
||||
user_subscriptions = UserSubscription.objects.filter(
|
||||
user=user,
|
||||
active=True,
|
||||
subscription_group__is_active=True
|
||||
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
|
||||
|
||||
# Calculate overall statistics from cached data (only where cache exists)
|
||||
user_stats = UserStatistics.objects.filter(user=user)
|
||||
if user_stats.exists():
|
||||
total_connections = sum(stat.total_connections for stat in user_stats)
|
||||
recent_connections = sum(stat.recent_connections for stat in user_stats)
|
||||
logger.info(f"User {user.username} cached stats: total_connections={total_connections}, recent_connections={recent_connections}")
|
||||
else:
|
||||
# No cache available, set to zero and suggest cache update
|
||||
total_connections = 0
|
||||
recent_connections = 0
|
||||
logger.warning(f"No cached statistics found for user {user.username}. Run statistics update task.")
|
||||
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
|
||||
total_connections = 0
|
||||
recent_connections = 0
|
||||
logger.info(f"Using zero stats during transition for user {user.username}")
|
||||
|
||||
# Determine protocol scheme
|
||||
scheme = 'https' if request.is_secure() else 'http'
|
||||
|
||||
# Group links by server
|
||||
servers_data = {}
|
||||
total_links = 0
|
||||
# Group inbounds by subscription group
|
||||
groups_data = {}
|
||||
total_inbounds = 0
|
||||
|
||||
for link in acl_links:
|
||||
server = link.acl.server
|
||||
server_name = server.name
|
||||
logger.debug(f"Processing link {link.link} for server {server_name}")
|
||||
for subscription in user_subscriptions:
|
||||
group = subscription.subscription_group
|
||||
group_name = group.name
|
||||
logger.debug(f"Processing subscription group {group_name}")
|
||||
|
||||
if server_name not in servers_data:
|
||||
# Get server status and info
|
||||
try:
|
||||
server_status = server.get_server_status()
|
||||
server_accessible = True
|
||||
server_error = None
|
||||
logger.debug(f"Server {server_name} status retrieved successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get status for server {server_name}: {e}")
|
||||
server_status = {}
|
||||
server_accessible = False
|
||||
server_error = str(e)
|
||||
|
||||
# Calculate server-level totals from cached stats (only where cache exists)
|
||||
server_stats = user_stats.filter(server_name=server_name)
|
||||
if server_stats.exists():
|
||||
server_total_connections = sum(stat.total_connections for stat in server_stats)
|
||||
else:
|
||||
server_total_connections = 0
|
||||
|
||||
servers_data[server_name] = {
|
||||
'server': server,
|
||||
'status': server_status,
|
||||
'accessible': server_accessible,
|
||||
'error': server_error,
|
||||
'links': [],
|
||||
'server_type': server.server_type,
|
||||
'total_connections': server_total_connections,
|
||||
}
|
||||
logger.debug(f"Created server data for {server_name} with {server_total_connections} cached connections")
|
||||
# Get all inbounds for this group
|
||||
group_inbounds = group.inbounds.all()
|
||||
|
||||
# Calculate time since last access
|
||||
last_access_display = "Never used"
|
||||
if link.last_access_time:
|
||||
time_diff = timezone.now() - link.last_access_time
|
||||
if time_diff.days > 0:
|
||||
last_access_display = f"{time_diff.days} days ago"
|
||||
elif time_diff.seconds > 3600:
|
||||
hours = time_diff.seconds // 3600
|
||||
last_access_display = f"{hours} hours ago"
|
||||
elif time_diff.seconds > 60:
|
||||
minutes = time_diff.seconds // 60
|
||||
last_access_display = f"{minutes} minutes ago"
|
||||
else:
|
||||
last_access_display = "Just now"
|
||||
|
||||
# Get cached statistics for this specific link
|
||||
try:
|
||||
link_stats = UserStatistics.objects.get(
|
||||
user=user,
|
||||
server_name=server_name,
|
||||
acl_link_id=link.link
|
||||
)
|
||||
logger.debug(f"Found cached stats for link {link.link}: {link_stats.total_connections} connections, max_daily={link_stats.max_daily}")
|
||||
|
||||
link_connections = link_stats.total_connections
|
||||
link_recent_connections = link_stats.recent_connections
|
||||
daily_usage = link_stats.daily_usage or []
|
||||
max_daily = link_stats.max_daily
|
||||
|
||||
except UserStatistics.DoesNotExist:
|
||||
logger.warning(f"No cached stats found for link {link.link} on server {server_name}, using fallback")
|
||||
|
||||
# Fallback: Since AccessLog doesn't track specific links, show zero for link-specific stats
|
||||
# but keep server-level stats for context
|
||||
link_connections = 0
|
||||
link_recent_connections = 0
|
||||
daily_usage = [0] * 30 # Empty 30-day chart
|
||||
max_daily = 0
|
||||
|
||||
logger.warning(f"Using zero stats for uncached link {link.link} - AccessLog doesn't track individual links")
|
||||
|
||||
logger.debug(f"Link {link.link} stats: connections={link_connections}, recent={link_recent_connections}, max_daily={max_daily}")
|
||||
|
||||
# Add link information with statistics
|
||||
link_url = f"{EXTERNAL_ADDRESS}/ss/{link.link}#{server_name}"
|
||||
|
||||
link_data = {
|
||||
'link': link,
|
||||
'url': link_url,
|
||||
'comment': link.comment or 'Default',
|
||||
'last_access': link.last_access_time,
|
||||
'last_access_display': last_access_display,
|
||||
'connections': link_connections,
|
||||
'recent_connections': link_recent_connections,
|
||||
'daily_usage': daily_usage,
|
||||
'max_daily': max_daily,
|
||||
groups_data[group_name] = {
|
||||
'group': group,
|
||||
'subscription': subscription,
|
||||
'inbounds': [],
|
||||
'total_connections': 0, # Placeholder during transition
|
||||
}
|
||||
|
||||
servers_data[server_name]['links'].append(link_data)
|
||||
total_links += 1
|
||||
|
||||
logger.debug(f"Added comprehensive link data for {link.link}")
|
||||
for inbound in group_inbounds:
|
||||
logger.debug(f"Processing inbound {inbound.name} in group {group_name}")
|
||||
|
||||
# Generate connection URLs based on protocol
|
||||
connection_urls = []
|
||||
|
||||
if inbound.protocol == 'vless':
|
||||
# Generate VLESS URL - this is a placeholder implementation
|
||||
# In the real implementation, you'd generate proper VLESS URLs with user UUID
|
||||
connection_url = f"vless://user-uuid@{inbound.domain or EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}"
|
||||
connection_urls.append({
|
||||
'url': connection_url,
|
||||
'protocol': 'VLESS',
|
||||
'name': inbound.name
|
||||
})
|
||||
elif inbound.protocol == 'vmess':
|
||||
# Generate VMess URL - placeholder
|
||||
connection_url = f"vmess://user-config@{inbound.domain or EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}"
|
||||
connection_urls.append({
|
||||
'url': connection_url,
|
||||
'protocol': 'VMess',
|
||||
'name': inbound.name
|
||||
})
|
||||
elif inbound.protocol == 'trojan':
|
||||
# Generate Trojan URL - placeholder
|
||||
connection_url = f"trojan://user-password@{inbound.domain or EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}"
|
||||
connection_urls.append({
|
||||
'url': connection_url,
|
||||
'protocol': 'Trojan',
|
||||
'name': inbound.name
|
||||
})
|
||||
elif inbound.protocol == 'shadowsocks':
|
||||
# Generate Shadowsocks URL - placeholder
|
||||
connection_url = f"ss://user-config@{inbound.domain or EXTERNAL_ADDRESS}:{inbound.port}#{inbound.name}"
|
||||
connection_urls.append({
|
||||
'url': connection_url,
|
||||
'protocol': 'Shadowsocks',
|
||||
'name': inbound.name
|
||||
})
|
||||
|
||||
inbound_data = {
|
||||
'inbound': inbound,
|
||||
'connection_urls': connection_urls,
|
||||
'protocol': inbound.protocol.upper(),
|
||||
'port': inbound.port,
|
||||
'domain': inbound.domain or EXTERNAL_ADDRESS,
|
||||
'network': inbound.network,
|
||||
'security': inbound.security,
|
||||
'connections': 0, # Placeholder during transition
|
||||
'last_access_display': "Never used", # Placeholder
|
||||
}
|
||||
|
||||
groups_data[group_name]['inbounds'].append(inbound_data)
|
||||
total_inbounds += 1
|
||||
|
||||
logger.debug(f"Added inbound data for {inbound.name}")
|
||||
|
||||
logger.info(f"Prepared data for {len(servers_data)} servers and {total_links} total links")
|
||||
logger.info(f"Prepared data for {len(groups_data)} subscription groups and {total_inbounds} total inbounds")
|
||||
logger.info(f"Portal statistics: total_connections={total_connections}, recent_connections={recent_connections}")
|
||||
|
||||
# Check if user has access to any Xray servers
|
||||
from vpn.server_plugins import XrayCoreServer, XrayInboundServer
|
||||
has_xray_servers = any(
|
||||
isinstance(acl_link.acl.server.get_real_instance(), (XrayCoreServer, XrayInboundServer))
|
||||
for acl_link in acl_links
|
||||
)
|
||||
# Check if user has any Xray subscription groups
|
||||
has_xray_access = user_subscriptions.exists()
|
||||
|
||||
# Also get old-style ACL links for backwards compatibility
|
||||
acl_links = []
|
||||
try:
|
||||
from .models import ACLLink
|
||||
acl_links = ACLLink.objects.filter(acl__user=user).select_related('acl__server', 'acl')
|
||||
except:
|
||||
pass
|
||||
|
||||
context = {
|
||||
'user': user,
|
||||
'user_links': acl_links, # For accessing user's links in template
|
||||
'servers_data': servers_data,
|
||||
'total_servers': len(servers_data),
|
||||
'total_links': total_links,
|
||||
'user_subscriptions': user_subscriptions, # For accessing user's subscriptions in template
|
||||
'groups_data': groups_data,
|
||||
'total_groups': len(groups_data),
|
||||
'total_inbounds': total_inbounds,
|
||||
'total_connections': total_connections,
|
||||
'recent_connections': recent_connections,
|
||||
'external_address': EXTERNAL_ADDRESS,
|
||||
'has_xray_servers': has_xray_servers,
|
||||
'has_xray_access': has_xray_access,
|
||||
'force_scheme': scheme, # Override request.scheme in template
|
||||
'acl_links': acl_links, # For backwards compatibility
|
||||
'has_old_links': len(acl_links) > 0,
|
||||
'xray_subscription_url': f"{EXTERNAL_ADDRESS}/xray/{user.hash}",
|
||||
}
|
||||
|
||||
logger.debug(f"Context prepared with keys: {list(context.keys())}")
|
||||
logger.debug(f"Servers in context: {list(servers_data.keys())}")
|
||||
logger.debug(f"Groups in context: {list(groups_data.keys())}")
|
||||
|
||||
# Log sample server data for debugging
|
||||
for server_name, server_data in servers_data.items():
|
||||
logger.debug(f"Server {server_name}: total_connections={server_data['total_connections']}, links_count={len(server_data['links'])}")
|
||||
for i, link_data in enumerate(server_data['links']):
|
||||
logger.debug(f" Link {i}: connections={link_data['connections']}, recent={link_data['recent_connections']}, last_access='{link_data['last_access_display']}'")
|
||||
# Log sample group data for debugging
|
||||
for group_name, group_data in groups_data.items():
|
||||
logger.debug(f"Group {group_name}: total_connections={group_data['total_connections']}, inbounds_count={len(group_data['inbounds'])}")
|
||||
for i, inbound_data in enumerate(group_data['inbounds']):
|
||||
logger.debug(f" Inbound {i}: protocol={inbound_data['protocol']}, port={inbound_data['port']}, connections={inbound_data['connections']}")
|
||||
|
||||
return render(request, 'vpn/user_portal.html', context)
|
||||
|
||||
@@ -239,15 +218,27 @@ def shadowsocks(request, link):
|
||||
)
|
||||
return JsonResponse({"error": f"Couldn't get credentials from server. {e}"}, status=500)
|
||||
|
||||
# Handle both dict and object formats for server_user
|
||||
if isinstance(server_user, dict):
|
||||
password = server_user.get('password', '')
|
||||
method = server_user.get('method', 'aes-128-gcm')
|
||||
port = server_user.get('port', 8080)
|
||||
access_url = server_user.get('access_url', '')
|
||||
else:
|
||||
password = getattr(server_user, 'password', '')
|
||||
method = getattr(server_user, 'method', 'aes-128-gcm')
|
||||
port = getattr(server_user, 'port', 8080)
|
||||
access_url = getattr(server_user, 'access_url', '')
|
||||
|
||||
if request.GET.get('mode') == 'json':
|
||||
config = {
|
||||
"info": "Managed by OutFleet_2 [github.com/house-of-vanity/OutFleet/]",
|
||||
"password": server_user.password,
|
||||
"method": server_user.method,
|
||||
"password": password,
|
||||
"method": method,
|
||||
"prefix": "\u0005\u00dc_\u00e0\u0001",
|
||||
"server": acl.server.client_hostname,
|
||||
"server_port": server_user.port,
|
||||
"access_url": server_user.access_url,
|
||||
"server_port": port,
|
||||
"access_url": access_url,
|
||||
"outfleet": {
|
||||
"acl_link": link,
|
||||
"server_name": acl.server.name,
|
||||
@@ -261,16 +252,16 @@ def shadowsocks(request, link):
|
||||
"$type": "tcpudp",
|
||||
"tcp": {
|
||||
"$type": "shadowsocks",
|
||||
"endpoint": f"{acl.server.client_hostname}:{server_user.port}",
|
||||
"cipher": f"{server_user.method}",
|
||||
"secret": f"{server_user.password}",
|
||||
"endpoint": f"{acl.server.client_hostname}:{port}",
|
||||
"cipher": f"{method}",
|
||||
"secret": f"{password}",
|
||||
"prefix": "\u0005\u00dc_\u00e0\u0001"
|
||||
},
|
||||
"udp": {
|
||||
"$type": "shadowsocks",
|
||||
"endpoint": f"{acl.server.client_hostname}:{server_user.port}",
|
||||
"cipher": f"{server_user.method}",
|
||||
"secret": f"{server_user.password}",
|
||||
"endpoint": f"{acl.server.client_hostname}:{port}",
|
||||
"cipher": f"{method}",
|
||||
"secret": f"{password}",
|
||||
"prefix": "\u0005\u00dc_\u00e0\u0001"
|
||||
}
|
||||
}
|
||||
@@ -293,112 +284,269 @@ def shadowsocks(request, link):
|
||||
return HttpResponse(response, content_type=f"{ 'application/json; charset=utf-8' if request.GET.get('mode') == 'json' else 'text/html' }")
|
||||
|
||||
|
||||
def xray_subscription(request, link):
|
||||
def xray_subscription(request, user_hash):
|
||||
"""
|
||||
Return Xray subscription with all available protocols for the user.
|
||||
This generates a single subscription link that includes all inbounds the user has access to.
|
||||
This generates configs based on user's subscription groups.
|
||||
"""
|
||||
from .models import ACLLink, AccessLog
|
||||
from vpn.server_plugins import XrayCoreServer, XrayInboundServer
|
||||
from .models import User, AccessLog
|
||||
from .models_xray import UserSubscription
|
||||
import logging
|
||||
from django.utils import timezone
|
||||
import base64
|
||||
import uuid
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Clean user_hash from any trailing slashes
|
||||
user_hash = user_hash.rstrip('/')
|
||||
|
||||
try:
|
||||
acl_link = get_object_or_404(ACLLink, link=link)
|
||||
acl = acl_link.acl
|
||||
logger.info(f"Found ACL link for user {acl.user.username} on server {acl.server.name}")
|
||||
user = get_object_or_404(User, hash=user_hash)
|
||||
logger.info(f"Found user {user.username} for Xray subscription generation")
|
||||
except Http404:
|
||||
logger.warning(f"ACL link not found: {link}")
|
||||
logger.warning(f"User not found for hash: {user_hash}")
|
||||
AccessLog.objects.create(
|
||||
user=None,
|
||||
server="Unknown",
|
||||
acl_link_id=link,
|
||||
acl_link_id=user_hash,
|
||||
action="Failed",
|
||||
data=f"ACL not found for link: {link}"
|
||||
data=f"User not found for hash: {user_hash}"
|
||||
)
|
||||
return HttpResponse("Not found", status=404)
|
||||
|
||||
# Check if this is a JSON request for web display
|
||||
if request.GET.get('format') == 'json':
|
||||
return xray_subscription_json(request, user, user_hash)
|
||||
|
||||
try:
|
||||
# Get all servers this user has access to
|
||||
user_acls = acl.user.acl_set.all()
|
||||
# Check if specific group is requested
|
||||
group_filter = request.GET.get('group')
|
||||
|
||||
# Get subscription groups for the user
|
||||
user_subscriptions = UserSubscription.objects.filter(
|
||||
user=user,
|
||||
active=True,
|
||||
subscription_group__is_active=True
|
||||
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
|
||||
|
||||
# Filter by specific group if requested
|
||||
if group_filter:
|
||||
user_subscriptions = user_subscriptions.filter(subscription_group__name=group_filter)
|
||||
logger.info(f"Filtering subscription for group: {group_filter}")
|
||||
|
||||
subscription_configs = []
|
||||
|
||||
for user_acl in user_acls:
|
||||
server = user_acl.server.get_real_instance()
|
||||
for subscription in user_subscriptions:
|
||||
group = subscription.subscription_group
|
||||
logger.info(f"Processing subscription group {group.name} for user {user.username}")
|
||||
|
||||
# Handle XrayInboundServer (individual inbounds)
|
||||
if isinstance(server, XrayInboundServer):
|
||||
if server.xray_inbound:
|
||||
config = server.get_user(acl.user, raw=True)
|
||||
if config and 'connection_string' in config:
|
||||
subscription_configs.append(config['connection_string'])
|
||||
logger.info(f"Added XrayInboundServer config for {server.name}")
|
||||
|
||||
# Handle XrayCoreServer (parent server with multiple inbounds)
|
||||
elif isinstance(server, XrayCoreServer):
|
||||
# Get all inbounds from this group
|
||||
for inbound in group.inbounds.all():
|
||||
try:
|
||||
# Get all inbounds for this server that have this user
|
||||
for inbound in server.inbounds.filter(enabled=True):
|
||||
# Check if user has a client in this inbound
|
||||
client = inbound.clients.filter(user=acl.user).first()
|
||||
if client:
|
||||
connection_string = server._generate_connection_string(client)
|
||||
if connection_string:
|
||||
subscription_configs.append(connection_string)
|
||||
logger.info(f"Added inbound {inbound.tag} config for user {acl.user.username}")
|
||||
# Generate connection string based on protocol
|
||||
connection_string = generate_xray_connection_string(user, inbound)
|
||||
|
||||
if connection_string:
|
||||
subscription_configs.append(connection_string)
|
||||
logger.info(f"Added {inbound.protocol} config for inbound {inbound.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get configs from XrayCoreServer {server.name}: {e}")
|
||||
logger.warning(f"Failed to generate config for inbound {inbound.name}: {e}")
|
||||
continue
|
||||
|
||||
if not subscription_configs:
|
||||
logger.warning(f"No Xray configurations found for user {acl.user.username}")
|
||||
group_msg = f" for group '{group_filter}'" if group_filter else ""
|
||||
logger.warning(f"No Xray configurations found for user {user.username}{group_msg}")
|
||||
AccessLog.objects.create(
|
||||
user=acl.user.username,
|
||||
server="Multiple",
|
||||
acl_link_id=acl_link.link,
|
||||
user=user.username,
|
||||
server="Xray-Subscription",
|
||||
acl_link_id=user_hash,
|
||||
action="Failed",
|
||||
data="No Xray configurations available"
|
||||
data=f"No Xray configurations available{group_msg}"
|
||||
)
|
||||
return HttpResponse("No configurations available", status=404)
|
||||
return HttpResponse(f"No configurations available{group_msg}", status=404)
|
||||
|
||||
# Join all configs with newlines and encode in base64 for subscription format
|
||||
subscription_content = '\n'.join(subscription_configs)
|
||||
logger.info(f"Raw subscription content for {acl.user.username}:\n{subscription_content}")
|
||||
logger.info(f"Raw subscription content for {user.username}: {len(subscription_configs)} configs")
|
||||
|
||||
subscription_b64 = base64.b64encode(subscription_content.encode('utf-8')).decode('utf-8')
|
||||
logger.info(f"Base64 subscription length: {len(subscription_b64)}")
|
||||
|
||||
# Update last access time
|
||||
acl_link.last_access_time = timezone.now()
|
||||
acl_link.save(update_fields=['last_access_time'])
|
||||
|
||||
# Create access log
|
||||
group_msg = f" for group '{group_filter}'" if group_filter else ""
|
||||
AccessLog.objects.create(
|
||||
user=acl.user.username,
|
||||
user=user.username,
|
||||
server="Xray-Subscription",
|
||||
acl_link_id=acl_link.link,
|
||||
acl_link_id=user_hash,
|
||||
action="Success",
|
||||
data=f"Generated subscription with {len(subscription_configs)} configs. Content: {subscription_content[:200]}..."
|
||||
data=f"Generated subscription with {len(subscription_configs)} configs from {user_subscriptions.count()} groups{group_msg}"
|
||||
)
|
||||
|
||||
logger.info(f"Generated Xray subscription for {acl.user.username} with {len(subscription_configs)} configs")
|
||||
logger.info(f"Generated Xray subscription for {user.username} with {len(subscription_configs)} configs{group_msg}")
|
||||
|
||||
# Return with proper headers for subscription
|
||||
response = HttpResponse(subscription_b64, content_type="text/plain; charset=utf-8")
|
||||
response['Content-Disposition'] = 'attachment; filename="xray_subscription.txt"'
|
||||
response['Content-Disposition'] = f'attachment; filename="{user.username}_xray_subscription.txt"'
|
||||
response['Cache-Control'] = 'no-cache'
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate Xray subscription for {acl.user.username}: {e}")
|
||||
logger.error(f"Failed to generate Xray subscription for {user.username}: {e}")
|
||||
AccessLog.objects.create(
|
||||
user=acl.user.username,
|
||||
user=user.username,
|
||||
server="Xray-Subscription",
|
||||
acl_link_id=acl_link.link,
|
||||
acl_link_id=user_hash,
|
||||
action="Failed",
|
||||
data=f"Failed to generate subscription: {e}"
|
||||
)
|
||||
return HttpResponse(f"Error generating subscription: {e}", status=500)
|
||||
|
||||
|
||||
def xray_subscription_json(request, user, user_hash):
|
||||
"""Return Xray subscription in JSON format for web display"""
|
||||
from .models_xray import UserSubscription
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# Get all active subscription groups for the user
|
||||
user_subscriptions = UserSubscription.objects.filter(
|
||||
user=user,
|
||||
active=True,
|
||||
subscription_group__is_active=True
|
||||
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
|
||||
|
||||
groups_data = {}
|
||||
|
||||
for subscription in user_subscriptions:
|
||||
group = subscription.subscription_group
|
||||
group_configs = []
|
||||
|
||||
# Get all inbounds from this group
|
||||
for inbound in group.inbounds.all():
|
||||
try:
|
||||
# Generate connection string
|
||||
connection_string = generate_xray_connection_string(user, inbound)
|
||||
|
||||
if connection_string:
|
||||
group_configs.append({
|
||||
'name': inbound.name,
|
||||
'protocol': inbound.protocol.upper(),
|
||||
'port': inbound.port,
|
||||
'network': inbound.network,
|
||||
'security': inbound.security,
|
||||
'domain': inbound.domain,
|
||||
'connection_string': connection_string
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate config for inbound {inbound.name}: {e}")
|
||||
continue
|
||||
|
||||
if group_configs:
|
||||
groups_data[group.name] = {
|
||||
'group_name': group.name,
|
||||
'description': group.description,
|
||||
'configs': group_configs
|
||||
}
|
||||
|
||||
return JsonResponse(groups_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate Xray JSON for {user.username}: {e}")
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
|
||||
def generate_xray_connection_string(user, inbound):
|
||||
"""Generate Xray connection string for user and inbound"""
|
||||
import uuid
|
||||
import base64
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
# Generate user UUID based on user ID and inbound
|
||||
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
|
||||
|
||||
# Get host (domain or EXTERNAL_ADDRESS)
|
||||
host = inbound.domain if inbound.domain else EXTERNAL_ADDRESS
|
||||
|
||||
if inbound.protocol == 'vless':
|
||||
# VLESS URL format: vless://uuid@host:port?params#name
|
||||
params = []
|
||||
|
||||
if inbound.network != 'tcp':
|
||||
params.append(f"type={inbound.network}")
|
||||
|
||||
if inbound.security != 'none':
|
||||
params.append(f"security={inbound.security}")
|
||||
if inbound.security == 'tls' and inbound.domain:
|
||||
params.append(f"sni={inbound.domain}")
|
||||
|
||||
if inbound.network == 'ws':
|
||||
params.append(f"path=/{inbound.name}")
|
||||
elif inbound.network == 'grpc':
|
||||
params.append(f"serviceName={inbound.name}")
|
||||
|
||||
param_string = '&'.join(params)
|
||||
query_part = f"?{param_string}" if param_string else ""
|
||||
|
||||
connection_string = f"vless://{user_uuid}@{host}:{inbound.port}{query_part}#{quote(inbound.name)}"
|
||||
|
||||
elif inbound.protocol == 'vmess':
|
||||
# VMess JSON format encoded in base64
|
||||
vmess_config = {
|
||||
"v": "2",
|
||||
"ps": inbound.name,
|
||||
"add": host,
|
||||
"port": str(inbound.port),
|
||||
"id": user_uuid,
|
||||
"aid": "0",
|
||||
"scy": "auto",
|
||||
"net": inbound.network,
|
||||
"type": "none",
|
||||
"host": inbound.domain if inbound.domain else "",
|
||||
"path": f"/{inbound.name}" if inbound.network == 'ws' else "",
|
||||
"tls": inbound.security if inbound.security != 'none' else ""
|
||||
}
|
||||
|
||||
vmess_json = json.dumps(vmess_config)
|
||||
vmess_b64 = base64.b64encode(vmess_json.encode()).decode()
|
||||
connection_string = f"vmess://{vmess_b64}"
|
||||
|
||||
elif inbound.protocol == 'trojan':
|
||||
# Trojan URL format: trojan://password@host:port?params#name
|
||||
# Use user UUID as password
|
||||
params = []
|
||||
|
||||
if inbound.security != 'none' and inbound.domain:
|
||||
params.append(f"sni={inbound.domain}")
|
||||
|
||||
if inbound.network != 'tcp':
|
||||
params.append(f"type={inbound.network}")
|
||||
if inbound.network == 'ws':
|
||||
params.append(f"path=/{inbound.name}")
|
||||
elif inbound.network == 'grpc':
|
||||
params.append(f"serviceName={inbound.name}")
|
||||
|
||||
param_string = '&'.join(params)
|
||||
query_part = f"?{param_string}" if param_string else ""
|
||||
|
||||
connection_string = f"trojan://{user_uuid}@{host}:{inbound.port}{query_part}#{quote(inbound.name)}"
|
||||
|
||||
else:
|
||||
# Fallback for unknown protocols
|
||||
connection_string = f"{inbound.protocol}://{user_uuid}@{host}:{inbound.port}#{quote(inbound.name)}"
|
||||
|
||||
return connection_string
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to generate connection string for {inbound.name}: {e}")
|
||||
return None
|
||||
|
||||
|
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
Xray Manager - Python library for managing Xray proxy server via gRPC API.
|
||||
|
||||
Supports VLESS, VMess, and Trojan protocols.
|
||||
"""
|
||||
|
||||
from .client import XrayClient
|
||||
from .models import User, VlessUser, VmessUser, TrojanUser, Stats
|
||||
from .exceptions import XrayError, APIError, InboundNotFoundError, UserNotFoundError
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__all__ = [
|
||||
"XrayClient",
|
||||
"User",
|
||||
"VlessUser",
|
||||
"VmessUser",
|
||||
"TrojanUser",
|
||||
"Stats",
|
||||
"XrayError",
|
||||
"APIError",
|
||||
"InboundNotFoundError",
|
||||
"UserNotFoundError"
|
||||
]
|
@@ -1,577 +0,0 @@
|
||||
"""
|
||||
Main Xray client for managing proxy server via gRPC API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .exceptions import APIError, InboundNotFoundError, UserNotFoundError
|
||||
from .models import Stats, TrojanUser, User, VlessUser, VmessUser
|
||||
from .protocols import TrojanProtocol, VlessProtocol, VmessProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class XrayClient:
|
||||
"""Main client for Xray server management."""
|
||||
|
||||
def __init__(self, server: str):
|
||||
"""
|
||||
Initialize Xray client.
|
||||
|
||||
Args:
|
||||
server: Xray gRPC API server address (host:port)
|
||||
"""
|
||||
self.server = server
|
||||
self.hostname = server.split(':')[0] # Extract hostname for client links
|
||||
|
||||
# Protocol handlers
|
||||
self._protocols = {}
|
||||
|
||||
# Inbound management
|
||||
|
||||
def add_vless_inbound(self, port: int, users: List[VlessUser], tag: Optional[str] = None,
|
||||
listen: str = "0.0.0.0", network: str = "tcp") -> None:
|
||||
"""Add VLESS inbound with users."""
|
||||
protocol = VlessProtocol(port, tag, listen, network)
|
||||
self._protocols[protocol.tag] = protocol
|
||||
config = protocol.create_inbound_config(users)
|
||||
self._add_inbound(config)
|
||||
|
||||
def add_vmess_inbound(self, port: int, users: List[VmessUser], tag: Optional[str] = None,
|
||||
listen: str = "0.0.0.0", network: str = "tcp") -> None:
|
||||
"""Add VMess inbound with users."""
|
||||
protocol = VmessProtocol(port, tag, listen, network)
|
||||
self._protocols[protocol.tag] = protocol
|
||||
config = protocol.create_inbound_config(users)
|
||||
self._add_inbound(config)
|
||||
|
||||
def add_trojan_inbound(self, port: int, users: List[TrojanUser], tag: Optional[str] = None,
|
||||
listen: str = "0.0.0.0", network: str = "tcp",
|
||||
cert_pem: Optional[str] = None, key_pem: Optional[str] = None,
|
||||
hostname: Optional[str] = None) -> None:
|
||||
"""Add Trojan inbound with users and optional custom certificates."""
|
||||
hostname = hostname or self.hostname
|
||||
protocol = TrojanProtocol(port, tag, listen, network, cert_pem, key_pem, hostname)
|
||||
self._protocols[protocol.tag] = protocol
|
||||
config = protocol.create_inbound_config(users)
|
||||
self._add_inbound(config)
|
||||
|
||||
|
||||
def remove_inbound(self, protocol_type_or_tag: str) -> None:
|
||||
"""
|
||||
Remove inbound by protocol type or tag.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
"""
|
||||
# Try to find by protocol type first
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
|
||||
|
||||
config = {"tag": tag}
|
||||
self._remove_inbound(config)
|
||||
|
||||
if tag in self._protocols:
|
||||
del self._protocols[tag]
|
||||
|
||||
def list_inbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List all inbounds."""
|
||||
return self._list_inbounds()
|
||||
|
||||
# User management
|
||||
|
||||
def add_user(self, protocol_type_or_tag: str, user: User) -> None:
|
||||
"""
|
||||
Add user to existing inbound by recreating it with updated users.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
user: User object matching the protocol type
|
||||
"""
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
# Check if it's a protocol type or direct tag
|
||||
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
|
||||
|
||||
# If protocol not registered, we need to get inbound info first
|
||||
if tag not in self._protocols:
|
||||
logger.debug(f"Protocol for tag '{tag}' not registered, attempting to retrieve inbound info")
|
||||
|
||||
# Try to get inbound info to determine protocol
|
||||
try:
|
||||
inbounds = self._list_inbounds()
|
||||
if isinstance(inbounds, dict) and 'inbounds' in inbounds:
|
||||
inbound_list = inbounds['inbounds']
|
||||
else:
|
||||
inbound_list = inbounds if isinstance(inbounds, list) else []
|
||||
|
||||
# Find the inbound by tag
|
||||
for inbound in inbound_list:
|
||||
if inbound.get('tag') == tag:
|
||||
# Determine protocol from proxySettings
|
||||
proxy_settings = inbound.get('proxySettings', {})
|
||||
typed_message = proxy_settings.get('_TypedMessage_', '')
|
||||
|
||||
if 'vless' in typed_message.lower():
|
||||
from .protocols import VlessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VlessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'vmess' in typed_message.lower():
|
||||
from .protocols import VmessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VmessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'trojan' in typed_message.lower():
|
||||
from .protocols import TrojanProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = TrojanProtocol(port, tag, listen, network, hostname=self.hostname)
|
||||
self._protocols[tag] = protocol
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve inbound info for tag '{tag}': {e}")
|
||||
|
||||
if tag not in self._protocols:
|
||||
raise InboundNotFoundError(f"Inbound {protocol_type_or_tag} not found or could not determine protocol")
|
||||
|
||||
protocol = self._protocols[tag]
|
||||
|
||||
# Use the recreate method since direct API doesn't work reliably
|
||||
self._recreate_inbound_with_user(protocol, user)
|
||||
|
||||
def remove_user(self, protocol_type_or_tag: str, email: str) -> None:
|
||||
"""
|
||||
Remove user from inbound by recreating it without the user.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
email: User email to remove
|
||||
"""
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
|
||||
|
||||
# Use same logic as add_user to find/register protocol
|
||||
if tag not in self._protocols:
|
||||
logger.debug(f"Protocol for tag '{tag}' not registered, attempting to retrieve inbound info")
|
||||
|
||||
# Try to get inbound info to determine protocol
|
||||
try:
|
||||
inbounds = self._list_inbounds()
|
||||
if isinstance(inbounds, dict) and 'inbounds' in inbounds:
|
||||
inbound_list = inbounds['inbounds']
|
||||
else:
|
||||
inbound_list = inbounds if isinstance(inbounds, list) else []
|
||||
|
||||
# Find the inbound by tag
|
||||
for inbound in inbound_list:
|
||||
if inbound.get('tag') == tag:
|
||||
# Determine protocol from proxySettings
|
||||
proxy_settings = inbound.get('proxySettings', {})
|
||||
typed_message = proxy_settings.get('_TypedMessage_', '')
|
||||
|
||||
if 'vless' in typed_message.lower():
|
||||
from .protocols import VlessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VlessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'vmess' in typed_message.lower():
|
||||
from .protocols import VmessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VmessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'trojan' in typed_message.lower():
|
||||
from .protocols import TrojanProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = TrojanProtocol(port, tag, listen, network, hostname=self.hostname)
|
||||
self._protocols[tag] = protocol
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve inbound info for tag '{tag}': {e}")
|
||||
|
||||
if tag not in self._protocols:
|
||||
raise InboundNotFoundError(f"Inbound {protocol_type_or_tag} not found or could not determine protocol")
|
||||
|
||||
protocol = self._protocols[tag]
|
||||
|
||||
# Use the recreate method
|
||||
self._recreate_inbound_without_user(protocol, email)
|
||||
|
||||
# Client link generation
|
||||
|
||||
def generate_client_link(self, protocol_type_or_tag: str, user: User) -> str:
|
||||
"""
|
||||
Generate client connection link.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
user: User object
|
||||
|
||||
Returns:
|
||||
Client connection link (vless://, vmess://, trojan://)
|
||||
"""
|
||||
# First try to find by protocol type
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
# Check if it's a protocol type or direct tag
|
||||
tag = tag_map.get(protocol_type_or_tag)
|
||||
if tag and tag in self._protocols:
|
||||
protocol = self._protocols[tag]
|
||||
elif protocol_type_or_tag in self._protocols:
|
||||
protocol = self._protocols[protocol_type_or_tag]
|
||||
else:
|
||||
# Try to find any protocol matching the type
|
||||
for stored_tag, stored_protocol in self._protocols.items():
|
||||
if protocol_type_or_tag in ['vless', 'vmess', 'trojan']:
|
||||
protocol_class_name = f"{protocol_type_or_tag.title()}Protocol"
|
||||
if stored_protocol.__class__.__name__ == protocol_class_name:
|
||||
protocol = stored_protocol
|
||||
break
|
||||
else:
|
||||
raise InboundNotFoundError(f"Protocol {protocol_type_or_tag} not configured")
|
||||
|
||||
return protocol.generate_client_link(user, self.hostname)
|
||||
|
||||
# Statistics
|
||||
|
||||
def get_server_stats(self) -> Dict[str, Any]:
|
||||
"""Get server system statistics."""
|
||||
return self._get_stats_sys()
|
||||
|
||||
def get_user_stats(self, protocol_type: str, email: str) -> Stats:
|
||||
"""
|
||||
Get user traffic statistics.
|
||||
|
||||
Args:
|
||||
protocol_type: Protocol type
|
||||
email: User email
|
||||
|
||||
Returns:
|
||||
Stats object with uplink/downlink data
|
||||
"""
|
||||
# Implementation would require stats queries
|
||||
# This is a placeholder for the interface
|
||||
return Stats(uplink=0, downlink=0)
|
||||
|
||||
# Private API methods
|
||||
|
||||
def _add_inbound(self, config: Dict[str, Any]) -> None:
|
||||
"""Add inbound via API."""
|
||||
result = self._run_api_command("adi", stdin_data=json.dumps(config))
|
||||
if "error" in result.get("stderr", "").lower():
|
||||
raise APIError(f"Failed to add inbound: {result['stderr']}")
|
||||
|
||||
def _remove_inbound(self, config: Dict[str, Any]) -> None:
|
||||
"""Remove inbound via API."""
|
||||
tag = config.get("tag")
|
||||
if tag:
|
||||
# Use tag directly as argument instead of JSON
|
||||
result = self._run_api_command("rmi", args=[tag])
|
||||
else:
|
||||
# Fallback to JSON if no tag
|
||||
result = self._run_api_command("rmi", stdin_data=json.dumps(config))
|
||||
|
||||
if result["returncode"] != 0 and "no inbound to remove" not in result.get("stderr", ""):
|
||||
raise APIError(f"Failed to remove inbound: {result['stderr']}")
|
||||
|
||||
def _list_inbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List inbounds via API."""
|
||||
result = self._run_api_command("lsi")
|
||||
if result["returncode"] != 0:
|
||||
raise APIError(f"Failed to list inbounds: {result['stderr']}")
|
||||
|
||||
try:
|
||||
return json.loads(result["stdout"])
|
||||
except json.JSONDecodeError:
|
||||
raise APIError("Invalid JSON response from API")
|
||||
|
||||
def _add_user(self, config: Dict[str, Any]) -> None:
|
||||
"""Add user via API."""
|
||||
logger.debug(f"Adding user with config: {json.dumps(config, indent=2)}")
|
||||
result = self._run_api_command("adu", stdin_data=json.dumps(config))
|
||||
|
||||
if result["returncode"] != 0 or "error" in result.get("stderr", "").lower():
|
||||
logger.error(f"Failed to add user. stdout: {result['stdout']}, stderr: {result['stderr']}")
|
||||
raise APIError(f"Failed to add user: {result['stderr'] or result['stdout']}")
|
||||
|
||||
logger.info(f"User {config.get('email')} added successfully to inbound {config.get('tag')}")
|
||||
|
||||
def _remove_user(self, inbound_tag: str, email: str) -> None:
|
||||
"""Remove user via API."""
|
||||
result = self._run_api_command("rmu", args=[f"--email={email}", inbound_tag])
|
||||
if "error" in result.get("stderr", "").lower():
|
||||
raise APIError(f"Failed to remove user: {result['stderr']}")
|
||||
|
||||
def _get_stats_sys(self) -> Dict[str, Any]:
|
||||
"""Get system stats via API."""
|
||||
result = self._run_api_command("statssys")
|
||||
if result["returncode"] != 0:
|
||||
raise APIError(f"Failed to get stats: {result['stderr']}")
|
||||
|
||||
try:
|
||||
return json.loads(result["stdout"])
|
||||
except json.JSONDecodeError:
|
||||
raise APIError("Invalid JSON response from API")
|
||||
|
||||
def _build_user_config(self, tag: str, user: User, protocol) -> Dict[str, Any]:
|
||||
"""
|
||||
Build user configuration for Xray API.
|
||||
|
||||
Args:
|
||||
tag: Inbound tag
|
||||
user: User object (VlessUser, VmessUser, or TrojanUser)
|
||||
protocol: Protocol handler
|
||||
|
||||
Returns:
|
||||
User configuration dict for Xray API
|
||||
"""
|
||||
from .models import VlessUser, VmessUser, TrojanUser
|
||||
|
||||
base_config = {
|
||||
"tag": tag,
|
||||
"email": user.email
|
||||
}
|
||||
|
||||
if isinstance(user, VlessUser):
|
||||
base_config["account"] = {
|
||||
"_TypedMessage_": "xray.proxy.vless.Account",
|
||||
"id": user.uuid
|
||||
}
|
||||
elif isinstance(user, VmessUser):
|
||||
base_config["account"] = {
|
||||
"_TypedMessage_": "xray.proxy.vmess.Account",
|
||||
"id": user.uuid,
|
||||
"alterId": getattr(user, 'alter_id', 0)
|
||||
}
|
||||
elif isinstance(user, TrojanUser):
|
||||
base_config["account"] = {
|
||||
"_TypedMessage_": "xray.proxy.trojan.Account",
|
||||
"password": user.password
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unsupported user type: {type(user)}")
|
||||
|
||||
return base_config
|
||||
|
||||
def _recreate_inbound_without_user(self, protocol, email: str) -> None:
|
||||
"""
|
||||
Recreate inbound without specified user.
|
||||
"""
|
||||
# Get existing users from the inbound
|
||||
existing_users = self._get_existing_users(protocol.tag)
|
||||
|
||||
# Filter out the user to remove
|
||||
all_users = [user for user in existing_users if user.email != email]
|
||||
|
||||
if len(all_users) == len(existing_users):
|
||||
logger.warning(f"User {email} not found in inbound {protocol.tag}")
|
||||
return
|
||||
|
||||
# Remove existing inbound
|
||||
try:
|
||||
self.remove_inbound(protocol.tag)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove inbound {protocol.tag}: {e}")
|
||||
|
||||
# Recreate inbound with remaining users
|
||||
if hasattr(protocol, '__class__') and 'Vless' in protocol.__class__.__name__:
|
||||
self.add_vless_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Vmess' in protocol.__class__.__name__:
|
||||
self.add_vmess_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Trojan' in protocol.__class__.__name__:
|
||||
self.add_trojan_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network,
|
||||
hostname=getattr(protocol, 'hostname', 'localhost')
|
||||
)
|
||||
|
||||
def _recreate_inbound_with_user(self, protocol, new_user: User) -> None:
|
||||
"""
|
||||
Recreate inbound with existing users plus new user.
|
||||
This is a workaround since Xray API doesn't support reliable dynamic user addition.
|
||||
"""
|
||||
# Get existing users from the inbound
|
||||
existing_users = self._get_existing_users(protocol.tag)
|
||||
|
||||
# Check if user already exists
|
||||
for existing_user in existing_users:
|
||||
if existing_user.email == new_user.email:
|
||||
return # User already exists, no need to recreate
|
||||
|
||||
# Add new user to existing users list
|
||||
all_users = existing_users + [new_user]
|
||||
|
||||
# Remove existing inbound
|
||||
try:
|
||||
self.remove_inbound(protocol.tag)
|
||||
except Exception as e:
|
||||
# If removal fails, log but continue - inbound might not exist
|
||||
pass
|
||||
|
||||
# Recreate inbound with all users
|
||||
if hasattr(protocol, '__class__') and 'Vless' in protocol.__class__.__name__:
|
||||
self.add_vless_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Vmess' in protocol.__class__.__name__:
|
||||
self.add_vmess_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Trojan' in protocol.__class__.__name__:
|
||||
self.add_trojan_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network,
|
||||
hostname=getattr(protocol, 'hostname', 'localhost')
|
||||
)
|
||||
|
||||
def _get_existing_users(self, tag: str) -> List[User]:
|
||||
"""
|
||||
Get existing users from an inbound.
|
||||
"""
|
||||
from .models import VlessUser, VmessUser, TrojanUser
|
||||
|
||||
try:
|
||||
# Use inbounduser API command to get existing users
|
||||
result = self._run_api_command("inbounduser", args=[f"-tag={tag}"])
|
||||
|
||||
if result["returncode"] != 0:
|
||||
return [] # No users or inbound doesn't exist
|
||||
|
||||
import json
|
||||
user_data = json.loads(result["stdout"])
|
||||
users = []
|
||||
|
||||
if "users" in user_data:
|
||||
for user_info in user_data["users"]:
|
||||
email = user_info.get("email", "")
|
||||
account = user_info.get("account", {})
|
||||
|
||||
# Determine protocol based on account type
|
||||
account_type = account.get("_TypedMessage_", "")
|
||||
|
||||
if "vless" in account_type.lower():
|
||||
users.append(VlessUser(
|
||||
email=email,
|
||||
uuid=account.get("id", "")
|
||||
))
|
||||
elif "vmess" in account_type.lower():
|
||||
users.append(VmessUser(
|
||||
email=email,
|
||||
uuid=account.get("id", ""),
|
||||
alter_id=account.get("alterId", 0)
|
||||
))
|
||||
elif "trojan" in account_type.lower():
|
||||
users.append(TrojanUser(
|
||||
email=email,
|
||||
password=account.get("password", "")
|
||||
))
|
||||
|
||||
return users
|
||||
|
||||
except Exception as e:
|
||||
# If we can't get existing users, return empty list
|
||||
return []
|
||||
|
||||
def _run_api_command(self, command: str, args: List[str] = None, stdin_data: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Run xray api command.
|
||||
|
||||
Args:
|
||||
command: API command (adi, rmi, lsi, etc.)
|
||||
args: Additional command arguments
|
||||
stdin_data: Data to pass via stdin
|
||||
|
||||
Returns:
|
||||
Dict with stdout, stderr, returncode
|
||||
"""
|
||||
cmd = ["xray", "api", command, f"--server={self.server}"]
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
logger.debug(f"Running command: {' '.join(cmd)}")
|
||||
if stdin_data:
|
||||
logger.debug(f"With stdin data: {stdin_data}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=stdin_data,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
logger.debug(f"Command result - returncode: {result.returncode}, stdout: {result.stdout}, stderr: {result.stderr}")
|
||||
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"returncode": result.returncode
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"API command timeout for: {' '.join(cmd)}")
|
||||
raise APIError("API command timeout")
|
||||
except FileNotFoundError:
|
||||
logger.error("xray command not found in PATH")
|
||||
raise APIError("xray command not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error running command: {e}")
|
||||
raise APIError(f"Failed to run command: {e}")
|
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
Custom exceptions for Xray Manager.
|
||||
"""
|
||||
|
||||
|
||||
class XrayError(Exception):
|
||||
"""Base exception for all Xray-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class APIError(XrayError):
|
||||
"""Error occurred during API communication."""
|
||||
pass
|
||||
|
||||
|
||||
class InboundNotFoundError(XrayError):
|
||||
"""Inbound with specified tag not found."""
|
||||
pass
|
||||
|
||||
|
||||
class UserNotFoundError(XrayError):
|
||||
"""User with specified email not found."""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(XrayError):
|
||||
"""Error in Xray configuration."""
|
||||
pass
|
||||
|
||||
|
||||
class CertificateError(XrayError):
|
||||
"""Error related to TLS certificates."""
|
||||
pass
|
@@ -1,93 +0,0 @@
|
||||
"""
|
||||
Data models for Xray Manager.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any
|
||||
from .utils import generate_uuid
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Base user model."""
|
||||
email: str
|
||||
level: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert user to dictionary representation."""
|
||||
return {
|
||||
"email": self.email,
|
||||
"level": self.level
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class VlessUser(User):
|
||||
"""VLESS protocol user."""
|
||||
uuid: str = field(default_factory=generate_uuid)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"id": self.uuid
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class VmessUser(User):
|
||||
"""VMess protocol user."""
|
||||
uuid: str = field(default_factory=generate_uuid)
|
||||
alter_id: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"id": self.uuid,
|
||||
"alterId": self.alter_id
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanUser(User):
|
||||
"""Trojan protocol user."""
|
||||
password: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"password": self.password
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class Inbound:
|
||||
"""Inbound configuration."""
|
||||
tag: str
|
||||
protocol: str
|
||||
port: int
|
||||
listen: str = "0.0.0.0"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"tag": self.tag,
|
||||
"protocol": self.protocol,
|
||||
"port": self.port,
|
||||
"listen": self.listen
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
"""Statistics data."""
|
||||
uplink: int = 0
|
||||
downlink: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total traffic (uplink + downlink)."""
|
||||
return self.uplink + self.downlink
|
@@ -1,15 +0,0 @@
|
||||
"""
|
||||
Protocol-specific implementations for Xray Manager.
|
||||
"""
|
||||
|
||||
from .base import BaseProtocol
|
||||
from .vless import VlessProtocol
|
||||
from .vmess import VmessProtocol
|
||||
from .trojan import TrojanProtocol
|
||||
|
||||
__all__ = [
|
||||
"BaseProtocol",
|
||||
"VlessProtocol",
|
||||
"VmessProtocol",
|
||||
"TrojanProtocol"
|
||||
]
|
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
Base protocol implementation.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Any, Optional
|
||||
from ..models import User
|
||||
|
||||
|
||||
class BaseProtocol(ABC):
|
||||
"""Base class for all protocol implementations."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"):
|
||||
self.port = port
|
||||
self.tag = tag or self._default_tag()
|
||||
self.listen = listen
|
||||
self.network = network
|
||||
|
||||
@abstractmethod
|
||||
def _default_tag(self) -> str:
|
||||
"""Return default tag for this protocol."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_inbound_config(self, users: List[User]) -> Dict[str, Any]:
|
||||
"""Create inbound configuration for this protocol."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_user_config(self, user: User) -> Dict[str, Any]:
|
||||
"""Create user configuration for adding to existing inbound."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_client_link(self, user: User, hostname: str, network: str = None, security: str = None, **kwargs) -> str:
|
||||
"""Generate client connection link."""
|
||||
pass
|
||||
|
||||
def _base_inbound_config(self) -> Dict[str, Any]:
|
||||
"""Common inbound configuration."""
|
||||
return {
|
||||
"listen": self.listen,
|
||||
"port": self.port,
|
||||
"tag": self.tag
|
||||
}
|
@@ -1,94 +0,0 @@
|
||||
"""
|
||||
Trojan protocol implementation.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .base import BaseProtocol
|
||||
from ..models import User, TrojanUser
|
||||
from ..utils import generate_self_signed_cert, pem_to_lines
|
||||
from ..exceptions import CertificateError
|
||||
|
||||
|
||||
class TrojanProtocol(BaseProtocol):
|
||||
"""Trojan protocol handler."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0",
|
||||
network: str = "tcp", cert_pem: Optional[str] = None,
|
||||
key_pem: Optional[str] = None, hostname: str = "localhost"):
|
||||
super().__init__(port, tag, listen, network)
|
||||
self.hostname = hostname
|
||||
|
||||
if cert_pem and key_pem:
|
||||
self.cert_pem = cert_pem
|
||||
self.key_pem = key_pem
|
||||
else:
|
||||
# Generate self-signed certificate
|
||||
self.cert_pem, self.key_pem = generate_self_signed_cert(hostname)
|
||||
|
||||
def _default_tag(self) -> str:
|
||||
return "trojan-inbound"
|
||||
|
||||
def create_inbound_config(self, users: List[TrojanUser]) -> Dict[str, Any]:
|
||||
"""Create Trojan inbound configuration."""
|
||||
config = self._base_inbound_config()
|
||||
config.update({
|
||||
"protocol": "trojan",
|
||||
"settings": {
|
||||
"_TypedMessage_": "xray.proxy.trojan.Config",
|
||||
"clients": [self._user_to_client(user) for user in users],
|
||||
"fallbacks": [{"dest": 80}]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": self.network,
|
||||
"security": "tls",
|
||||
"tlsSettings": {
|
||||
"alpn": ["http/1.1"],
|
||||
"certificates": [{
|
||||
"certificate": pem_to_lines(self.cert_pem),
|
||||
"key": pem_to_lines(self.key_pem),
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
return {"inbounds": [config]}
|
||||
|
||||
def create_user_config(self, user: TrojanUser) -> Dict[str, Any]:
|
||||
"""Create user configuration for Trojan."""
|
||||
return {
|
||||
"inboundTag": self.tag,
|
||||
"proxySettings": {
|
||||
"_TypedMessage_": "xray.proxy.trojan.Config",
|
||||
"clients": [self._user_to_client(user)]
|
||||
}
|
||||
}
|
||||
|
||||
def generate_client_link(self, user: TrojanUser, hostname: str, network: str = None, security: str = None, **kwargs) -> str:
|
||||
"""Generate Trojan client link."""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Use provided parameters or defaults
|
||||
network_type = network or self.network
|
||||
|
||||
params = {
|
||||
'type': network_type
|
||||
}
|
||||
|
||||
# Add security if provided
|
||||
if security and security != 'none':
|
||||
params['security'] = security
|
||||
|
||||
query_string = urlencode(params)
|
||||
return f"trojan://{user.password}@{hostname}:{self.port}?{query_string}#{user.email}"
|
||||
|
||||
def get_client_note(self) -> str:
|
||||
"""Get note for client configuration when using self-signed certificates."""
|
||||
return "Add 'allowInsecure: true' to TLS settings for self-signed certificates"
|
||||
|
||||
def _user_to_client(self, user: TrojanUser) -> Dict[str, Any]:
|
||||
"""Convert TrojanUser to client configuration."""
|
||||
return {
|
||||
"password": user.password,
|
||||
"level": user.level,
|
||||
"email": user.email
|
||||
}
|
@@ -1,71 +0,0 @@
|
||||
"""
|
||||
VLESS protocol implementation.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .base import BaseProtocol
|
||||
from ..models import User, VlessUser
|
||||
|
||||
|
||||
class VlessProtocol(BaseProtocol):
|
||||
"""VLESS protocol handler."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"):
|
||||
super().__init__(port, tag, listen, network)
|
||||
|
||||
def _default_tag(self) -> str:
|
||||
return "vless-inbound"
|
||||
|
||||
def create_inbound_config(self, users: List[VlessUser]) -> Dict[str, Any]:
|
||||
"""Create VLESS inbound configuration."""
|
||||
config = self._base_inbound_config()
|
||||
config.update({
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"_TypedMessage_": "xray.proxy.vless.inbound.Config",
|
||||
"clients": [self._user_to_client(user) for user in users],
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": self.network
|
||||
}
|
||||
})
|
||||
return {"inbounds": [config]}
|
||||
|
||||
def create_user_config(self, user: VlessUser) -> Dict[str, Any]:
|
||||
"""Create user configuration for VLESS."""
|
||||
return {
|
||||
"inboundTag": self.tag,
|
||||
"proxySettings": {
|
||||
"_TypedMessage_": "xray.proxy.vless.inbound.Config",
|
||||
"clients": [self._user_to_client(user)]
|
||||
}
|
||||
}
|
||||
|
||||
def generate_client_link(self, user: VlessUser, hostname: str, network: str = None, security: str = None, **kwargs) -> str:
|
||||
"""Generate VLESS client link."""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Use provided parameters or defaults
|
||||
network_type = network or self.network
|
||||
encryption = kwargs.get('encryption', 'none')
|
||||
|
||||
params = {
|
||||
'encryption': encryption,
|
||||
'type': network_type
|
||||
}
|
||||
|
||||
# Add security if provided
|
||||
if security and security != 'none':
|
||||
params['security'] = security
|
||||
|
||||
query_string = urlencode(params)
|
||||
return f"vless://{user.uuid}@{hostname}:{self.port}?{query_string}#{user.email}"
|
||||
|
||||
def _user_to_client(self, user: VlessUser) -> Dict[str, Any]:
|
||||
"""Convert VlessUser to client configuration."""
|
||||
return {
|
||||
"id": user.uuid,
|
||||
"level": user.level,
|
||||
"email": user.email
|
||||
}
|
@@ -1,77 +0,0 @@
|
||||
"""
|
||||
VMess protocol implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .base import BaseProtocol
|
||||
from ..models import User, VmessUser
|
||||
|
||||
|
||||
class VmessProtocol(BaseProtocol):
|
||||
"""VMess protocol handler."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"):
|
||||
super().__init__(port, tag, listen, network)
|
||||
|
||||
def _default_tag(self) -> str:
|
||||
return "vmess-inbound"
|
||||
|
||||
def create_inbound_config(self, users: List[VmessUser]) -> Dict[str, Any]:
|
||||
"""Create VMess inbound configuration."""
|
||||
config = self._base_inbound_config()
|
||||
config.update({
|
||||
"protocol": "vmess",
|
||||
"settings": {
|
||||
"_TypedMessage_": "xray.proxy.vmess.inbound.Config",
|
||||
"clients": [self._user_to_client(user) for user in users]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": self.network
|
||||
}
|
||||
})
|
||||
return {"inbounds": [config]}
|
||||
|
||||
def create_user_config(self, user: VmessUser) -> Dict[str, Any]:
|
||||
"""Create user configuration for VMess."""
|
||||
return {
|
||||
"inboundTag": self.tag,
|
||||
"proxySettings": {
|
||||
"_TypedMessage_": "xray.proxy.vmess.inbound.Config",
|
||||
"clients": [self._user_to_client(user)]
|
||||
}
|
||||
}
|
||||
|
||||
def generate_client_link(self, user: VmessUser, hostname: str, network: str = None, security: str = None, **kwargs) -> str:
|
||||
"""Generate VMess client link."""
|
||||
# Use provided parameters or defaults
|
||||
network_type = network or self.network
|
||||
encryption = kwargs.get('encryption', 'auto')
|
||||
|
||||
config = {
|
||||
"v": "2",
|
||||
"ps": user.email,
|
||||
"add": hostname,
|
||||
"port": str(self.port),
|
||||
"id": user.uuid,
|
||||
"aid": str(user.alter_id),
|
||||
"net": network_type,
|
||||
"type": "none",
|
||||
"host": "",
|
||||
"path": "",
|
||||
"tls": security if security and security != 'none' else ""
|
||||
}
|
||||
|
||||
config_json = json.dumps(config, separators=(',', ':'))
|
||||
encoded = base64.b64encode(config_json.encode()).decode()
|
||||
return f"vmess://{encoded}"
|
||||
|
||||
def _user_to_client(self, user: VmessUser) -> Dict[str, Any]:
|
||||
"""Convert VmessUser to client configuration."""
|
||||
return {
|
||||
"id": user.uuid,
|
||||
"alterId": user.alter_id,
|
||||
"level": user.level,
|
||||
"email": user.email
|
||||
}
|
@@ -1,77 +0,0 @@
|
||||
"""
|
||||
Utility functions for Xray Manager.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import base64
|
||||
import secrets
|
||||
from typing import List
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
import datetime
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
"""Generate a random UUID for VLESS/VMess users."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
|
||||
|
||||
def generate_self_signed_cert(hostname: str = "localhost") -> tuple[str, str]:
|
||||
"""
|
||||
Generate self-signed certificate for Trojan.
|
||||
|
||||
Args:
|
||||
hostname: Common name for certificate
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
# Generate private key
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048
|
||||
)
|
||||
|
||||
# Create certificate
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||
])
|
||||
|
||||
cert = x509.CertificateBuilder().subject_name(
|
||||
subject
|
||||
).issuer_name(
|
||||
issuer
|
||||
).public_key(
|
||||
private_key.public_key()
|
||||
).serial_number(
|
||||
x509.random_serial_number()
|
||||
).not_valid_before(
|
||||
datetime.datetime.utcnow()
|
||||
).not_valid_after(
|
||||
datetime.datetime.utcnow() + datetime.timedelta(days=365)
|
||||
).add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.DNSName(hostname),
|
||||
]),
|
||||
critical=False,
|
||||
).sign(private_key, hashes.SHA256())
|
||||
|
||||
# Convert to PEM format
|
||||
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
|
||||
key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
return cert_pem.decode(), key_pem.decode()
|
||||
|
||||
|
||||
def pem_to_lines(pem_data: str) -> List[str]:
|
||||
"""Convert PEM data to list of lines for Xray JSON format."""
|
||||
return pem_data.strip().split('\n')
|
62
vpn/xray_api_v2/__init__.py
Normal file
62
vpn/xray_api_v2/__init__.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Xray API Python Library"""
|
||||
from .client import XrayClient
|
||||
from .stats import StatsManager, StatItem, SystemStats
|
||||
from .subscription import SubscriptionLinkGenerator
|
||||
from .exceptions import (
|
||||
XrayAPIError, XrayConnectionError, XrayCommandError,
|
||||
XrayConfigError, XrayNotFoundError, XrayValidationError
|
||||
)
|
||||
from .models import (
|
||||
# Base
|
||||
XrayProtocol, TransportProtocol, SecurityType,
|
||||
|
||||
# Protocols
|
||||
VLESSClient, VMeSSUser, TrojanUser, TrojanFallback,
|
||||
VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig,
|
||||
generate_uuid, validate_uuid,
|
||||
|
||||
# Transports
|
||||
StreamSettings, create_tcp_stream, create_ws_stream,
|
||||
create_grpc_stream, create_http_stream, create_xhttp_stream,
|
||||
|
||||
# Security
|
||||
TLSConfig, REALITYConfig,
|
||||
create_tls_config, create_reality_config,
|
||||
generate_self_signed_certificate, create_tls_config_with_self_signed,
|
||||
|
||||
# Inbound
|
||||
InboundConfig, InboundBuilder, SniffingConfig
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
# Client
|
||||
'XrayClient',
|
||||
|
||||
# Stats
|
||||
'StatsManager', 'StatItem', 'SystemStats',
|
||||
|
||||
# Subscription
|
||||
'SubscriptionLinkGenerator',
|
||||
|
||||
# Exceptions
|
||||
'XrayAPIError', 'XrayConnectionError', 'XrayCommandError',
|
||||
'XrayConfigError', 'XrayNotFoundError', 'XrayValidationError',
|
||||
|
||||
# Enums
|
||||
'XrayProtocol', 'TransportProtocol', 'SecurityType',
|
||||
|
||||
# Models
|
||||
'VLESSClient', 'VMeSSUser', 'TrojanUser', 'TrojanFallback',
|
||||
'VLESSInboundConfig', 'VMeSSInboundConfig', 'TrojanServerConfig',
|
||||
'StreamSettings', 'TLSConfig', 'REALITYConfig',
|
||||
'InboundConfig', 'InboundBuilder', 'SniffingConfig',
|
||||
|
||||
# Utils
|
||||
'generate_uuid', 'validate_uuid',
|
||||
'create_tcp_stream', 'create_ws_stream',
|
||||
'create_grpc_stream', 'create_http_stream', 'create_xhttp_stream',
|
||||
'create_tls_config', 'create_reality_config',
|
||||
'generate_self_signed_certificate', 'create_tls_config_with_self_signed',
|
||||
]
|
235
vpn/xray_api_v2/client.py
Normal file
235
vpn/xray_api_v2/client.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Xray API client implementation"""
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from .exceptions import (
|
||||
XrayConnectionError,
|
||||
XrayCommandError,
|
||||
XrayConfigError
|
||||
)
|
||||
from .models.base import BaseXrayModel
|
||||
|
||||
|
||||
class XrayClient:
|
||||
"""Client for interacting with Xray API via CLI commands"""
|
||||
|
||||
def __init__(self, server: str = "127.0.0.1:8080", timeout: int = 3):
|
||||
"""
|
||||
Initialize Xray client
|
||||
|
||||
Args:
|
||||
server: API server address (host:port)
|
||||
timeout: Command timeout in seconds
|
||||
"""
|
||||
self.server = server
|
||||
self.timeout = timeout
|
||||
self._xray_binary = "xray"
|
||||
|
||||
def execute_command(self,
|
||||
command: str,
|
||||
args: List[str] = None,
|
||||
json_files: List[Union[str, Dict, BaseXrayModel]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute xray API command
|
||||
|
||||
Args:
|
||||
command: API command (e.g., 'adi', 'adu', 'lsi')
|
||||
args: Additional command arguments
|
||||
json_files: JSON configurations (paths, dicts, or models)
|
||||
|
||||
Returns:
|
||||
Command output as dictionary
|
||||
"""
|
||||
cmd = [self._xray_binary, "api", command]
|
||||
cmd.extend([f"--server={self.server}", f"--timeout={self.timeout}"])
|
||||
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
temp_files = []
|
||||
try:
|
||||
# Handle JSON configurations
|
||||
if json_files:
|
||||
for config in json_files:
|
||||
if isinstance(config, str):
|
||||
# File path provided
|
||||
cmd.append(config)
|
||||
else:
|
||||
# Create temporary file for dict or model
|
||||
temp_file = self._create_temp_json(config)
|
||||
temp_files.append(temp_file)
|
||||
cmd.append(temp_file.name)
|
||||
|
||||
# Execute command
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.timeout + 5 # Add buffer to subprocess timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise XrayCommandError(f"Command failed: {result.stderr}")
|
||||
|
||||
# Parse output
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
# Some commands return plain text
|
||||
return {"output": output}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
raise XrayConnectionError(f"Command timed out after {self.timeout} seconds")
|
||||
except Exception as e:
|
||||
raise XrayCommandError(f"Command execution failed: {str(e)}")
|
||||
finally:
|
||||
# Cleanup temporary files
|
||||
for temp_file in temp_files:
|
||||
try:
|
||||
os.unlink(temp_file.name)
|
||||
except:
|
||||
pass
|
||||
|
||||
def _create_temp_json(self, config: Union[Dict, BaseXrayModel]) -> tempfile.NamedTemporaryFile:
|
||||
"""Create temporary JSON file from config"""
|
||||
if isinstance(config, BaseXrayModel):
|
||||
data = config.to_xray_json()
|
||||
else:
|
||||
data = config
|
||||
|
||||
temp_file = tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
suffix='.json',
|
||||
delete=False
|
||||
)
|
||||
json.dump(data, temp_file, indent=2)
|
||||
temp_file.close()
|
||||
return temp_file
|
||||
|
||||
# Inbound management
|
||||
def add_inbound(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add one or more inbounds"""
|
||||
# Wrap inbound configs in the required format
|
||||
wrapped_configs = []
|
||||
for config in configs:
|
||||
if isinstance(config, BaseXrayModel):
|
||||
config_dict = config.to_xray_json()
|
||||
else:
|
||||
config_dict = config
|
||||
|
||||
# Wrap in inbounds array if not already wrapped
|
||||
if "inbounds" not in config_dict:
|
||||
wrapped_config = {"inbounds": [config_dict]}
|
||||
else:
|
||||
wrapped_config = config_dict
|
||||
|
||||
wrapped_configs.append(wrapped_config)
|
||||
|
||||
return self.execute_command("adi", json_files=wrapped_configs)
|
||||
|
||||
def remove_inbound(self, tag: str) -> Dict[str, Any]:
|
||||
"""Remove inbound by tag"""
|
||||
return self.execute_command("rmi", args=[tag])
|
||||
|
||||
def list_inbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List all inbounds"""
|
||||
result = self.execute_command("lsi")
|
||||
return result.get("inbounds", [])
|
||||
|
||||
# Outbound management
|
||||
def add_outbound(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add one or more outbounds"""
|
||||
return self.execute_command("ado", json_files=list(configs))
|
||||
|
||||
def remove_outbound(self, tag: str) -> Dict[str, Any]:
|
||||
"""Remove outbound by tag"""
|
||||
return self.execute_command("rmo", args=[tag])
|
||||
|
||||
def list_outbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List all outbounds"""
|
||||
result = self.execute_command("lso")
|
||||
return result.get("outbounds", [])
|
||||
|
||||
# User management
|
||||
def add_users(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add users to inbounds using JSON files"""
|
||||
return self.execute_command("adu", json_files=list(configs))
|
||||
|
||||
def remove_users(self, tag: str, *emails: str) -> Dict[str, Any]:
|
||||
"""Remove users from inbounds using tag and email list"""
|
||||
args = [f"-tag={tag}"] + list(emails)
|
||||
return self.execute_command("rmu", args=args)
|
||||
|
||||
def get_inbound_user(self, tag: str, email: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get inbound user(s) information using -tag flag"""
|
||||
args = [f"-tag={tag}"]
|
||||
if email:
|
||||
args.append(f"-email={email}")
|
||||
return self.execute_command("inbounduser", args=args)
|
||||
|
||||
def get_inbound_user_count(self, tag: str) -> int:
|
||||
"""Get inbound user count using -tag flag"""
|
||||
args = [f"-tag={tag}"]
|
||||
result = self.execute_command("inboundusercount", args=args)
|
||||
# Parse the result - might be in output field or direct number
|
||||
if isinstance(result, dict):
|
||||
return result.get("count", 0)
|
||||
return 0
|
||||
|
||||
# Statistics
|
||||
def get_stats(self, pattern: str = "", reset: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Get statistics"""
|
||||
args = [pattern]
|
||||
if reset:
|
||||
args.append("-reset")
|
||||
if pattern:
|
||||
args.extend(["-json"])
|
||||
result = self.execute_command("statsquery", args=args)
|
||||
return result.get("stat", [])
|
||||
|
||||
def get_system_stats(self) -> Dict[str, Any]:
|
||||
"""Get system statistics"""
|
||||
return self.execute_command("statssys")
|
||||
|
||||
def get_online_stats(self, email: str) -> Dict[str, Any]:
|
||||
"""Get online session count for user"""
|
||||
return self.execute_command("statsonline", args=[email])
|
||||
|
||||
def get_online_ips(self, email: str) -> List[Dict[str, Any]]:
|
||||
"""Get user's online IP addresses"""
|
||||
result = self.execute_command("statsonlineiplist", args=[email])
|
||||
return result.get("ips", [])
|
||||
|
||||
# Routing rules
|
||||
def add_routing_rules(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add routing rules"""
|
||||
return self.execute_command("adrules", json_files=list(configs))
|
||||
|
||||
def remove_routing_rules(self, *tags: str) -> Dict[str, Any]:
|
||||
"""Remove routing rules by tags"""
|
||||
return self.execute_command("rmrules", args=list(tags))
|
||||
|
||||
# Other operations
|
||||
def restart_logger(self) -> Dict[str, Any]:
|
||||
"""Restart the logger"""
|
||||
return self.execute_command("restartlogger")
|
||||
|
||||
def get_balancer_info(self, tag: str) -> Dict[str, Any]:
|
||||
"""Get balancer information"""
|
||||
return self.execute_command("bi", args=[tag])
|
||||
|
||||
def override_balancer(self, tag: str, selectors: List[str]) -> Dict[str, Any]:
|
||||
"""Override balancer selection"""
|
||||
return self.execute_command("bo", args=[tag] + selectors)
|
||||
|
||||
def block_connection(self, source_ip: str, seconds: int) -> Dict[str, Any]:
|
||||
"""Block connections from source IP"""
|
||||
return self.execute_command("sib", args=[source_ip, str(seconds)])
|
0
vpn/xray_api_v2/commands/__init__.py
Normal file
0
vpn/xray_api_v2/commands/__init__.py
Normal file
33
vpn/xray_api_v2/commands/user.py
Normal file
33
vpn/xray_api_v2/commands/user.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""User management command wrappers"""
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from ..models.base import BaseXrayModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class AddUserRequest(BaseXrayModel):
|
||||
"""Request to add user to inbound"""
|
||||
inboundTag: str
|
||||
user: Dict[str, Any] # Protocol-specific user config
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoveUserRequest(BaseXrayModel):
|
||||
"""Request to remove user from inbound"""
|
||||
inboundTag: str
|
||||
email: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserStats(BaseXrayModel):
|
||||
"""User statistics data"""
|
||||
email: str
|
||||
uplink: int = 0
|
||||
downlink: int = 0
|
||||
online: bool = False
|
||||
ips: List[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.ips is None:
|
||||
self.ips = []
|
31
vpn/xray_api_v2/exceptions.py
Normal file
31
vpn/xray_api_v2/exceptions.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Exceptions for xray-api library"""
|
||||
|
||||
|
||||
class XrayAPIError(Exception):
|
||||
"""Base exception for all xray-api errors"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayConnectionError(XrayAPIError):
|
||||
"""Connection to Xray API server failed"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayCommandError(XrayAPIError):
|
||||
"""Xray command execution failed"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayConfigError(XrayAPIError):
|
||||
"""Invalid configuration"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayNotFoundError(XrayAPIError):
|
||||
"""Resource not found"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayValidationError(XrayAPIError):
|
||||
"""Validation error"""
|
||||
pass
|
55
vpn/xray_api_v2/models/__init__.py
Normal file
55
vpn/xray_api_v2/models/__init__.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Xray API models"""
|
||||
from .base import (
|
||||
BaseXrayModel, XrayConfig, XrayProtocol,
|
||||
TransportProtocol, SecurityType
|
||||
)
|
||||
from .protocols import (
|
||||
# VLESS
|
||||
VLESSAccount, VLESSClient, VLESSInboundConfig,
|
||||
# VMess
|
||||
VMeSSAccount, VMeSSUser, VMeSSInboundConfig, VMeSSSecurityConfig,
|
||||
# Trojan
|
||||
TrojanAccount, TrojanUser, TrojanServerConfig, TrojanFallback,
|
||||
# Shadowsocks
|
||||
ShadowsocksAccount, ShadowsocksUser, ShadowsocksServerConfig,
|
||||
# Utils
|
||||
generate_uuid, validate_uuid, create_protocol_config
|
||||
)
|
||||
from .transports import (
|
||||
StreamSettings, TCPSettings, WebSocketSettings, GRPCSettings,
|
||||
HTTPSettings, XHTTPSettings, KCPSettings, QUICSettings, DomainSocketSettings,
|
||||
create_tcp_stream, create_ws_stream, create_grpc_stream, create_http_stream, create_xhttp_stream
|
||||
)
|
||||
from .security import (
|
||||
TLSConfig, REALITYConfig, XTLSConfig, Certificate,
|
||||
create_tls_config, create_reality_config, create_reality_client_config,
|
||||
generate_self_signed_certificate, create_tls_config_with_self_signed
|
||||
)
|
||||
from .inbound import (
|
||||
InboundConfig, ReceiverConfig, SniffingConfig, InboundBuilder
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
'BaseXrayModel', 'XrayConfig', 'XrayProtocol', 'TransportProtocol', 'SecurityType',
|
||||
|
||||
# Protocols
|
||||
'VLESSAccount', 'VLESSClient', 'VLESSInboundConfig',
|
||||
'VMeSSAccount', 'VMeSSUser', 'VMeSSInboundConfig', 'VMeSSSecurityConfig',
|
||||
'TrojanAccount', 'TrojanUser', 'TrojanServerConfig', 'TrojanFallback',
|
||||
'ShadowsocksAccount', 'ShadowsocksUser', 'ShadowsocksServerConfig',
|
||||
'generate_uuid', 'validate_uuid', 'create_protocol_config',
|
||||
|
||||
# Transports
|
||||
'StreamSettings', 'TCPSettings', 'WebSocketSettings', 'GRPCSettings',
|
||||
'HTTPSettings', 'XHTTPSettings', 'KCPSettings', 'QUICSettings', 'DomainSocketSettings',
|
||||
'create_tcp_stream', 'create_ws_stream', 'create_grpc_stream', 'create_http_stream', 'create_xhttp_stream',
|
||||
|
||||
# Security
|
||||
'TLSConfig', 'REALITYConfig', 'XTLSConfig', 'Certificate',
|
||||
'create_tls_config', 'create_reality_config', 'create_reality_client_config',
|
||||
'generate_self_signed_certificate', 'create_tls_config_with_self_signed',
|
||||
|
||||
# Inbound
|
||||
'InboundConfig', 'ReceiverConfig', 'SniffingConfig', 'InboundBuilder',
|
||||
]
|
97
vpn/xray_api_v2/models/base.py
Normal file
97
vpn/xray_api_v2/models/base.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Base models for xray-api library"""
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from typing import Dict, Any, Optional, Type, TypeVar
|
||||
import json
|
||||
from enum import Enum
|
||||
|
||||
T = TypeVar('T', bound='BaseXrayModel')
|
||||
|
||||
|
||||
class BaseXrayModel(ABC):
|
||||
"""Base class for all Xray configuration models"""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for storage"""
|
||||
if hasattr(self, '__dataclass_fields__'):
|
||||
return self._clean_dict(asdict(self))
|
||||
return self._clean_dict(self.__dict__.copy())
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert model to Xray API format"""
|
||||
return self.to_dict()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
|
||||
"""Create model instance from dictionary"""
|
||||
return cls(**data)
|
||||
|
||||
def _clean_dict(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Remove None values and empty collections"""
|
||||
cleaned = {}
|
||||
for key, value in data.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (list, dict)) and not value:
|
||||
continue
|
||||
if isinstance(value, BaseXrayModel):
|
||||
cleaned[key] = value.to_dict()
|
||||
elif isinstance(value, list):
|
||||
cleaned[key] = [
|
||||
item.to_dict() if isinstance(item, BaseXrayModel) else item
|
||||
for item in value
|
||||
]
|
||||
elif isinstance(value, Enum):
|
||||
cleaned[key] = value.value
|
||||
else:
|
||||
cleaned[key] = value
|
||||
return cleaned
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Convert to JSON string"""
|
||||
return json.dumps(self.to_dict(), indent=2)
|
||||
|
||||
|
||||
@dataclass
|
||||
class XrayConfig(BaseXrayModel):
|
||||
"""Base configuration class"""
|
||||
_TypedMessage_: Optional[str] = field(default=None, init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Set TypedMessage after initialization"""
|
||||
if self._TypedMessage_ is None and hasattr(self, '__xray_type__'):
|
||||
self._TypedMessage_ = self.__xray_type__
|
||||
|
||||
|
||||
class XrayProtocol(str, Enum):
|
||||
"""Supported Xray protocols"""
|
||||
VLESS = "vless"
|
||||
VMESS = "vmess"
|
||||
TROJAN = "trojan"
|
||||
SHADOWSOCKS = "shadowsocks"
|
||||
DOKODEMO = "dokodemo-door"
|
||||
FREEDOM = "freedom"
|
||||
BLACKHOLE = "blackhole"
|
||||
DNS = "dns"
|
||||
HTTP = "http"
|
||||
SOCKS = "socks"
|
||||
|
||||
|
||||
class TransportProtocol(str, Enum):
|
||||
"""Transport protocols"""
|
||||
TCP = "tcp"
|
||||
KCP = "kcp"
|
||||
WS = "ws"
|
||||
HTTP = "http"
|
||||
XHTTP = "xhttp"
|
||||
DOMAINSOCKET = "domainsocket"
|
||||
QUIC = "quic"
|
||||
GRPC = "grpc"
|
||||
|
||||
|
||||
class SecurityType(str, Enum):
|
||||
"""Security types"""
|
||||
NONE = "none"
|
||||
TLS = "tls"
|
||||
REALITY = "reality"
|
||||
XTLS = "xtls"
|
176
vpn/xray_api_v2/models/inbound.py
Normal file
176
vpn/xray_api_v2/models/inbound.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Inbound configuration models"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, XrayProtocol
|
||||
from .protocols import (
|
||||
VLESSInboundConfig, VMeSSInboundConfig,
|
||||
TrojanServerConfig, ShadowsocksServerConfig
|
||||
)
|
||||
from .transports import StreamSettings
|
||||
|
||||
|
||||
@dataclass
|
||||
class SniffingConfig(BaseXrayModel):
|
||||
"""Traffic sniffing configuration"""
|
||||
enabled: bool = True
|
||||
destOverride: Optional[List[str]] = None
|
||||
metadataOnly: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
if self.destOverride is None:
|
||||
self.destOverride = ["http", "tls"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReceiverConfig(XrayConfig):
|
||||
"""Receiver configuration for inbound"""
|
||||
__xray_type__ = "xray.app.proxyman.ReceiverConfig"
|
||||
|
||||
listen: str = "0.0.0.0"
|
||||
port: Optional[int] = None
|
||||
portList: Optional[Union[int, str]] = None # Can be int or range like "10000-20000"
|
||||
streamSettings: Optional[StreamSettings] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format"""
|
||||
config = {"listen": self.listen}
|
||||
|
||||
# Either port or portList must be set
|
||||
if self.port is not None:
|
||||
config["port"] = self.port
|
||||
elif self.portList is not None:
|
||||
config["portList"] = self.portList
|
||||
else:
|
||||
raise ValueError("Either port or portList must be specified")
|
||||
|
||||
if self.streamSettings:
|
||||
config["streamSettings"] = self.streamSettings.to_xray_json()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@dataclass
|
||||
class InboundConfig(BaseXrayModel):
|
||||
"""Complete inbound configuration"""
|
||||
tag: str
|
||||
protocol: XrayProtocol
|
||||
settings: Union[VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig, ShadowsocksServerConfig]
|
||||
listen: str = "0.0.0.0"
|
||||
port: Optional[int] = None
|
||||
portList: Optional[Union[int, str]] = None
|
||||
streamSettings: Optional[StreamSettings] = None
|
||||
sniffing: Optional[SniffingConfig] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper field order and structure"""
|
||||
config = {
|
||||
"listen": self.listen,
|
||||
"tag": self.tag,
|
||||
"protocol": self.protocol.value if hasattr(self.protocol, 'value') else str(self.protocol),
|
||||
}
|
||||
|
||||
# Add port or portList (port comes before protocol in working format)
|
||||
if self.port is not None:
|
||||
config["port"] = self.port
|
||||
elif self.portList is not None:
|
||||
config["portList"] = self.portList
|
||||
else:
|
||||
raise ValueError("Either port or portList must be specified")
|
||||
|
||||
# Add protocol settings with _TypedMessage_
|
||||
settings = self.settings.to_xray_json()
|
||||
if "_TypedMessage_" not in settings:
|
||||
# Add _TypedMessage_ based on protocol
|
||||
protocol_type_map = {
|
||||
"vless": "xray.proxy.vless.inbound.Config",
|
||||
"vmess": "xray.proxy.vmess.inbound.Config",
|
||||
"trojan": "xray.proxy.trojan.inbound.Config",
|
||||
"shadowsocks": "xray.proxy.shadowsocks.inbound.Config"
|
||||
}
|
||||
protocol_name = self.protocol.value if hasattr(self.protocol, 'value') else str(self.protocol)
|
||||
if protocol_name in protocol_type_map:
|
||||
settings["_TypedMessage_"] = protocol_type_map[protocol_name]
|
||||
|
||||
config["settings"] = settings
|
||||
|
||||
# Add stream settings
|
||||
if self.streamSettings:
|
||||
config["streamSettings"] = self.streamSettings.to_xray_json()
|
||||
else:
|
||||
config["streamSettings"] = {"network": "tcp"} # Default TCP
|
||||
|
||||
# Add sniffing
|
||||
if self.sniffing:
|
||||
config["sniffing"] = self.sniffing.to_dict()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Builder for easier configuration
|
||||
class InboundBuilder:
|
||||
"""Builder for creating inbound configurations"""
|
||||
|
||||
def __init__(self, tag: str, protocol: XrayProtocol):
|
||||
self.tag = tag
|
||||
self.protocol = protocol
|
||||
self._settings = None
|
||||
self._listen = "0.0.0.0"
|
||||
self._port = None
|
||||
self._port_list = None
|
||||
self._stream_settings = None
|
||||
self._sniffing = None
|
||||
|
||||
def listen(self, address: str) -> 'InboundBuilder':
|
||||
"""Set listen address"""
|
||||
self._listen = address
|
||||
return self
|
||||
|
||||
def port(self, port: int) -> 'InboundBuilder':
|
||||
"""Set single port"""
|
||||
self._port = port
|
||||
self._port_list = None
|
||||
return self
|
||||
|
||||
def port_range(self, start: int, end: int) -> 'InboundBuilder':
|
||||
"""Set port range"""
|
||||
self._port_list = f"{start}-{end}"
|
||||
self._port = None
|
||||
return self
|
||||
|
||||
def stream_settings(self, settings: StreamSettings) -> 'InboundBuilder':
|
||||
"""Set stream settings"""
|
||||
self._stream_settings = settings
|
||||
return self
|
||||
|
||||
def sniffing(self, enabled: bool = True, dest_override: Optional[List[str]] = None) -> 'InboundBuilder':
|
||||
"""Configure sniffing"""
|
||||
self._sniffing = SniffingConfig(
|
||||
enabled=enabled,
|
||||
destOverride=dest_override
|
||||
)
|
||||
return self
|
||||
|
||||
def protocol_settings(self, settings: Union[VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig, ShadowsocksServerConfig]) -> 'InboundBuilder':
|
||||
"""Set protocol-specific settings"""
|
||||
self._settings = settings
|
||||
return self
|
||||
|
||||
def build(self) -> InboundConfig:
|
||||
"""Build the inbound configuration"""
|
||||
if not self._settings:
|
||||
raise ValueError("Protocol settings must be specified")
|
||||
|
||||
if not self._port and not self._port_list:
|
||||
raise ValueError("Either port or port range must be specified")
|
||||
|
||||
return InboundConfig(
|
||||
tag=self.tag,
|
||||
protocol=self.protocol,
|
||||
settings=self._settings,
|
||||
listen=self._listen,
|
||||
port=self._port,
|
||||
portList=self._port_list,
|
||||
streamSettings=self._stream_settings,
|
||||
sniffing=self._sniffing
|
||||
)
|
266
vpn/xray_api_v2/models/protocols.py
Normal file
266
vpn/xray_api_v2/models/protocols.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Protocol models for Xray"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import uuid4
|
||||
import re
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, XrayProtocol
|
||||
|
||||
|
||||
def validate_uuid(uuid_str: str) -> bool:
|
||||
"""Validate UUID format"""
|
||||
pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I)
|
||||
return bool(pattern.match(uuid_str))
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
"""Generate new UUID"""
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
# VLESS Protocol
|
||||
@dataclass
|
||||
class VLESSAccount(XrayConfig):
|
||||
"""VLESS account configuration"""
|
||||
__xray_type__ = "xray.proxy.vless.Account"
|
||||
|
||||
id: str
|
||||
flow: Optional[str] = None
|
||||
encryption: str = "none"
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
if not validate_uuid(self.id):
|
||||
raise ValueError(f"Invalid UUID: {self.id}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class VLESSClient(BaseXrayModel):
|
||||
"""VLESS client configuration"""
|
||||
email: str
|
||||
account: VLESSAccount
|
||||
level: int = 0
|
||||
|
||||
@classmethod
|
||||
def create(cls, email: str, uuid: Optional[str] = None, flow: Optional[str] = None) -> 'VLESSClient':
|
||||
"""Create VLESS client with optional UUID generation"""
|
||||
if uuid is None:
|
||||
uuid = generate_uuid()
|
||||
account = VLESSAccount(id=uuid, flow=flow)
|
||||
return cls(email=email, account=account)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VLESSInboundConfig(XrayConfig):
|
||||
"""VLESS inbound configuration"""
|
||||
__xray_type__ = "xray.proxy.vless.inbound.Config"
|
||||
|
||||
clients: List[VLESSClient] = field(default_factory=list)
|
||||
decryption: str = "none"
|
||||
fallbacks: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper structure"""
|
||||
config = {
|
||||
"_TypedMessage_": self.__xray_type__,
|
||||
"clients": [],
|
||||
"decryption": self.decryption
|
||||
}
|
||||
|
||||
# Convert clients to proper format
|
||||
for client in self.clients:
|
||||
client_data = {
|
||||
"id": client.account.id,
|
||||
"level": client.level,
|
||||
"email": client.email
|
||||
}
|
||||
if client.account.flow:
|
||||
client_data["flow"] = client.account.flow
|
||||
config["clients"].append(client_data)
|
||||
|
||||
if self.fallbacks:
|
||||
config["fallbacks"] = self.fallbacks
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# VMess Protocol
|
||||
@dataclass
|
||||
class VMeSSSecurityConfig(BaseXrayModel):
|
||||
"""VMess security configuration"""
|
||||
type: str = "AUTO" # AUTO, AES-128-GCM, CHACHA20-POLY1305, NONE
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMeSSAccount(XrayConfig):
|
||||
"""VMess account configuration"""
|
||||
__xray_type__ = "xray.proxy.vmess.Account"
|
||||
|
||||
id: str
|
||||
securitySettings: Optional[VMeSSSecurityConfig] = None
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
if not validate_uuid(self.id):
|
||||
raise ValueError(f"Invalid UUID: {self.id}")
|
||||
if self.securitySettings is None:
|
||||
self.securitySettings = VMeSSSecurityConfig()
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMeSSUser(BaseXrayModel):
|
||||
"""VMess user configuration"""
|
||||
email: str
|
||||
account: VMeSSAccount
|
||||
level: int = 0
|
||||
|
||||
@classmethod
|
||||
def create(cls, email: str, uuid: Optional[str] = None, security: str = "AUTO") -> 'VMeSSUser':
|
||||
"""Create VMess user with optional UUID generation"""
|
||||
if uuid is None:
|
||||
uuid = generate_uuid()
|
||||
account = VMeSSAccount(
|
||||
id=uuid,
|
||||
securitySettings=VMeSSSecurityConfig(type=security)
|
||||
)
|
||||
return cls(email=email, account=account)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMeSSInboundConfig(XrayConfig):
|
||||
"""VMess inbound configuration"""
|
||||
__xray_type__ = "xray.proxy.vmess.inbound.Config"
|
||||
|
||||
user: List[VMeSSUser] = field(default_factory=list)
|
||||
disableInsecureEncryption: bool = False
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper structure"""
|
||||
config = {
|
||||
"_TypedMessage_": self.__xray_type__,
|
||||
"clients": []
|
||||
}
|
||||
|
||||
# Convert users to proper format
|
||||
for user in self.user:
|
||||
client_data = {
|
||||
"id": user.account.id,
|
||||
"level": user.level,
|
||||
"email": user.email,
|
||||
"alterId": 0 # VMess specific
|
||||
}
|
||||
config["clients"].append(client_data)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Trojan Protocol
|
||||
@dataclass
|
||||
class TrojanAccount(XrayConfig):
|
||||
"""Trojan account configuration"""
|
||||
__xray_type__ = "xray.proxy.trojan.Account"
|
||||
|
||||
password: str
|
||||
|
||||
@classmethod
|
||||
def generate_password(cls) -> str:
|
||||
"""Generate secure password"""
|
||||
return generate_uuid()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanUser(BaseXrayModel):
|
||||
"""Trojan user configuration"""
|
||||
email: str
|
||||
account: TrojanAccount
|
||||
level: int = 0
|
||||
|
||||
@classmethod
|
||||
def create(cls, email: str, password: Optional[str] = None) -> 'TrojanUser':
|
||||
"""Create Trojan user with optional password generation"""
|
||||
if password is None:
|
||||
password = TrojanAccount.generate_password()
|
||||
account = TrojanAccount(password=password)
|
||||
return cls(email=email, account=account)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanFallback(BaseXrayModel):
|
||||
"""Trojan fallback configuration"""
|
||||
dest: str
|
||||
type: str = "tcp"
|
||||
xver: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanServerConfig(XrayConfig):
|
||||
"""Trojan server configuration"""
|
||||
__xray_type__ = "xray.proxy.trojan.ServerConfig"
|
||||
|
||||
users: List[TrojanUser] = field(default_factory=list)
|
||||
fallbacks: Optional[List[TrojanFallback]] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper structure"""
|
||||
config = {
|
||||
"_TypedMessage_": self.__xray_type__,
|
||||
"clients": []
|
||||
}
|
||||
|
||||
# Convert users to proper format
|
||||
for user in self.users:
|
||||
client_data = {
|
||||
"password": user.account.password,
|
||||
"level": user.level,
|
||||
"email": user.email
|
||||
}
|
||||
config["clients"].append(client_data)
|
||||
|
||||
if self.fallbacks:
|
||||
config["fallbacks"] = [fb.to_dict() for fb in self.fallbacks]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Shadowsocks Protocol
|
||||
@dataclass
|
||||
class ShadowsocksAccount(XrayConfig):
|
||||
"""Shadowsocks account configuration"""
|
||||
__xray_type__ = "xray.proxy.shadowsocks.Account"
|
||||
|
||||
method: str # aes-256-gcm, aes-128-gcm, chacha20-poly1305, etc.
|
||||
password: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShadowsocksUser(BaseXrayModel):
|
||||
"""Shadowsocks user configuration"""
|
||||
email: str
|
||||
account: ShadowsocksAccount
|
||||
level: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShadowsocksServerConfig(XrayConfig):
|
||||
"""Shadowsocks server configuration"""
|
||||
__xray_type__ = "xray.proxy.shadowsocks.ServerConfig"
|
||||
|
||||
users: List[ShadowsocksUser] = field(default_factory=list)
|
||||
network: str = "tcp,udp"
|
||||
|
||||
|
||||
# Protocol config factory
|
||||
def create_protocol_config(protocol: XrayProtocol, **kwargs) -> XrayConfig:
|
||||
"""Factory to create protocol configurations"""
|
||||
protocol_map = {
|
||||
XrayProtocol.VLESS: VLESSInboundConfig,
|
||||
XrayProtocol.VMESS: VMeSSInboundConfig,
|
||||
XrayProtocol.TROJAN: TrojanServerConfig,
|
||||
XrayProtocol.SHADOWSOCKS: ShadowsocksServerConfig,
|
||||
}
|
||||
|
||||
config_class = protocol_map.get(protocol)
|
||||
if not config_class:
|
||||
raise ValueError(f"Unsupported protocol: {protocol}")
|
||||
|
||||
return config_class(**kwargs)
|
389
vpn/xray_api_v2/models/security.py
Normal file
389
vpn/xray_api_v2/models/security.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""Security configuration models for Xray"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, SecurityType
|
||||
|
||||
|
||||
# TLS Configuration
|
||||
@dataclass
|
||||
class Certificate(BaseXrayModel):
|
||||
"""TLS certificate configuration"""
|
||||
certificateFile: Optional[str] = None
|
||||
keyFile: Optional[str] = None
|
||||
certificate: Optional[List[str]] = None # PEM format lines
|
||||
key: Optional[List[str]] = None # PEM format lines
|
||||
usage: str = "encipherment"
|
||||
ocspStapling: int = 3600
|
||||
oneTimeLoading: bool = False
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format"""
|
||||
config = {}
|
||||
|
||||
if self.certificateFile and self.keyFile:
|
||||
config["certificateFile"] = self.certificateFile
|
||||
config["keyFile"] = self.keyFile
|
||||
elif self.certificate and self.key:
|
||||
config["certificate"] = self.certificate
|
||||
config["key"] = self.key
|
||||
|
||||
config["usage"] = self.usage
|
||||
|
||||
if self.ocspStapling:
|
||||
config["ocspStapling"] = self.ocspStapling
|
||||
|
||||
if self.oneTimeLoading:
|
||||
config["OneTimeLoading"] = self.oneTimeLoading
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@dataclass
|
||||
class TLSConfig(XrayConfig):
|
||||
"""TLS configuration"""
|
||||
__xray_type__ = "xray.transport.internet.tls.Config"
|
||||
|
||||
serverName: Optional[str] = None
|
||||
allowInsecure: bool = False
|
||||
alpn: Optional[List[str]] = None
|
||||
minVersion: str = "1.2"
|
||||
maxVersion: str = "1.3"
|
||||
preferServerCipherSuites: bool = True
|
||||
cipherSuites: Optional[str] = None
|
||||
certificates: Optional[List[Certificate]] = None
|
||||
disableSystemRoot: bool = False
|
||||
enableSessionResumption: bool = False
|
||||
fingerprint: Optional[str] = None # Client-side
|
||||
pinnedPeerCertificateChainSha256: Optional[List[str]] = None
|
||||
rejectUnknownSni: bool = False # Server-side
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format with proper field handling"""
|
||||
config = super().to_xray_json()
|
||||
|
||||
# Handle certificates properly
|
||||
if self.certificates:
|
||||
config["certificates"] = [cert.to_xray_json() for cert in self.certificates]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# REALITY Configuration
|
||||
@dataclass
|
||||
class REALITYConfig(XrayConfig):
|
||||
"""REALITY configuration"""
|
||||
__xray_type__ = "xray.transport.internet.reality.Config"
|
||||
|
||||
# Server-side
|
||||
show: bool = False
|
||||
dest: Optional[str] = None # e.g., "example.com:443"
|
||||
xver: int = 0
|
||||
serverNames: Optional[List[str]] = None
|
||||
privateKey: Optional[str] = None
|
||||
shortIds: Optional[List[str]] = None
|
||||
|
||||
# Client-side
|
||||
serverName: Optional[str] = None
|
||||
fingerprint: str = "chrome"
|
||||
publicKey: Optional[str] = None
|
||||
shortId: Optional[str] = None
|
||||
spiderX: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def generate_keys(cls) -> Dict[str, str]:
|
||||
"""Generate REALITY key pair using xray x25519"""
|
||||
import subprocess
|
||||
try:
|
||||
# Use xray x25519 to generate proper keys
|
||||
result = subprocess.run(['xray', 'x25519'], capture_output=True, text=True, check=True)
|
||||
lines = result.stdout.strip().split('\n')
|
||||
|
||||
private_key = ""
|
||||
public_key = ""
|
||||
|
||||
for line in lines:
|
||||
if line.startswith('Private key:'):
|
||||
private_key = line.split(': ')[1].strip()
|
||||
elif line.startswith('Public key:'):
|
||||
public_key = line.split(': ')[1].strip()
|
||||
|
||||
return {
|
||||
"privateKey": private_key,
|
||||
"publicKey": public_key
|
||||
}
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, IndexError):
|
||||
# Fallback to base64 encoded random bytes (32 bytes for X25519)
|
||||
import base64
|
||||
private_bytes = secrets.token_bytes(32)
|
||||
public_bytes = secrets.token_bytes(32)
|
||||
return {
|
||||
"privateKey": base64.b64encode(private_bytes).decode().rstrip('='),
|
||||
"publicKey": base64.b64encode(public_bytes).decode().rstrip('=')
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def generate_short_id(cls) -> str:
|
||||
"""Generate random short ID (1-16 hex chars)"""
|
||||
# Generate 1-8 bytes (2-16 hex chars)
|
||||
length = secrets.randbelow(8) + 1
|
||||
return secrets.token_hex(length)
|
||||
|
||||
|
||||
# XTLS Configuration (deprecated but still supported)
|
||||
@dataclass
|
||||
class XTLSConfig(XrayConfig):
|
||||
"""XTLS configuration (legacy)"""
|
||||
__xray_type__ = "xray.transport.internet.xtls.Config"
|
||||
|
||||
serverName: Optional[str] = None
|
||||
allowInsecure: bool = False
|
||||
alpn: Optional[List[str]] = None
|
||||
minVersion: str = "1.2"
|
||||
maxVersion: str = "1.3"
|
||||
preferServerCipherSuites: bool = True
|
||||
cipherSuites: Optional[str] = None
|
||||
certificates: Optional[List[Certificate]] = None
|
||||
disableSystemRoot: bool = False
|
||||
fingerprint: Optional[str] = None
|
||||
rejectUnknownSni: bool = False
|
||||
|
||||
|
||||
# Security factory
|
||||
def create_tls_config(
|
||||
server_name: Optional[str] = None,
|
||||
cert_file: Optional[str] = None,
|
||||
key_file: Optional[str] = None,
|
||||
alpn: Optional[List[str]] = None,
|
||||
fingerprint: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> TLSConfig:
|
||||
"""Create TLS configuration"""
|
||||
config = TLSConfig(
|
||||
serverName=server_name,
|
||||
alpn=alpn or ["h2", "http/1.1"],
|
||||
fingerprint=fingerprint,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if cert_file and key_file:
|
||||
config.certificates = [Certificate(
|
||||
certificateFile=cert_file,
|
||||
keyFile=key_file
|
||||
)]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def create_reality_config(
|
||||
dest: str,
|
||||
server_names: Optional[List[str]] = None,
|
||||
private_key: Optional[str] = None,
|
||||
short_ids: Optional[List[str]] = None,
|
||||
**kwargs
|
||||
) -> REALITYConfig:
|
||||
"""Create REALITY configuration for server"""
|
||||
if not private_key:
|
||||
keys = REALITYConfig.generate_keys()
|
||||
private_key = keys["privateKey"]
|
||||
|
||||
if not short_ids:
|
||||
short_ids = [REALITYConfig.generate_short_id()]
|
||||
|
||||
return REALITYConfig(
|
||||
show=False,
|
||||
dest=dest,
|
||||
serverNames=server_names or [dest.split(":")[0]],
|
||||
privateKey=private_key,
|
||||
shortIds=short_ids,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def create_reality_client_config(
|
||||
server_name: str,
|
||||
public_key: str,
|
||||
short_id: str,
|
||||
fingerprint: str = "chrome",
|
||||
**kwargs
|
||||
) -> REALITYConfig:
|
||||
"""Create REALITY configuration for client"""
|
||||
return REALITYConfig(
|
||||
serverName=server_name,
|
||||
publicKey=public_key,
|
||||
shortId=short_id,
|
||||
fingerprint=fingerprint,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
# Certificate generation utilities
|
||||
def generate_self_signed_certificate(
|
||||
common_name: str = "localhost",
|
||||
san_list: Optional[List[str]] = None,
|
||||
days: int = 365,
|
||||
key_size: int = 2048,
|
||||
save_to_files: bool = False,
|
||||
cert_path: Optional[str] = None,
|
||||
key_path: Optional[str] = None
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Generate self-signed certificate
|
||||
|
||||
Args:
|
||||
common_name: Certificate common name
|
||||
san_list: Subject Alternative Names (domains/IPs)
|
||||
days: Certificate validity in days
|
||||
key_size: RSA key size
|
||||
save_to_files: Whether to save to files
|
||||
cert_path: Path to save certificate
|
||||
key_path: Path to save private key
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID, ExtensionOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
except ImportError:
|
||||
raise ImportError("cryptography package is required for certificate generation. Install with: pip install cryptography")
|
||||
|
||||
# Generate private key
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_size,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
# Create certificate subject
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
||||
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "State"),
|
||||
x509.NameAttribute(NameOID.LOCALITY_NAME, "City"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Xray Self-Signed"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
|
||||
])
|
||||
|
||||
# Create certificate builder
|
||||
builder = x509.CertificateBuilder()
|
||||
builder = builder.subject_name(subject)
|
||||
builder = builder.issuer_name(issuer)
|
||||
builder = builder.public_key(private_key.public_key())
|
||||
builder = builder.serial_number(x509.random_serial_number())
|
||||
builder = builder.not_valid_before(datetime.utcnow())
|
||||
builder = builder.not_valid_after(datetime.utcnow() + timedelta(days=days))
|
||||
|
||||
# Add Subject Alternative Names
|
||||
san_list = san_list or [common_name]
|
||||
alt_names = []
|
||||
for san in san_list:
|
||||
if san.replace('.', '').isdigit(): # IP address
|
||||
alt_names.append(x509.IPAddress(ipaddress.ip_address(san)))
|
||||
else: # Domain name
|
||||
alt_names.append(x509.DNSName(san))
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectAlternativeName(alt_names),
|
||||
critical=False,
|
||||
)
|
||||
|
||||
# Add basic constraints
|
||||
builder = builder.add_extension(
|
||||
x509.BasicConstraints(ca=True, path_length=0),
|
||||
critical=True,
|
||||
)
|
||||
|
||||
# Add key usage
|
||||
builder = builder.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
content_commitment=False,
|
||||
key_encipherment=True,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=True,
|
||||
crl_sign=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False,
|
||||
),
|
||||
critical=True,
|
||||
)
|
||||
|
||||
# Sign certificate
|
||||
certificate = builder.sign(private_key, hashes.SHA256(), default_backend())
|
||||
|
||||
# Serialize to PEM
|
||||
cert_pem = certificate.public_bytes(serialization.Encoding.PEM).decode('utf-8')
|
||||
key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
).decode('utf-8')
|
||||
|
||||
# Save to files if requested
|
||||
if save_to_files:
|
||||
cert_file = cert_path or f"{common_name}_cert.pem"
|
||||
key_file = key_path or f"{common_name}_key.pem"
|
||||
|
||||
with open(cert_file, 'w') as f:
|
||||
f.write(cert_pem)
|
||||
|
||||
with open(key_file, 'w') as f:
|
||||
f.write(key_pem)
|
||||
|
||||
# Set appropriate permissions
|
||||
Path(key_file).chmod(0o600)
|
||||
|
||||
return cert_pem, key_pem
|
||||
|
||||
|
||||
def create_tls_config_with_self_signed(
|
||||
common_name: str = "localhost",
|
||||
san_list: Optional[List[str]] = None,
|
||||
alpn: Optional[List[str]] = None,
|
||||
**tls_kwargs
|
||||
) -> Tuple[TLSConfig, str, str]:
|
||||
"""
|
||||
Create TLS configuration with self-signed certificate
|
||||
|
||||
Returns:
|
||||
Tuple of (TLSConfig, certificate_pem, private_key_pem)
|
||||
"""
|
||||
cert_pem, key_pem = generate_self_signed_certificate(
|
||||
common_name=common_name,
|
||||
san_list=san_list
|
||||
)
|
||||
|
||||
# Convert PEM to lines for Xray format
|
||||
cert_lines = cert_pem.strip().split('\n')
|
||||
key_lines = key_pem.strip().split('\n')
|
||||
|
||||
# Create certificate config
|
||||
certificate = Certificate(
|
||||
certificate=cert_lines,
|
||||
key=key_lines,
|
||||
oneTimeLoading=True
|
||||
)
|
||||
|
||||
# Create TLS config
|
||||
tls_config = TLSConfig(
|
||||
serverName=common_name,
|
||||
alpn=alpn or ["h2", "http/1.1"],
|
||||
certificates=[certificate],
|
||||
**tls_kwargs
|
||||
)
|
||||
|
||||
return tls_config, cert_pem, key_pem
|
||||
|
||||
|
||||
# Add missing import
|
||||
try:
|
||||
import ipaddress
|
||||
except ImportError:
|
||||
ipaddress = None
|
241
vpn/xray_api_v2/models/transports.py
Normal file
241
vpn/xray_api_v2/models/transports.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Transport configuration models for Xray"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, TransportProtocol
|
||||
|
||||
|
||||
# TCP Transport
|
||||
@dataclass
|
||||
class TCPSettings(XrayConfig):
|
||||
"""TCP transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.tcp.Config"
|
||||
|
||||
acceptProxyProtocol: bool = False
|
||||
header: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# KCP Transport
|
||||
@dataclass
|
||||
class KCPSettings(XrayConfig):
|
||||
"""KCP transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.kcp.Config"
|
||||
|
||||
mtu: int = 1350
|
||||
tti: int = 50
|
||||
uplinkCapacity: int = 5
|
||||
downlinkCapacity: int = 20
|
||||
congestion: bool = False
|
||||
readBufferSize: int = 2
|
||||
writeBufferSize: int = 2
|
||||
header: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# WebSocket Transport
|
||||
@dataclass
|
||||
class WebSocketSettings(XrayConfig):
|
||||
"""WebSocket transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.websocket.Config"
|
||||
|
||||
path: str = "/"
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
acceptProxyProtocol: bool = False
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format"""
|
||||
config = super().to_xray_json()
|
||||
# Ensure headers is a dict even if empty
|
||||
if self.headers:
|
||||
config["headers"] = self.headers
|
||||
return config
|
||||
|
||||
|
||||
# HTTP/2 Transport
|
||||
@dataclass
|
||||
class HTTPSettings(XrayConfig):
|
||||
"""HTTP/2 transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.http.Config"
|
||||
|
||||
path: str = "/"
|
||||
host: Optional[List[str]] = None
|
||||
method: str = "PUT"
|
||||
headers: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
|
||||
# XHTTP Transport (New)
|
||||
@dataclass
|
||||
class XHTTPSettings(XrayConfig):
|
||||
"""XHTTP transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.xhttp.Config"
|
||||
|
||||
path: str = "/"
|
||||
host: Optional[str] = None
|
||||
method: str = "GET"
|
||||
headers: Optional[Dict[str, Any]] = None
|
||||
mode: str = "auto"
|
||||
|
||||
|
||||
# Domain Socket Transport
|
||||
@dataclass
|
||||
class DomainSocketSettings(XrayConfig):
|
||||
"""Domain socket transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.domainsocket.Config"
|
||||
|
||||
path: str
|
||||
abstract: bool = False
|
||||
padding: bool = False
|
||||
|
||||
|
||||
# QUIC Transport
|
||||
@dataclass
|
||||
class QUICSettings(XrayConfig):
|
||||
"""QUIC transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.quic.Config"
|
||||
|
||||
security: str = "none"
|
||||
key: str = ""
|
||||
header: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# gRPC Transport
|
||||
@dataclass
|
||||
class GRPCSettings(XrayConfig):
|
||||
"""gRPC transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.grpc.encoding.Config"
|
||||
|
||||
serviceName: str = ""
|
||||
multiMode: bool = False
|
||||
idle_timeout: int = 60
|
||||
health_check_timeout: int = 20
|
||||
permit_without_stream: bool = False
|
||||
initial_windows_size: int = 0
|
||||
|
||||
|
||||
# Stream Settings
|
||||
@dataclass
|
||||
class StreamSettings(BaseXrayModel):
|
||||
"""Stream settings for inbound/outbound"""
|
||||
|
||||
network: TransportProtocol = TransportProtocol.TCP
|
||||
security: Optional[str] = None
|
||||
tlsSettings: Optional[Any] = None
|
||||
xtlsSettings: Optional[Any] = None
|
||||
realitySettings: Optional[Any] = None
|
||||
tcpSettings: Optional[TCPSettings] = None
|
||||
kcpSettings: Optional[KCPSettings] = None
|
||||
wsSettings: Optional[WebSocketSettings] = None
|
||||
httpSettings: Optional[HTTPSettings] = None
|
||||
xhttpSettings: Optional[XHTTPSettings] = None
|
||||
dsSettings: Optional[DomainSocketSettings] = None
|
||||
quicSettings: Optional[QUICSettings] = None
|
||||
grpcSettings: Optional[GRPCSettings] = None
|
||||
sockopt: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format with correct field names"""
|
||||
config = {
|
||||
"network": self.network.value if isinstance(self.network, TransportProtocol) else self.network
|
||||
}
|
||||
|
||||
if self.security:
|
||||
config["security"] = self.security
|
||||
|
||||
# Map transport settings
|
||||
transport_map = {
|
||||
TransportProtocol.TCP: ("tcpSettings", self.tcpSettings),
|
||||
TransportProtocol.KCP: ("kcpSettings", self.kcpSettings),
|
||||
TransportProtocol.WS: ("wsSettings", self.wsSettings),
|
||||
TransportProtocol.HTTP: ("httpSettings", self.httpSettings),
|
||||
TransportProtocol.XHTTP: ("xhttpSettings", self.xhttpSettings),
|
||||
TransportProtocol.DOMAINSOCKET: ("dsSettings", self.dsSettings),
|
||||
TransportProtocol.QUIC: ("quicSettings", self.quicSettings),
|
||||
TransportProtocol.GRPC: ("grpcSettings", self.grpcSettings),
|
||||
}
|
||||
|
||||
network = self.network if isinstance(self.network, TransportProtocol) else TransportProtocol(self.network)
|
||||
field_name, settings = transport_map.get(network, (None, None))
|
||||
|
||||
if field_name and settings:
|
||||
config[field_name] = settings.to_xray_json() if hasattr(settings, 'to_xray_json') else settings
|
||||
|
||||
# Add security settings
|
||||
if self.tlsSettings:
|
||||
config["tlsSettings"] = self.tlsSettings if isinstance(self.tlsSettings, dict) else self.tlsSettings.to_xray_json()
|
||||
if self.xtlsSettings:
|
||||
config["xtlsSettings"] = self.xtlsSettings if isinstance(self.xtlsSettings, dict) else self.xtlsSettings.to_xray_json()
|
||||
if self.realitySettings:
|
||||
config["realitySettings"] = self.realitySettings if isinstance(self.realitySettings, dict) else self.realitySettings.to_xray_json()
|
||||
|
||||
if self.sockopt:
|
||||
config["sockopt"] = self.sockopt
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Factory functions
|
||||
def create_tcp_stream(
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create TCP stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.TCP,
|
||||
security=security,
|
||||
tcpSettings=TCPSettings(**kwargs) if kwargs else None
|
||||
)
|
||||
|
||||
|
||||
def create_ws_stream(
|
||||
path: str = "/",
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create WebSocket stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.WS,
|
||||
security=security,
|
||||
wsSettings=WebSocketSettings(path=path, headers=headers, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
def create_grpc_stream(
|
||||
service_name: str = "",
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create gRPC stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.GRPC,
|
||||
security=security,
|
||||
grpcSettings=GRPCSettings(serviceName=service_name, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
def create_http_stream(
|
||||
path: str = "/",
|
||||
host: Optional[List[str]] = None,
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create HTTP/2 stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.HTTP,
|
||||
security=security,
|
||||
httpSettings=HTTPSettings(path=path, host=host, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
def create_xhttp_stream(
|
||||
path: str = "/",
|
||||
host: Optional[str] = None,
|
||||
security: Optional[str] = None,
|
||||
mode: str = "auto",
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create XHTTP stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.XHTTP,
|
||||
security=security,
|
||||
xhttpSettings=XHTTPSettings(path=path, host=host, mode=mode, **kwargs)
|
||||
)
|
0
vpn/xray_api_v2/serializers/__init__.py
Normal file
0
vpn/xray_api_v2/serializers/__init__.py
Normal file
184
vpn/xray_api_v2/stats.py
Normal file
184
vpn/xray_api_v2/stats.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Statistics functionality for Xray API"""
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict, Optional, Tuple, Any
|
||||
from datetime import datetime
|
||||
|
||||
from .client import XrayClient
|
||||
from .models.base import BaseXrayModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatItem(BaseXrayModel):
|
||||
"""Single statistics item"""
|
||||
name: str
|
||||
value: int
|
||||
|
||||
@property
|
||||
def parts(self) -> List[str]:
|
||||
"""Split stat name into parts"""
|
||||
return self.name.split(">>>")
|
||||
|
||||
@property
|
||||
def stat_type(self) -> str:
|
||||
"""Get stat type (inbound/outbound/user)"""
|
||||
return self.parts[0] if self.parts else ""
|
||||
|
||||
@property
|
||||
def tag(self) -> str:
|
||||
"""Get inbound/outbound tag"""
|
||||
return self.parts[1] if len(self.parts) > 1 else ""
|
||||
|
||||
@property
|
||||
def metric(self) -> str:
|
||||
"""Get metric name (traffic/uplink/downlink)"""
|
||||
return self.parts[-1] if self.parts else ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemStats(BaseXrayModel):
|
||||
"""System statistics"""
|
||||
numGoroutine: int = 0
|
||||
numGC: int = 0
|
||||
alloc: int = 0
|
||||
totalAlloc: int = 0
|
||||
sys: int = 0
|
||||
mallocs: int = 0
|
||||
frees: int = 0
|
||||
liveObjects: int = 0
|
||||
pauseTotalNs: int = 0
|
||||
uptime: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'SystemStats':
|
||||
"""Create from API response with proper field mapping"""
|
||||
# Map API response fields to model fields
|
||||
field_mapping = {
|
||||
'NumGoroutine': 'numGoroutine',
|
||||
'NumGC': 'numGC',
|
||||
'Alloc': 'alloc',
|
||||
'TotalAlloc': 'totalAlloc',
|
||||
'Sys': 'sys',
|
||||
'Mallocs': 'mallocs',
|
||||
'Frees': 'frees',
|
||||
'LiveObjects': 'liveObjects',
|
||||
'PauseTotalNs': 'pauseTotalNs',
|
||||
'Uptime': 'uptime'
|
||||
}
|
||||
|
||||
normalized = {}
|
||||
for api_key, model_key in field_mapping.items():
|
||||
if api_key in data:
|
||||
normalized[model_key] = data[api_key]
|
||||
|
||||
return cls(**normalized)
|
||||
|
||||
@property
|
||||
def uptime_seconds(self) -> int:
|
||||
"""Get uptime in seconds"""
|
||||
return self.uptime
|
||||
|
||||
@property
|
||||
def memory_mb(self) -> float:
|
||||
"""Get allocated memory in MB"""
|
||||
return self.alloc / 1024 / 1024
|
||||
|
||||
|
||||
class StatsManager:
|
||||
"""Manager for Xray statistics"""
|
||||
|
||||
def __init__(self, client: XrayClient):
|
||||
self.client = client
|
||||
|
||||
def get_all_stats(self, reset: bool = False) -> List[StatItem]:
|
||||
"""Get all statistics"""
|
||||
stats = self.client.get_stats("", reset=reset)
|
||||
result = []
|
||||
for stat in stats:
|
||||
if isinstance(stat, dict) and 'name' in stat and 'value' in stat:
|
||||
result.append(StatItem(name=stat['name'], value=stat['value']))
|
||||
return result
|
||||
|
||||
def get_inbound_stats(self, tag: str, reset: bool = False) -> Dict[str, int]:
|
||||
"""Get statistics for specific inbound"""
|
||||
pattern = f"inbound>>>{tag}>>>traffic>>>"
|
||||
stats = self.client.get_stats(pattern, reset=reset)
|
||||
|
||||
result = {"uplink": 0, "downlink": 0}
|
||||
for stat in stats:
|
||||
item = StatItem(**stat)
|
||||
if item.metric in result:
|
||||
result[item.metric] = item.value
|
||||
|
||||
return result
|
||||
|
||||
def get_outbound_stats(self, tag: str, reset: bool = False) -> Dict[str, int]:
|
||||
"""Get statistics for specific outbound"""
|
||||
pattern = f"outbound>>>{tag}>>>traffic>>>"
|
||||
stats = self.client.get_stats(pattern, reset=reset)
|
||||
|
||||
result = {"uplink": 0, "downlink": 0}
|
||||
for stat in stats:
|
||||
item = StatItem(**stat)
|
||||
if item.metric in result:
|
||||
result[item.metric] = item.value
|
||||
|
||||
return result
|
||||
|
||||
def get_user_stats(self, email: str, reset: bool = False) -> Dict[str, int]:
|
||||
"""Get statistics for specific user"""
|
||||
pattern = f"user>>>{email}>>>traffic>>>"
|
||||
stats = self.client.get_stats(pattern, reset=reset)
|
||||
|
||||
result = {"uplink": 0, "downlink": 0}
|
||||
for stat in stats:
|
||||
item = StatItem(**stat)
|
||||
if item.metric in result:
|
||||
result[item.metric] = item.value
|
||||
|
||||
return result
|
||||
|
||||
def get_user_online_info(self, email: str) -> Dict[str, Any]:
|
||||
"""Get user online information"""
|
||||
online = self.client.get_online_stats(email)
|
||||
ips_data = self.client.get_online_ips(email)
|
||||
|
||||
return {
|
||||
"email": email,
|
||||
"online": online.get("count", 0) > 0,
|
||||
"sessions": online.get("count", 0),
|
||||
"ips": ips_data
|
||||
}
|
||||
|
||||
def get_system_stats(self) -> SystemStats:
|
||||
"""Get system statistics"""
|
||||
stats = self.client.get_system_stats()
|
||||
return SystemStats.from_dict(stats)
|
||||
|
||||
def get_traffic_summary(self) -> Dict[str, Dict[str, int]]:
|
||||
"""Get traffic summary for all inbounds/outbounds"""
|
||||
all_stats = self.get_all_stats()
|
||||
summary = {
|
||||
"inbounds": {},
|
||||
"outbounds": {},
|
||||
"users": {}
|
||||
}
|
||||
|
||||
for stat in all_stats:
|
||||
if stat.stat_type == "inbound":
|
||||
if stat.tag not in summary["inbounds"]:
|
||||
summary["inbounds"][stat.tag] = {"uplink": 0, "downlink": 0}
|
||||
summary["inbounds"][stat.tag][stat.metric] = stat.value
|
||||
|
||||
elif stat.stat_type == "outbound":
|
||||
if stat.tag not in summary["outbounds"]:
|
||||
summary["outbounds"][stat.tag] = {"uplink": 0, "downlink": 0}
|
||||
summary["outbounds"][stat.tag][stat.metric] = stat.value
|
||||
|
||||
elif stat.stat_type == "user":
|
||||
user_email = stat.parts[1] if len(stat.parts) > 1 else ""
|
||||
if user_email and user_email not in summary["users"]:
|
||||
summary["users"][user_email] = {"uplink": 0, "downlink": 0}
|
||||
if user_email:
|
||||
summary["users"][user_email][stat.metric] = stat.value
|
||||
|
||||
return summary
|
392
vpn/xray_api_v2/subscription.py
Normal file
392
vpn/xray_api_v2/subscription.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""Subscription link generation for Xray protocols"""
|
||||
import json
|
||||
import base64
|
||||
import urllib.parse
|
||||
from typing import Dict, Any, Optional, List, Union
|
||||
from .models.base import BaseXrayModel
|
||||
from .models.inbound import InboundConfig
|
||||
from .models.protocols import VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig
|
||||
|
||||
|
||||
class SubscriptionLinkGenerator:
|
||||
"""Generate subscription links for various Xray protocols"""
|
||||
|
||||
@staticmethod
|
||||
def _url_encode(value: str) -> str:
|
||||
"""URL encode string with safe characters"""
|
||||
return urllib.parse.quote(value, safe='')
|
||||
|
||||
@staticmethod
|
||||
def _format_ipv6(address: str) -> str:
|
||||
"""Format IPv6 address with brackets if needed"""
|
||||
if ':' in address and not address.startswith('['):
|
||||
return f'[{address}]'
|
||||
return address
|
||||
|
||||
@classmethod
|
||||
def generate_vless_link(cls,
|
||||
uuid: str,
|
||||
address: str,
|
||||
port: int,
|
||||
remark: str = "VLESS",
|
||||
encryption: str = "none",
|
||||
security: str = "none",
|
||||
sni: Optional[str] = None,
|
||||
transport_type: str = "tcp",
|
||||
path: Optional[str] = None,
|
||||
host: Optional[str] = None,
|
||||
alpn: Optional[str] = None,
|
||||
fp: str = "chrome",
|
||||
flow: Optional[str] = None,
|
||||
allow_insecure: bool = False,
|
||||
# REALITY parameters
|
||||
pbk: Optional[str] = None,
|
||||
sid: Optional[str] = None,
|
||||
spx: Optional[str] = None,
|
||||
**kwargs) -> str:
|
||||
"""
|
||||
Generate VLESS subscription link
|
||||
|
||||
Args:
|
||||
uuid: Client UUID
|
||||
address: Server address
|
||||
port: Server port
|
||||
remark: Link description/name
|
||||
encryption: Encryption method (default: none)
|
||||
security: Security layer (none, tls, reality)
|
||||
sni: SNI for TLS
|
||||
transport_type: Transport type (tcp, ws, grpc, xhttp, h2, kcp)
|
||||
path: Path for WebSocket/HTTP2/gRPC
|
||||
host: Host header for WebSocket
|
||||
alpn: ALPN negotiation
|
||||
fp: Fingerprint
|
||||
flow: Flow control (xtls-rprx-vision, etc.)
|
||||
pbk: REALITY public key
|
||||
sid: REALITY short ID
|
||||
spx: REALITY spider X
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
VLESS subscription link
|
||||
"""
|
||||
# Build query parameters
|
||||
params = {
|
||||
'encryption': encryption,
|
||||
'security': security,
|
||||
'type': transport_type,
|
||||
'fp': fp
|
||||
}
|
||||
|
||||
# Add optional parameters
|
||||
if sni:
|
||||
params['sni'] = sni
|
||||
if path:
|
||||
params['path'] = path # Don't double encode - urlencode will handle it
|
||||
if host:
|
||||
params['host'] = host
|
||||
if alpn:
|
||||
params['alpn'] = alpn
|
||||
if flow:
|
||||
params['flow'] = flow
|
||||
if allow_insecure:
|
||||
params['allowInsecure'] = '1'
|
||||
|
||||
# Add REALITY parameters
|
||||
if pbk:
|
||||
params['pbk'] = pbk
|
||||
if sid:
|
||||
params['sid'] = sid
|
||||
if spx:
|
||||
params['spx'] = spx
|
||||
|
||||
# Add any additional parameters
|
||||
params.update(kwargs)
|
||||
|
||||
# Remove None values
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
# Build query string (keep - and _ safe for REALITY keys)
|
||||
query_string = urllib.parse.urlencode(params, safe='-_')
|
||||
|
||||
# Format address (handle IPv6)
|
||||
formatted_address = cls._format_ipv6(address)
|
||||
|
||||
# Build VLESS URL
|
||||
url = f"vless://{uuid}@{formatted_address}:{port}?{query_string}#{cls._url_encode(remark)}"
|
||||
|
||||
return url
|
||||
|
||||
@classmethod
|
||||
def generate_vmess_link(cls,
|
||||
uuid: str,
|
||||
address: str,
|
||||
port: int,
|
||||
remark: str = "VMess",
|
||||
alterId: int = 0,
|
||||
security: str = "auto",
|
||||
network: str = "tcp",
|
||||
transport_type: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
host: Optional[str] = None,
|
||||
tls: str = "",
|
||||
sni: Optional[str] = None,
|
||||
alpn: Optional[str] = None,
|
||||
fp: Optional[str] = None,
|
||||
allow_insecure: bool = False,
|
||||
**kwargs) -> str:
|
||||
"""
|
||||
Generate VMess subscription link
|
||||
|
||||
Args:
|
||||
uuid: Client UUID
|
||||
address: Server address
|
||||
port: Server port
|
||||
remark: Link description/name
|
||||
alterId: Alter ID (default: 0)
|
||||
security: Security method (auto, aes-128-gcm, chacha20-poly1305, none)
|
||||
network: Network type (tcp, kcp, ws, h2, quic, grpc)
|
||||
transport_type: Transport type (same as network for compatibility)
|
||||
path: Path for WebSocket/HTTP2/gRPC
|
||||
host: Host header
|
||||
tls: TLS settings ("" for none, "tls" for TLS)
|
||||
sni: SNI for TLS
|
||||
alpn: ALPN negotiation
|
||||
fp: Fingerprint
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
VMess subscription link
|
||||
"""
|
||||
# Use transport_type if provided, otherwise use network
|
||||
net = transport_type or network
|
||||
|
||||
# Build VMess configuration object
|
||||
vmess_config = {
|
||||
'v': '2',
|
||||
'ps': remark,
|
||||
'add': address,
|
||||
'port': str(port),
|
||||
'id': uuid,
|
||||
'aid': str(alterId),
|
||||
'scy': security,
|
||||
'net': net,
|
||||
'type': kwargs.get('type', 'none'),
|
||||
'host': host or '',
|
||||
'path': path or '',
|
||||
'tls': tls,
|
||||
'sni': sni or '',
|
||||
'alpn': alpn or '',
|
||||
'fp': fp or ''
|
||||
}
|
||||
|
||||
# Add allowInsecure only if True (VMess format - try multiple approaches)
|
||||
if allow_insecure:
|
||||
vmess_config['allowInsecure'] = 1
|
||||
vmess_config['skip-cert-verify'] = True # For compatibility with some clients
|
||||
|
||||
# Add any additional parameters
|
||||
for key, value in kwargs.items():
|
||||
if key not in vmess_config and value is not None:
|
||||
vmess_config[key] = str(value)
|
||||
|
||||
# Convert to JSON and base64 encode
|
||||
json_str = json.dumps(vmess_config, separators=(',', ':'))
|
||||
encoded = base64.b64encode(json_str.encode('utf-8')).decode('ascii')
|
||||
|
||||
return f"vmess://{encoded}"
|
||||
|
||||
@classmethod
|
||||
def generate_trojan_link(cls,
|
||||
password: str,
|
||||
address: str,
|
||||
port: int,
|
||||
remark: str = "Trojan",
|
||||
security: str = "tls",
|
||||
sni: Optional[str] = None,
|
||||
transport_type: str = "tcp",
|
||||
path: Optional[str] = None,
|
||||
host: Optional[str] = None,
|
||||
alpn: Optional[str] = None,
|
||||
fp: str = "chrome",
|
||||
allow_insecure: bool = False,
|
||||
**kwargs) -> str:
|
||||
"""
|
||||
Generate Trojan subscription link
|
||||
|
||||
Args:
|
||||
password: Trojan password
|
||||
address: Server address
|
||||
port: Server port
|
||||
remark: Link description/name
|
||||
security: Security layer (tls, none)
|
||||
sni: SNI for TLS
|
||||
transport_type: Transport type (tcp, ws, grpc)
|
||||
path: Path for WebSocket/gRPC
|
||||
host: Host header
|
||||
alpn: ALPN negotiation
|
||||
fp: Fingerprint
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Trojan subscription link
|
||||
"""
|
||||
# Build query parameters
|
||||
params = {
|
||||
'security': security,
|
||||
'type': transport_type,
|
||||
'fp': fp
|
||||
}
|
||||
|
||||
# Add optional parameters
|
||||
if sni:
|
||||
params['sni'] = sni
|
||||
if path:
|
||||
params['path'] = path # Don't double encode - urlencode will handle it
|
||||
if host:
|
||||
params['host'] = host
|
||||
if alpn:
|
||||
params['alpn'] = alpn
|
||||
if allow_insecure:
|
||||
params['allowInsecure'] = '1'
|
||||
|
||||
# Add any additional parameters
|
||||
params.update(kwargs)
|
||||
|
||||
# Remove None values
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
# Build query string
|
||||
query_string = urllib.parse.urlencode(params)
|
||||
|
||||
# Format address (handle IPv6)
|
||||
formatted_address = cls._format_ipv6(address)
|
||||
|
||||
# URL encode password
|
||||
encoded_password = cls._url_encode(password)
|
||||
|
||||
# Build Trojan URL
|
||||
url = f"trojan://{encoded_password}@{formatted_address}:{port}?{query_string}#{cls._url_encode(remark)}"
|
||||
|
||||
return url
|
||||
|
||||
@classmethod
|
||||
def generate_shadowsocks_link(cls,
|
||||
method: str,
|
||||
password: str,
|
||||
address: str,
|
||||
port: int,
|
||||
remark: str = "Shadowsocks") -> str:
|
||||
"""
|
||||
Generate Shadowsocks subscription link
|
||||
|
||||
Args:
|
||||
method: Encryption method
|
||||
password: SS password
|
||||
address: Server address
|
||||
port: Server port
|
||||
remark: Link description/name
|
||||
|
||||
Returns:
|
||||
Shadowsocks subscription link
|
||||
"""
|
||||
# Format: ss://base64(method:password)@server:port#remark
|
||||
user_info = f"{method}:{password}"
|
||||
encoded_user_info = base64.b64encode(user_info.encode('utf-8')).decode('ascii')
|
||||
|
||||
# Format address (handle IPv6)
|
||||
formatted_address = cls._format_ipv6(address)
|
||||
|
||||
return f"ss://{encoded_user_info}@{formatted_address}:{port}#{cls._url_encode(remark)}"
|
||||
|
||||
@classmethod
|
||||
def generate_subscription_from_inbound(cls,
|
||||
inbound: InboundConfig,
|
||||
server_address: str,
|
||||
remark_prefix: str = "",
|
||||
**transport_params) -> List[str]:
|
||||
"""
|
||||
Generate subscription links from inbound configuration
|
||||
|
||||
Args:
|
||||
inbound: Inbound configuration
|
||||
server_address: Public server address
|
||||
remark_prefix: Prefix for link remarks
|
||||
**transport_params: Additional transport parameters
|
||||
|
||||
Returns:
|
||||
List of subscription links for all users
|
||||
"""
|
||||
links = []
|
||||
protocol = inbound.protocol.lower()
|
||||
port = inbound.port
|
||||
tag = inbound.tag
|
||||
|
||||
# Extract protocol-specific settings
|
||||
if isinstance(inbound.settings, VLESSInboundConfig):
|
||||
for client in inbound.settings.clients:
|
||||
remark = f"{remark_prefix}{tag}_{client.email}" if remark_prefix else f"{tag}_{client.email}"
|
||||
link = cls.generate_vless_link(
|
||||
uuid=client.account.id,
|
||||
address=server_address,
|
||||
port=port,
|
||||
remark=remark,
|
||||
**transport_params
|
||||
)
|
||||
links.append(link)
|
||||
|
||||
elif isinstance(inbound.settings, VMeSSInboundConfig):
|
||||
for client in inbound.settings.user:
|
||||
remark = f"{remark_prefix}{tag}_{client.email}" if remark_prefix else f"{tag}_{client.email}"
|
||||
link = cls.generate_vmess_link(
|
||||
uuid=client.account.id,
|
||||
address=server_address,
|
||||
port=port,
|
||||
remark=remark,
|
||||
**transport_params
|
||||
)
|
||||
links.append(link)
|
||||
|
||||
elif isinstance(inbound.settings, TrojanServerConfig):
|
||||
for client in inbound.settings.users:
|
||||
remark = f"{remark_prefix}{tag}_{client.email}" if remark_prefix else f"{tag}_{client.email}"
|
||||
link = cls.generate_trojan_link(
|
||||
password=client.password,
|
||||
address=server_address,
|
||||
port=port,
|
||||
remark=remark,
|
||||
**transport_params
|
||||
)
|
||||
links.append(link)
|
||||
|
||||
return links
|
||||
|
||||
@classmethod
|
||||
def create_subscription_content(cls, links: List[str]) -> str:
|
||||
"""
|
||||
Create base64 encoded subscription content
|
||||
|
||||
Args:
|
||||
links: List of subscription links
|
||||
|
||||
Returns:
|
||||
Base64 encoded subscription content
|
||||
"""
|
||||
content = '\n'.join(links)
|
||||
return base64.b64encode(content.encode('utf-8')).decode('ascii')
|
||||
|
||||
@classmethod
|
||||
def parse_subscription_content(cls, content: str) -> List[str]:
|
||||
"""
|
||||
Parse base64 encoded subscription content
|
||||
|
||||
Args:
|
||||
content: Base64 encoded subscription content
|
||||
|
||||
Returns:
|
||||
List of subscription links
|
||||
"""
|
||||
try:
|
||||
decoded = base64.b64decode(content).decode('utf-8')
|
||||
return [link.strip() for link in decoded.split('\n') if link.strip()]
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid subscription content: {e}")
|
Reference in New Issue
Block a user