Xray works

This commit is contained in:
AB from home.homenet
2025-08-08 05:46:36 +03:00
parent 56b0b160e3
commit 787432cbcf
46 changed files with 5625 additions and 3551 deletions

View File

@@ -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
View 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'

View 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'
]

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

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

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

View File

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

View File

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

View 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),
),
]

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

View File

@@ -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
View 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})"

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View 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"

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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"
]

View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -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"
]

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

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

View 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
View 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)])

View File

View 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 = []

View 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

View 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',
]

View 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"

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

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

View 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

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

View File

184
vpn/xray_api_v2/stats.py Normal file
View 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

View 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}")