Xray init support

This commit is contained in:
AB from home.homenet
2025-08-05 01:23:07 +03:00
parent c5a94d17dc
commit ea3d74ccbd
29 changed files with 4309 additions and 294 deletions

View File

@@ -26,7 +26,9 @@ from .server_plugins import (
OutlineServer,
OutlineServerAdmin,
XrayCoreServer,
XrayCoreServerAdmin)
XrayCoreServerAdmin,
XrayInbound,
XrayClient)
@admin.register(TaskExecutionLog)
@@ -265,6 +267,7 @@ class ServerAdmin(PolymorphicParentModelAdmin):
custom_urls = [
path('move-clients/', self.admin_site.admin_view(self.move_clients_view), name='server_move_clients'),
path('<int:server_id>/check-status/', self.admin_site.admin_view(self.check_server_status_view), name='server_check_status'),
path('<int:object_id>/sync/', self.admin_site.admin_view(self.sync_server_view), name='server_sync'),
]
return custom_urls + urls
@@ -492,6 +495,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
if isinstance(real_server, OutlineServer):
try:
@@ -519,9 +523,51 @@ class ServerAdmin(PolymorphicParentModelAdmin):
'status': 'error',
'message': f'Connection error: {str(e)[:100]}'
})
elif isinstance(real_server, XrayCoreServer):
try:
logger.info(f"Checking Xray server: {server.name}")
# Try to get server status from Xray
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"]}'
logger.info(f"Xray server {server.name} is online: {message}")
return JsonResponse({
'success': True,
'status': 'online',
'message': message
})
else:
logger.warning(f"Xray server {server.name} returned status: {status}")
return JsonResponse({
'success': True,
'status': 'offline',
'message': f'Server status: {status.get("message", "Unknown error")}'
})
else:
logger.warning(f"Xray server {server.name} returned no status")
return JsonResponse({
'success': True,
'status': 'offline',
'message': 'Server not responding'
})
except Exception as e:
logger.error(f"Error checking Xray server {server.name}: {e}")
return JsonResponse({
'success': True,
'status': 'error',
'message': f'Connection error: {str(e)[:100]}'
})
else:
# For non-Outline servers, just return basic info
logger.info(f"Non-Outline server {server.name}, type: {server.server_type}")
# For other server types, just return basic info
logger.info(f"Server {server.name}, type: {server.server_type}")
return JsonResponse({
'success': True,
'status': 'unknown',
@@ -808,6 +854,29 @@ class ServerAdmin(PolymorphicParentModelAdmin):
'acl_set__user'
)
return qs
def sync_server_view(self, request, object_id):
"""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
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/')
# Fallback for other server types
else:
messages.info(request, f"Sync not implemented for server type: {real_server.server_type}")
return redirect('admin:vpn_server_changelist')
except Exception as e:
messages.error(request, f"Error during sync: {e}")
return redirect('admin:vpn_server_changelist')
#admin.site.register(User, UserAdmin)
@admin.register(User)

View File

@@ -0,0 +1,42 @@
# Generated by Django 5.1.7 on 2025-07-27 17:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0008_rename_vpn_accessl_acl_lin_b23c6e_idx_vpn_accessl_acl_lin_9f3bc5_idx'),
]
operations = [
migrations.CreateModel(
name='XrayCoreServer',
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')),
('api_address', models.CharField(help_text='Xray Core API address (e.g., http://127.0.0.1:8080)', max_length=255)),
('api_port', models.IntegerField(default=8080, help_text='API port for management interface')),
('api_token', models.CharField(blank=True, help_text='API authentication token', max_length=255)),
('server_address', models.CharField(help_text='Server address for clients to connect', max_length=255)),
('server_port', models.IntegerField(default=443, help_text='Server port for client connections')),
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('shadowsocks', 'Shadowsocks'), ('trojan', 'Trojan')], default='vless', help_text='Primary protocol for this server', max_length=20)),
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY'), ('xtls', 'XTLS')], default='tls', help_text='Security layer configuration', max_length=20)),
('transport', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('http', 'HTTP/2'), ('grpc', 'gRPC'), ('quic', 'QUIC')], default='tcp', help_text='Transport protocol', max_length=20)),
('config_json', models.JSONField(blank=True, default=dict, help_text='Complete Xray configuration in JSON format')),
('panel_url', models.CharField(blank=True, help_text='Web panel URL if using 3X-UI or similar management panel', max_length=255)),
('panel_username', models.CharField(blank=True, help_text='Panel admin username', max_length=100)),
('panel_password', models.CharField(blank=True, help_text='Panel admin password', max_length=100)),
],
options={
'verbose_name': 'Xray Core Server',
'verbose_name_plural': 'Xray Core Servers',
},
bases=('vpn.server',),
),
migrations.AlterField(
model_name='server',
name='server_type',
field=models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard'), ('xray_core', 'Xray Core')], editable=False, max_length=50),
),
]

View File

@@ -0,0 +1,137 @@
# Generated by Django 5.1.7 on 2025-07-28 22:34
import django.contrib.postgres.fields
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0009_xraycoreserver_alter_server_server_type'),
]
operations = [
migrations.RemoveField(
model_name='xraycoreserver',
name='api_address',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='api_port',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='api_token',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='config_json',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='panel_password',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='panel_url',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='panel_username',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='protocol',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='security',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='server_address',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='server_port',
),
migrations.RemoveField(
model_name='xraycoreserver',
name='transport',
),
migrations.AddField(
model_name='xraycoreserver',
name='default_protocol',
field=models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], default='vless', help_text='Default protocol for new inbounds', max_length=20),
),
migrations.AddField(
model_name='xraycoreserver',
name='enable_stats',
field=models.BooleanField(default=True, help_text='Enable traffic statistics tracking'),
),
migrations.AddField(
model_name='xraycoreserver',
name='grpc_address',
field=models.CharField(default='127.0.0.1', help_text='Xray Core gRPC API address', max_length=255),
),
migrations.AddField(
model_name='xraycoreserver',
name='grpc_port',
field=models.IntegerField(default=10085, help_text='gRPC API port (usually 10085)'),
),
migrations.CreateModel(
name='XrayInbound',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tag', models.CharField(help_text='Unique identifier for this inbound', max_length=100)),
('port', models.IntegerField(help_text='Port to listen on')),
('listen', models.CharField(default='0.0.0.0', help_text='IP address to listen on', max_length=255)),
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], max_length=20)),
('enabled', models.BooleanField(default=True)),
('is_default', models.BooleanField(default=False, help_text='Use this inbound for new users by default')),
('network', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('http', 'HTTP/2'), ('grpc', 'gRPC'), ('quic', 'QUIC')], default='tcp', max_length=20)),
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY')], default='none', max_length=20)),
('server_address', models.CharField(blank=True, help_text='Public server address for client connections (if different from listen address)', max_length=255)),
('ss_method', models.CharField(blank=True, default='chacha20-ietf-poly1305', help_text='Shadowsocks encryption method', max_length=50)),
('ss_password', models.CharField(blank=True, help_text='Shadowsocks password (for single-user mode)', max_length=255)),
('tls_cert_file', models.CharField(blank=True, max_length=255)),
('tls_key_file', models.CharField(blank=True, max_length=255)),
('tls_alpn', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), blank=True, default=list, size=None)),
('stream_settings', models.JSONField(blank=True, default=dict)),
('sniffing_settings', models.JSONField(blank=True, default=dict)),
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inbounds', to='vpn.xraycoreserver')),
],
options={
'ordering': ['port'],
'unique_together': {('server', 'port'), ('server', 'tag')},
},
),
migrations.CreateModel(
name='XrayClient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
('email', models.CharField(help_text='Email for statistics', max_length=255)),
('level', models.IntegerField(default=0)),
('enable', models.BooleanField(default=True)),
('flow', models.CharField(blank=True, help_text='VLESS flow control', max_length=50)),
('alter_id', models.IntegerField(default=0, help_text='VMess alterId')),
('password', models.CharField(blank=True, help_text='Password for Trojan/Shadowsocks', max_length=255)),
('total_gb', models.IntegerField(blank=True, help_text='Traffic limit in GB', null=True)),
('expiry_time', models.DateTimeField(blank=True, help_text='Account expiration time', null=True)),
('up', models.BigIntegerField(default=0, help_text='Upload bytes')),
('down', models.BigIntegerField(default=0, help_text='Download bytes')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('inbound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clients', to='vpn.xrayinbound')),
],
options={
'ordering': ['created_at'],
'unique_together': {('inbound', 'user')},
},
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.1.7 on 2025-07-31 21:52
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('vpn', '0010_remove_xraycoreserver_api_address_and_more'),
]
operations = [
migrations.CreateModel(
name='XrayInboundProxy',
fields=[
],
options={
'verbose_name': 'Xray Inbound (Server View)',
'verbose_name_plural': 'Xray Inbounds (Server View)',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('vpn.xrayinbound',),
),
migrations.RemoveField(
model_name='xraycoreserver',
name='default_protocol',
),
migrations.RemoveField(
model_name='xrayinbound',
name='is_default',
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.1.7 on 2025-07-31 21:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0011_xrayinboundproxy_and_more'),
]
operations = [
migrations.CreateModel(
name='XrayInboundServer',
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')),
('xray_inbound', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='server_proxy', to='vpn.xrayinbound')),
],
options={
'verbose_name': 'Xray Inbound Server',
'verbose_name_plural': 'Xray Inbound Servers',
},
bases=('vpn.server',),
),
migrations.DeleteModel(
name='XrayInboundProxy',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-07-31 22:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0012_xrayinboundserver_delete_xrayinboundproxy'),
]
operations = [
migrations.AddField(
model_name='xraycoreserver',
name='client_hostname',
field=models.CharField(default='127.0.0.1', help_text='Hostname or IP address for client connections (what clients use to connect)', max_length=255),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.7 on 2025-08-04 22:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0013_add_client_hostname'),
]
operations = [
migrations.AlterField(
model_name='xraycoreserver',
name='client_hostname',
field=models.CharField(default='127.0.0.1', help_text='Hostname or IP address for client connections', max_length=255),
),
migrations.AlterField(
model_name='xrayinbound',
name='server_address',
field=models.CharField(blank=True, help_text='Public server address for client connections', max_length=255),
),
]

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
from .xray_core import XrayCoreServer, XrayCoreServerAdmin, XrayInbound, XrayClient, XrayInboundServer, XrayInboundServerAdmin
from .urls import urlpatterns

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,108 @@ def cleanup_task_logs():
logger.error(f"Error cleaning up task logs: {e}")
return f"Error cleaning up task logs: {e}"
@shared_task(name="sync_xray_inbounds", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 30})
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
start_time = time.time()
task_id = self.request.id
server = None
try:
server = Server.objects.get(id=server_id)
if not isinstance(server.get_real_instance(), XrayCoreServer):
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)
return {"error": error_message}
create_task_log(task_id, "sync_xray_inbounds", f"Starting inbound sync for {server.name}", 'STARTED', server=server)
logger.info(f"Starting inbound sync for Xray server {server.name}")
real_server = server.get_real_instance()
inbound_result = real_server.sync_inbounds()
success_message = f"Successfully synced inbounds for {server.name}"
logger.info(f"{success_message}. Result: {inbound_result}")
create_task_log(task_id, "sync_xray_inbounds", "Inbound sync completed", 'SUCCESS', server=server, message=f"{success_message}. Result: {inbound_result}", execution_time=time.time() - start_time)
return inbound_result
except Server.DoesNotExist:
error_message = f"Server with id {server_id} not found"
logger.error(error_message)
create_task_log(task_id, "sync_xray_inbounds", "Server not found", 'FAILURE', message=error_message, execution_time=time.time() - start_time)
return {"error": error_message}
except Exception as e:
error_message = f"Error syncing inbounds for {server.name if server else server_id}: {e}"
logger.error(error_message)
if self.request.retries < 3:
retry_message = f"Retrying inbound sync for {server.name if server else server_id} (attempt {self.request.retries + 1})"
logger.info(retry_message)
create_task_log(task_id, "sync_xray_inbounds", "Retrying inbound sync", 'RETRY', server=server, message=retry_message)
raise self.retry(countdown=30)
create_task_log(task_id, "sync_xray_inbounds", "Inbound sync failed after retries", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
return {"error": error_message}
@shared_task(name="sync_xray_users", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 30})
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
start_time = time.time()
task_id = self.request.id
server = None
try:
server = Server.objects.get(id=server_id)
if not isinstance(server.get_real_instance(), XrayCoreServer):
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)
return {"error": error_message}
create_task_log(task_id, "sync_xray_users", f"Starting user sync for {server.name}", 'STARTED', server=server)
logger.info(f"Starting user sync for Xray server {server.name}")
real_server = server.get_real_instance()
user_result = real_server.sync_users()
success_message = f"Successfully synced {user_result.get('users_added', 0)} users for {server.name}"
logger.info(f"{success_message}. Result: {user_result}")
create_task_log(task_id, "sync_xray_users", "User sync completed", 'SUCCESS', server=server, message=f"{success_message}. Result: {user_result}", execution_time=time.time() - start_time)
return user_result
except Server.DoesNotExist:
error_message = f"Server with id {server_id} not found"
logger.error(error_message)
create_task_log(task_id, "sync_xray_users", "Server not found", 'FAILURE', message=error_message, execution_time=time.time() - start_time)
return {"error": error_message}
except Exception as e:
error_message = f"Error syncing users for {server.name if server else server_id}: {e}"
logger.error(error_message)
if self.request.retries < 3:
retry_message = f"Retrying user sync for {server.name if server else server_id} (attempt {self.request.retries + 1})"
logger.info(retry_message)
create_task_log(task_id, "sync_xray_users", "Retrying user sync", 'RETRY', server=server, message=retry_message)
raise self.retry(countdown=30)
create_task_log(task_id, "sync_xray_users", "User sync failed after retries", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
return {"error": error_message}
class TaskFailedException(Exception):
def __init__(self, message=""):
self.message = message
@@ -145,15 +247,66 @@ 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}")
sync_result = server.sync_users()
# 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()
else:
# For non-Xray servers, just sync users
sync_result = server.sync_users()
if sync_result:
# Check if sync was successful (can be boolean or dict/string)
sync_successful = bool(sync_result) and (
sync_result is not False and
(isinstance(sync_result, str) and "failed" not in sync_result.lower()) or
isinstance(sync_result, dict) or
sync_result is True
)
if sync_successful:
success_message = f"Successfully synced {user_count} users for server {server.name}"
if isinstance(sync_result, (str, dict)):
success_message += f". Details: {sync_result}"
logger.info(success_message)
create_task_log(task_id, "sync_all_users_on_server", "User sync completed", 'SUCCESS', server=server, message=success_message, execution_time=time.time() - start_time)
return success_message
else:
error_message = f"Sync failed for server {server.name}"
error_message = f"Sync failed for server {server.name}. Result: {sync_result}"
create_task_log(task_id, "sync_all_users_on_server", "User sync failed", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
raise TaskFailedException(error_message)

View File

@@ -473,6 +473,20 @@
<p>📊 Statistics are updated every 5 minutes and show your connection history</p>
{% endif %}
</div>
<!-- Xray Subscription Link -->
{% if has_xray_servers and user_links %}
<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 %}{{ request.scheme }}://{{ request.get_host }}{{ xray_url }}
<button class="copy-btn" onclick="copyToClipboard('{{ request.scheme }}://{{ request.get_host }}{{ xray_url }}')">Copy</button>
</div>
</div>
{% endif %}
</div>
{% if servers_data %}

View File

@@ -140,14 +140,23 @@ def userPortal(request, user_hash):
logger.info(f"Prepared data for {len(servers_data)} servers and {total_links} total links")
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
)
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,
'total_connections': total_connections,
'recent_connections': recent_connections,
'external_address': EXTERNAL_ADDRESS,
'has_xray_servers': has_xray_servers,
}
logger.debug(f"Context prepared with keys: {list(context.keys())}")
@@ -279,3 +288,113 @@ 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):
"""
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.
"""
from .models import ACLLink, AccessLog
from vpn.server_plugins import XrayCoreServer, XrayInboundServer
import logging
from django.utils import timezone
import base64
logger = logging.getLogger(__name__)
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}")
except Http404:
logger.warning(f"ACL link not found: {link}")
AccessLog.objects.create(
user=None,
server="Unknown",
acl_link_id=link,
action="Failed",
data=f"ACL not found for link: {link}"
)
return HttpResponse("Not found", status=404)
try:
# Get all servers this user has access to
user_acls = acl.user.acl_set.all()
subscription_configs = []
for user_acl in user_acls:
server = user_acl.server.get_real_instance()
# 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):
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}")
except Exception as e:
logger.warning(f"Failed to get configs from XrayCoreServer {server.name}: {e}")
if not subscription_configs:
logger.warning(f"No Xray configurations found for user {acl.user.username}")
AccessLog.objects.create(
user=acl.user.username,
server="Multiple",
acl_link_id=acl_link.link,
action="Failed",
data="No Xray configurations available"
)
return HttpResponse("No configurations available", 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}")
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
AccessLog.objects.create(
user=acl.user.username,
server="Xray-Subscription",
acl_link_id=acl_link.link,
action="Success",
data=f"Generated subscription with {len(subscription_configs)} configs. Content: {subscription_content[:200]}..."
)
logger.info(f"Generated Xray subscription for {acl.user.username} with {len(subscription_configs)} configs")
# 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['Cache-Control'] = 'no-cache'
return response
except Exception as e:
logger.error(f"Failed to generate Xray subscription for {acl.user.username}: {e}")
AccessLog.objects.create(
user=acl.user.username,
server="Xray-Subscription",
acl_link_id=acl_link.link,
action="Failed",
data=f"Failed to generate subscription: {e}"
)
return HttpResponse(f"Error generating subscription: {e}", status=500)

23
vpn/xray_api/__init__.py Normal file
View File

@@ -0,0 +1,23 @@
"""
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"
]

577
vpn/xray_api/client.py Normal file
View File

@@ -0,0 +1,577 @@
"""
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

@@ -0,0 +1,33 @@
"""
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

93
vpn/xray_api/models.py Normal file
View File

@@ -0,0 +1,93 @@
"""
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

@@ -0,0 +1,15 @@
"""
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

@@ -0,0 +1,45 @@
"""
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) -> 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

@@ -0,0 +1,80 @@
"""
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) -> str:
"""Generate Trojan client link."""
return f"trojan://{user.password}@{hostname}:{self.port}#{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

@@ -0,0 +1,55 @@
"""
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) -> str:
"""Generate VLESS client link."""
return f"vless://{user.uuid}@{hostname}:{self.port}?encryption=none&type={self.network}#{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

@@ -0,0 +1,73 @@
"""
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) -> str:
"""Generate VMess client link."""
config = {
"v": "2",
"ps": user.email,
"add": hostname,
"port": str(self.port),
"id": user.uuid,
"aid": str(user.alter_id),
"net": self.network,
"type": "none",
"host": "",
"path": "",
"tls": ""
}
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
}

77
vpn/xray_api/utils.py Normal file
View File

@@ -0,0 +1,77 @@
"""
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')