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:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -6,3 +6,16 @@ debug.log
|
|||||||
staticfiles/
|
staticfiles/
|
||||||
*.__pycache__.*
|
*.__pycache__.*
|
||||||
celerybeat-schedule*
|
celerybeat-schedule*
|
||||||
|
|
||||||
|
# macOS system files
|
||||||
|
._*
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
/tmp/
|
||||||
|
*.tmp
|
||||||
|
12
Dockerfile
12
Dockerfile
@@ -15,12 +15,22 @@ ENV BRANCH_NAME=${BRANCH_NAME}
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies first (this layer will be cached)
|
# Install system dependencies first (this layer will be cached)
|
||||||
RUN apk update && apk add git
|
RUN apk update && apk add git curl unzip
|
||||||
|
|
||||||
# Copy and install Python dependencies (this layer will be cached when requirements.txt doesn't change)
|
# Copy and install Python dependencies (this layer will be cached when requirements.txt doesn't change)
|
||||||
COPY ./requirements.txt .
|
COPY ./requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Install Xray-core
|
||||||
|
RUN XRAY_VERSION=$(curl -s https://api.github.com/repos/XTLS/Xray-core/releases/latest | sed -n 's/.*"tag_name": "\([^"]*\)".*/\1/p') && \
|
||||||
|
curl -L -o /tmp/xray.zip "https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/Xray-linux-64.zip" && \
|
||||||
|
cd /tmp && unzip xray.zip && \
|
||||||
|
ls -la /tmp/ && \
|
||||||
|
find /tmp -name "xray" -type f && \
|
||||||
|
cp xray /usr/local/bin/xray && \
|
||||||
|
chmod +x /usr/local/bin/xray && \
|
||||||
|
rm -rf /tmp/xray.zip /tmp/xray
|
||||||
|
|
||||||
# Copy the rest of the application code (this layer will change frequently)
|
# Copy the rest of the application code (this layer will change frequently)
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
@@ -11,7 +11,7 @@ ENV = environ.Env(
|
|||||||
environ.Env.read_env()
|
environ.Env.read_env()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
SECRET_KEY=ENV('SECRET_KEY', default=get_random_secret_key())
|
SECRET_KEY=ENV('SECRET_KEY', default='django-insecure-change-me-in-production')
|
||||||
TIME_ZONE = ENV('TIMEZONE', default='Asia/Nicosia')
|
TIME_ZONE = ENV('TIMEZONE', default='Asia/Nicosia')
|
||||||
EXTERNAL_ADDRESS = ENV('EXTERNAL_ADDRESS', default='https://example.org')
|
EXTERNAL_ADDRESS = ENV('EXTERNAL_ADDRESS', default='https://example.org')
|
||||||
|
|
||||||
@@ -140,7 +140,10 @@ BUILD_DATE = ENV('BUILD_DATE', default='unknown')
|
|||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
'DIRS': [os.path.join(BASE_DIR, 'vpn', 'templates')],
|
'DIRS': [
|
||||||
|
os.path.join(BASE_DIR, 'templates'),
|
||||||
|
os.path.join(BASE_DIR, 'vpn', 'templates')
|
||||||
|
],
|
||||||
'APP_DIRS': True,
|
'APP_DIRS': True,
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
'context_processors': [
|
'context_processors': [
|
||||||
|
@@ -17,12 +17,13 @@ Including another URLconf
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
from vpn.views import shadowsocks, userFrontend, userPortal
|
from vpn.views import shadowsocks, userFrontend, userPortal, xray_subscription
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('ss/<path:link>', shadowsocks, name='shadowsocks'),
|
path('ss/<path:link>', shadowsocks, name='shadowsocks'),
|
||||||
path('dynamic/<path:link>', shadowsocks, name='shadowsocks'),
|
path('dynamic/<path:link>', shadowsocks, name='shadowsocks'),
|
||||||
|
path('xray/<path:link>', xray_subscription, name='xray_subscription'),
|
||||||
path('stat/<path:user_hash>', userFrontend, name='userFrontend'),
|
path('stat/<path:user_hash>', userFrontend, name='userFrontend'),
|
||||||
path('u/<path:user_hash>', userPortal, name='userPortal'),
|
path('u/<path:user_hash>', userPortal, name='userPortal'),
|
||||||
path('', RedirectView.as_view(url='/admin/', permanent=False)),
|
path('', RedirectView.as_view(url='/admin/', permanent=False)),
|
||||||
|
@@ -15,3 +15,4 @@ whitenoise==6.9.0
|
|||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
setuptools==75.2.0
|
setuptools==75.2.0
|
||||||
shortuuid==1.0.13
|
shortuuid==1.0.13
|
||||||
|
cryptography==45.0.5
|
||||||
|
289
static/admin/js/xray_inbound_defaults.js
Normal file
289
static/admin/js/xray_inbound_defaults.js
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// Xray Inbound Auto-Fill Helper
|
||||||
|
console.log('Xray inbound helper script loaded');
|
||||||
|
|
||||||
|
// Protocol configurations based on Xray documentation
|
||||||
|
const protocolConfigs = {
|
||||||
|
'vless': {
|
||||||
|
port: 443,
|
||||||
|
network: 'tcp',
|
||||||
|
security: 'tls',
|
||||||
|
description: 'VLESS - Lightweight protocol with UUID authentication'
|
||||||
|
},
|
||||||
|
'vmess': {
|
||||||
|
port: 443,
|
||||||
|
network: 'ws',
|
||||||
|
security: 'tls',
|
||||||
|
description: 'VMess - V2Ray protocol with encryption and authentication'
|
||||||
|
},
|
||||||
|
'trojan': {
|
||||||
|
port: 443,
|
||||||
|
network: 'tcp',
|
||||||
|
security: 'tls',
|
||||||
|
description: 'Trojan - TLS-based protocol mimicking HTTPS traffic'
|
||||||
|
},
|
||||||
|
'shadowsocks': {
|
||||||
|
port: 8388,
|
||||||
|
network: 'tcp',
|
||||||
|
security: 'none',
|
||||||
|
ss_method: 'aes-256-gcm',
|
||||||
|
description: 'Shadowsocks - SOCKS5 proxy with encryption'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
console.log('DOM ready, initializing Xray helper');
|
||||||
|
|
||||||
|
// Add help text and generate buttons
|
||||||
|
addHelpText();
|
||||||
|
addGenerateButtons();
|
||||||
|
|
||||||
|
// Watch for protocol field changes
|
||||||
|
const protocolField = document.getElementById('id_protocol');
|
||||||
|
if (protocolField) {
|
||||||
|
protocolField.addEventListener('change', function() {
|
||||||
|
handleProtocolChange(this.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-fill on initial load if new inbound
|
||||||
|
if (protocolField.value && isNewInbound()) {
|
||||||
|
handleProtocolChange(protocolField.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function isNewInbound() {
|
||||||
|
// Check if this is a new inbound (no port value set)
|
||||||
|
const portField = document.getElementById('id_port');
|
||||||
|
return !portField || !portField.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProtocolChange(protocol) {
|
||||||
|
if (!protocol || !protocolConfigs[protocol]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = protocolConfigs[protocol];
|
||||||
|
|
||||||
|
// Only auto-fill for new inbounds to avoid overwriting user data
|
||||||
|
if (isNewInbound()) {
|
||||||
|
console.log('Auto-filling fields for new', protocol, 'inbound');
|
||||||
|
autoFillFields(protocol, config);
|
||||||
|
showMessage(`Auto-filled ${protocol.toUpperCase()} configuration`, 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoFillFields(protocol, config) {
|
||||||
|
// Fill basic fields only if they're empty
|
||||||
|
fillIfEmpty('id_port', config.port);
|
||||||
|
fillIfEmpty('id_network', config.network);
|
||||||
|
fillIfEmpty('id_security', config.security);
|
||||||
|
|
||||||
|
// Protocol-specific fields
|
||||||
|
if (config.ss_method && protocol === 'shadowsocks') {
|
||||||
|
fillIfEmpty('id_ss_method', config.ss_method);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate helpful JSON configs
|
||||||
|
generateJsonConfigs(protocol, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillIfEmpty(fieldId, value) {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (field && !field.value && value !== undefined) {
|
||||||
|
field.value = value;
|
||||||
|
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateJsonConfigs(protocol, config) {
|
||||||
|
// Generate stream settings
|
||||||
|
const streamField = document.getElementById('id_stream_settings');
|
||||||
|
if (streamField && !streamField.value) {
|
||||||
|
const streamSettings = getStreamSettings(protocol, config.network);
|
||||||
|
if (streamSettings) {
|
||||||
|
streamField.value = JSON.stringify(streamSettings, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sniffing settings
|
||||||
|
const sniffingField = document.getElementById('id_sniffing_settings');
|
||||||
|
if (sniffingField && !sniffingField.value) {
|
||||||
|
const sniffingSettings = {
|
||||||
|
enabled: true,
|
||||||
|
destOverride: ['http', 'tls'],
|
||||||
|
metadataOnly: false
|
||||||
|
};
|
||||||
|
sniffingField.value = JSON.stringify(sniffingSettings, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStreamSettings(protocol, network) {
|
||||||
|
const settings = {};
|
||||||
|
|
||||||
|
switch (network) {
|
||||||
|
case 'ws':
|
||||||
|
settings.wsSettings = {
|
||||||
|
path: '/ws',
|
||||||
|
headers: {
|
||||||
|
Host: 'example.com'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'grpc':
|
||||||
|
settings.grpcSettings = {
|
||||||
|
serviceName: 'GunService'
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'h2':
|
||||||
|
settings.httpSettings = {
|
||||||
|
host: ['example.com'],
|
||||||
|
path: '/path'
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'tcp':
|
||||||
|
settings.tcpSettings = {
|
||||||
|
header: {
|
||||||
|
type: 'none'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'kcp':
|
||||||
|
settings.kcpSettings = {
|
||||||
|
mtu: 1350,
|
||||||
|
tti: 50,
|
||||||
|
uplinkCapacity: 5,
|
||||||
|
downlinkCapacity: 20,
|
||||||
|
congestion: false,
|
||||||
|
readBufferSize: 2,
|
||||||
|
writeBufferSize: 2,
|
||||||
|
header: {
|
||||||
|
type: 'none'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(settings).length > 0 ? settings : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHelpText() {
|
||||||
|
// Add help text to complex fields
|
||||||
|
addFieldHelp('id_stream_settings',
|
||||||
|
'Transport settings: TCP (none), WebSocket (path/host), gRPC (serviceName), etc. Format: JSON');
|
||||||
|
|
||||||
|
addFieldHelp('id_sniffing_settings',
|
||||||
|
'Traffic sniffing for routing: enabled, destOverride ["http","tls"], metadataOnly');
|
||||||
|
|
||||||
|
addFieldHelp('id_tls_cert_file',
|
||||||
|
'TLS certificate file path (required for TLS security). Example: /path/to/cert.pem');
|
||||||
|
|
||||||
|
addFieldHelp('id_tls_key_file',
|
||||||
|
'TLS private key file path (required for TLS security). Example: /path/to/key.pem');
|
||||||
|
|
||||||
|
addFieldHelp('id_protocol',
|
||||||
|
'VLESS: lightweight + UUID | VMess: V2Ray encrypted | Trojan: HTTPS-like | Shadowsocks: SOCKS5');
|
||||||
|
|
||||||
|
addFieldHelp('id_network',
|
||||||
|
'Transport: tcp (direct), ws (WebSocket), grpc (HTTP/2), h2 (HTTP/2), kcp (mKCP)');
|
||||||
|
|
||||||
|
addFieldHelp('id_security',
|
||||||
|
'Encryption: none (no TLS), tls (standard TLS), reality (advanced steganography)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFieldHelp(fieldId, helpText) {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (!field) return;
|
||||||
|
|
||||||
|
const helpDiv = document.createElement('div');
|
||||||
|
helpDiv.className = 'help';
|
||||||
|
helpDiv.style.cssText = 'font-size: 11px; color: #666; margin-top: 2px; line-height: 1.3;';
|
||||||
|
helpDiv.textContent = helpText;
|
||||||
|
|
||||||
|
field.parentNode.appendChild(helpDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(message, type = 'info') {
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
messageDiv.className = `alert alert-${type}`;
|
||||||
|
messageDiv.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: ${type === 'success' ? '#d4edda' : '#cce7ff'};
|
||||||
|
border: 1px solid ${type === 'success' ? '#c3e6cb' : '#b8daff'};
|
||||||
|
color: ${type === 'success' ? '#155724' : '#004085'};
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
`;
|
||||||
|
messageDiv.textContent = message;
|
||||||
|
|
||||||
|
document.body.appendChild(messageDiv);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
messageDiv.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for generating values
|
||||||
|
function generateRandomString(length = 8) {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateShortId() {
|
||||||
|
return Math.random().toString(16).substr(2, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestPort(protocol) {
|
||||||
|
const ports = {
|
||||||
|
'vless': [443, 8443, 2053, 2083],
|
||||||
|
'vmess': [443, 80, 8080, 8443],
|
||||||
|
'trojan': [443, 8443, 2087],
|
||||||
|
'shadowsocks': [8388, 1080, 8080]
|
||||||
|
};
|
||||||
|
const protocolPorts = ports[protocol] || [443];
|
||||||
|
return protocolPorts[Math.floor(Math.random() * protocolPorts.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add generate buttons to fields
|
||||||
|
function addGenerateButtons() {
|
||||||
|
console.log('Adding generate buttons');
|
||||||
|
|
||||||
|
// Add tag generator
|
||||||
|
addGenerateButton('id_tag', '🎲', () => `inbound-${generateShortId()}`);
|
||||||
|
|
||||||
|
// Add port suggestion based on protocol
|
||||||
|
addGenerateButton('id_port', '🎯', () => {
|
||||||
|
const protocol = document.getElementById('id_protocol')?.value;
|
||||||
|
return suggestPort(protocol);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGenerateButton(fieldId, icon, generator) {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
if (!field || field.nextElementSibling?.classList.contains('generate-btn')) return;
|
||||||
|
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'generate-btn btn btn-sm btn-secondary';
|
||||||
|
button.innerHTML = icon;
|
||||||
|
button.title = 'Generate value';
|
||||||
|
button.style.cssText = 'margin-left: 5px; padding: 2px 6px; font-size: 12px;';
|
||||||
|
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const value = generator();
|
||||||
|
field.value = value;
|
||||||
|
showMessage(`Generated: ${value}`, 'success');
|
||||||
|
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
field.parentNode.insertBefore(button, field.nextSibling);
|
||||||
|
}
|
202
templates/admin/create_xray_inbound.html
Normal file
202
templates/admin/create_xray_inbound.html
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="protocol">Protocol *</label>
|
||||||
|
<select name="protocol" id="protocol" class="form-control" required>
|
||||||
|
<option value="">Select Protocol</option>
|
||||||
|
{% for proto in protocols %}
|
||||||
|
<option value="{{ proto }}">{{ proto|upper }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="port">Port *</label>
|
||||||
|
<input type="number" name="port" id="port" class="form-control"
|
||||||
|
min="1" max="65535" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tag">Tag</label>
|
||||||
|
<input type="text" name="tag" id="tag" class="form-control"
|
||||||
|
placeholder="Auto-generated if empty">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="network">Network</label>
|
||||||
|
<select name="network" id="network" class="form-control">
|
||||||
|
{% for net in networks %}
|
||||||
|
<option value="{{ net }}" {% if net == 'tcp' %}selected{% endif %}>
|
||||||
|
{{ net|upper }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="security">Security</label>
|
||||||
|
<select name="security" id="security" class="form-control">
|
||||||
|
{% for sec in securities %}
|
||||||
|
<option value="{{ sec }}" {% if sec == 'none' %}selected{% endif %}>
|
||||||
|
{{ sec|upper }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Note:</strong> The inbound will be created on both the Django database and the Xray server via gRPC API.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-success">
|
||||||
|
➕ Create Inbound
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'admin:vpn_xraycoreserver_change' server.pk %}" class="btn btn-secondary">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const protocolField = document.getElementById('protocol');
|
||||||
|
const portField = document.getElementById('port');
|
||||||
|
const tagField = document.getElementById('tag');
|
||||||
|
|
||||||
|
// Auto-suggest ports based on protocol
|
||||||
|
protocolField.addEventListener('change', function() {
|
||||||
|
const protocol = this.value;
|
||||||
|
const ports = {
|
||||||
|
'vless': 443,
|
||||||
|
'vmess': 443,
|
||||||
|
'trojan': 443
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ports[protocol] && !portField.value) {
|
||||||
|
portField.value = ports[protocol];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (protocol && !tagField.value) {
|
||||||
|
tagField.placeholder = `${protocol}-${portField.value || 'PORT'}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
portField.addEventListener('input', function() {
|
||||||
|
const protocol = protocolField.value;
|
||||||
|
if (protocol && !tagField.value) {
|
||||||
|
tagField.placeholder = `${protocol}-${this.value}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 20px auto;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background: #d1ecf1;
|
||||||
|
border: 1px solid #bee5eb;
|
||||||
|
color: #0c5460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
margin: 0 -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-md-6 {
|
||||||
|
flex: 0 0 50%;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
75
vpn/admin.py
75
vpn/admin.py
@@ -26,7 +26,9 @@ from .server_plugins import (
|
|||||||
OutlineServer,
|
OutlineServer,
|
||||||
OutlineServerAdmin,
|
OutlineServerAdmin,
|
||||||
XrayCoreServer,
|
XrayCoreServer,
|
||||||
XrayCoreServerAdmin)
|
XrayCoreServerAdmin,
|
||||||
|
XrayInbound,
|
||||||
|
XrayClient)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(TaskExecutionLog)
|
@admin.register(TaskExecutionLog)
|
||||||
@@ -265,6 +267,7 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
|||||||
custom_urls = [
|
custom_urls = [
|
||||||
path('move-clients/', self.admin_site.admin_view(self.move_clients_view), name='server_move_clients'),
|
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: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
|
return custom_urls + urls
|
||||||
|
|
||||||
@@ -492,6 +495,7 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
|||||||
|
|
||||||
# Check server status based on type
|
# Check server status based on type
|
||||||
from vpn.server_plugins.outline import OutlineServer
|
from vpn.server_plugins.outline import OutlineServer
|
||||||
|
from vpn.server_plugins.xray_core import XrayCoreServer
|
||||||
|
|
||||||
if isinstance(real_server, OutlineServer):
|
if isinstance(real_server, OutlineServer):
|
||||||
try:
|
try:
|
||||||
@@ -519,9 +523,51 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Connection error: {str(e)[:100]}'
|
'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:
|
else:
|
||||||
# For non-Outline servers, just return basic info
|
# For other server types, just return basic info
|
||||||
logger.info(f"Non-Outline server {server.name}, type: {server.server_type}")
|
logger.info(f"Server {server.name}, type: {server.server_type}")
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'success': True,
|
'success': True,
|
||||||
'status': 'unknown',
|
'status': 'unknown',
|
||||||
@@ -809,6 +855,29 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
|||||||
)
|
)
|
||||||
return qs
|
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.site.register(User, UserAdmin)
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class UserAdmin(admin.ModelAdmin):
|
class UserAdmin(admin.ModelAdmin):
|
||||||
|
@@ -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 .generic import Server
|
||||||
from .outline import OutlineServer, OutlineServerAdmin
|
from .outline import OutlineServer, OutlineServerAdmin
|
||||||
from .wireguard import WireguardServer, WireguardServerAdmin
|
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
|
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}")
|
logger.error(f"Error cleaning up task logs: {e}")
|
||||||
return 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):
|
class TaskFailedException(Exception):
|
||||||
def __init__(self, message=""):
|
def __init__(self, message=""):
|
||||||
self.message = 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}")
|
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 sync_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()
|
||||||
|
|
||||||
|
# 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}"
|
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)
|
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)
|
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
|
return success_message
|
||||||
else:
|
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)
|
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)
|
raise TaskFailedException(error_message)
|
||||||
|
|
||||||
|
@@ -473,6 +473,20 @@
|
|||||||
<p>📊 Statistics are updated every 5 minutes and show your connection history</p>
|
<p>📊 Statistics are updated every 5 minutes and show your connection history</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{% if servers_data %}
|
{% 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"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}")
|
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 = {
|
context = {
|
||||||
'user': user,
|
'user': user,
|
||||||
|
'user_links': acl_links, # For accessing user's links in template
|
||||||
'servers_data': servers_data,
|
'servers_data': servers_data,
|
||||||
'total_servers': len(servers_data),
|
'total_servers': len(servers_data),
|
||||||
'total_links': total_links,
|
'total_links': total_links,
|
||||||
'total_connections': total_connections,
|
'total_connections': total_connections,
|
||||||
'recent_connections': recent_connections,
|
'recent_connections': recent_connections,
|
||||||
'external_address': EXTERNAL_ADDRESS,
|
'external_address': EXTERNAL_ADDRESS,
|
||||||
|
'has_xray_servers': has_xray_servers,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(f"Context prepared with keys: {list(context.keys())}")
|
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' }")
|
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