mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Xray init support
This commit is contained in:
75
vpn/admin.py
75
vpn/admin.py
@@ -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)
|
||||
|
@@ -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),
|
||||
),
|
||||
]
|
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
34
vpn/migrations/0011_xrayinboundproxy_and_more.py
Normal file
34
vpn/migrations/0011_xrayinboundproxy_and_more.py
Normal 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',
|
||||
),
|
||||
]
|
@@ -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',
|
||||
),
|
||||
]
|
18
vpn/migrations/0013_add_client_hostname.py
Normal file
18
vpn/migrations/0013_add_client_hostname.py
Normal 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),
|
||||
),
|
||||
]
|
@@ -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),
|
||||
),
|
||||
]
|
@@ -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
159
vpn/tasks.py
159
vpn/tasks.py
@@ -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)
|
||||
|
||||
|
@@ -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 %}
|
||||
|
119
vpn/views.py
119
vpn/views.py
@@ -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
23
vpn/xray_api/__init__.py
Normal 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
577
vpn/xray_api/client.py
Normal 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}")
|
33
vpn/xray_api/exceptions.py
Normal file
33
vpn/xray_api/exceptions.py
Normal 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
93
vpn/xray_api/models.py
Normal 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
|
15
vpn/xray_api/protocols/__init__.py
Normal file
15
vpn/xray_api/protocols/__init__.py
Normal 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"
|
||||
]
|
45
vpn/xray_api/protocols/base.py
Normal file
45
vpn/xray_api/protocols/base.py
Normal 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
|
||||
}
|
80
vpn/xray_api/protocols/trojan.py
Normal file
80
vpn/xray_api/protocols/trojan.py
Normal 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
|
||||
}
|
55
vpn/xray_api/protocols/vless.py
Normal file
55
vpn/xray_api/protocols/vless.py
Normal 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
|
||||
}
|
73
vpn/xray_api/protocols/vmess.py
Normal file
73
vpn/xray_api/protocols/vmess.py
Normal 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
77
vpn/xray_api/utils.py
Normal 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')
|
Reference in New Issue
Block a user