mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-12-16 17:37:51 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
777af49ebf | ||
|
|
d4042435fe | ||
|
|
f304825836 | ||
|
|
c4057180b9 | ||
|
|
7584e80477 | ||
|
|
95e0d08b51 | ||
| 57cef79748 | |||
|
|
9158e330e5 | ||
|
|
14590aaddc | ||
|
|
afd7ad2b28 | ||
|
|
36f9e495b5 | ||
|
|
402e4d84fc | ||
|
|
c148bb99dc | ||
|
|
dcad41711e | ||
|
|
05465f9595 | ||
|
|
4c32679d86 | ||
|
|
397e05b3cc | ||
|
|
99b79c38a0 | ||
|
|
042ce6bd3f | ||
|
|
9363bd4db8 | ||
|
|
2fe59062c9 | ||
|
|
fe56811b33 | ||
|
|
787432cbcf | ||
|
|
56b0b160e3 | ||
|
|
1f7953a74c |
@@ -11,7 +11,7 @@
|
||||
|
||||
  
|
||||
|
||||
<img width="1454" alt="image" src="https://github.com/user-attachments/assets/20555dd9-54ea-4b95-aa13-a7dd54e34ef4" />
|
||||
<img width="1282" height="840" alt="image" src="https://github.com/user-attachments/assets/3b66f928-853b-4af0-8968-1eacb2c16a1c" />
|
||||
|
||||
## About The Project
|
||||
|
||||
|
||||
@@ -89,6 +89,11 @@ LOGGING = {
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
},
|
||||
'telegram_bot': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
},
|
||||
'requests': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
@@ -115,6 +120,7 @@ INSTALLED_APPS = [
|
||||
'django_celery_results',
|
||||
'django_celery_beat',
|
||||
'vpn',
|
||||
'telegram_bot',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -23,7 +23,7 @@ urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('ss/<path:link>', shadowsocks, name='shadowsocks'),
|
||||
path('dynamic/<path:link>', shadowsocks, name='shadowsocks'),
|
||||
path('xray/<path:link>', xray_subscription, name='xray_subscription'),
|
||||
path('xray/<str:user_hash>', xray_subscription, name='xray_subscription'),
|
||||
path('stat/<path:user_hash>', userFrontend, name='userFrontend'),
|
||||
path('u/<path:user_hash>', userPortal, name='userPortal'),
|
||||
path('', RedirectView.as_view(url='/admin/', permanent=False)),
|
||||
|
||||
@@ -16,3 +16,7 @@ psycopg2-binary==2.9.10
|
||||
setuptools==75.2.0
|
||||
shortuuid==1.0.13
|
||||
cryptography==45.0.5
|
||||
acme>=2.0.0
|
||||
cloudflare>=4.3.1
|
||||
josepy>=2.0.0
|
||||
python-telegram-bot==21.10
|
||||
|
||||
239
static/admin/css/main.css
Normal file
239
static/admin/css/main.css
Normal file
@@ -0,0 +1,239 @@
|
||||
/* static/admin/css/main.css */
|
||||
|
||||
/* Bulk Action Section Styling */
|
||||
.bulk-actions-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 1px solid #dee2e6;
|
||||
border-left: 4px solid #007cba;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.bulk-actions-section h3 {
|
||||
color: #007cba;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.bulk-actions-section p {
|
||||
color: #6c757d;
|
||||
margin-bottom: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Action Button Styles */
|
||||
.server-action-btn, .bulk-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.server-action-btn:before, .bulk-action-btn:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.server-action-btn:hover:before, .bulk-action-btn:hover:before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.server-action-btn:hover, .bulk-action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Specific button colors */
|
||||
.btn-move-clients {
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-move-clients:hover {
|
||||
background-color: #005a8b !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-purge-users {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-purge-users:hover {
|
||||
background-color: #c82333 !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Server list action buttons */
|
||||
.field-server_actions {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.field-server_actions .server-action-btn {
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
gap: 4px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
/* Server statistics section */
|
||||
.server-stats-section {
|
||||
background-color: #e8f4fd;
|
||||
border: 1px solid #bee5eb;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.server-stats-grid {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #007cba;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Tip section styling */
|
||||
.tip-section {
|
||||
background-color: rgba(255, 243, 205, 0.8);
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.tip-section small {
|
||||
color: #856404;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.server-action-btn.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.server-action-btn.loading:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: auto;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.bulk-actions-section {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.server-action-btn, .bulk-action-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.server-stats-grid {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-server_actions > div {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.field-server_actions .server-action-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bulk-actions-section h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.server-action-btn, .bulk-action-btn {
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bulk-actions-section {
|
||||
background: linear-gradient(135deg, #2d3748 0%, #4a5568 100%);
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.bulk-actions-section h3 {
|
||||
color: #63b3ed;
|
||||
}
|
||||
|
||||
.bulk-actions-section p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.server-stats-section {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #63b3ed;
|
||||
}
|
||||
}
|
||||
@@ -333,3 +333,10 @@ table.changelist-results td:nth-child(6) {
|
||||
margin: 0 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Hide xray-subscriptions tab if it appears */
|
||||
#xray-subscriptions-tab,
|
||||
a[href="#xray-subscriptions-tab"],
|
||||
li:has(a[href="#xray-subscriptions-tab"]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
0
telegram_bot/__init__.py
Normal file
0
telegram_bot/__init__.py
Normal file
854
telegram_bot/admin.py
Normal file
854
telegram_bot/admin.py
Normal file
@@ -0,0 +1,854 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import path, reverse
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from django.utils import timezone
|
||||
from django import forms
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from .models import BotSettings, TelegramMessage, AccessRequest
|
||||
from .localization import MessageLocalizer
|
||||
from vpn.models import User
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotSettingsAdminForm(forms.ModelForm):
|
||||
"""Custom form for BotSettings with Telegram admin selection"""
|
||||
|
||||
class Meta:
|
||||
model = BotSettings
|
||||
fields = '__all__'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Show all users for telegram_admins selection
|
||||
if 'telegram_admins' in self.fields:
|
||||
self.fields['telegram_admins'].queryset = User.objects.all().order_by('username')
|
||||
self.fields['telegram_admins'].help_text = (
|
||||
"Select users who will have admin access in the bot. "
|
||||
"Users will get admin rights when they connect to the bot with their Telegram account."
|
||||
)
|
||||
|
||||
def clean_telegram_admins(self):
|
||||
"""Validate that selected admins have telegram_user_id or telegram_username"""
|
||||
admins = self.cleaned_data.get('telegram_admins')
|
||||
# No validation needed - admins can be selected even without telegram connection
|
||||
# They will get admin rights when they connect via bot
|
||||
return admins
|
||||
|
||||
|
||||
class AccessRequestAdminForm(forms.ModelForm):
|
||||
"""Custom form for AccessRequest with existing user selection"""
|
||||
|
||||
class Meta:
|
||||
model = AccessRequest
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'selected_subscription_groups': FilteredSelectMultiple(
|
||||
verbose_name='Subscription Groups',
|
||||
is_stacked=False
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Rename the field for better UI
|
||||
if 'selected_existing_user' in self.fields:
|
||||
self.fields['selected_existing_user'].label = 'Link to existing user'
|
||||
self.fields['selected_existing_user'].empty_label = "— Create new user —"
|
||||
self.fields['selected_existing_user'].help_text = "Select an existing user without Telegram to link, or leave empty to create new user"
|
||||
# Get users without telegram_user_id
|
||||
from vpn.models import User
|
||||
self.fields['selected_existing_user'].queryset = User.objects.filter(
|
||||
telegram_user_id__isnull=True
|
||||
).order_by('username')
|
||||
|
||||
# Configure subscription group fields
|
||||
if 'selected_subscription_groups' in self.fields:
|
||||
from vpn.models_xray import SubscriptionGroup
|
||||
self.fields['selected_subscription_groups'].queryset = SubscriptionGroup.objects.filter(
|
||||
is_active=True
|
||||
).order_by('name')
|
||||
self.fields['selected_subscription_groups'].label = 'Subscription Groups'
|
||||
self.fields['selected_subscription_groups'].help_text = 'Select subscription groups to assign to this user'
|
||||
|
||||
|
||||
@admin.register(BotSettings)
|
||||
class BotSettingsAdmin(admin.ModelAdmin):
|
||||
form = BotSettingsAdminForm
|
||||
list_display = ('__str__', 'enabled', 'bot_token_display', 'admin_count_display', 'updated_at')
|
||||
fieldsets = (
|
||||
('Bot Configuration', {
|
||||
'fields': ('bot_token', 'enabled', 'bot_status_display'),
|
||||
'description': 'Configure bot settings and view current status'
|
||||
}),
|
||||
('Admin Management', {
|
||||
'fields': ('telegram_admins', 'admin_info_display'),
|
||||
'description': 'Select users with linked Telegram accounts who will have admin access in the bot'
|
||||
}),
|
||||
('Connection Settings', {
|
||||
'fields': ('api_base_url', 'connection_timeout', 'use_proxy', 'proxy_url'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
readonly_fields = ('created_at', 'updated_at', 'bot_status_display', 'admin_info_display')
|
||||
filter_horizontal = ('telegram_admins',)
|
||||
|
||||
def bot_token_display(self, obj):
|
||||
"""Mask bot token for security"""
|
||||
if obj.bot_token:
|
||||
token = obj.bot_token
|
||||
if len(token) > 10:
|
||||
return f"{token[:6]}...{token[-4:]}"
|
||||
return "Token set"
|
||||
return "No token set"
|
||||
bot_token_display.short_description = "Bot Token"
|
||||
|
||||
def admin_count_display(self, obj):
|
||||
"""Display count of Telegram admins"""
|
||||
count = obj.telegram_admins.count()
|
||||
if count == 0:
|
||||
return "No admins"
|
||||
elif count == 1:
|
||||
return "1 admin"
|
||||
else:
|
||||
return f"{count} admins"
|
||||
admin_count_display.short_description = "Telegram Admins"
|
||||
|
||||
def admin_info_display(self, obj):
|
||||
"""Display detailed admin information"""
|
||||
if not obj.pk:
|
||||
return "Save settings first to manage admins"
|
||||
|
||||
admins = obj.telegram_admins.all()
|
||||
|
||||
if not admins.exists():
|
||||
html = '<div style="background: #fff3cd; padding: 10px; border-radius: 4px; border-left: 4px solid #ffc107;">'
|
||||
html += '<p style="margin: 0; color: #856404;"><strong>⚠️ No Telegram admins configured</strong></p>'
|
||||
html += '<p style="margin: 5px 0 0 0; color: #856404;">Select users above to give them admin access in the Telegram bot.</p>'
|
||||
html += '</div>'
|
||||
else:
|
||||
html = '<div style="background: #d4edda; padding: 10px; border-radius: 4px; border-left: 4px solid #28a745;">'
|
||||
html += f'<p style="margin: 0; color: #155724;"><strong>✅ {admins.count()} Telegram admin(s) configured</strong></p>'
|
||||
html += '<div style="margin-top: 8px;">'
|
||||
|
||||
for admin in admins:
|
||||
html += '<div style="background: white; margin: 4px 0; padding: 6px 10px; border-radius: 3px; border: 1px solid #c3e6cb;">'
|
||||
html += f'<strong>{admin.username}</strong>'
|
||||
|
||||
if admin.telegram_username:
|
||||
html += f' (@{admin.telegram_username})'
|
||||
|
||||
html += f' <small style="color: #6c757d;">ID: {admin.telegram_user_id}</small>'
|
||||
|
||||
if admin.first_name or admin.last_name:
|
||||
name_parts = []
|
||||
if admin.first_name:
|
||||
name_parts.append(admin.first_name)
|
||||
if admin.last_name:
|
||||
name_parts.append(admin.last_name)
|
||||
html += f'<br><small style="color: #6c757d;">Name: {" ".join(name_parts)}</small>'
|
||||
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
html += '<p style="margin: 8px 0 0 0; color: #155724; font-size: 12px;">These users will receive notifications about new access requests and can approve/reject them directly in Telegram.</p>'
|
||||
html += '</div>'
|
||||
|
||||
return format_html(html)
|
||||
admin_info_display.short_description = "Admin Configuration"
|
||||
|
||||
def bot_status_display(self, obj):
|
||||
"""Display bot status with control buttons"""
|
||||
from .bot import TelegramBotManager
|
||||
import os
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
manager = TelegramBotManager()
|
||||
|
||||
# Check if lock file exists - only reliable indicator
|
||||
lock_dir = os.path.join(getattr(django_settings, 'BASE_DIR', '/tmp'), 'telegram_bot_locks')
|
||||
lock_path = os.path.join(lock_dir, 'telegram_bot.lock')
|
||||
is_running = os.path.exists(lock_path)
|
||||
|
||||
if is_running:
|
||||
status_html = '<span style="color: green; font-weight: bold;">🟢 Bot is RUNNING</span>'
|
||||
else:
|
||||
status_html = '<span style="color: red; font-weight: bold;">🔴 Bot is STOPPED</span>'
|
||||
|
||||
# Add control buttons
|
||||
status_html += '<br><br>'
|
||||
if is_running:
|
||||
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_stop_bot")}">Stop Bot</a> '
|
||||
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_restart_bot")}">Restart Bot</a>'
|
||||
else:
|
||||
if obj.enabled and obj.bot_token:
|
||||
status_html += f'<a class="button" href="{reverse("admin:telegram_bot_start_bot")}">Start Bot</a>'
|
||||
else:
|
||||
status_html += '<span style="color: gray;">Configure bot token and enable bot to start</span>'
|
||||
|
||||
return format_html(status_html)
|
||||
bot_status_display.short_description = "Bot Status"
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('start-bot/', self.start_bot, name='telegram_bot_start_bot'),
|
||||
path('stop-bot/', self.stop_bot, name='telegram_bot_stop_bot'),
|
||||
path('restart-bot/', self.restart_bot, name='telegram_bot_restart_bot'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def start_bot(self, request):
|
||||
"""Start the telegram bot"""
|
||||
try:
|
||||
from .bot import TelegramBotManager
|
||||
manager = TelegramBotManager()
|
||||
manager.start()
|
||||
messages.success(request, "Bot started successfully!")
|
||||
except Exception as e:
|
||||
messages.error(request, f"Failed to start bot: {e}")
|
||||
logger.error(f"Failed to start bot: {e}")
|
||||
|
||||
return redirect('admin:telegram_bot_botsettings_change', object_id=1)
|
||||
|
||||
def stop_bot(self, request):
|
||||
"""Stop the telegram bot"""
|
||||
try:
|
||||
from .bot import TelegramBotManager
|
||||
manager = TelegramBotManager()
|
||||
manager.stop()
|
||||
messages.success(request, "Bot stopped successfully!")
|
||||
except Exception as e:
|
||||
messages.error(request, f"Failed to stop bot: {e}")
|
||||
logger.error(f"Failed to stop bot: {e}")
|
||||
|
||||
return redirect('admin:telegram_bot_botsettings_change', object_id=1)
|
||||
|
||||
def restart_bot(self, request):
|
||||
"""Restart the telegram bot"""
|
||||
try:
|
||||
from .bot import TelegramBotManager
|
||||
manager = TelegramBotManager()
|
||||
manager.restart()
|
||||
messages.success(request, "Bot restarted successfully!")
|
||||
except Exception as e:
|
||||
messages.error(request, f"Failed to restart bot: {e}")
|
||||
logger.error(f"Failed to restart bot: {e}")
|
||||
|
||||
return redirect('admin:telegram_bot_botsettings_change', object_id=1)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Prevent creating multiple instances
|
||||
return not BotSettings.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
# Prevent deletion
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(TelegramMessage)
|
||||
class TelegramMessageAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'created_at',
|
||||
'direction_display',
|
||||
'user_display',
|
||||
'language_display',
|
||||
'message_preview',
|
||||
'linked_user'
|
||||
)
|
||||
list_filter = (
|
||||
'direction',
|
||||
'created_at',
|
||||
('linked_user', admin.EmptyFieldListFilter),
|
||||
)
|
||||
search_fields = (
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
'message_text',
|
||||
'telegram_user_id'
|
||||
)
|
||||
readonly_fields = (
|
||||
'direction',
|
||||
'telegram_user_id',
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
'chat_id',
|
||||
'message_id',
|
||||
'message_text',
|
||||
'raw_data_display',
|
||||
'created_at',
|
||||
'linked_user',
|
||||
'user_language'
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Message Info', {
|
||||
'fields': (
|
||||
'direction',
|
||||
'message_text',
|
||||
'created_at'
|
||||
)
|
||||
}),
|
||||
('Telegram User', {
|
||||
'fields': (
|
||||
'telegram_user_id',
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
)
|
||||
}),
|
||||
('Technical Details', {
|
||||
'fields': (
|
||||
'chat_id',
|
||||
'message_id',
|
||||
'linked_user',
|
||||
'raw_data_display'
|
||||
),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
ordering = ['-created_at']
|
||||
list_per_page = 50
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
def direction_display(self, obj):
|
||||
"""Display direction with icon"""
|
||||
if obj.direction == 'incoming':
|
||||
return format_html('<span style="color: blue;">⬇️ Incoming</span>')
|
||||
else:
|
||||
return format_html('<span style="color: green;">⬆️ Outgoing</span>')
|
||||
direction_display.short_description = "Direction"
|
||||
|
||||
def user_display(self, obj):
|
||||
"""Display user info"""
|
||||
display = obj.display_name
|
||||
if obj.telegram_user_id:
|
||||
display += f" (ID: {obj.telegram_user_id})"
|
||||
return display
|
||||
user_display.short_description = "Telegram User"
|
||||
|
||||
def language_display(self, obj):
|
||||
"""Display user language"""
|
||||
lang_map = {'ru': '🇷🇺 RU', 'en': '🇺🇸 EN'}
|
||||
return lang_map.get(obj.user_language, obj.user_language or 'Unknown')
|
||||
language_display.short_description = "Language"
|
||||
|
||||
def message_preview(self, obj):
|
||||
"""Show message preview"""
|
||||
if len(obj.message_text) > 100:
|
||||
return obj.message_text[:100] + "..."
|
||||
return obj.message_text
|
||||
message_preview.short_description = "Message"
|
||||
|
||||
def raw_data_display(self, obj):
|
||||
"""Display raw data as formatted JSON"""
|
||||
import json
|
||||
if obj.raw_data:
|
||||
formatted = json.dumps(obj.raw_data, indent=2, ensure_ascii=False)
|
||||
return format_html('<pre style="max-width: 800px; overflow: auto;">{}</pre>', formatted)
|
||||
return "No raw data"
|
||||
raw_data_display.short_description = "Raw Data"
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Messages are created automatically by bot
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
# Messages are read-only
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
# Allow deletion for cleanup
|
||||
return request.user.is_superuser
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Add custom actions"""
|
||||
actions = super().get_actions(request)
|
||||
if not request.user.is_superuser:
|
||||
# Remove delete action for non-superusers
|
||||
if 'delete_selected' in actions:
|
||||
del actions['delete_selected']
|
||||
return actions
|
||||
|
||||
|
||||
@admin.register(AccessRequest)
|
||||
class AccessRequestAdmin(admin.ModelAdmin):
|
||||
form = AccessRequestAdminForm
|
||||
list_display = (
|
||||
'created_at',
|
||||
'user_display',
|
||||
'approved_display',
|
||||
'language_display',
|
||||
'desired_username_display',
|
||||
'message_preview',
|
||||
'created_user',
|
||||
'processed_by'
|
||||
)
|
||||
list_filter = (
|
||||
'approved',
|
||||
'created_at',
|
||||
'processed_at',
|
||||
('processed_by', admin.EmptyFieldListFilter),
|
||||
)
|
||||
search_fields = (
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
'telegram_user_id',
|
||||
'message_text'
|
||||
)
|
||||
readonly_fields = (
|
||||
'telegram_user_id',
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
'message_text',
|
||||
'chat_id',
|
||||
'created_at',
|
||||
'first_message',
|
||||
'processed_at',
|
||||
'processed_by',
|
||||
'created_user',
|
||||
'user_language'
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Request Info', {
|
||||
'fields': (
|
||||
'approved',
|
||||
'admin_comment',
|
||||
'created_at',
|
||||
'processed_at',
|
||||
'processed_by'
|
||||
)
|
||||
}),
|
||||
('User Creation', {
|
||||
'fields': (
|
||||
'selected_existing_user',
|
||||
'desired_username',
|
||||
),
|
||||
'description': 'Choose existing user to link OR specify username for new user'
|
||||
}),
|
||||
('VPN Access Configuration', {
|
||||
'fields': (
|
||||
'selected_subscription_groups',
|
||||
),
|
||||
'description': 'Select subscription groups to assign to the user'
|
||||
}),
|
||||
('Telegram User', {
|
||||
'fields': (
|
||||
'telegram_user_id',
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
)
|
||||
}),
|
||||
('Message Details', {
|
||||
'fields': (
|
||||
'message_text',
|
||||
'chat_id',
|
||||
'first_message'
|
||||
),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Processing Results', {
|
||||
'fields': (
|
||||
'created_user',
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
ordering = ['-created_at']
|
||||
list_per_page = 50
|
||||
date_hierarchy = 'created_at'
|
||||
actions = ['approve_requests']
|
||||
|
||||
def user_display(self, obj):
|
||||
"""Display user info"""
|
||||
return obj.display_name
|
||||
user_display.short_description = "Telegram User"
|
||||
|
||||
def approved_display(self, obj):
|
||||
"""Display approved status with colors"""
|
||||
if obj.approved:
|
||||
return format_html('<span style="color: green; font-weight: bold;">✅ Approved</span>')
|
||||
else:
|
||||
return format_html('<span style="color: orange; font-weight: bold;">🔄 Pending</span>')
|
||||
approved_display.short_description = "Status"
|
||||
|
||||
def message_preview(self, obj):
|
||||
"""Show message preview"""
|
||||
if len(obj.message_text) > 100:
|
||||
return obj.message_text[:100] + "..."
|
||||
return obj.message_text
|
||||
message_preview.short_description = "Message"
|
||||
|
||||
def desired_username_display(self, obj):
|
||||
"""Display desired username"""
|
||||
if obj.desired_username:
|
||||
return obj.desired_username
|
||||
else:
|
||||
fallback = obj.telegram_username or obj.telegram_first_name or f"tg_{obj.telegram_user_id}"
|
||||
return format_html('<span style="color: gray; font-style: italic;">{}</span>', fallback)
|
||||
desired_username_display.short_description = "Desired Username"
|
||||
|
||||
def language_display(self, obj):
|
||||
"""Display user language with flag"""
|
||||
lang_map = {'ru': '🇷🇺 RU', 'en': '🇺🇸 EN'}
|
||||
return lang_map.get(obj.user_language, obj.user_language or 'Unknown')
|
||||
language_display.short_description = "Language"
|
||||
|
||||
def approve_requests(self, request, queryset):
|
||||
"""Approve selected access requests"""
|
||||
pending_requests = queryset.filter(approved=False)
|
||||
count = 0
|
||||
errors = []
|
||||
|
||||
for access_request in pending_requests:
|
||||
try:
|
||||
logger.info(f"Approving request {access_request.id} from user {access_request.telegram_user_id}")
|
||||
user = self._create_user_from_request(access_request, request.user)
|
||||
|
||||
if user:
|
||||
access_request.approved = True
|
||||
access_request.processed_by = request.user
|
||||
access_request.processed_at = timezone.now()
|
||||
access_request.created_user = user
|
||||
access_request.save()
|
||||
|
||||
logger.info(f"Successfully approved request {access_request.id}, created user {user.username}")
|
||||
|
||||
# Send notification to user
|
||||
self._send_approval_notification(access_request)
|
||||
count += 1
|
||||
else:
|
||||
errors.append(f"Failed to create user for {access_request.display_name}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to approve request from {access_request.display_name}: {e}"
|
||||
logger.error(error_msg)
|
||||
errors.append(error_msg)
|
||||
|
||||
if count:
|
||||
messages.success(request, f"Successfully approved {count} request(s)")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
messages.error(request, error)
|
||||
|
||||
approve_requests.short_description = "✅ Approve selected requests"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Override save to handle existing user linking"""
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
# If approved and existing user was selected, link them
|
||||
if obj.approved and obj.selected_existing_user and not obj.created_user:
|
||||
try:
|
||||
# Link telegram data to selected user
|
||||
obj.selected_existing_user.telegram_user_id = obj.telegram_user_id
|
||||
obj.selected_existing_user.telegram_username = obj.telegram_username
|
||||
obj.selected_existing_user.telegram_first_name = obj.telegram_first_name or ""
|
||||
obj.selected_existing_user.telegram_last_name = obj.telegram_last_name or ""
|
||||
obj.selected_existing_user.save()
|
||||
|
||||
# Update the request to reference the linked user
|
||||
obj.created_user = obj.selected_existing_user
|
||||
obj.processed_by = request.user
|
||||
obj.processed_at = timezone.now()
|
||||
obj.save()
|
||||
|
||||
# Assign VPN access to the linked user
|
||||
try:
|
||||
self._assign_vpn_access(obj.selected_existing_user, obj)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to assign VPN access: {e}")
|
||||
messages.warning(request, f"User linked but VPN access assignment failed: {e}")
|
||||
|
||||
# Send notification
|
||||
self._send_approval_notification(obj)
|
||||
|
||||
messages.success(request, f"Successfully linked Telegram user to existing user {obj.selected_existing_user.username}")
|
||||
logger.info(f"Linked Telegram user {obj.telegram_user_id} to existing user {obj.selected_existing_user.username}")
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Failed to link existing user: {e}")
|
||||
logger.error(f"Failed to link existing user: {e}")
|
||||
|
||||
def _create_user_from_request(self, access_request, admin_user):
|
||||
"""Create User from AccessRequest or link to existing user"""
|
||||
from vpn.models import User
|
||||
import secrets
|
||||
import string
|
||||
|
||||
try:
|
||||
# Check if user already exists by telegram_user_id
|
||||
existing_user = User.objects.filter(telegram_user_id=access_request.telegram_user_id).first()
|
||||
if existing_user:
|
||||
logger.info(f"User already exists: {existing_user.username}")
|
||||
return existing_user
|
||||
|
||||
# Check if admin selected an existing user to link
|
||||
if access_request.selected_existing_user:
|
||||
selected_user = access_request.selected_existing_user
|
||||
logger.info(f"Linking Telegram user {access_request.telegram_user_id} to selected existing user {selected_user.username}")
|
||||
|
||||
# Link telegram data to selected user
|
||||
selected_user.telegram_user_id = access_request.telegram_user_id
|
||||
selected_user.telegram_username = access_request.telegram_username
|
||||
selected_user.telegram_first_name = access_request.telegram_first_name or ""
|
||||
selected_user.telegram_last_name = access_request.telegram_last_name or ""
|
||||
selected_user.save()
|
||||
|
||||
# Assign VPN access to the linked user
|
||||
try:
|
||||
self._assign_vpn_access(selected_user, access_request)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to assign VPN access to user {selected_user.username}: {e}")
|
||||
|
||||
return selected_user
|
||||
|
||||
# Check if we can link to existing user by telegram_username
|
||||
if access_request.telegram_username:
|
||||
existing_user_by_username = User.objects.filter(
|
||||
telegram_username__iexact=access_request.telegram_username,
|
||||
telegram_user_id__isnull=True # Not yet linked to Telegram
|
||||
).first()
|
||||
|
||||
if existing_user_by_username:
|
||||
# Link telegram data to existing user
|
||||
logger.info(f"Linking Telegram @{access_request.telegram_username} to existing user {existing_user_by_username.username}")
|
||||
existing_user_by_username.telegram_user_id = access_request.telegram_user_id
|
||||
existing_user_by_username.telegram_username = access_request.telegram_username
|
||||
existing_user_by_username.telegram_first_name = access_request.telegram_first_name or ""
|
||||
existing_user_by_username.telegram_last_name = access_request.telegram_last_name or ""
|
||||
existing_user_by_username.save()
|
||||
|
||||
# Assign VPN access to the linked user
|
||||
try:
|
||||
self._assign_vpn_access(existing_user_by_username, access_request)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to assign VPN access to user {existing_user_by_username.username}: {e}")
|
||||
|
||||
return existing_user_by_username
|
||||
|
||||
# Use desired_username if provided, otherwise fallback to Telegram data
|
||||
username = access_request.desired_username
|
||||
if not username:
|
||||
# Fallback to telegram_username, first_name or user_id
|
||||
username = access_request.telegram_username or access_request.telegram_first_name or f"tg_{access_request.telegram_user_id}"
|
||||
|
||||
# Clean username (remove special characters)
|
||||
username = ''.join(c for c in username if c.isalnum() or c in '_-').lower()
|
||||
if not username:
|
||||
username = f"tg_{access_request.telegram_user_id}"
|
||||
|
||||
# Make sure username is unique
|
||||
original_username = username
|
||||
counter = 1
|
||||
while User.objects.filter(username=username).exists():
|
||||
username = f"{original_username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# Create new user since no existing user found to link
|
||||
# Generate random password
|
||||
password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
|
||||
|
||||
logger.info(f"Creating new user with username: {username}")
|
||||
|
||||
# Create user
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
password=password,
|
||||
first_name=access_request.telegram_first_name or "",
|
||||
last_name=access_request.telegram_last_name or "",
|
||||
telegram_user_id=access_request.telegram_user_id,
|
||||
telegram_username=access_request.telegram_username or "",
|
||||
telegram_first_name=access_request.telegram_first_name or "",
|
||||
telegram_last_name=access_request.telegram_last_name or "",
|
||||
is_active=True
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created user {user.username} (ID: {user.id}) from Telegram request {access_request.id}")
|
||||
|
||||
# Assign VPN access (inbounds and subscription groups)
|
||||
try:
|
||||
self._assign_vpn_access(user, access_request)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to assign VPN access to user {user.username}: {e}")
|
||||
# Continue even if VPN assignment fails - user is already created
|
||||
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating user from request {access_request.id}: {e}")
|
||||
raise
|
||||
|
||||
def _assign_vpn_access(self, user, access_request):
|
||||
"""Assign selected subscription groups to the user"""
|
||||
try:
|
||||
from vpn.models_xray import UserSubscription
|
||||
|
||||
# Assign subscription groups
|
||||
group_count = 0
|
||||
for subscription_group in access_request.selected_subscription_groups.all():
|
||||
user_subscription, created = UserSubscription.objects.get_or_create(
|
||||
user=user,
|
||||
subscription_group=subscription_group,
|
||||
defaults={'active': True}
|
||||
)
|
||||
if created:
|
||||
logger.info(f"Assigned subscription group '{subscription_group.name}' to user {user.username}")
|
||||
group_count += 1
|
||||
else:
|
||||
# Ensure it's active if it already existed
|
||||
if not user_subscription.active:
|
||||
user_subscription.active = True
|
||||
user_subscription.save()
|
||||
logger.info(f"Re-activated subscription group '{subscription_group.name}' for user {user.username}")
|
||||
group_count += 1
|
||||
|
||||
logger.info(f"Successfully assigned {group_count} subscription groups to user {user.username}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error assigning VPN access to user {user.username}: {e}")
|
||||
raise
|
||||
|
||||
def _send_approval_notification(self, access_request):
|
||||
"""Send approval notification via Telegram"""
|
||||
try:
|
||||
from .models import BotSettings
|
||||
from telegram import Bot
|
||||
import asyncio
|
||||
|
||||
settings = BotSettings.get_settings()
|
||||
if not settings.enabled or not settings.bot_token:
|
||||
logger.warning("Bot not configured, skipping notification")
|
||||
return
|
||||
|
||||
# Create a simple Bot instance for sending notification
|
||||
# This bypasses the need for the running bot manager
|
||||
async def send_notification():
|
||||
try:
|
||||
# Create bot with custom request settings
|
||||
from telegram.request import HTTPXRequest
|
||||
|
||||
request_kwargs = {
|
||||
'connection_pool_size': 1,
|
||||
'read_timeout': settings.connection_timeout,
|
||||
'write_timeout': settings.connection_timeout,
|
||||
'connect_timeout': settings.connection_timeout,
|
||||
}
|
||||
|
||||
if settings.use_proxy and settings.proxy_url:
|
||||
request_kwargs['proxy'] = settings.proxy_url
|
||||
|
||||
request = HTTPXRequest(**request_kwargs)
|
||||
bot = Bot(token=settings.bot_token, request=request)
|
||||
|
||||
# Send localized approval message with new keyboard
|
||||
from telegram import ReplyKeyboardMarkup, KeyboardButton
|
||||
language = access_request.user_language or 'en'
|
||||
|
||||
# Get localized texts
|
||||
message = MessageLocalizer.get_message('approval_notification', language)
|
||||
access_btn_text = MessageLocalizer.get_button_text('access', language)
|
||||
|
||||
# Create keyboard with Access button
|
||||
keyboard = [[KeyboardButton(access_btn_text)]]
|
||||
reply_markup = ReplyKeyboardMarkup(
|
||||
keyboard,
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=False
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=access_request.telegram_user_id,
|
||||
text=message,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
|
||||
logger.info(f"Sent approval notification to {access_request.telegram_user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram message: {e}")
|
||||
finally:
|
||||
try:
|
||||
# Clean up bot connection
|
||||
await request.shutdown()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Run in thread to avoid blocking admin interface
|
||||
import threading
|
||||
|
||||
def run_async_notification():
|
||||
try:
|
||||
asyncio.run(send_notification())
|
||||
except Exception as e:
|
||||
logger.error(f"Error in notification thread: {e}")
|
||||
|
||||
thread = threading.Thread(target=run_async_notification, daemon=True)
|
||||
thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send approval notification: {e}")
|
||||
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Requests are created by bot
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
# Allow changing only status and comment
|
||||
return True
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Automatically handle approval and user creation"""
|
||||
# Check if this is a change to approved
|
||||
was_approved = False
|
||||
|
||||
# If desired_username was changed and is empty, set default from Telegram data
|
||||
if change and 'desired_username' in form.changed_data and not obj.desired_username:
|
||||
obj.desired_username = obj.telegram_username or obj.telegram_first_name or f"tg_{obj.telegram_user_id}"
|
||||
|
||||
if change and 'approved' in form.changed_data and obj.approved:
|
||||
# Set processed_by and processed_at
|
||||
if not obj.processed_by:
|
||||
obj.processed_by = request.user
|
||||
if not obj.processed_at:
|
||||
obj.processed_at = timezone.now()
|
||||
was_approved = True
|
||||
|
||||
# If approved and no user created yet, create user
|
||||
if was_approved and not obj.created_user:
|
||||
try:
|
||||
logger.info(f"Auto-creating user for approved request {obj.id}")
|
||||
user = self._create_user_from_request(obj, request.user)
|
||||
if user:
|
||||
obj.created_user = user
|
||||
messages.success(request, f"User '{user.username}' created successfully!")
|
||||
logger.info(f"Auto-created user {user.username} for request {obj.id}")
|
||||
|
||||
# Send approval notification
|
||||
self._send_approval_notification(obj)
|
||||
else:
|
||||
messages.error(request, f"Failed to create user for approved request {obj.id}")
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error creating user: {e}")
|
||||
logger.error(f"Error auto-creating user for request {obj.id}: {e}")
|
||||
|
||||
# Save the object
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
75
telegram_bot/apps.py
Normal file
75
telegram_bot/apps.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from django.apps import AppConfig
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramBotConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'telegram_bot'
|
||||
|
||||
def ready(self):
|
||||
"""Called when Django starts - attempt to auto-start bot if enabled"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Skip auto-start in various scenarios
|
||||
skip_conditions = [
|
||||
# Management commands
|
||||
'migrate' in sys.argv,
|
||||
'makemigrations' in sys.argv,
|
||||
'collectstatic' in sys.argv,
|
||||
'shell' in sys.argv,
|
||||
'test' in sys.argv,
|
||||
# Celery processes
|
||||
'celery' in sys.argv,
|
||||
'worker' in sys.argv,
|
||||
'beat' in sys.argv,
|
||||
# Environment variables that indicate worker/beat processes
|
||||
os.environ.get('CELERY_WORKER_NAME'),
|
||||
os.environ.get('CELERY_BEAT'),
|
||||
# Process name detection
|
||||
any('celery' in arg.lower() for arg in sys.argv),
|
||||
any('worker' in arg.lower() for arg in sys.argv),
|
||||
any('beat' in arg.lower() for arg in sys.argv),
|
||||
]
|
||||
|
||||
if any(skip_conditions):
|
||||
logger.info(f"Skipping Telegram bot auto-start in process: {' '.join(sys.argv)}")
|
||||
return
|
||||
|
||||
# Additional process detection by checking if we're in main process
|
||||
try:
|
||||
# Check if this is the main Django process (not a worker)
|
||||
current_process = os.environ.get('DJANGO_SETTINGS_MODULE')
|
||||
if not current_process:
|
||||
logger.info("Skipping bot auto-start: not in main Django process")
|
||||
return
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delay import to avoid circular imports
|
||||
try:
|
||||
from .bot import TelegramBotManager
|
||||
import threading
|
||||
import time
|
||||
|
||||
def delayed_autostart():
|
||||
# Wait a bit for Django to fully initialize
|
||||
time.sleep(2)
|
||||
try:
|
||||
manager = TelegramBotManager()
|
||||
if manager.auto_start_if_enabled():
|
||||
logger.info("Telegram bot auto-started successfully")
|
||||
else:
|
||||
logger.info("Telegram bot auto-start skipped (disabled or already running)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-start Telegram bot: {e}")
|
||||
|
||||
logger.info("Starting Telegram bot auto-start thread")
|
||||
# Start in background thread to not block Django startup
|
||||
thread = threading.Thread(target=delayed_autostart, daemon=True)
|
||||
thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up Telegram bot auto-start: {e}")
|
||||
1897
telegram_bot/bot.py
Normal file
1897
telegram_bot/bot.py
Normal file
File diff suppressed because it is too large
Load Diff
267
telegram_bot/localization.py
Normal file
267
telegram_bot/localization.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Message localization for Telegram bot
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Translation dictionaries
|
||||
MESSAGES = {
|
||||
'en': {
|
||||
'help_text': "📋 Welcome! Use buttons below to navigate.\n\n📊 Access - View your VPN subscriptions\n\nFor support contact administrator.",
|
||||
'access_request_created': "Access request created, please wait.",
|
||||
'new_user_welcome': "Welcome! To get access to VPN services, please request access using the button below.",
|
||||
'pending_request_msg': "Your access request is pending approval. Please wait for administrator to review it.",
|
||||
'choose_subscription': "**Choose subscription option:**",
|
||||
'all_in_one_desc': "🌍 **All-in-one** - Get all subscriptions in one link",
|
||||
'group_desc': "**Group** - Get specific group subscription",
|
||||
'select_option': "Select an option below:",
|
||||
'no_subscriptions': "❌ You don't have any active Xray subscriptions.\n\nPlease contact administrator for access.",
|
||||
'group_subscription': "**Group: {group_name}**",
|
||||
'subscription_link': "**🔗 Subscription Link:**",
|
||||
'web_portal': "**🌐 Web Portal:**",
|
||||
'tap_to_copy': "_Tap the subscription link to copy it. Use it in your Xray client._",
|
||||
'all_in_one_subscription': "🌍 **All-in-one Subscription**",
|
||||
'your_access_includes': "**Your Access Includes:**",
|
||||
'universal_subscription_link': "**🔗 Universal Subscription Link:**",
|
||||
'all_subscriptions_note': "_This link includes all your active subscriptions. Tap to copy._",
|
||||
'error_loading_subscriptions': "❌ Error loading subscriptions. Please try again later.",
|
||||
'error_loading_group': "❌ Error loading group subscription. Please try again later.",
|
||||
'received_content': "Received your {message_type}. An administrator will review it.",
|
||||
'approval_notification': "✅ Access approved!",
|
||||
'content_types': {
|
||||
'photo': 'photo',
|
||||
'document': 'document',
|
||||
'voice': 'voice',
|
||||
'video': 'video',
|
||||
'content': 'content'
|
||||
},
|
||||
'guide_title': "📖 **VPN Setup Guide**",
|
||||
'guide_choose_platform': "Select your device platform:",
|
||||
'web_portal_description': "_Web portal shows your access list on one convenient page with some statistics._",
|
||||
'servers_in_group': "🔒 **Servers in group:**",
|
||||
|
||||
# Admin messages
|
||||
'admin_new_request_notification': "🔔 **New Access Request**\n\n👤 **User:** {user_info}\n📱 **Telegram:** {telegram_info}\n📅 **Date:** {date}\n\n💬 **Message:** {message}",
|
||||
'admin_access_requests_title': "📋 **Pending Access Requests**",
|
||||
'admin_no_pending_requests': "✅ No pending access requests",
|
||||
'admin_request_item': "👤 **{user_info}**\n📅 {date}\n💬 _{message_preview}_",
|
||||
'admin_choose_subscription_groups': "📦 **Choose Subscription Groups for {user_info}:**\n\nSelect groups to assign to this user:",
|
||||
'admin_approval_success': "✅ **Request Approved!**\n\n👤 User: {user_info}\n📦 Groups: {groups}\n\nUser has been notified and given access.",
|
||||
'admin_rejection_success': "❌ **Request Rejected**\n\n👤 User: {user_info}\n\nUser has been notified.",
|
||||
'admin_request_already_processed': "⚠️ This request has already been processed by another admin.",
|
||||
'admin_error_processing': "❌ Error processing request: {error}",
|
||||
|
||||
'android_guide': "🤖 **Android Setup Guide**\n\n**Step 1: Install the app**\nDownload V2RayTUN from Google Play:\nhttps://play.google.com/store/apps/details?id=com.v2raytun.android\n\n**Step 2: Add subscription**\n• Open the app\n• Tap the **+** button in the top right corner\n• Paste your subscription link from the bot\n• The app will automatically load all VPN servers\n\n**Step 3: Connect**\n• Choose a server from the list\n• Tap **Connect**\n• All your traffic will now go through VPN\n\n**💡 Useful settings:**\n• In settings, enable direct access for banking apps and local sites\n• You can choose specific apps to use VPN while others use direct connection\n\n**🔄 If VPN stops working:**\nTap the refresh icon next to the server list to update your subscription.",
|
||||
'ios_guide': " **iOS Setup Guide**\n\n**Step 1: Install the app**\nDownload V2RayTUN from App Store:\nhttps://apps.apple.com/us/app/v2raytun/id6476628951\n\n**Step 2: Add subscription**\n• Open the app\n• Tap the **+** button in the top right corner\n• Paste your subscription link from the bot\n• The app will automatically load all VPN servers\n\n**Step 3: Connect**\n• Choose a server from the list\n• Tap **Connect**\n• All your traffic will now go through VPN\n\n**⚠️ Note for iOS users:**\nCurrently, only VLESS protocol works reliably on iOS. Other protocols may have connectivity issues.\n\n**💡 Useful settings:**\n• In settings, enable direct access for banking apps and local sites to improve performance\n\n**🔄 If VPN stops working:**\nTap the refresh icon next to the server list to update your subscription.",
|
||||
'buttons': {
|
||||
'access': "🌍 Get access",
|
||||
'guide': "📖 Guide",
|
||||
'android': "🤖 Android",
|
||||
'ios': " iOS",
|
||||
'web_portal': "🌐 Web Portal",
|
||||
'all_in_one': "🌍 All-in-one",
|
||||
'back': "⬅️ Back",
|
||||
'group_prefix': "Group: ",
|
||||
'request_access': "🔑 Request Access",
|
||||
# Admin buttons
|
||||
'access_requests': "📋 Access Requests",
|
||||
'approve': "✅ Approve",
|
||||
'reject': "❌ Reject",
|
||||
'details': "👁 Details",
|
||||
'confirm_approval': "✅ Confirm Approval",
|
||||
'confirm_rejection': "❌ Confirm Rejection",
|
||||
'cancel': "🚫 Cancel"
|
||||
}
|
||||
},
|
||||
'ru': {
|
||||
'help_text': "📋 Добро пожаловать! Используйте кнопки ниже для навигации.\n\n📊 Доступ - Просмотр VPN подписок\n\nДля поддержки обратитесь к администратору.",
|
||||
'access_request_created': "Запрос на доступ создан, ожидайте.",
|
||||
'new_user_welcome': "Добро пожаловать! Для получения доступа к VPN сервисам, пожалуйста запросите доступ с помощью кнопки ниже.",
|
||||
'pending_request_msg': "Ваш запрос на доступ ожидает одобрения. Пожалуйста, дождитесь рассмотрения администратором.",
|
||||
'choose_subscription': "**Выберите вариант подписки:**",
|
||||
'all_in_one_desc': "🌍 **Все в одном** - Получить все подписки в одной ссылке",
|
||||
'group_desc': "**Группа** - Получить подписку на группу",
|
||||
'select_option': "Выберите вариант ниже:",
|
||||
'no_subscriptions': "❌ У вас нет активных Xray подписок.\n\nОбратитесь к администратору для получения доступа.",
|
||||
'group_subscription': "**Группа: {group_name}**",
|
||||
'subscription_link': "**🔗 **",
|
||||
'web_portal': "**🌐 Веб-портал пользователя:**",
|
||||
'tap_to_copy': "_Нажмите на ссылку чтобы скопировать. Используйте в вашем Xray клиенте как подписку._",
|
||||
'all_in_one_subscription': "🌍 **Подписка «Все в одном»**",
|
||||
'your_access_includes': "**Ваш доступ включает:**",
|
||||
'universal_subscription_link': "**🔗 Универсальная ссылка на подписку:**",
|
||||
'all_subscriptions_note': "_Эта ссылка включает все ваши активные подписки. Нажмите чтобы скопировать._",
|
||||
'error_loading_subscriptions': "❌ Ошибка загрузки подписок. Попробуйте позже.",
|
||||
'error_loading_group': "❌ Ошибка загрузки подписки группы. Попробуйте позже.",
|
||||
'received_content': "Получен ваш {message_type}. Администратор его рассмотрит.",
|
||||
'approval_notification': "✅ Доступ одобрен!",
|
||||
'content_types': {
|
||||
'photo': 'фото',
|
||||
'document': 'документ',
|
||||
'voice': 'голосовое сообщение',
|
||||
'video': 'видео',
|
||||
'content': 'контент'
|
||||
},
|
||||
'guide_title': "📖 **Руководство по настройке VPN**",
|
||||
'guide_choose_platform': "Выберите платформу вашего устройства:",
|
||||
'web_portal_description': "_Веб-портал показывает список ваших доступов на одной удобной странице с некоторой статистикой._",
|
||||
'servers_in_group': "🔒 **Серверы в группе:**",
|
||||
|
||||
# Admin messages
|
||||
'admin_new_request_notification': "🔔 **Новый запрос на доступ**\n\n👤 **Пользователь:** {user_info}\n📱 **Telegram:** {telegram_info}\n📅 **Дата:** {date}\n\n💬 **Сообщение:** {message}",
|
||||
'admin_access_requests_title': "📋 **Ожидающие запросы на доступ**",
|
||||
'admin_no_pending_requests': "✅ Нет ожидающих запросов на доступ",
|
||||
'admin_request_item': "👤 **{user_info}**\n📅 {date}\n💬 _{message_preview}_",
|
||||
'admin_choose_subscription_groups': "📦 **Выберите группы подписки для {user_info}:**\n\nВыберите группы для назначения этому пользователю:",
|
||||
'admin_approval_success': "✅ **Запрос одобрен!**\n\n👤 Пользователь: {user_info}\n📦 Группы: {groups}\n\nПользователь уведомлен и получил доступ.",
|
||||
'admin_rejection_success': "❌ **Запрос отклонен**\n\n👤 Пользователь: {user_info}\n\nПользователь уведомлен.",
|
||||
'admin_request_already_processed': "⚠️ Этот запрос уже обработан другим администратором.",
|
||||
'admin_error_processing': "❌ Ошибка обработки запроса: {error}",
|
||||
|
||||
'android_guide': "🤖 **Руководство для Android**\n\n**Шаг 1: Установите приложение**\nСкачайте V2RayTUN из Google Play:\nhttps://play.google.com/store/apps/details?id=com.v2raytun.android\n\n**Шаг 2: Добавьте подписку**\n• Откройте приложение\n• Нажмите кнопку **+** в правом верхнем углу\n• Вставьте ссылку на подписку из бота\n• Приложение автоматически загрузит список VPN серверов\n\n**Шаг 3: Подключитесь**\n• Выберите сервер из списка\n• Нажмите **Подключиться**\n• Весь ваш трафик будет проходить через VPN\n\n**💡 Полезные настройки:**\n• В настройках включите прямой доступ для банковских приложений и местных сайтов\n• Вы можете выбрать конкретные приложения для использования VPN, в то время как остальные будут работать напрямую\n\n**🔄 Если VPN перестал работать:**\nНажмите иконку обновления рядом со списком серверов для обновления подписки.",
|
||||
'ios_guide': " **Руководство для iOS**\n\n**Шаг 1: Установите приложение**\nСкачайте V2RayTUN из App Store:\nhttps://apps.apple.com/us/app/v2raytun/id6476628951\n\n**Шаг 2: Добавьте подписку**\n• Откройте приложение\n• Нажмите кнопку **+** в правом верхнем углу\n• Вставьте ссылку на подписку из бота\n• Приложение автоматически загрузит список VPN серверов\n\n**Шаг 3: Подключитесь**\n• Выберите сервер из списка\n• Нажмите **Подключиться**\n• Весь ваш трафик будет проходить через VPN\n\n**⚠️ Важно для пользователей iOS:**\nВ настоящее время на iOS стабильно работает только протокол VLESS. Другие протоколы могут иметь проблемы с подключением.\n\n**💡 Полезные настройки:**\n• В настройках включите прямой доступ для банковских приложений и местных сайтов для улучшения производительности\n\n**🔄 Если VPN перестал работать:**\nНажмите иконку обновления рядом со списком серверов для обновления подписки.",
|
||||
'buttons': {
|
||||
'access': "🌍 Получить VPN",
|
||||
'guide': "📖 Гайд",
|
||||
'android': "🤖 Android",
|
||||
'ios': " iOS",
|
||||
'web_portal': "🌐 Веб-портал",
|
||||
'all_in_one': "🌍 Все в одном",
|
||||
'back': "⬅️ Назад",
|
||||
'group_prefix': "Группа: ",
|
||||
'request_access': "🔑 Запросить доступ",
|
||||
# Admin buttons
|
||||
'access_requests': "📋 Запросы на доступ",
|
||||
'approve': "✅ Одобрить",
|
||||
'reject': "❌ Отклонить",
|
||||
'details': "👁 Подробности",
|
||||
'confirm_approval': "✅ Подтвердить одобрение",
|
||||
'confirm_rejection': "❌ Подтвердить отклонение",
|
||||
'cancel': "🚫 Отмена"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MessageLocalizer:
|
||||
"""Class for bot message localization"""
|
||||
|
||||
@staticmethod
|
||||
def get_user_language(telegram_user) -> str:
|
||||
"""
|
||||
Determines user language from Telegram language_code
|
||||
|
||||
Args:
|
||||
telegram_user: Telegram user object
|
||||
|
||||
Returns:
|
||||
str: Language code ('ru' or 'en')
|
||||
"""
|
||||
if not telegram_user:
|
||||
return 'en'
|
||||
|
||||
language_code = getattr(telegram_user, 'language_code', None)
|
||||
|
||||
if not language_code:
|
||||
return 'en'
|
||||
|
||||
# Support Russian and English
|
||||
if language_code.startswith('ru'):
|
||||
return 'ru'
|
||||
else:
|
||||
return 'en'
|
||||
|
||||
@staticmethod
|
||||
def get_message(key: str, language: str = 'en', **kwargs) -> str:
|
||||
"""
|
||||
Gets localized message
|
||||
|
||||
Args:
|
||||
key: Message key
|
||||
language: Language code
|
||||
**kwargs: Formatting parameters
|
||||
|
||||
Returns:
|
||||
str: Localized message
|
||||
"""
|
||||
try:
|
||||
# Fallback to English if language not supported
|
||||
if language not in MESSAGES:
|
||||
language = 'en'
|
||||
|
||||
message = MESSAGES[language].get(key, MESSAGES['en'].get(key, f"Missing translation: {key}"))
|
||||
|
||||
# Format with parameters
|
||||
if kwargs:
|
||||
try:
|
||||
message = message.format(**kwargs)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.warning(f"Error formatting message {key}: {e}")
|
||||
|
||||
return message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting message {key} for language {language}: {e}")
|
||||
return f"Error: {key}"
|
||||
|
||||
@staticmethod
|
||||
def get_button_text(button_key: str, language: str = 'en') -> str:
|
||||
"""
|
||||
Gets button text
|
||||
|
||||
Args:
|
||||
button_key: Button key
|
||||
language: Language code
|
||||
|
||||
Returns:
|
||||
str: Button text
|
||||
"""
|
||||
try:
|
||||
if language not in MESSAGES:
|
||||
language = 'en'
|
||||
|
||||
buttons = MESSAGES[language].get('buttons', {})
|
||||
return buttons.get(button_key, MESSAGES['en']['buttons'].get(button_key, button_key))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting button text {button_key} for language {language}: {e}")
|
||||
return button_key
|
||||
|
||||
@staticmethod
|
||||
def get_content_type_name(content_type: str, language: str = 'en') -> str:
|
||||
"""
|
||||
Gets localized content type name
|
||||
|
||||
Args:
|
||||
content_type: Content type
|
||||
language: Language code
|
||||
|
||||
Returns:
|
||||
str: Localized name
|
||||
"""
|
||||
try:
|
||||
if language not in MESSAGES:
|
||||
language = 'en'
|
||||
|
||||
content_types = MESSAGES[language].get('content_types', {})
|
||||
return content_types.get(content_type, content_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting content type {content_type} for language {language}: {e}")
|
||||
return content_type
|
||||
|
||||
# Convenience functions for use in code
|
||||
def get_localized_message(telegram_user, message_key: str, **kwargs) -> str:
|
||||
"""Get localized message for user"""
|
||||
language = MessageLocalizer.get_user_language(telegram_user)
|
||||
return MessageLocalizer.get_message(message_key, language, **kwargs)
|
||||
|
||||
def get_localized_button(telegram_user, button_key: str) -> str:
|
||||
"""Get localized button text for user"""
|
||||
language = MessageLocalizer.get_user_language(telegram_user)
|
||||
return MessageLocalizer.get_button_text(button_key, language)
|
||||
|
||||
def get_user_language(telegram_user) -> str:
|
||||
"""Get user language"""
|
||||
return MessageLocalizer.get_user_language(telegram_user)
|
||||
0
telegram_bot/management/__init__.py
Normal file
0
telegram_bot/management/__init__.py
Normal file
0
telegram_bot/management/commands/__init__.py
Normal file
0
telegram_bot/management/commands/__init__.py
Normal file
99
telegram_bot/management/commands/run_telegram_bot.py
Normal file
99
telegram_bot/management/commands/run_telegram_bot.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from telegram_bot.models import BotSettings, BotStatus
|
||||
from telegram_bot.bot import TelegramBotManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Run the Telegram bot'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.bot_manager = None
|
||||
self.running = False
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Force start even if bot is disabled in settings',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Main command handler"""
|
||||
# Set up signal handlers
|
||||
signal.signal(signal.SIGINT, self.signal_handler)
|
||||
signal.signal(signal.SIGTERM, self.signal_handler)
|
||||
|
||||
# Check settings
|
||||
settings = BotSettings.get_settings()
|
||||
|
||||
if not settings.bot_token:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('Bot token is not configured. Please configure it in the admin panel.')
|
||||
)
|
||||
return
|
||||
|
||||
if not settings.enabled and not options['force']:
|
||||
self.stdout.write(
|
||||
self.style.WARNING('Bot is disabled in settings. Use --force to override.')
|
||||
)
|
||||
return
|
||||
|
||||
# Initialize bot manager
|
||||
self.bot_manager = TelegramBotManager()
|
||||
|
||||
try:
|
||||
# Start the bot
|
||||
self.stdout.write(self.style.SUCCESS('Starting Telegram bot...'))
|
||||
self.bot_manager.start()
|
||||
self.running = True
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Bot is running. Press Ctrl+C to stop.')
|
||||
)
|
||||
|
||||
# Keep the main thread alive
|
||||
while self.running:
|
||||
time.sleep(1)
|
||||
|
||||
# Check if bot is still running
|
||||
if not self.bot_manager.is_running:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('Bot stopped unexpectedly. Check logs for errors.')
|
||||
)
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.stdout.write('\nReceived interrupt signal...')
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Error running bot: {e}')
|
||||
)
|
||||
logger.error(f'Error running bot: {e}', exc_info=True)
|
||||
|
||||
# Update status
|
||||
status = BotStatus.get_status()
|
||||
status.is_running = False
|
||||
status.last_error = str(e)
|
||||
status.last_stopped = timezone.now()
|
||||
status.save()
|
||||
|
||||
finally:
|
||||
# Stop the bot
|
||||
if self.bot_manager:
|
||||
self.stdout.write('Stopping bot...')
|
||||
self.bot_manager.stop()
|
||||
self.stdout.write(self.style.SUCCESS('Bot stopped.'))
|
||||
|
||||
def signal_handler(self, signum, frame):
|
||||
"""Handle shutdown signals"""
|
||||
self.stdout.write('\nShutting down gracefully...')
|
||||
self.running = False
|
||||
112
telegram_bot/management/commands/telegram_bot_status.py
Normal file
112
telegram_bot/management/commands/telegram_bot_status.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import logging
|
||||
import os
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from telegram_bot.models import BotSettings, BotStatus
|
||||
from telegram_bot.bot import TelegramBotManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check Telegram bot status and optionally start it'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--auto-start',
|
||||
action='store_true',
|
||||
help='Automatically start bot if enabled in settings',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--sync-status',
|
||||
action='store_true',
|
||||
help='Sync database status with real bot state',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Check bot status"""
|
||||
try:
|
||||
manager = TelegramBotManager()
|
||||
settings = BotSettings.get_settings()
|
||||
status = BotStatus.get_status()
|
||||
|
||||
# Show current configuration
|
||||
self.stdout.write(f"Bot Configuration:")
|
||||
self.stdout.write(f" Enabled: {settings.enabled}")
|
||||
self.stdout.write(f" Token configured: {'Yes' if settings.bot_token else 'No'}")
|
||||
|
||||
# Show status
|
||||
real_running = manager.is_running
|
||||
db_running = status.is_running
|
||||
|
||||
self.stdout.write(f"\nBot Status:")
|
||||
self.stdout.write(f" Database status: {'Running' if db_running else 'Stopped'}")
|
||||
self.stdout.write(f" Real status: {'Running' if real_running else 'Stopped'}")
|
||||
|
||||
# Check lock file status
|
||||
from django.conf import settings as django_settings
|
||||
lock_dir = os.path.join(getattr(django_settings, 'BASE_DIR', '/tmp'), 'telegram_bot_locks')
|
||||
lock_path = os.path.join(lock_dir, 'telegram_bot.lock')
|
||||
|
||||
if os.path.exists(lock_path):
|
||||
try:
|
||||
with open(lock_path, 'r') as f:
|
||||
lock_pid = f.read().strip()
|
||||
self.stdout.write(f" Lock file: exists (PID: {lock_pid})")
|
||||
except:
|
||||
self.stdout.write(f" Lock file: exists (unreadable)")
|
||||
else:
|
||||
self.stdout.write(f" Lock file: not found")
|
||||
|
||||
if db_running != real_running:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("⚠️ Status mismatch detected!")
|
||||
)
|
||||
|
||||
if options['sync_status']:
|
||||
status.is_running = real_running
|
||||
if not real_running:
|
||||
status.last_stopped = timezone.now()
|
||||
status.save()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("✅ Status synchronized")
|
||||
)
|
||||
|
||||
# Show timestamps
|
||||
if status.last_started:
|
||||
self.stdout.write(f" Last started: {status.last_started}")
|
||||
if status.last_stopped:
|
||||
self.stdout.write(f" Last stopped: {status.last_stopped}")
|
||||
if status.last_error:
|
||||
self.stdout.write(f" Last error: {status.last_error}")
|
||||
|
||||
# Auto-start if requested
|
||||
if options['auto_start']:
|
||||
if not real_running and settings.enabled and settings.bot_token:
|
||||
self.stdout.write("\nAttempting to start bot...")
|
||||
try:
|
||||
manager.start()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("✅ Bot started successfully")
|
||||
)
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"❌ Failed to start bot: {e}")
|
||||
)
|
||||
elif real_running:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("✅ Bot is already running")
|
||||
)
|
||||
elif not settings.enabled:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("⚠️ Bot is disabled in settings")
|
||||
)
|
||||
elif not settings.bot_token:
|
||||
self.stdout.write(
|
||||
self.style.ERROR("❌ Bot token not configured")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"❌ Error checking bot status: {e}")
|
||||
)
|
||||
70
telegram_bot/migrations/0001_initial.py
Normal file
70
telegram_bot/migrations/0001_initial.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 11:18
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BotSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('bot_token', models.CharField(help_text='Telegram Bot Token from @BotFather', max_length=255)),
|
||||
('enabled', models.BooleanField(default=False, help_text='Enable/Disable the bot')),
|
||||
('welcome_message', models.TextField(default='Hello! Your message has been received. An administrator will review it.', help_text='Message sent when user starts conversation')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Bot Settings',
|
||||
'verbose_name_plural': 'Bot Settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BotStatus',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_running', models.BooleanField(default=False)),
|
||||
('last_started', models.DateTimeField(blank=True, null=True)),
|
||||
('last_stopped', models.DateTimeField(blank=True, null=True)),
|
||||
('last_error', models.TextField(blank=True)),
|
||||
('last_update_id', models.BigIntegerField(blank=True, help_text='Last processed update ID from Telegram', null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Bot Status',
|
||||
'verbose_name_plural': 'Bot Status',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TelegramMessage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('direction', models.CharField(choices=[('incoming', 'Incoming'), ('outgoing', 'Outgoing')], db_index=True, max_length=10)),
|
||||
('telegram_user_id', models.BigIntegerField(db_index=True)),
|
||||
('telegram_username', models.CharField(blank=True, db_index=True, max_length=255, null=True)),
|
||||
('telegram_first_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('telegram_last_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('chat_id', models.BigIntegerField(db_index=True)),
|
||||
('message_id', models.BigIntegerField(blank=True, null=True)),
|
||||
('message_text', models.TextField(blank=True)),
|
||||
('raw_data', models.JSONField(blank=True, default=dict, help_text='Full message data from Telegram')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('linked_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='telegram_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Telegram Message',
|
||||
'verbose_name_plural': 'Telegram Messages',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['-created_at', 'direction'], name='telegram_bo_created_19b81b_idx'), models.Index(fields=['telegram_user_id', '-created_at'], name='telegram_bo_telegra_f71f27_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
33
telegram_bot/migrations/0002_add_connection_settings.py
Normal file
33
telegram_bot/migrations/0002_add_connection_settings.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 12:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='api_base_url',
|
||||
field=models.URLField(blank=True, default='https://api.telegram.org', help_text='Telegram API base URL (change for local bot API server)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='connection_timeout',
|
||||
field=models.IntegerField(default=30, help_text='Connection timeout in seconds'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='proxy_url',
|
||||
field=models.URLField(blank=True, help_text='Proxy URL (e.g., http://proxy:8080 or socks5://proxy:1080)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='use_proxy',
|
||||
field=models.BooleanField(default=False, help_text='Enable proxy for Telegram API connections'),
|
||||
),
|
||||
]
|
||||
42
telegram_bot/migrations/0003_accessrequest.py
Normal file
42
telegram_bot/migrations/0003_accessrequest.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 12:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0002_add_connection_settings'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AccessRequest',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('telegram_user_id', models.BigIntegerField(db_index=True, help_text='Telegram user ID who made the request')),
|
||||
('telegram_username', models.CharField(blank=True, help_text='Telegram username (without @)', max_length=255, null=True)),
|
||||
('telegram_first_name', models.CharField(blank=True, help_text='First name from Telegram', max_length=255, null=True)),
|
||||
('telegram_last_name', models.CharField(blank=True, help_text='Last name from Telegram', max_length=255, null=True)),
|
||||
('message_text', models.TextField(help_text='The message sent by user when requesting access')),
|
||||
('chat_id', models.BigIntegerField(help_text='Telegram chat ID for sending notifications')),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=20)),
|
||||
('admin_comment', models.TextField(blank=True, help_text='Admin comment for approval/rejection')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('processed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_user', models.ForeignKey(blank=True, help_text='User created from this request (when approved)', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('first_message', models.ForeignKey(blank=True, help_text='First message from this user', null=True, on_delete=django.db.models.deletion.SET_NULL, to='telegram_bot.telegrammessage')),
|
||||
('processed_by', models.ForeignKey(blank=True, help_text='Admin who processed this request', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='processed_requests', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Access Request',
|
||||
'verbose_name_plural': 'Access Requests',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['telegram_user_id'], name='telegram_bo_telegra_e3429d_idx'), models.Index(fields=['status', '-created_at'], name='telegram_bo_status_cf9310_idx'), models.Index(fields=['-created_at'], name='telegram_bo_created_c82a74_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('telegram_user_id',), name='unique_telegram_user_request')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 13:49
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0003_accessrequest'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='accessrequest',
|
||||
name='telegram_bo_status_cf9310_idx',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='accessrequest',
|
||||
name='status',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='approved',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='Request approved by administrator'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accessrequest',
|
||||
name='admin_comment',
|
||||
field=models.TextField(blank=True, help_text='Admin comment for approval'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='accessrequest',
|
||||
index=models.Index(fields=['approved', '-created_at'], name='telegram_bo_approve_7ae92d_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 22:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0004_remove_accessrequest_telegram_bo_status_cf9310_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='BotStatus',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='botsettings',
|
||||
name='welcome_message',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='help_message',
|
||||
field=models.TextField(default='📋 Available commands:\n/start - Start conversation\n📊 Access - View your VPN subscriptions\n\nFor support contact administrator.', help_text='Help message sent for unrecognized commands'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 22:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0005_delete_botstatus_remove_botsettings_welcome_message_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='desired_username',
|
||||
field=models.CharField(blank=True, help_text='Desired username for VPN user (defaults to Telegram username)', max_length=150),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 22:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0006_accessrequest_desired_username'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='botsettings',
|
||||
name='help_message',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='user_language',
|
||||
field=models.CharField(default='en', help_text="User's preferred language (en/ru)", max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='telegrammessage',
|
||||
name='user_language',
|
||||
field=models.CharField(default='en', help_text="User's preferred language (en/ru)", max_length=10),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated migration for adding selected_existing_user field
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('telegram_bot', '0007_remove_botsettings_help_message_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='selected_existing_user',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Existing user selected to link with this Telegram account',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='selected_for_requests',
|
||||
to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-15 12:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0008_accessrequest_selected_existing_user'),
|
||||
('vpn', '0026_alter_subscriptiongroup_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='selected_inbounds',
|
||||
field=models.ManyToManyField(blank=True, help_text='Inbound templates to assign to the user', to='vpn.inbound'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accessrequest',
|
||||
name='selected_subscription_groups',
|
||||
field=models.ManyToManyField(blank=True, help_text='Subscription groups to assign to the user', to='vpn.subscriptiongroup'),
|
||||
),
|
||||
]
|
||||
20
telegram_bot/migrations/0010_botsettings_telegram_admins.py
Normal file
20
telegram_bot/migrations/0010_botsettings_telegram_admins.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-15 13:00
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('telegram_bot', '0009_accessrequest_selected_inbounds_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='botsettings',
|
||||
name='telegram_admins',
|
||||
field=models.ManyToManyField(blank=True, help_text='Users with linked Telegram accounts who will have admin access in the bot', related_name='bot_admin_settings', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
0
telegram_bot/migrations/__init__.py
Normal file
0
telegram_bot/migrations/__init__.py
Normal file
318
telegram_bot/models.py
Normal file
318
telegram_bot/models.py
Normal file
@@ -0,0 +1,318 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
import json
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class BotSettings(models.Model):
|
||||
"""Singleton model for bot settings"""
|
||||
bot_token = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Telegram Bot Token from @BotFather"
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Enable/Disable the bot"
|
||||
)
|
||||
use_proxy = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Enable proxy for Telegram API connections"
|
||||
)
|
||||
proxy_url = models.URLField(
|
||||
blank=True,
|
||||
help_text="Proxy URL (e.g., http://proxy:8080 or socks5://proxy:1080)"
|
||||
)
|
||||
api_base_url = models.URLField(
|
||||
blank=True,
|
||||
default="https://api.telegram.org",
|
||||
help_text="Telegram API base URL (change for local bot API server)"
|
||||
)
|
||||
connection_timeout = models.IntegerField(
|
||||
default=30,
|
||||
help_text="Connection timeout in seconds"
|
||||
)
|
||||
telegram_admins = models.ManyToManyField(
|
||||
User,
|
||||
blank=True,
|
||||
related_name='bot_admin_settings',
|
||||
help_text="Users with linked Telegram accounts who will have admin access in the bot"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Bot Settings"
|
||||
verbose_name_plural = "Bot Settings"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Ensure only one instance exists
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Prevent deletion
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_settings(cls):
|
||||
"""Get or create singleton settings"""
|
||||
obj, created = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
def __str__(self):
|
||||
return f"Bot Settings ({'Enabled' if self.enabled else 'Disabled'})"
|
||||
|
||||
|
||||
class TelegramMessage(models.Model):
|
||||
"""Store all telegram messages"""
|
||||
DIRECTION_CHOICES = [
|
||||
('incoming', 'Incoming'),
|
||||
('outgoing', 'Outgoing'),
|
||||
]
|
||||
|
||||
direction = models.CharField(
|
||||
max_length=10,
|
||||
choices=DIRECTION_CHOICES,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Telegram user info
|
||||
telegram_user_id = models.BigIntegerField(db_index=True)
|
||||
telegram_username = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True
|
||||
)
|
||||
telegram_first_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
telegram_last_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
user_language = models.CharField(
|
||||
max_length=10,
|
||||
default='en',
|
||||
help_text="User's preferred language (en/ru)"
|
||||
)
|
||||
|
||||
# Message info
|
||||
chat_id = models.BigIntegerField(db_index=True)
|
||||
message_id = models.BigIntegerField(null=True, blank=True)
|
||||
message_text = models.TextField(blank=True)
|
||||
|
||||
# Additional data
|
||||
raw_data = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Full message data from Telegram"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
# Optional link to VPN user if identified
|
||||
linked_user = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='telegram_messages'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Telegram Message"
|
||||
verbose_name_plural = "Telegram Messages"
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['-created_at', 'direction']),
|
||||
models.Index(fields=['telegram_user_id', '-created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
username = self.telegram_username or f"ID:{self.telegram_user_id}"
|
||||
direction_icon = "⬇️" if self.direction == 'incoming' else "⬆️"
|
||||
text_preview = self.message_text[:50] + "..." if len(self.message_text) > 50 else self.message_text
|
||||
return f"{direction_icon} {username}: {text_preview}"
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Get full name of telegram user"""
|
||||
parts = []
|
||||
if self.telegram_first_name:
|
||||
parts.append(self.telegram_first_name)
|
||||
if self.telegram_last_name:
|
||||
parts.append(self.telegram_last_name)
|
||||
return " ".join(parts) if parts else f"User {self.telegram_user_id}"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get best available display name"""
|
||||
if self.telegram_username:
|
||||
return f"`@{self.telegram_username}`"
|
||||
return self.full_name
|
||||
|
||||
|
||||
|
||||
|
||||
class AccessRequest(models.Model):
|
||||
"""Access requests from Telegram users"""
|
||||
|
||||
# Telegram user information
|
||||
telegram_user_id = models.BigIntegerField(
|
||||
db_index=True,
|
||||
help_text="Telegram user ID who made the request"
|
||||
)
|
||||
telegram_username = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Telegram username (without @)"
|
||||
)
|
||||
telegram_first_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="First name from Telegram"
|
||||
)
|
||||
telegram_last_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Last name from Telegram"
|
||||
)
|
||||
|
||||
# Request details
|
||||
message_text = models.TextField(
|
||||
help_text="The message sent by user when requesting access"
|
||||
)
|
||||
chat_id = models.BigIntegerField(
|
||||
help_text="Telegram chat ID for sending notifications"
|
||||
)
|
||||
|
||||
# Username for VPN user creation
|
||||
desired_username = models.CharField(
|
||||
max_length=150,
|
||||
blank=True,
|
||||
help_text="Desired username for VPN user (defaults to Telegram username)"
|
||||
)
|
||||
|
||||
# User language
|
||||
user_language = models.CharField(
|
||||
max_length=10,
|
||||
default='en',
|
||||
help_text="User's preferred language (en/ru)"
|
||||
)
|
||||
|
||||
# Status and processing
|
||||
approved = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Request approved by administrator"
|
||||
)
|
||||
admin_comment = models.TextField(
|
||||
blank=True,
|
||||
help_text="Admin comment for approval"
|
||||
)
|
||||
|
||||
# Related objects
|
||||
selected_existing_user = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='selected_for_requests',
|
||||
help_text="Existing user selected to link with this Telegram account"
|
||||
)
|
||||
created_user = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text="User created from this request (when approved)"
|
||||
)
|
||||
processed_by = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='processed_requests',
|
||||
help_text="Admin who processed this request"
|
||||
)
|
||||
first_message = models.ForeignKey(
|
||||
TelegramMessage,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text="First message from this user"
|
||||
)
|
||||
|
||||
# Inbound templates and subscription groups
|
||||
selected_inbounds = models.ManyToManyField(
|
||||
'vpn.Inbound',
|
||||
blank=True,
|
||||
help_text="Inbound templates to assign to the user"
|
||||
)
|
||||
selected_subscription_groups = models.ManyToManyField(
|
||||
'vpn.SubscriptionGroup',
|
||||
blank=True,
|
||||
help_text="Subscription groups to assign to the user"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
processed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Access Request"
|
||||
verbose_name_plural = "Access Requests"
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['telegram_user_id']),
|
||||
models.Index(fields=['approved', '-created_at']),
|
||||
models.Index(fields=['-created_at']),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['telegram_user_id'],
|
||||
name='unique_telegram_user_request'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
username = self.telegram_username or f"ID:{self.telegram_user_id}"
|
||||
status = "Approved" if self.approved else "Pending"
|
||||
return f"Request from @{username} ({status})"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get best available display name"""
|
||||
if self.telegram_username:
|
||||
return f"`@{self.telegram_username}`"
|
||||
|
||||
name_parts = []
|
||||
if self.telegram_first_name:
|
||||
name_parts.append(self.telegram_first_name)
|
||||
if self.telegram_last_name:
|
||||
name_parts.append(self.telegram_last_name)
|
||||
|
||||
if name_parts:
|
||||
return " ".join(name_parts)
|
||||
|
||||
return f"User {self.telegram_user_id}"
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Get full name of telegram user"""
|
||||
parts = []
|
||||
if self.telegram_first_name:
|
||||
parts.append(self.telegram_first_name)
|
||||
if self.telegram_last_name:
|
||||
parts.append(self.telegram_last_name)
|
||||
return " ".join(parts) if parts else None
|
||||
3
telegram_bot/tests.py
Normal file
3
telegram_bot/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
telegram_bot/views.py
Normal file
3
telegram_bot/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
0
telegram_bot_locks/telegram_bot.lock
Normal file
0
telegram_bot_locks/telegram_bot.lock
Normal file
1785
vpn/admin.py
1785
vpn/admin.py
File diff suppressed because it is too large
Load Diff
45
vpn/admin/__init__.py
Normal file
45
vpn/admin/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
VPN Admin Module
|
||||
|
||||
This module provides Django admin interfaces for the VPN application.
|
||||
The admin interface has been refactored into separate modules for better organization:
|
||||
|
||||
- base.py: Common utilities and base classes
|
||||
- user.py: User management admin interface
|
||||
- server.py: Server management admin interface
|
||||
- access.py: Access control (ACL/ACLLink) admin interfaces
|
||||
- logs.py: Logging (TaskExecutionLog/AccessLog) admin interfaces
|
||||
|
||||
All admin classes are automatically registered with Django admin.
|
||||
"""
|
||||
|
||||
# Import all admin classes to ensure they are registered
|
||||
from .user import UserAdmin
|
||||
from .server import ServerAdmin, UserACLInline
|
||||
from .access import (
|
||||
ACLAdmin,
|
||||
ACLLinkAdmin,
|
||||
UserNameFilter,
|
||||
ServerNameFilter,
|
||||
LastAccessFilter,
|
||||
ACLLinkInline
|
||||
)
|
||||
from .logs import TaskExecutionLogAdmin, AccessLogAdmin
|
||||
from .base import BaseVPNAdmin, format_bytes
|
||||
|
||||
# Re-export for backward compatibility
|
||||
__all__ = [
|
||||
'UserAdmin',
|
||||
'ServerAdmin',
|
||||
'UserACLInline',
|
||||
'ACLAdmin',
|
||||
'ACLLinkAdmin',
|
||||
'TaskExecutionLogAdmin',
|
||||
'AccessLogAdmin',
|
||||
'BaseVPNAdmin',
|
||||
'format_bytes',
|
||||
'UserNameFilter',
|
||||
'ServerNameFilter',
|
||||
'LastAccessFilter',
|
||||
'ACLLinkInline'
|
||||
]
|
||||
485
vpn/admin/access.py
Normal file
485
vpn/admin/access.py
Normal file
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
Access control admin interfaces (ACL, ACLLink)
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from django.utils.timezone import localtime
|
||||
from django.db.models import Q
|
||||
|
||||
from mysite.settings import EXTERNAL_ADDRESS
|
||||
from vpn.models import ACL, ACLLink, User
|
||||
from .base import BaseVPNAdmin
|
||||
from vpn.utils import format_object
|
||||
|
||||
|
||||
class UserNameFilter(admin.SimpleListFilter):
|
||||
title = 'User'
|
||||
parameter_name = 'user'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
users = set(User.objects.values_list('username', flat=True))
|
||||
return [(user, user) for user in users]
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value():
|
||||
return queryset.filter(user__username=self.value())
|
||||
return queryset
|
||||
|
||||
|
||||
class ServerNameFilter(admin.SimpleListFilter):
|
||||
title = 'Server Name'
|
||||
parameter_name = 'acl__server__name'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
servers = set(ACL.objects.values_list('server__name', flat=True))
|
||||
return [(server, server) for server in servers]
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value():
|
||||
return queryset.filter(acl__server__name=self.value())
|
||||
return queryset
|
||||
|
||||
|
||||
class LastAccessFilter(admin.SimpleListFilter):
|
||||
title = 'Last Access'
|
||||
parameter_name = 'last_access_status'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return [
|
||||
('never', 'Never accessed'),
|
||||
('week', 'Last week'),
|
||||
('month', 'Last month'),
|
||||
('old', 'Older than 3 months'),
|
||||
]
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
if self.value() == 'never':
|
||||
# Links that have never been accessed
|
||||
return queryset.filter(last_access_time__isnull=True)
|
||||
elif self.value() == 'week':
|
||||
# Links accessed in the last week
|
||||
week_ago = timezone.now() - timedelta(days=7)
|
||||
return queryset.filter(last_access_time__gte=week_ago)
|
||||
elif self.value() == 'month':
|
||||
# Links accessed in the last month
|
||||
month_ago = timezone.now() - timedelta(days=30)
|
||||
return queryset.filter(last_access_time__gte=month_ago)
|
||||
elif self.value() == 'old':
|
||||
# Links not accessed for more than 3 months
|
||||
three_months_ago = timezone.now() - timedelta(days=90)
|
||||
return queryset.filter(last_access_time__lt=three_months_ago)
|
||||
return queryset
|
||||
|
||||
|
||||
class ACLLinkInline(admin.TabularInline):
|
||||
model = ACLLink
|
||||
extra = 1
|
||||
help_text = 'Add or change ACL links'
|
||||
verbose_name = 'Dynamic link'
|
||||
verbose_name_plural = 'Dynamic links'
|
||||
fields = ('link', 'generate_link_button', 'comment')
|
||||
readonly_fields = ('generate_link_button',)
|
||||
|
||||
@admin.display(description="Generate")
|
||||
def generate_link_button(self, obj=None):
|
||||
return format_html(
|
||||
'<button type="button" class="generate-link" onclick="generateLink(this)">🔄</button>'
|
||||
)
|
||||
|
||||
class Media:
|
||||
js = ('admin/js/generate_link.js',)
|
||||
|
||||
|
||||
@admin.register(ACL)
|
||||
class ACLAdmin(BaseVPNAdmin):
|
||||
list_display = ('user', 'server', 'server_type', 'display_links', 'created_at')
|
||||
list_filter = (UserNameFilter, 'server__server_type', ServerNameFilter)
|
||||
# Fixed search_fields - removed problematic polymorphic server fields
|
||||
search_fields = ('user__username', 'user__comment', 'links__link')
|
||||
readonly_fields = ('user_info',)
|
||||
inlines = [ACLLinkInline]
|
||||
|
||||
@admin.display(description='Server Type', ordering='server__server_type')
|
||||
def server_type(self, obj):
|
||||
return obj.server.get_server_type_display()
|
||||
|
||||
@admin.display(description='Client info')
|
||||
def user_info(self, obj):
|
||||
server = obj.server
|
||||
user = obj.user
|
||||
try:
|
||||
# Use cached statistics instead of direct server requests
|
||||
from vpn.models import UserStatistics
|
||||
user_stats = UserStatistics.objects.filter(
|
||||
user=user,
|
||||
server_name=server.name
|
||||
).first()
|
||||
|
||||
if user_stats:
|
||||
# Format cached data nicely
|
||||
data = {
|
||||
'user': user.username,
|
||||
'server': server.name,
|
||||
'total_connections': user_stats.total_connections,
|
||||
'recent_connections': user_stats.recent_connections,
|
||||
'max_daily': user_stats.max_daily,
|
||||
'last_updated': user_stats.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'status': 'from_cache'
|
||||
}
|
||||
return format_object(data)
|
||||
else:
|
||||
# Fallback to minimal server check (avoid slow API calls on admin pages)
|
||||
return mark_safe(
|
||||
'<div style="background: #fff3cd; border: 1px solid #ffeaa7; padding: 8px; border-radius: 4px;">' +
|
||||
'<strong>ℹ️ User Statistics:</strong><br>' +
|
||||
'No cached statistics available.<br>' +
|
||||
'<small>Run "Update user statistics cache" action to populate data.</small>' +
|
||||
'</div>'
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to get cached user info for {user.username} on {server.name}: {e}")
|
||||
return mark_safe(f"<span style='color: red;'>Cache error: {e}</span>")
|
||||
|
||||
@admin.display(description='User Links')
|
||||
def display_links(self, obj):
|
||||
links_count = obj.links.count()
|
||||
portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.user.hash}"
|
||||
|
||||
return format_html(
|
||||
'<div style="font-size: 12px; margin-bottom: 8px;">'
|
||||
'<strong>🔗 {} link(s)</strong>'
|
||||
'</div>'
|
||||
'<a href="{}" target="_blank" style="background: #4ade80; color: #000; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 11px; font-weight: bold;">🌐 User Portal</a>',
|
||||
links_count, portal_url
|
||||
)
|
||||
|
||||
|
||||
# Note: UserStatistics is not registered separately as admin model.
|
||||
# All user statistics functionality is integrated into ACLLinkAdmin below.
|
||||
@admin.register(ACLLink)
|
||||
class ACLLinkAdmin(BaseVPNAdmin):
|
||||
list_display = ('link_display', 'user_display', 'server_display', 'comment_display', 'stats_display', 'usage_chart_display', 'last_access_display', 'created_display')
|
||||
list_filter = ('acl__server__name', 'acl__server__server_type', LastAccessFilter, 'acl__user__username')
|
||||
search_fields = ('link', 'comment', 'acl__user__username', 'acl__server__name')
|
||||
list_per_page = 100
|
||||
actions = ['delete_selected_links', 'update_statistics_action']
|
||||
list_select_related = ('acl__user', 'acl__server')
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.select_related('acl__user', 'acl__server')
|
||||
return qs
|
||||
|
||||
@admin.display(description='Link', ordering='link')
|
||||
def link_display(self, obj):
|
||||
link_url = f"{EXTERNAL_ADDRESS}/ss/{obj.link}#{obj.acl.server.name}"
|
||||
return format_html(
|
||||
'<a href="{}" target="_blank" style="color: #2563eb; text-decoration: none; font-family: monospace;">{}</a>',
|
||||
link_url, obj.link[:16] + '...' if len(obj.link) > 16 else obj.link
|
||||
)
|
||||
|
||||
@admin.display(description='User', ordering='acl__user__username')
|
||||
def user_display(self, obj):
|
||||
return obj.acl.user.username
|
||||
|
||||
@admin.display(description='Server', ordering='acl__server__name')
|
||||
def server_display(self, obj):
|
||||
server_type_icons = {
|
||||
'outline': '🔵',
|
||||
'wireguard': '🟢',
|
||||
'xray_core': '🟣',
|
||||
}
|
||||
icon = server_type_icons.get(obj.acl.server.server_type, '⚪')
|
||||
return f"{icon} {obj.acl.server.name}"
|
||||
|
||||
@admin.display(description='Comment', ordering='comment')
|
||||
def comment_display(self, obj):
|
||||
if obj.comment:
|
||||
return obj.comment[:50] + '...' if len(obj.comment) > 50 else obj.comment
|
||||
return '-'
|
||||
|
||||
@admin.display(description='Statistics')
|
||||
def stats_display(self, obj):
|
||||
try:
|
||||
from vpn.models import UserStatistics
|
||||
stats = UserStatistics.objects.get(
|
||||
user=obj.acl.user,
|
||||
server_name=obj.acl.server.name,
|
||||
acl_link_id=obj.link
|
||||
)
|
||||
|
||||
# Color coding based on usage
|
||||
if stats.total_connections > 100:
|
||||
color = '#16a34a' # green - high usage
|
||||
elif stats.total_connections > 10:
|
||||
color = '#eab308' # yellow - medium usage
|
||||
elif stats.total_connections > 0:
|
||||
color = '#f97316' # orange - low usage
|
||||
else:
|
||||
color = '#9ca3af' # gray - no usage
|
||||
|
||||
return mark_safe(
|
||||
f'<div style="font-size: 12px;">'
|
||||
f'<span style="color: {color}; font-weight: bold;">✨ {stats.total_connections} total</span><br>'
|
||||
f'<span style="color: #6b7280;">📅 {stats.recent_connections} last 30d</span>'
|
||||
f'</div>'
|
||||
)
|
||||
except:
|
||||
return mark_safe('<span style="color: #dc2626; font-size: 12px;">No cache</span>')
|
||||
|
||||
@admin.display(description='30-day Chart')
|
||||
def usage_chart_display(self, obj):
|
||||
try:
|
||||
from vpn.models import UserStatistics
|
||||
stats = UserStatistics.objects.get(
|
||||
user=obj.acl.user,
|
||||
server_name=obj.acl.server.name,
|
||||
acl_link_id=obj.link
|
||||
)
|
||||
|
||||
if not stats.daily_usage:
|
||||
return mark_safe('<span style="color: #9ca3af; font-size: 11px;">No data</span>')
|
||||
|
||||
# Create wider mini chart for better visibility
|
||||
max_val = max(stats.daily_usage) if stats.daily_usage else 1
|
||||
chart_html = '<div style="display: flex; align-items: end; gap: 1px; height: 35px; width: 180px;">'
|
||||
|
||||
# Show last 30 days with wider bars for better visibility
|
||||
for day_count in stats.daily_usage[-30:]: # Last 30 days
|
||||
if max_val > 0:
|
||||
height_percent = (day_count / max_val) * 100
|
||||
else:
|
||||
height_percent = 0
|
||||
|
||||
color = '#4ade80' if day_count > 0 else '#e5e7eb'
|
||||
chart_html += f'<div style="background: {color}; width: 5px; height: {height_percent}%; min-height: 1px; border-radius: 1px;" title="{day_count} connections"></div>'
|
||||
|
||||
chart_html += '</div>'
|
||||
|
||||
# Add summary info below chart
|
||||
total_last_30 = sum(stats.daily_usage[-30:]) if stats.daily_usage else 0
|
||||
avg_daily = total_last_30 / 30 if total_last_30 > 0 else 0
|
||||
chart_html += f'<div style="font-size: 10px; color: #6b7280; margin-top: 2px;">'
|
||||
chart_html += f'Max: {max_val} | Avg: {avg_daily:.1f}'
|
||||
chart_html += f'</div>'
|
||||
|
||||
return mark_safe(chart_html)
|
||||
except:
|
||||
return mark_safe('<span style="color: #dc2626; font-size: 11px;">-</span>')
|
||||
|
||||
@admin.display(description='Last Access', ordering='last_access_time')
|
||||
def last_access_display(self, obj):
|
||||
if obj.last_access_time:
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
local_time = localtime(obj.last_access_time)
|
||||
now = timezone.now()
|
||||
diff = now - obj.last_access_time
|
||||
|
||||
# Color coding based on age
|
||||
if diff <= timedelta(days=7):
|
||||
color = '#16a34a' # green - recent
|
||||
elif diff <= timedelta(days=30):
|
||||
color = '#eab308' # yellow - medium
|
||||
elif diff <= timedelta(days=90):
|
||||
color = '#f97316' # orange - old
|
||||
else:
|
||||
color = '#dc2626' # red - very old
|
||||
|
||||
formatted_date = local_time.strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
# Add relative time info
|
||||
if diff.days > 365:
|
||||
relative = f'{diff.days // 365}y ago'
|
||||
elif diff.days > 30:
|
||||
relative = f'{diff.days // 30}mo ago'
|
||||
elif diff.days > 0:
|
||||
relative = f'{diff.days}d ago'
|
||||
elif diff.seconds > 3600:
|
||||
relative = f'{diff.seconds // 3600}h ago'
|
||||
else:
|
||||
relative = 'Recently'
|
||||
|
||||
return mark_safe(
|
||||
f'<span style="color: {color}; font-weight: bold; font-size: 12px;">{formatted_date}</span>'
|
||||
f'<br><small style="color: {color}; font-size: 10px;">{relative}</small>'
|
||||
)
|
||||
return mark_safe('<span style="color: #dc2626; font-weight: bold; font-size: 12px;">Never</span>')
|
||||
|
||||
@admin.display(description='Created', ordering='acl__created_at')
|
||||
def created_display(self, obj):
|
||||
local_time = localtime(obj.acl.created_at)
|
||||
return local_time.strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
def delete_selected_links(self, request, queryset):
|
||||
count = queryset.count()
|
||||
queryset.delete()
|
||||
self.message_user(request, f'Successfully deleted {count} ACL link(s).')
|
||||
delete_selected_links.short_description = "Delete selected ACL links"
|
||||
|
||||
def update_statistics_action(self, request, queryset):
|
||||
"""Trigger comprehensive statistics update for all users and links"""
|
||||
# This action doesn't require selected items
|
||||
try:
|
||||
from vpn.tasks import update_user_statistics
|
||||
|
||||
# Start the statistics update task
|
||||
task = update_user_statistics.delay()
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'📊 Statistics update started successfully! Task ID: {task.id}. '
|
||||
f'This will recalculate usage statistics for all users and links. '
|
||||
f'Refresh this page in a few moments to see updated data.',
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f'❌ Failed to start statistics update: {e}',
|
||||
level=messages.ERROR
|
||||
)
|
||||
update_statistics_action.short_description = "📊 Update all user statistics and usage data"
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Remove default delete action and keep only custom one"""
|
||||
actions = super().get_actions(request)
|
||||
if 'delete_selected' in actions:
|
||||
del actions['delete_selected']
|
||||
return actions
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return True
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return True
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
# Handle actions that don't require item selection
|
||||
if 'action' in request.POST:
|
||||
action = request.POST['action']
|
||||
if action == 'update_statistics_action':
|
||||
# Call the action directly without queryset requirement
|
||||
self.update_statistics_action(request, None)
|
||||
# Return redirect to prevent AttributeError
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
# Add comprehensive statistics to the changelist
|
||||
extra_context = extra_context or {}
|
||||
|
||||
# Get queryset for statistics
|
||||
queryset = self.get_queryset(request)
|
||||
|
||||
total_links = queryset.count()
|
||||
never_accessed = queryset.filter(last_access_time__isnull=True).count()
|
||||
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.db.models import Count, Max, Min
|
||||
|
||||
now = timezone.now()
|
||||
one_week_ago = now - timedelta(days=7)
|
||||
one_month_ago = now - timedelta(days=30)
|
||||
three_months_ago = now - timedelta(days=90)
|
||||
|
||||
# Access time statistics
|
||||
old_links = queryset.filter(
|
||||
Q(last_access_time__lt=three_months_ago) | Q(last_access_time__isnull=True)
|
||||
).count()
|
||||
|
||||
recent_week = queryset.filter(last_access_time__gte=one_week_ago).count()
|
||||
recent_month = queryset.filter(last_access_time__gte=one_month_ago).count()
|
||||
|
||||
# Calculate comprehensive statistics from cache
|
||||
try:
|
||||
from vpn.models import UserStatistics
|
||||
from django.db import models
|
||||
|
||||
# Total usage statistics
|
||||
cached_stats = UserStatistics.objects.aggregate(
|
||||
total_uses=models.Sum('total_connections'),
|
||||
recent_uses=models.Sum('recent_connections'),
|
||||
max_daily_peak=models.Max('max_daily')
|
||||
)
|
||||
total_uses = cached_stats['total_uses'] or 0
|
||||
recent_uses = cached_stats['recent_uses'] or 0
|
||||
max_daily_peak = cached_stats['max_daily_peak'] or 0
|
||||
|
||||
# Server and user breakdown
|
||||
server_stats = UserStatistics.objects.values('server_name').annotate(
|
||||
total_connections=models.Sum('total_connections'),
|
||||
link_count=models.Count('id')
|
||||
).order_by('-total_connections')[:5] # Top 5 servers
|
||||
|
||||
user_stats = UserStatistics.objects.values('user__username').annotate(
|
||||
total_connections=models.Sum('total_connections'),
|
||||
link_count=models.Count('id')
|
||||
).order_by('-total_connections')[:5] # Top 5 users
|
||||
|
||||
# Links with cache data count
|
||||
cached_links_count = UserStatistics.objects.filter(
|
||||
acl_link_id__isnull=False
|
||||
).count()
|
||||
|
||||
except Exception as e:
|
||||
total_uses = 0
|
||||
recent_uses = 0
|
||||
max_daily_peak = 0
|
||||
server_stats = []
|
||||
user_stats = []
|
||||
cached_links_count = 0
|
||||
|
||||
# Active vs inactive breakdown
|
||||
active_links = total_links - never_accessed - old_links
|
||||
if active_links < 0:
|
||||
active_links = 0
|
||||
|
||||
extra_context.update({
|
||||
'total_links': total_links,
|
||||
'never_accessed': never_accessed,
|
||||
'old_links': old_links,
|
||||
'active_links': active_links,
|
||||
'recent_week': recent_week,
|
||||
'recent_month': recent_month,
|
||||
'total_uses': total_uses,
|
||||
'recent_uses': recent_uses,
|
||||
'max_daily_peak': max_daily_peak,
|
||||
'server_stats': server_stats,
|
||||
'user_stats': user_stats,
|
||||
'cached_links_count': cached_links_count,
|
||||
'cache_coverage_percent': (cached_links_count * 100 // total_links) if total_links > 0 else 0,
|
||||
})
|
||||
|
||||
return super().changelist_view(request, extra_context)
|
||||
|
||||
def get_ordering(self, request):
|
||||
"""Allow sorting by annotated fields"""
|
||||
# Handle sorting by last_access_time if requested
|
||||
order_var = request.GET.get('o')
|
||||
if order_var:
|
||||
try:
|
||||
field_index = int(order_var.lstrip('-'))
|
||||
# Check if this corresponds to the last_access column (index 6 in list_display)
|
||||
if field_index == 6: # last_access_display is at index 6
|
||||
if order_var.startswith('-'):
|
||||
return ['-last_access_time']
|
||||
else:
|
||||
return ['last_access_time']
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Default ordering
|
||||
return ['-acl__created_at', 'acl__user__username']
|
||||
57
vpn/admin/base.py
Normal file
57
vpn/admin/base.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Base utilities and common imports for VPN admin interfaces
|
||||
"""
|
||||
import json
|
||||
import shortuuid
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import render, redirect
|
||||
from django.contrib import messages
|
||||
from django.urls import path, reverse
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.utils.timezone import localtime
|
||||
from django.db.models import Max, Subquery, OuterRef, Q
|
||||
|
||||
from mysite.settings import EXTERNAL_ADDRESS
|
||||
|
||||
|
||||
def format_bytes(bytes_val):
|
||||
"""Format bytes to human readable format"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if bytes_val < 1024.0:
|
||||
return f"{bytes_val:.1f}{unit}"
|
||||
bytes_val /= 1024.0
|
||||
return f"{bytes_val:.1f}PB"
|
||||
|
||||
|
||||
class BaseVPNAdmin(admin.ModelAdmin):
|
||||
"""Base admin class with common functionality"""
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('admin/css/vpn_admin.css',)
|
||||
}
|
||||
|
||||
def get_external_address(self):
|
||||
"""Get external address for links"""
|
||||
return EXTERNAL_ADDRESS
|
||||
|
||||
def format_hash_link(self, obj, hash_value):
|
||||
"""Format hash as clickable link"""
|
||||
if not hash_value:
|
||||
return mark_safe('<span style="color: #dc3545;">No hash</span>')
|
||||
|
||||
portal_url = f"https://{EXTERNAL_ADDRESS}/u/{hash_value}"
|
||||
return mark_safe(
|
||||
f'<div style="display: flex; align-items: center; gap: 10px;">'
|
||||
f'<code style="background: #f8f9fa; padding: 4px 8px; border-radius: 3px; font-size: 12px;">{hash_value[:12]}...</code>'
|
||||
f'<a href="{portal_url}" target="_blank" style="color: #007cba; text-decoration: none;">🔗 Portal</a>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
|
||||
class BaseListFilter(admin.SimpleListFilter):
|
||||
"""Base filter class with common functionality"""
|
||||
pass
|
||||
179
vpn/admin/logs.py
Normal file
179
vpn/admin/logs.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Logging admin interfaces (TaskExecutionLog, AccessLog)
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from django.utils.timezone import localtime
|
||||
|
||||
from vpn.models import TaskExecutionLog, AccessLog
|
||||
from .base import BaseVPNAdmin
|
||||
from vpn.utils import format_object
|
||||
|
||||
|
||||
@admin.register(TaskExecutionLog)
|
||||
class TaskExecutionLogAdmin(BaseVPNAdmin):
|
||||
list_display = ('task_name_display', 'action', 'status_display', 'server', 'user', 'execution_time_display', 'created_at')
|
||||
list_filter = ('task_name', 'status', 'server', 'created_at')
|
||||
search_fields = ('task_id', 'task_name', 'action', 'user__username', 'server__name', 'message')
|
||||
readonly_fields = ('task_id', 'task_name', 'server', 'user', 'action', 'status', 'message_formatted', 'execution_time', 'created_at')
|
||||
ordering = ('-created_at',)
|
||||
list_per_page = 100
|
||||
date_hierarchy = 'created_at'
|
||||
actions = ['trigger_full_sync', 'trigger_statistics_update']
|
||||
|
||||
fieldsets = (
|
||||
('Task Information', {
|
||||
'fields': ('task_id', 'task_name', 'action', 'status')
|
||||
}),
|
||||
('Related Objects', {
|
||||
'fields': ('server', 'user')
|
||||
}),
|
||||
('Execution Details', {
|
||||
'fields': ('message_formatted', 'execution_time', 'created_at')
|
||||
}),
|
||||
)
|
||||
|
||||
def trigger_full_sync(self, request, queryset):
|
||||
"""Trigger manual full synchronization of all servers"""
|
||||
# This action doesn't require selected items
|
||||
try:
|
||||
from vpn.tasks import sync_all_users
|
||||
|
||||
# Start the sync task
|
||||
task = sync_all_users.delay()
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'Full synchronization started successfully. Task ID: {task.id}. Check logs below for progress.',
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f'Failed to start full synchronization: {e}',
|
||||
level=messages.ERROR
|
||||
)
|
||||
|
||||
trigger_full_sync.short_description = "🔄 Trigger full sync of all servers"
|
||||
|
||||
def trigger_statistics_update(self, request, queryset):
|
||||
"""Trigger manual update of user statistics cache"""
|
||||
# This action doesn't require selected items
|
||||
try:
|
||||
from vpn.tasks import update_user_statistics
|
||||
|
||||
# Start the statistics update task
|
||||
task = update_user_statistics.delay()
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'User statistics update started successfully. Task ID: {task.id}. Check logs below for progress.',
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f'Failed to start statistics update: {e}',
|
||||
level=messages.ERROR
|
||||
)
|
||||
|
||||
trigger_statistics_update.short_description = "📊 Update user statistics cache"
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Remove default delete action for logs"""
|
||||
actions = super().get_actions(request)
|
||||
if 'delete_selected' in actions:
|
||||
del actions['delete_selected']
|
||||
return actions
|
||||
|
||||
@admin.display(description='Task', ordering='task_name')
|
||||
def task_name_display(self, obj):
|
||||
task_names = {
|
||||
'sync_all_servers': '🔄 Sync All',
|
||||
'sync_server_users': '👥 Server Sync',
|
||||
'sync_server_info': '⚙️ Server Info',
|
||||
'sync_user_on_server': '👤 User Sync',
|
||||
'cleanup_task_logs': '🧹 Cleanup',
|
||||
'update_user_statistics': '📊 Statistics',
|
||||
}
|
||||
return task_names.get(obj.task_name, obj.task_name)
|
||||
|
||||
@admin.display(description='Status', ordering='status')
|
||||
def status_display(self, obj):
|
||||
status_icons = {
|
||||
'STARTED': '🟡 Started',
|
||||
'SUCCESS': '✅ Success',
|
||||
'FAILURE': '❌ Failed',
|
||||
'RETRY': '🔄 Retry',
|
||||
}
|
||||
return status_icons.get(obj.status, obj.status)
|
||||
|
||||
@admin.display(description='Time', ordering='execution_time')
|
||||
def execution_time_display(self, obj):
|
||||
if obj.execution_time:
|
||||
if obj.execution_time < 1:
|
||||
return f"{obj.execution_time*1000:.0f}ms"
|
||||
else:
|
||||
return f"{obj.execution_time:.2f}s"
|
||||
return '-'
|
||||
|
||||
@admin.display(description='Message')
|
||||
def message_formatted(self, obj):
|
||||
if obj.message:
|
||||
return mark_safe(f"<pre style='white-space: pre-wrap; max-width: 800px;'>{obj.message}</pre>")
|
||||
return '-'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
"""Override to handle actions that don't require item selection"""
|
||||
# Handle actions that don't require selection
|
||||
if 'action' in request.POST:
|
||||
action = request.POST['action']
|
||||
if action == 'trigger_full_sync':
|
||||
# Call the action directly without queryset requirement
|
||||
self.trigger_full_sync(request, None)
|
||||
# Return redirect to prevent AttributeError
|
||||
return redirect(request.get_full_path())
|
||||
elif action == 'trigger_statistics_update':
|
||||
# Call the statistics update action
|
||||
self.trigger_statistics_update(request, None)
|
||||
# Return redirect to prevent AttributeError
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
return super().changelist_view(request, extra_context)
|
||||
|
||||
|
||||
@admin.register(AccessLog)
|
||||
class AccessLogAdmin(BaseVPNAdmin):
|
||||
list_display = ('user', 'server', 'acl_link_display', 'action', 'formatted_timestamp')
|
||||
list_filter = ('user', 'server', 'action', 'timestamp')
|
||||
search_fields = ('user', 'server', 'acl_link_id', 'action', 'timestamp', 'data')
|
||||
readonly_fields = ('server', 'user', 'acl_link_id', 'formatted_timestamp', 'action', 'formated_data')
|
||||
|
||||
@admin.display(description='Link', ordering='acl_link_id')
|
||||
def acl_link_display(self, obj):
|
||||
if obj.acl_link_id:
|
||||
return format_html(
|
||||
'<span style="font-family: monospace; color: #2563eb;">{}</span>',
|
||||
obj.acl_link_id[:12] + '...' if len(obj.acl_link_id) > 12 else obj.acl_link_id
|
||||
)
|
||||
return '-'
|
||||
|
||||
@admin.display(description='Timestamp')
|
||||
def formatted_timestamp(self, obj):
|
||||
local_time = localtime(obj.timestamp)
|
||||
return local_time.strftime('%Y-%m-%d %H:%M:%S %Z')
|
||||
|
||||
@admin.display(description='Details')
|
||||
def formated_data(self, obj):
|
||||
return format_object(obj.data)
|
||||
864
vpn/admin/server.py
Normal file
864
vpn/admin/server.py
Normal file
@@ -0,0 +1,864 @@
|
||||
"""
|
||||
Server admin interface
|
||||
"""
|
||||
import re
|
||||
from polymorphic.admin import PolymorphicParentModelAdmin
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.db.models import Count, Case, When, Value, IntegerField, F, Subquery, OuterRef
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib import messages
|
||||
from django.urls import path, reverse
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
|
||||
from mysite.settings import EXTERNAL_ADDRESS
|
||||
from vpn.models import Server, ACL, ACLLink
|
||||
from .base import BaseVPNAdmin, format_bytes
|
||||
from vpn.server_plugins import (
|
||||
OutlineServer,
|
||||
WireguardServer,
|
||||
XrayServerV2
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Server)
|
||||
class ServerAdmin(PolymorphicParentModelAdmin, BaseVPNAdmin):
|
||||
base_model = Server
|
||||
child_models = (OutlineServer, WireguardServer, XrayServerV2)
|
||||
list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'server_status_compact', 'registration_date')
|
||||
search_fields = ('name', 'comment')
|
||||
list_filter = ('server_type', )
|
||||
actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers', 'sync_xray_inbounds', 'check_status']
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('admin/css/vpn_admin.css',)
|
||||
}
|
||||
js = ('admin/js/server_status_check.js',)
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('move-clients/', self.admin_site.admin_view(self.move_clients_view), name='server_move_clients'),
|
||||
path('<int:server_id>/check-status/', self.admin_site.admin_view(self.check_server_status_view), name='server_check_status'),
|
||||
path('<int:object_id>/sync/', self.admin_site.admin_view(self.sync_server_view), name='server_sync'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def move_clients_action(self, request, queryset):
|
||||
"""Custom action to move client links between servers"""
|
||||
if queryset.count() == 0:
|
||||
self.message_user(request, "Please select at least one server.", level=messages.ERROR)
|
||||
return
|
||||
|
||||
# Redirect to move clients page
|
||||
selected_ids = ','.join(str(server.id) for server in queryset)
|
||||
return HttpResponseRedirect(f"{reverse('admin:server_move_clients')}?servers={selected_ids}")
|
||||
|
||||
move_clients_action.short_description = "Move client links between servers"
|
||||
|
||||
def move_clients_view(self, request):
|
||||
"""View for moving clients between servers"""
|
||||
if request.method == 'GET':
|
||||
# Get selected servers from URL parameters
|
||||
server_ids = request.GET.get('servers', '').split(',')
|
||||
if not server_ids or server_ids == ['']:
|
||||
messages.error(request, "No servers selected.")
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
try:
|
||||
# Only work with database objects, don't check server connectivity
|
||||
servers = Server.objects.filter(id__in=server_ids)
|
||||
all_servers = Server.objects.all()
|
||||
|
||||
# Get ACL links for selected servers with related data
|
||||
# This is purely database operation, no server connectivity required
|
||||
links_by_server = {}
|
||||
for server in servers:
|
||||
try:
|
||||
# Get all ACL links for this server with user and ACL data
|
||||
links = ACLLink.objects.filter(
|
||||
acl__server=server
|
||||
).select_related('acl__user', 'acl__server').order_by('acl__user__username', 'comment')
|
||||
links_by_server[server] = links
|
||||
except Exception as e:
|
||||
# Log the error but continue with other servers
|
||||
messages.warning(request, f"Warning: Could not load links for server {server.name}: {e}")
|
||||
links_by_server[server] = []
|
||||
|
||||
context = {
|
||||
'title': 'Move Client Links Between Servers',
|
||||
'servers': servers,
|
||||
'all_servers': all_servers,
|
||||
'links_by_server': links_by_server,
|
||||
}
|
||||
|
||||
return render(request, 'admin/move_clients.html', context)
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Database error while loading data: {e}")
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
elif request.method == 'POST':
|
||||
# Process the transfer of ACL links - purely database operations
|
||||
try:
|
||||
source_server_id = request.POST.get('source_server')
|
||||
target_server_id = request.POST.get('target_server')
|
||||
selected_link_ids = request.POST.getlist('selected_links')
|
||||
comment_regex = request.POST.get('comment_regex', '').strip()
|
||||
|
||||
if not source_server_id or not target_server_id:
|
||||
messages.error(request, "Please select both source and target servers.")
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
if source_server_id == target_server_id:
|
||||
messages.error(request, "Source and target servers cannot be the same.")
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
if not selected_link_ids:
|
||||
messages.error(request, "Please select at least one link to move.")
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
# Parse and validate regex pattern if provided
|
||||
regex_pattern = None
|
||||
regex_replacement = None
|
||||
regex_parts = None
|
||||
if comment_regex:
|
||||
try:
|
||||
regex_parts = comment_regex.split(' -> ')
|
||||
if len(regex_parts) != 2:
|
||||
messages.error(request, "Invalid regex format. Use: pattern -> replacement")
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
pattern_str = regex_parts[0]
|
||||
replacement_str = regex_parts[1]
|
||||
|
||||
# Convert JavaScript-style $1, $2, $3 to Python-style \1, \2, \3
|
||||
python_replacement = replacement_str
|
||||
# Replace $1, $2, etc. with \1, \2, etc. for Python regex
|
||||
python_replacement = re.sub(r'\$(\d+)', r'\\\1', replacement_str)
|
||||
|
||||
# Test compile the regex pattern
|
||||
regex_pattern = re.compile(pattern_str)
|
||||
regex_replacement = python_replacement
|
||||
|
||||
# Test the replacement on a sample string to validate syntax
|
||||
test_result = regex_pattern.sub(regex_replacement, "test sample")
|
||||
|
||||
except re.error as e:
|
||||
messages.error(request, f"Invalid regular expression pattern '{regex_parts[0] if regex_parts else comment_regex}': {e}")
|
||||
return redirect(request.get_full_path())
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error in regex replacement '{replacement_str if 'replacement_str' in locals() else 'unknown'}': {e}")
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
# Get server objects from database only
|
||||
try:
|
||||
source_server = Server.objects.get(id=source_server_id)
|
||||
target_server = Server.objects.get(id=target_server_id)
|
||||
except Server.DoesNotExist:
|
||||
messages.error(request, "One of the selected servers was not found in database.")
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
moved_count = 0
|
||||
errors = []
|
||||
users_processed = set()
|
||||
comments_transformed = 0
|
||||
|
||||
# Process each selected link - database operations only
|
||||
for link_id in selected_link_ids:
|
||||
try:
|
||||
# Get the ACL link with related ACL and user data
|
||||
acl_link = ACLLink.objects.select_related('acl__user', 'acl__server').get(
|
||||
id=link_id,
|
||||
acl__server=source_server
|
||||
)
|
||||
user = acl_link.acl.user
|
||||
|
||||
# Apply regex transformation to comment if provided
|
||||
original_comment = acl_link.comment
|
||||
if regex_pattern and regex_replacement is not None:
|
||||
try:
|
||||
# Use Python's re.sub for replacement, which properly handles $1, $2 groups
|
||||
new_comment = regex_pattern.sub(regex_replacement, original_comment)
|
||||
if new_comment != original_comment:
|
||||
acl_link.comment = new_comment
|
||||
comments_transformed += 1
|
||||
# Debug logging - shows both original and converted patterns
|
||||
print(f"DEBUG: Transformed '{original_comment}' -> '{new_comment}'")
|
||||
print(f" Original pattern: '{regex_parts[0]}' -> '{regex_parts[1]}'")
|
||||
print(f" Python pattern: '{regex_parts[0]}' -> '{regex_replacement}'")
|
||||
except Exception as e:
|
||||
errors.append(f"Error applying regex to link {link_id} ('{original_comment}'): {e}")
|
||||
# Continue with original comment
|
||||
|
||||
# Check if user already has ACL on target server
|
||||
target_acl = ACL.objects.filter(user=user, server=target_server).first()
|
||||
|
||||
if target_acl:
|
||||
created = False
|
||||
else:
|
||||
# Create new ACL without auto-creating default link
|
||||
target_acl = ACL(user=user, server=target_server)
|
||||
target_acl.save(auto_create_link=False)
|
||||
created = True
|
||||
|
||||
# Move the link to target ACL - pure database operation
|
||||
acl_link.acl = target_acl
|
||||
acl_link.save()
|
||||
|
||||
moved_count += 1
|
||||
users_processed.add(user.username)
|
||||
|
||||
if created:
|
||||
messages.info(request, f"Created new ACL for user {user.username} on server {target_server.name}")
|
||||
|
||||
except ACLLink.DoesNotExist:
|
||||
errors.append(f"Link with ID {link_id} not found on source server")
|
||||
except Exception as e:
|
||||
errors.append(f"Database error moving link {link_id}: {e}")
|
||||
|
||||
# Clean up empty ACLs on source server - database operation only
|
||||
try:
|
||||
empty_acls = ACL.objects.filter(
|
||||
server=source_server,
|
||||
links__isnull=True
|
||||
)
|
||||
deleted_acls_count = empty_acls.count()
|
||||
empty_acls.delete()
|
||||
except Exception as e:
|
||||
messages.warning(request, f"Warning: Could not clean up empty ACLs: {e}")
|
||||
deleted_acls_count = 0
|
||||
|
||||
if moved_count > 0:
|
||||
success_msg = (
|
||||
f"Successfully moved {moved_count} link(s) for {len(users_processed)} user(s) "
|
||||
f"from '{source_server.name}' to '{target_server.name}'. "
|
||||
f"Cleaned up {deleted_acls_count} empty ACL(s)."
|
||||
)
|
||||
if comments_transformed > 0:
|
||||
success_msg += f" Transformed {comments_transformed} comment(s) using regex."
|
||||
|
||||
messages.success(request, success_msg)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
messages.error(request, error)
|
||||
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Database error during link transfer: {e}")
|
||||
return redirect('admin:vpn_server_changelist')
|
||||
|
||||
def check_server_status_view(self, request, server_id):
|
||||
"""AJAX view to check server status"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
logger.info(f"Checking status for server ID: {server_id}")
|
||||
server = Server.objects.get(pk=server_id)
|
||||
real_server = server.get_real_instance()
|
||||
logger.info(f"Server found: {server.name}, type: {type(real_server).__name__}")
|
||||
|
||||
# Check server status based on type
|
||||
from vpn.server_plugins.outline import OutlineServer
|
||||
# Old xray_core module removed - skip this server type
|
||||
|
||||
if isinstance(real_server, OutlineServer):
|
||||
try:
|
||||
logger.info(f"Checking Outline server: {server.name}")
|
||||
# Try to get server info to check if it's online
|
||||
info = real_server.client.get_server_information()
|
||||
if info:
|
||||
logger.info(f"Server {server.name} is online with {info.get('accessKeyCount', info.get('access_key_count', 'unknown'))} keys")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'online',
|
||||
'message': f'Server is online. Keys: {info.get("accessKeyCount", info.get("access_key_count", "unknown"))}'
|
||||
})
|
||||
else:
|
||||
logger.warning(f"Server {server.name} returned no info")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'offline',
|
||||
'message': 'Server not responding'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking Outline server {server.name}: {e}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'error',
|
||||
'message': f'Connection error: {str(e)[:100]}'
|
||||
})
|
||||
|
||||
elif isinstance(real_server, XrayServerV2):
|
||||
try:
|
||||
logger.info(f"Checking Xray v2 server: {server.name}")
|
||||
# Get server status from new Xray implementation
|
||||
status = real_server.get_server_status()
|
||||
if status and isinstance(status, dict):
|
||||
if status.get('accessible', False):
|
||||
message = f'✅ Server is {status.get("status", "accessible")}. '
|
||||
message += f'Host: {status.get("client_hostname", "N/A")}, '
|
||||
message += f'API: {status.get("api_address", "N/A")}'
|
||||
|
||||
if status.get('api_connected'):
|
||||
message += ' (Connected)'
|
||||
# Add stats if available
|
||||
api_stats = status.get('api_stats', {})
|
||||
if api_stats and isinstance(api_stats, dict):
|
||||
if 'connection' in api_stats:
|
||||
message += f', Stats: {api_stats.get("connection", "ok")}'
|
||||
if api_stats.get('library') == 'not_available':
|
||||
message += ' [Basic check only]'
|
||||
elif status.get('api_error'):
|
||||
message += f' ({status.get("api_error")})'
|
||||
|
||||
message += f', Inbounds: {status.get("total_inbounds", 0)}'
|
||||
|
||||
logger.info(f"Xray v2 server {server.name} status: {message}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'online',
|
||||
'message': message
|
||||
})
|
||||
else:
|
||||
error_msg = status.get('error') or status.get('api_error', 'Unknown error')
|
||||
logger.warning(f"Xray v2 server {server.name} not accessible: {error_msg}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'offline',
|
||||
'message': f'❌ Server not accessible: {error_msg}'
|
||||
})
|
||||
else:
|
||||
logger.warning(f"Xray v2 server {server.name} returned invalid status")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'offline',
|
||||
'message': 'Invalid server response'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking Xray v2 server {server.name}: {e}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'error',
|
||||
'message': f'Connection error: {str(e)[:100]}'
|
||||
})
|
||||
|
||||
else:
|
||||
# For other server types, just return basic info
|
||||
logger.info(f"Server {server.name}, type: {server.server_type}")
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'status': 'unknown',
|
||||
'message': f'Status check not implemented for {server.server_type} servers'
|
||||
})
|
||||
|
||||
except Server.DoesNotExist:
|
||||
logger.error(f"Server with ID {server_id} not found")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Server not found'
|
||||
}, status=404)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error checking server {server_id}: {e}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': f'Unexpected error: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
logger.warning(f"Invalid request method {request.method} for server status check")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Invalid request method'
|
||||
}, status=405)
|
||||
|
||||
def purge_all_keys_action(self, request, queryset):
|
||||
"""Purge all keys from selected servers without changing database"""
|
||||
if queryset.count() == 0:
|
||||
self.message_user(request, "Please select at least one server.", level=messages.ERROR)
|
||||
return
|
||||
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
total_keys_removed = 0
|
||||
|
||||
for server in queryset:
|
||||
try:
|
||||
# Get the real polymorphic instance
|
||||
real_server = server.get_real_instance()
|
||||
server_type = type(real_server).__name__
|
||||
|
||||
# Check if this is an Outline server
|
||||
from vpn.server_plugins.outline import OutlineServer
|
||||
|
||||
if isinstance(real_server, OutlineServer) and hasattr(real_server, 'client'):
|
||||
# For Outline servers, get all keys and delete them
|
||||
try:
|
||||
keys = real_server.client.get_keys()
|
||||
keys_count = len(keys)
|
||||
|
||||
for key in keys:
|
||||
try:
|
||||
real_server.client.delete_key(key.key_id)
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f"Failed to delete key {key.key_id} from {server.name}: {e}",
|
||||
level=messages.WARNING
|
||||
)
|
||||
|
||||
total_keys_removed += keys_count
|
||||
success_count += 1
|
||||
self.message_user(
|
||||
request,
|
||||
f"Successfully purged {keys_count} keys from server '{server.name}'.",
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
self.message_user(
|
||||
request,
|
||||
f"Failed to connect to server '{server.name}': {e}",
|
||||
level=messages.ERROR
|
||||
)
|
||||
else:
|
||||
self.message_user(
|
||||
request,
|
||||
f"Key purging only supported for Outline servers. Skipping '{server.name}' (type: {server_type}).",
|
||||
level=messages.INFO
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
self.message_user(
|
||||
request,
|
||||
f"Unexpected error with server '{server.name}': {e}",
|
||||
level=messages.ERROR
|
||||
)
|
||||
|
||||
# Summary message
|
||||
if success_count > 0:
|
||||
self.message_user(
|
||||
request,
|
||||
f"Purge completed! {success_count} server(s) processed, {total_keys_removed} total keys removed. "
|
||||
f"Database unchanged - run sync to restore proper keys.",
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
if error_count > 0:
|
||||
self.message_user(
|
||||
request,
|
||||
f"{error_count} server(s) had errors during purge.",
|
||||
level=messages.WARNING
|
||||
)
|
||||
|
||||
purge_all_keys_action.short_description = "🗑️ Purge all keys from server (database unchanged)"
|
||||
|
||||
def sync_all_selected_servers(self, request, queryset):
|
||||
"""Trigger sync for all users on selected servers"""
|
||||
if queryset.count() == 0:
|
||||
self.message_user(request, "Please select at least one server.", level=messages.ERROR)
|
||||
return
|
||||
|
||||
try:
|
||||
from vpn.tasks import sync_server_users
|
||||
from celery import current_app
|
||||
|
||||
tasks_started = 0
|
||||
errors = []
|
||||
scheduled_tasks = set() # Track already scheduled tasks to avoid duplicates
|
||||
|
||||
for server in queryset:
|
||||
try:
|
||||
# Check if a task is already running for this server
|
||||
task_key = f"sync_server_{server.id}"
|
||||
|
||||
# Use Celery's inspect to check active tasks (optional, for better UX)
|
||||
try:
|
||||
inspect = current_app.control.inspect()
|
||||
active_tasks = inspect.active()
|
||||
|
||||
# Check if task is already scheduled for this server
|
||||
task_already_running = False
|
||||
if active_tasks:
|
||||
for worker, tasks in active_tasks.items():
|
||||
for task_info in tasks:
|
||||
if task_info.get('name') == 'sync_server_users':
|
||||
# Check if server.id is in the task args
|
||||
task_args = task_info.get('args', [])
|
||||
if task_args and len(task_args) > 0 and task_args[0] == server.id:
|
||||
task_already_running = True
|
||||
break
|
||||
|
||||
if task_already_running:
|
||||
self.message_user(
|
||||
request,
|
||||
f"⏳ Sync already in progress for '{server.name}'",
|
||||
level=messages.WARNING
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
# If we can't check active tasks, just proceed
|
||||
logger.debug(f"Could not check active tasks: {e}")
|
||||
|
||||
# Avoid scheduling duplicate tasks in this batch
|
||||
if server.id in scheduled_tasks:
|
||||
continue
|
||||
|
||||
task = sync_server_users.delay(server.id)
|
||||
scheduled_tasks.add(server.id)
|
||||
tasks_started += 1
|
||||
self.message_user(
|
||||
request,
|
||||
f"🔄 Sync task started for '{server.name}' (Task ID: {task.id})",
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f"'{server.name}': {e}")
|
||||
|
||||
if tasks_started > 0:
|
||||
self.message_user(
|
||||
request,
|
||||
f"✅ Started sync tasks for {tasks_started} server(s). Check Task Execution Logs for progress.",
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
self.message_user(
|
||||
request,
|
||||
f"❌ Failed to sync {error}",
|
||||
level=messages.ERROR
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f"❌ Failed to start sync tasks: {e}",
|
||||
level=messages.ERROR
|
||||
)
|
||||
|
||||
sync_all_selected_servers.short_description = "🔄 Sync all users on selected servers"
|
||||
|
||||
def check_status(self, request, queryset):
|
||||
"""Check status for selected servers"""
|
||||
for server in queryset:
|
||||
try:
|
||||
status = server.get_server_status()
|
||||
msg = f"{server.name}: {status.get('accessible', 'Unknown')} - {status.get('status', 'N/A')}"
|
||||
self.message_user(request, msg, level=messages.INFO)
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error checking {server.name}: {e}", level=messages.ERROR)
|
||||
check_status.short_description = "📊 Check server status"
|
||||
|
||||
def sync_xray_inbounds(self, request, queryset):
|
||||
"""Sync inbounds for selected servers (Xray v2 only)"""
|
||||
synced_count = 0
|
||||
|
||||
for server in queryset:
|
||||
try:
|
||||
real_server = server.get_real_instance()
|
||||
if isinstance(real_server, XrayServerV2):
|
||||
real_server.sync_inbounds()
|
||||
synced_count += 1
|
||||
self.message_user(request, f"Scheduled inbound sync for {server.name}", level=messages.SUCCESS)
|
||||
else:
|
||||
self.message_user(request, f"{server.name} is not an Xray v2 server", level=messages.WARNING)
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error syncing inbounds for {server.name}: {e}", level=messages.ERROR)
|
||||
|
||||
if synced_count > 0:
|
||||
self.message_user(request, f"Scheduled inbound sync for {synced_count} server(s)", level=messages.SUCCESS)
|
||||
sync_xray_inbounds.short_description = "🔧 Sync Xray inbounds"
|
||||
|
||||
@admin.display(description='Server', ordering='name')
|
||||
def name_with_icon(self, obj):
|
||||
"""Display server name with type icon"""
|
||||
icons = {
|
||||
'outline': '🔵',
|
||||
'wireguard': '🟢',
|
||||
'xray_core': '🟣',
|
||||
'xray_v2': '🟡',
|
||||
}
|
||||
icon = icons.get(obj.server_type, '')
|
||||
name_part = f"{icon} {obj.name}" if icon else obj.name
|
||||
return name_part
|
||||
|
||||
@admin.display(description='Comment')
|
||||
def comment_short(self, obj):
|
||||
"""Display shortened comment"""
|
||||
if obj.comment:
|
||||
short_comment = obj.comment[:40] + '...' if len(obj.comment) > 40 else obj.comment
|
||||
return mark_safe(f'<span title="{obj.comment}" style="font-size: 12px;">{short_comment}</span>')
|
||||
return '-'
|
||||
|
||||
@admin.display(description='Users & Links')
|
||||
def user_stats(self, obj):
|
||||
"""Display user count and active links statistics (optimized)"""
|
||||
try:
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
user_count = obj.user_count if hasattr(obj, 'user_count') else 0
|
||||
|
||||
# Different logic for Xray vs legacy servers
|
||||
if obj.server_type == 'xray_v2':
|
||||
# For Xray servers, count inbounds and active subscriptions
|
||||
from vpn.models_xray import ServerInbound
|
||||
total_inbounds = ServerInbound.objects.filter(server=obj, active=True).count()
|
||||
|
||||
# Count recent subscription accesses via AccessLog
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
from vpn.models import AccessLog
|
||||
active_accesses = AccessLog.objects.filter(
|
||||
server='Xray-Subscription',
|
||||
action='Success',
|
||||
timestamp__gte=thirty_days_ago
|
||||
).values('user').distinct().count()
|
||||
|
||||
total_links = total_inbounds
|
||||
active_links = min(active_accesses, user_count) # Can't be more than total users
|
||||
else:
|
||||
# Legacy servers: use ACL links as before
|
||||
if hasattr(obj, 'acl_set'):
|
||||
all_links = []
|
||||
for acl in obj.acl_set.all():
|
||||
if hasattr(acl, 'links') and hasattr(acl.links, 'all'):
|
||||
all_links.extend(acl.links.all())
|
||||
|
||||
total_links = len(all_links)
|
||||
|
||||
# Count active links from prefetched data
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
active_links = sum(1 for link in all_links
|
||||
if link.last_access_time and link.last_access_time >= thirty_days_ago)
|
||||
else:
|
||||
# Fallback to direct queries (less efficient)
|
||||
total_links = ACLLink.objects.filter(acl__server=obj).count()
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
active_links = ACLLink.objects.filter(
|
||||
acl__server=obj,
|
||||
last_access_time__isnull=False,
|
||||
last_access_time__gte=thirty_days_ago
|
||||
).count()
|
||||
|
||||
# Color coding based on activity
|
||||
if user_count == 0:
|
||||
color = '#9ca3af' # gray - no users
|
||||
elif total_links == 0:
|
||||
color = '#dc2626' # red - no links/inbounds
|
||||
elif obj.server_type == 'xray_v2':
|
||||
# For Xray: base on user activity rather than link activity
|
||||
if active_links > user_count * 0.5: # More than half users active
|
||||
color = '#16a34a' # green
|
||||
elif active_links > user_count * 0.2: # More than 20% users active
|
||||
color = '#eab308' # yellow
|
||||
else:
|
||||
color = '#f97316' # orange - low activity
|
||||
else:
|
||||
# Legacy servers: base on link activity
|
||||
if total_links > 0 and active_links > total_links * 0.7: # High activity
|
||||
color = '#16a34a' # green
|
||||
elif total_links > 0 and active_links > total_links * 0.3: # Medium activity
|
||||
color = '#eab308' # yellow
|
||||
else:
|
||||
color = '#f97316' # orange - low activity
|
||||
|
||||
# Different display for Xray vs legacy
|
||||
if obj.server_type == 'xray_v2':
|
||||
# Try to get traffic stats if stats enabled
|
||||
traffic_info = ""
|
||||
# Get the real XrayServerV2 instance to access its fields
|
||||
xray_server = obj.get_real_instance()
|
||||
if hasattr(xray_server, 'stats_enabled') and xray_server.stats_enabled and xray_server.api_enabled:
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
from vpn.xray_api_v2.stats import StatsManager
|
||||
|
||||
client = XrayClient(server=xray_server.api_address)
|
||||
stats_manager = StatsManager(client)
|
||||
traffic_summary = stats_manager.get_traffic_summary()
|
||||
|
||||
# Calculate total traffic
|
||||
total_uplink = 0
|
||||
total_downlink = 0
|
||||
|
||||
# Sum up user traffic
|
||||
for user_email, user_traffic in traffic_summary.get('users', {}).items():
|
||||
total_uplink += user_traffic.get('uplink', 0)
|
||||
total_downlink += user_traffic.get('downlink', 0)
|
||||
|
||||
# Format traffic
|
||||
|
||||
if total_uplink > 0 or total_downlink > 0:
|
||||
traffic_info = f'<div style="color: #6b7280; font-size: 11px;">↑{format_bytes(total_uplink)} ↓{format_bytes(total_downlink)}</div>'
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Could not get stats for Xray server {xray_server.name}: {e}")
|
||||
|
||||
return mark_safe(
|
||||
f'<div style="font-size: 12px;">' +
|
||||
f'<div style="color: {color}; font-weight: bold;">👥 {user_count} users</div>' +
|
||||
f'<div style="color: #6b7280;">📡 {total_links} inbounds</div>' +
|
||||
traffic_info +
|
||||
f'</div>'
|
||||
)
|
||||
else:
|
||||
return mark_safe(
|
||||
f'<div style="font-size: 12px;">' +
|
||||
f'<div style="color: {color}; font-weight: bold;">👥 {user_count} users</div>' +
|
||||
f'<div style="color: #6b7280;">🔗 {active_links}/{total_links} active</div>' +
|
||||
f'</div>'
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error in user_stats for server {obj.name}: {e}", exc_info=True)
|
||||
return mark_safe(f'<span style="color: #dc2626; font-size: 11px;">Stats error: {e}</span>')
|
||||
|
||||
@admin.display(description='Activity')
|
||||
def activity_summary(self, obj):
|
||||
"""Display recent activity summary (optimized)"""
|
||||
try:
|
||||
# Simplified version - avoid heavy DB queries on list page
|
||||
# This could be computed once per page load if needed
|
||||
return mark_safe(
|
||||
f'<div style="font-size: 11px; color: #6b7280;">' +
|
||||
f'<div>📊 Activity data</div>' +
|
||||
f'<div><small>Click to view details</small></div>' +
|
||||
f'</div>'
|
||||
)
|
||||
except Exception as e:
|
||||
return mark_safe(f'<span style="color: #dc2626; font-size: 11px;">Activity unavailable</span>')
|
||||
|
||||
@admin.display(description='Status')
|
||||
def server_status_compact(self, obj):
|
||||
"""Display server status in compact format (optimized)"""
|
||||
try:
|
||||
# Avoid expensive server connectivity checks on list page
|
||||
# Show basic info and let users click to check status
|
||||
server_type_icons = {
|
||||
'outline': '🔵',
|
||||
'wireguard': '🟢',
|
||||
'xray_core': '🟣',
|
||||
}
|
||||
icon = server_type_icons.get(obj.server_type, '⚪')
|
||||
|
||||
return mark_safe(
|
||||
f'<div style="color: #6b7280; font-size: 11px;">' +
|
||||
f'{icon} {obj.server_type.title()}<br>' +
|
||||
f'<button type="button" class="check-status-btn btn btn-xs" '
|
||||
f'data-server-id="{obj.id}" data-server-name="{obj.name}" '
|
||||
f'data-server-type="{obj.server_type}" '
|
||||
f'style="background: #007cba; color: white; border: none; padding: 2px 6px; '
|
||||
f'border-radius: 3px; font-size: 10px; cursor: pointer;">'
|
||||
f'⚪ Check Status'
|
||||
f'</button>' +
|
||||
f'</div>'
|
||||
)
|
||||
except Exception as e:
|
||||
return mark_safe(
|
||||
f'<div style="color: #f97316; font-size: 11px; font-weight: bold;">' +
|
||||
f'⚠️ Error<br>' +
|
||||
f'<span style="font-weight: normal;" title="{str(e)}">' +
|
||||
f'{str(e)[:25]}...</span>' +
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
from vpn.models_xray import UserSubscription, ServerInbound
|
||||
|
||||
qs = super().get_queryset(request)
|
||||
|
||||
# Count ACL users for all servers
|
||||
qs = qs.annotate(
|
||||
acl_user_count=Count('acl__user', distinct=True)
|
||||
)
|
||||
|
||||
# For Xray servers, calculate user count separately
|
||||
# Create subquery to count Xray users
|
||||
xray_user_count_subquery = ServerInbound.objects.filter(
|
||||
server_id=OuterRef('pk'),
|
||||
active=True,
|
||||
inbound__subscriptiongroup__usersubscription__active=True,
|
||||
inbound__subscriptiongroup__is_active=True
|
||||
).values('server_id').annotate(
|
||||
count=Count('inbound__subscriptiongroup__usersubscription__user', distinct=True)
|
||||
).values('count')
|
||||
|
||||
qs = qs.annotate(
|
||||
xray_user_count=Subquery(xray_user_count_subquery, output_field=IntegerField()),
|
||||
user_count=Case(
|
||||
When(server_type='xray_v2', then=F('xray_user_count')),
|
||||
default=F('acl_user_count'),
|
||||
output_field=IntegerField()
|
||||
)
|
||||
)
|
||||
|
||||
# Handle None values from subquery
|
||||
qs = qs.annotate(
|
||||
user_count=Case(
|
||||
When(server_type='xray_v2', user_count__isnull=True, then=Value(0)),
|
||||
When(server_type='xray_v2', then=F('xray_user_count')),
|
||||
default=F('acl_user_count'),
|
||||
output_field=IntegerField()
|
||||
)
|
||||
)
|
||||
|
||||
qs = qs.prefetch_related(
|
||||
'acl_set__links',
|
||||
'acl_set__user'
|
||||
)
|
||||
return qs
|
||||
|
||||
def sync_server_view(self, request, object_id):
|
||||
"""Dispatch sync to appropriate server type."""
|
||||
try:
|
||||
server = get_object_or_404(Server, pk=object_id)
|
||||
real_server = server.get_real_instance()
|
||||
|
||||
# Handle XrayServerV2
|
||||
if isinstance(real_server, XrayServerV2):
|
||||
return redirect(f'/admin/vpn/xrayserverv2/{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')
|
||||
|
||||
|
||||
# Inline for legacy VPN access (Outline/Wireguard)
|
||||
class UserACLInline(admin.TabularInline):
|
||||
model = ACL
|
||||
extra = 0
|
||||
fields = ('server', 'created_at', 'link_count')
|
||||
readonly_fields = ('created_at', 'link_count')
|
||||
verbose_name = "Legacy VPN Server Access"
|
||||
verbose_name_plural = "Legacy VPN Server Access (Outline/Wireguard)"
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
if db_field.name == "server":
|
||||
# Only show old-style servers (Outline/Wireguard)
|
||||
kwargs["queryset"] = Server.objects.exclude(server_type='xray_v2')
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
|
||||
@admin.display(description='Links')
|
||||
def link_count(self, obj):
|
||||
count = obj.links.count()
|
||||
return format_html(
|
||||
'<span style="font-weight: bold;">{}</span> link(s)',
|
||||
count
|
||||
)
|
||||
702
vpn/admin/user.py
Normal file
702
vpn/admin/user.py
Normal file
@@ -0,0 +1,702 @@
|
||||
"""
|
||||
User admin interface
|
||||
"""
|
||||
import shortuuid
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.contrib import messages
|
||||
from django.urls import path, reverse
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.utils.timezone import localtime
|
||||
|
||||
from mysite.settings import EXTERNAL_ADDRESS
|
||||
from vpn.models import User, ACL, ACLLink, Server, AccessLog, UserStatistics
|
||||
from vpn.forms import UserForm
|
||||
from .base import BaseVPNAdmin, format_bytes
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseVPNAdmin):
|
||||
form = UserForm
|
||||
list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count')
|
||||
search_fields = ('username', 'hash', 'telegram_user_id', 'telegram_username')
|
||||
readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary', 'telegram_info_display', 'subscription_management_info')
|
||||
inlines = [] # Inlines will be added by subscription management function
|
||||
|
||||
fieldsets = (
|
||||
('User Information', {
|
||||
'fields': ('username', 'first_name', 'last_name', 'email', 'comment')
|
||||
}),
|
||||
('Telegram Integration', {
|
||||
'fields': ('telegram_username', 'telegram_info_display'),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Link existing users to Telegram by setting telegram_username (without @)'
|
||||
}),
|
||||
('Access Information', {
|
||||
'fields': ('hash_link', 'is_active', 'vpn_access_summary')
|
||||
}),
|
||||
('Statistics & Server Management', {
|
||||
'fields': ('user_statistics_summary',),
|
||||
'classes': ('wide',)
|
||||
}),
|
||||
('Subscription Management', {
|
||||
'fields': ('subscription_management_info',),
|
||||
'classes': ('wide',),
|
||||
'description': 'Manage user\'s Xray subscription groups. Use the "User\'s Subscription Groups" section below to add/remove subscriptions.'
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.display(description='VPN Access Summary')
|
||||
def vpn_access_summary(self, obj):
|
||||
"""Display summary of user's VPN access"""
|
||||
if not obj.pk:
|
||||
return "Save user first to see VPN access"
|
||||
|
||||
# Get legacy VPN access
|
||||
acl_count = ACL.objects.filter(user=obj).count()
|
||||
legacy_links = ACLLink.objects.filter(acl__user=obj).count()
|
||||
|
||||
# Get Xray access
|
||||
from vpn.models_xray import UserSubscription
|
||||
xray_subs = UserSubscription.objects.filter(user=obj, active=True).select_related('subscription_group')
|
||||
xray_groups = [sub.subscription_group.name for sub in xray_subs]
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
|
||||
|
||||
# Legacy VPN section
|
||||
html += '<div style="margin-bottom: 15px;">'
|
||||
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">📡 Legacy VPN (Outline/Wireguard)</h4>'
|
||||
if acl_count > 0:
|
||||
html += f'<p style="margin: 5px 0;">✅ Access to <strong>{acl_count}</strong> server(s)</p>'
|
||||
html += f'<p style="margin: 5px 0;">🔗 Total links: <strong>{legacy_links}</strong></p>'
|
||||
else:
|
||||
html += '<p style="margin: 5px 0; color: #6c757d;">No legacy VPN access</p>'
|
||||
html += '</div>'
|
||||
|
||||
# Xray section
|
||||
html += '<div>'
|
||||
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">🚀 Xray VPN</h4>'
|
||||
if xray_groups:
|
||||
html += f'<p style="margin: 5px 0;">✅ Active subscriptions: <strong>{len(xray_groups)}</strong></p>'
|
||||
html += '<ul style="margin: 5px 0; padding-left: 20px;">'
|
||||
for group in xray_groups:
|
||||
html += f'<li>{group}</li>'
|
||||
html += '</ul>'
|
||||
|
||||
# Try to get traffic statistics for this user
|
||||
try:
|
||||
from vpn.server_plugins.xray_v2 import XrayServerV2
|
||||
traffic_total_up = 0
|
||||
traffic_total_down = 0
|
||||
servers_checked = set()
|
||||
|
||||
# Get all Xray servers
|
||||
xray_servers = XrayServerV2.objects.filter(api_enabled=True, stats_enabled=True)
|
||||
|
||||
for server in xray_servers:
|
||||
if server.name not in servers_checked:
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
from vpn.xray_api_v2.stats import StatsManager
|
||||
|
||||
client = XrayClient(server=server.api_address)
|
||||
stats_manager = StatsManager(client)
|
||||
|
||||
# Get user stats (use email format: username@servername)
|
||||
user_email = f"{obj.username}@{server.name}"
|
||||
user_stats = stats_manager.get_user_stats(user_email)
|
||||
|
||||
if user_stats:
|
||||
traffic_total_up += user_stats.get('uplink', 0)
|
||||
traffic_total_down += user_stats.get('downlink', 0)
|
||||
|
||||
servers_checked.add(server.name)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Could not get user stats from server {server.name}: {e}")
|
||||
|
||||
# Format traffic if we got any
|
||||
if traffic_total_up > 0 or traffic_total_down > 0:
|
||||
|
||||
html += f'<p style="margin: 10px 0 5px 0; color: #007cba;"><strong>📊 Traffic Statistics:</strong></p>'
|
||||
html += f'<p style="margin: 5px 0 5px 20px;">↑ Upload: <strong>{format_bytes(traffic_total_up)}</strong></p>'
|
||||
html += f'<p style="margin: 5px 0 5px 20px;">↓ Download: <strong>{format_bytes(traffic_total_down)}</strong></p>'
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Could not get traffic stats for user {obj.username}: {e}")
|
||||
else:
|
||||
html += '<p style="margin: 5px 0; color: #6c757d;">No Xray subscriptions</p>'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
|
||||
return format_html(html)
|
||||
|
||||
@admin.display(description='User Portal', ordering='hash')
|
||||
def hash_link(self, obj):
|
||||
portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.hash}"
|
||||
json_url = f"{EXTERNAL_ADDRESS}/stat/{obj.hash}"
|
||||
return format_html(
|
||||
'<div style="display: flex; gap: 10px; flex-wrap: wrap;">' +
|
||||
'<a href="{}" target="_blank" style="background: #4ade80; color: #000; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 12px; font-weight: bold;">🌐 Portal</a>' +
|
||||
'<a href="{}" target="_blank" style="background: #3b82f6; color: #fff; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 12px; font-weight: bold;">📄 JSON</a>' +
|
||||
'</div>',
|
||||
portal_url, json_url
|
||||
)
|
||||
|
||||
@admin.display(description='User Statistics Summary')
|
||||
def user_statistics_summary(self, obj):
|
||||
"""Display user statistics with integrated server management"""
|
||||
try:
|
||||
from vpn.models import UserStatistics
|
||||
from django.db import models
|
||||
|
||||
# Get statistics for this user
|
||||
user_stats = UserStatistics.objects.filter(user=obj).aggregate(
|
||||
total_connections=models.Sum('total_connections'),
|
||||
recent_connections=models.Sum('recent_connections'),
|
||||
total_links=models.Count('id'),
|
||||
max_daily_peak=models.Max('max_daily')
|
||||
)
|
||||
|
||||
# Get server breakdown
|
||||
server_breakdown = UserStatistics.objects.filter(user=obj).values('server_name').annotate(
|
||||
connections=models.Sum('total_connections'),
|
||||
links=models.Count('id')
|
||||
).order_by('-connections')
|
||||
|
||||
# Get all ACLs and links for this user
|
||||
user_acls = ACL.objects.filter(user=obj).select_related('server').prefetch_related('links')
|
||||
|
||||
# Get available servers not yet assigned
|
||||
all_servers = Server.objects.all()
|
||||
assigned_server_ids = [acl.server.id for acl in user_acls]
|
||||
unassigned_servers = all_servers.exclude(id__in=assigned_server_ids)
|
||||
|
||||
html = '<div class="user-management-section">'
|
||||
|
||||
# Overall Statistics
|
||||
html += '<div style="background: #e7f3ff; border-left: 4px solid #007cba; padding: 12px; margin-bottom: 16px; border-radius: 4px;">'
|
||||
html += f'<div style="display: flex; gap: 20px; margin-bottom: 8px; flex-wrap: wrap;">'
|
||||
html += f'<div><strong>Total Uses:</strong> {user_stats["total_connections"] or 0}</div>'
|
||||
html += f'<div><strong>Recent (30d):</strong> {user_stats["recent_connections"] or 0}</div>'
|
||||
html += f'<div><strong>Total Links:</strong> {user_stats["total_links"] or 0}</div>'
|
||||
if user_stats["max_daily_peak"]:
|
||||
html += f'<div><strong>Daily Peak:</strong> {user_stats["max_daily_peak"]}</div>'
|
||||
html += f'</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Server Management
|
||||
if user_acls:
|
||||
html += '<h4 style="color: #007cba; margin: 16px 0 8px 0;">🔗 Server Access & Links</h4>'
|
||||
|
||||
for acl in user_acls:
|
||||
server = acl.server
|
||||
links = list(acl.links.all())
|
||||
|
||||
# Server header (no slow server status checks)
|
||||
# Determine server type icon and label
|
||||
if server.server_type == 'Outline':
|
||||
type_icon = '🔵'
|
||||
type_label = 'Outline'
|
||||
elif server.server_type == 'Wireguard':
|
||||
type_icon = '🟢'
|
||||
type_label = 'Wireguard'
|
||||
elif server.server_type in ['xray_core', 'xray_v2']:
|
||||
type_icon = '🟣'
|
||||
type_label = 'Xray'
|
||||
else:
|
||||
type_icon = '❓'
|
||||
type_label = server.server_type
|
||||
|
||||
html += f'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">'
|
||||
html += f'<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600;">{type_icon} {server.name} ({type_label})</h5>'
|
||||
|
||||
# Server stats
|
||||
server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None)
|
||||
if server_stat:
|
||||
html += f'<span style="background: #f8f9fa; padding: 4px 8px; border-radius: 4px; font-size: 12px; color: #6c757d;">'
|
||||
html += f'📊 {server_stat["connections"]} uses ({server_stat["links"]} links)'
|
||||
html += f'</span>'
|
||||
html += f'</div>'
|
||||
|
||||
html += '<div class="server-section">'
|
||||
|
||||
# Links display
|
||||
if links:
|
||||
for link in links:
|
||||
# Get link stats
|
||||
link_stats = UserStatistics.objects.filter(
|
||||
user=obj, server_name=server.name, acl_link_id=link.link
|
||||
).first()
|
||||
|
||||
html += '<div class="link-item">'
|
||||
html += f'<div style="flex: 1;">'
|
||||
html += f'<div style="font-family: monospace; font-size: 13px; color: #007cba; font-weight: 500;">'
|
||||
html += f'{link.link[:16]}...' if len(link.link) > 16 else link.link
|
||||
html += f'</div>'
|
||||
if link.comment:
|
||||
html += f'<div style="font-size: 11px; color: #6c757d;">{link.comment}</div>'
|
||||
html += f'</div>'
|
||||
|
||||
# Link stats and actions
|
||||
html += f'<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">'
|
||||
if link_stats:
|
||||
html += f'<span style="background: #d4edda; color: #155724; padding: 2px 6px; border-radius: 3px; font-size: 10px;">'
|
||||
html += f'✨ {link_stats.total_connections}'
|
||||
html += f'</span>'
|
||||
|
||||
# Test link button
|
||||
html += f'<a href="{EXTERNAL_ADDRESS}/ss/{link.link}#{server.name}" target="_blank" '
|
||||
html += f'class="btn btn-sm btn-primary btn-sm-custom">🔗</a>'
|
||||
|
||||
# Delete button
|
||||
html += f'<button type="button" class="btn btn-sm btn-danger btn-sm-custom delete-link-btn" '
|
||||
html += f'data-link-id="{link.id}" data-link-name="{link.link[:12]}">🗑️</button>'
|
||||
|
||||
# Last access
|
||||
if link.last_access_time:
|
||||
local_time = localtime(link.last_access_time)
|
||||
html += f'<span style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px; font-size: 10px; color: #6c757d;">'
|
||||
html += f'{local_time.strftime("%m-%d %H:%M")}'
|
||||
html += f'</span>'
|
||||
else:
|
||||
html += f'<span style="background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 10px;">'
|
||||
html += f'Never'
|
||||
html += f'</span>'
|
||||
|
||||
html += f'</div></div>'
|
||||
|
||||
# Add link button
|
||||
html += f'<div style="text-align: center; margin-top: 12px;">'
|
||||
html += f'<button type="button" class="btn btn-sm btn-success add-link-btn" '
|
||||
html += f'data-server-id="{server.id}" data-server-name="{server.name}">'
|
||||
html += f'➕ Add Link'
|
||||
html += f'</button>'
|
||||
html += f'</div>'
|
||||
|
||||
html += '</div>' # End server-section
|
||||
|
||||
# Add server access section
|
||||
if unassigned_servers:
|
||||
html += '<div style="background: #d1ecf1; border-left: 4px solid #bee5eb; padding: 12px; margin-top: 16px; border-radius: 4px;">'
|
||||
html += '<h5 style="margin: 0 0 8px 0; color: #0c5460; font-size: 13px;">➕ Available Servers</h5>'
|
||||
html += '<div style="display: flex; gap: 8px; flex-wrap: wrap;">'
|
||||
for server in unassigned_servers:
|
||||
# Determine server type icon and label
|
||||
if server.server_type == 'Outline':
|
||||
type_icon = '🔵'
|
||||
type_label = 'Outline'
|
||||
elif server.server_type == 'Wireguard':
|
||||
type_icon = '🟢'
|
||||
type_label = 'Wireguard'
|
||||
elif server.server_type in ['xray_core', 'xray_v2']:
|
||||
type_icon = '🟣'
|
||||
type_label = 'Xray'
|
||||
else:
|
||||
type_icon = '❓'
|
||||
type_label = server.server_type
|
||||
|
||||
html += f'<button type="button" class="btn btn-sm btn-outline-info btn-sm-custom add-server-btn" '
|
||||
html += f'data-server-id="{server.id}" data-server-name="{server.name}" '
|
||||
html += f'title="{type_label} server">'
|
||||
html += f'{type_icon} {server.name} ({type_label})'
|
||||
html += f'</button>'
|
||||
html += '</div></div>'
|
||||
|
||||
html += '</div>' # End user-management-section
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<span style="color: #dc3545;">Error loading management interface: {e}</span>')
|
||||
|
||||
@admin.display(description='Recent Activity')
|
||||
def recent_activity_display(self, obj):
|
||||
"""Display recent activity in compact admin-friendly format"""
|
||||
try:
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
# Get recent access logs for this user (last 7 days, limited)
|
||||
seven_days_ago = timezone.now() - timedelta(days=7)
|
||||
recent_logs = AccessLog.objects.filter(
|
||||
user=obj.username,
|
||||
timestamp__gte=seven_days_ago
|
||||
).order_by('-timestamp')[:15] # Limit to 15 most recent
|
||||
|
||||
if not recent_logs:
|
||||
return mark_safe('<div style="color: #6c757d; font-style: italic; padding: 12px; background: #f8f9fa; border-radius: 4px;">No recent activity (last 7 days)</div>')
|
||||
|
||||
html = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 0; max-height: 300px; overflow-y: auto;">'
|
||||
|
||||
# Header
|
||||
html += '<div style="background: #e9ecef; padding: 8px 12px; border-bottom: 1px solid #dee2e6; font-weight: 600; font-size: 12px; color: #495057;">'
|
||||
html += f'📊 Recent Activity ({recent_logs.count()} entries, last 7 days)'
|
||||
html += '</div>'
|
||||
|
||||
# Activity entries
|
||||
for i, log in enumerate(recent_logs):
|
||||
bg_color = '#ffffff' if i % 2 == 0 else '#f8f9fa'
|
||||
local_time = localtime(log.timestamp)
|
||||
|
||||
# Status icon and color
|
||||
if log.action == 'Success':
|
||||
icon = '✅'
|
||||
status_color = '#28a745'
|
||||
elif log.action == 'Failed':
|
||||
icon = '❌'
|
||||
status_color = '#dc3545'
|
||||
else:
|
||||
icon = 'ℹ️'
|
||||
status_color = '#6c757d'
|
||||
|
||||
html += f'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; border-bottom: 1px solid #f1f3f4; background: {bg_color};">'
|
||||
|
||||
# Left side - server and link info
|
||||
html += f'<div style="display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0;">'
|
||||
html += f'<span style="color: {status_color}; font-size: 14px;">{icon}</span>'
|
||||
html += f'<div style="overflow: hidden;">'
|
||||
html += f'<div style="font-weight: 500; font-size: 12px; color: #495057; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{log.server}</div>'
|
||||
|
||||
if log.acl_link_id:
|
||||
link_short = log.acl_link_id[:12] + '...' if len(log.acl_link_id) > 12 else log.acl_link_id
|
||||
html += f'<div style="font-family: monospace; font-size: 10px; color: #6c757d;">{link_short}</div>'
|
||||
|
||||
html += f'</div></div>'
|
||||
|
||||
# Right side - timestamp and status
|
||||
html += f'<div style="text-align: right; flex-shrink: 0;">'
|
||||
html += f'<div style="font-size: 10px; color: #6c757d;">{local_time.strftime("%m-%d %H:%M")}</div>'
|
||||
html += f'<div style="font-size: 9px; color: {status_color}; font-weight: 500;">{log.action}</div>'
|
||||
html += f'</div>'
|
||||
|
||||
html += f'</div>'
|
||||
|
||||
# Footer with summary if there are more entries
|
||||
total_recent = AccessLog.objects.filter(
|
||||
user=obj.username,
|
||||
timestamp__gte=seven_days_ago
|
||||
).count()
|
||||
|
||||
if total_recent > 15:
|
||||
html += f'<div style="background: #e9ecef; padding: 6px 12px; font-size: 11px; color: #6c757d; text-align: center;">'
|
||||
html += f'Showing 15 of {total_recent} entries from last 7 days'
|
||||
html += f'</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<span style="color: #dc3545; font-size: 12px;">Error loading activity: {e}</span>')
|
||||
|
||||
@admin.display(description='Telegram Account')
|
||||
def telegram_info_display(self, obj):
|
||||
"""Display Telegram account information"""
|
||||
if not obj.telegram_user_id:
|
||||
if obj.telegram_username:
|
||||
return mark_safe(f'<div style="background: #fff3cd; padding: 10px; border-radius: 5px; border-left: 4px solid #ffc107;">'
|
||||
f'<span style="color: #856404;">🔗 Ready to link: @{obj.telegram_username}</span><br/>'
|
||||
f'<small>User will be automatically linked when they message the bot</small></div>')
|
||||
else:
|
||||
return mark_safe('<span style="color: #6c757d;">No Telegram account linked</span>')
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
|
||||
html += '<h4 style="margin: 0 0 10px 0; color: #495057;">📱 Telegram Account Information</h4>'
|
||||
|
||||
# Telegram User ID
|
||||
html += f'<p style="margin: 5px 0;"><strong>User ID:</strong> <code>{obj.telegram_user_id}</code></p>'
|
||||
|
||||
# Telegram Username
|
||||
if obj.telegram_username:
|
||||
html += f'<p style="margin: 5px 0;"><strong>Username:</strong> @{obj.telegram_username}</p>'
|
||||
|
||||
# Telegram Names
|
||||
name_parts = []
|
||||
if obj.telegram_first_name:
|
||||
name_parts.append(obj.telegram_first_name)
|
||||
if obj.telegram_last_name:
|
||||
name_parts.append(obj.telegram_last_name)
|
||||
|
||||
if name_parts:
|
||||
full_name = ' '.join(name_parts)
|
||||
html += f'<p style="margin: 5px 0;"><strong>Name:</strong> {full_name}</p>'
|
||||
|
||||
# Telegram Phone (if available)
|
||||
if obj.telegram_phone:
|
||||
html += f'<p style="margin: 5px 0;"><strong>Phone:</strong> {obj.telegram_phone}</p>'
|
||||
|
||||
# Access requests count (if any)
|
||||
try:
|
||||
from telegram_bot.models import AccessRequest
|
||||
requests_count = AccessRequest.objects.filter(telegram_user_id=obj.telegram_user_id).count()
|
||||
if requests_count > 0:
|
||||
html += f'<p style="margin: 10px 0 5px 0; color: #007cba;"><strong>📝 Access Requests:</strong> {requests_count}</p>'
|
||||
|
||||
# Show latest request status
|
||||
latest_request = AccessRequest.objects.filter(telegram_user_id=obj.telegram_user_id).order_by('-created_at').first()
|
||||
if latest_request:
|
||||
status_color = '#28a745' if latest_request.approved else '#ffc107'
|
||||
status_text = 'Approved' if latest_request.approved else 'Pending'
|
||||
html += f'<p style="margin: 5px 0 5px 20px;">Latest: <span style="color: {status_color}; font-weight: bold;">{status_text}</span></p>'
|
||||
except:
|
||||
pass # Telegram bot app might not be available
|
||||
|
||||
# Add unlink button
|
||||
unlink_url = reverse('admin:vpn_user_unlink_telegram', args=[obj.pk])
|
||||
html += f'<div style="margin-top: 15px; padding-top: 10px; border-top: 1px solid #dee2e6;">'
|
||||
html += f'<a href="{unlink_url}" class="button" style="background: #dc3545; color: white; text-decoration: none; padding: 8px 15px; border-radius: 3px; font-size: 13px;" onclick="return confirm(\'Are you sure you want to unlink this Telegram account?\')">🔗💥 Unlink Telegram Account</a>'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
@admin.display(description='Subscription Management')
|
||||
def subscription_management_info(self, obj):
|
||||
"""Display subscription management information and quick access"""
|
||||
if not obj.pk:
|
||||
return "Save user first to manage subscriptions"
|
||||
|
||||
try:
|
||||
from vpn.models_xray import UserSubscription, SubscriptionGroup
|
||||
|
||||
# Get user's current subscriptions
|
||||
user_subscriptions = UserSubscription.objects.filter(user=obj).select_related('subscription_group')
|
||||
active_subs = user_subscriptions.filter(active=True)
|
||||
inactive_subs = user_subscriptions.filter(active=False)
|
||||
|
||||
# Get available subscription groups
|
||||
all_groups = SubscriptionGroup.objects.filter(is_active=True)
|
||||
subscribed_group_ids = user_subscriptions.values_list('subscription_group_id', flat=True)
|
||||
available_groups = all_groups.exclude(id__in=subscribed_group_ids)
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 10px 0;">'
|
||||
html += '<h4 style="margin: 0 0 15px 0; color: #495057;">🚀 Xray Subscription Management</h4>'
|
||||
|
||||
# Active subscriptions
|
||||
if active_subs.exists():
|
||||
html += '<div style="margin-bottom: 15px;">'
|
||||
html += '<h5 style="color: #28a745; margin: 0 0 8px 0;">✅ Active Subscriptions</h5>'
|
||||
for sub in active_subs:
|
||||
html += f'<div style="background: #d4edda; padding: 8px 12px; border-radius: 4px; margin: 4px 0; display: flex; justify-content: space-between; align-items: center;">'
|
||||
html += f'<span><strong>{sub.subscription_group.name}</strong>'
|
||||
if sub.subscription_group.description:
|
||||
html += f' - {sub.subscription_group.description[:50]}{"..." if len(sub.subscription_group.description) > 50 else ""}'
|
||||
html += f'</span>'
|
||||
html += f'<small style="color: #155724;">Since: {sub.created_at.strftime("%Y-%m-%d")}</small>'
|
||||
html += f'</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Inactive subscriptions
|
||||
if inactive_subs.exists():
|
||||
html += '<div style="margin-bottom: 15px;">'
|
||||
html += '<h5 style="color: #dc3545; margin: 0 0 8px 0;">❌ Inactive Subscriptions</h5>'
|
||||
for sub in inactive_subs:
|
||||
html += f'<div style="background: #f8d7da; padding: 8px 12px; border-radius: 4px; margin: 4px 0;">'
|
||||
html += f'<span style="color: #721c24;"><strong>{sub.subscription_group.name}</strong></span>'
|
||||
html += f'</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Available subscription groups
|
||||
if available_groups.exists():
|
||||
html += '<div style="margin-bottom: 15px;">'
|
||||
html += '<h5 style="color: #007cba; margin: 0 0 8px 0;">➕ Available Subscription Groups</h5>'
|
||||
html += '<div style="display: flex; gap: 8px; flex-wrap: wrap;">'
|
||||
for group in available_groups[:10]: # Limit to avoid clutter
|
||||
html += f'<span style="background: #cce7ff; color: #004085; padding: 4px 8px; border-radius: 3px; font-size: 12px;">'
|
||||
html += f'{group.name}'
|
||||
html += f'</span>'
|
||||
if available_groups.count() > 10:
|
||||
html += f'<span style="color: #6c757d; font-style: italic;">+{available_groups.count() - 10} more...</span>'
|
||||
html += '</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Quick access links
|
||||
html += '<div style="border-top: 1px solid #dee2e6; padding-top: 12px; margin-top: 15px;">'
|
||||
html += '<h5 style="margin: 0 0 8px 0; color: #495057;">🔗 Quick Access</h5>'
|
||||
html += '<div style="display: flex; gap: 10px; flex-wrap: wrap;">'
|
||||
|
||||
# Link to standalone UserSubscription admin
|
||||
subscription_admin_url = f"/admin/vpn/usersubscription/?user__id__exact={obj.id}"
|
||||
html += f'<a href="{subscription_admin_url}" class="button" style="background: #007cba; color: white; text-decoration: none; padding: 6px 12px; border-radius: 3px; font-size: 12px;">📋 Manage All Subscriptions</a>'
|
||||
|
||||
# Link to add new subscription
|
||||
add_subscription_url = f"/admin/vpn/usersubscription/add/?user={obj.id}"
|
||||
html += f'<a href="{add_subscription_url}" class="button" style="background: #28a745; color: white; text-decoration: none; padding: 6px 12px; border-radius: 3px; font-size: 12px;">➕ Add New Subscription</a>'
|
||||
|
||||
# Link to subscription groups admin
|
||||
groups_admin_url = "/admin/vpn/subscriptiongroup/"
|
||||
html += f'<a href="{groups_admin_url}" class="button" style="background: #17a2b8; color: white; text-decoration: none; padding: 6px 12px; border-radius: 3px; font-size: 12px;">⚙️ Manage Groups</a>'
|
||||
|
||||
html += '</div>'
|
||||
html += '</div>'
|
||||
|
||||
# Statistics
|
||||
total_subs = user_subscriptions.count()
|
||||
if total_subs > 0:
|
||||
html += '<div style="border-top: 1px solid #dee2e6; padding-top: 8px; margin-top: 10px;">'
|
||||
html += f'<small style="color: #6c757d;">📊 Total: {total_subs} subscription(s) | Active: {active_subs.count()} | Inactive: {inactive_subs.count()}</small>'
|
||||
html += '</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
|
||||
except Exception as e:
|
||||
return mark_safe(f'<div style="background: #f8d7da; padding: 10px; border-radius: 4px; color: #721c24;">❌ Error loading subscription management: {e}</div>')
|
||||
|
||||
@admin.display(description='Allowed servers', ordering='server_count')
|
||||
def server_count(self, obj):
|
||||
return obj.server_count
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(server_count=Count('acl'))
|
||||
return qs
|
||||
|
||||
def get_urls(self):
|
||||
"""Add custom URLs for link management"""
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('<int:user_id>/add-link/', self.admin_site.admin_view(self.add_link_view), name='user_add_link'),
|
||||
path('<int:user_id>/delete-link/<int:link_id>/', self.admin_site.admin_view(self.delete_link_view), name='user_delete_link'),
|
||||
path('<int:user_id>/add-server-access/', self.admin_site.admin_view(self.add_server_access_view), name='user_add_server_access'),
|
||||
path('<int:user_id>/unlink-telegram/', self.admin_site.admin_view(self.unlink_telegram_view), name='vpn_user_unlink_telegram'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def add_link_view(self, request, user_id):
|
||||
"""AJAX view to add a new link for user on specific server"""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
server_id = request.POST.get('server_id')
|
||||
comment = request.POST.get('comment', '')
|
||||
|
||||
if not server_id:
|
||||
return JsonResponse({'error': 'Server ID is required'}, status=400)
|
||||
|
||||
server = Server.objects.get(pk=server_id)
|
||||
acl = ACL.objects.get(user=user, server=server)
|
||||
|
||||
# Create new link
|
||||
new_link = ACLLink.objects.create(
|
||||
acl=acl,
|
||||
comment=comment,
|
||||
link=shortuuid.ShortUUID().random(length=16)
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'link_id': new_link.id,
|
||||
'link': new_link.link,
|
||||
'comment': new_link.comment,
|
||||
'url': f"{EXTERNAL_ADDRESS}/ss/{new_link.link}#{server.name}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||
|
||||
def delete_link_view(self, request, user_id, link_id):
|
||||
"""AJAX view to delete a specific link"""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
link = ACLLink.objects.get(pk=link_id, acl__user=user)
|
||||
link.delete()
|
||||
|
||||
return JsonResponse({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||
|
||||
def add_server_access_view(self, request, user_id):
|
||||
"""AJAX view to add server access for user"""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
server_id = request.POST.get('server_id')
|
||||
|
||||
if not server_id:
|
||||
return JsonResponse({'error': 'Server ID is required'}, status=400)
|
||||
|
||||
server = Server.objects.get(pk=server_id)
|
||||
|
||||
# Check if ACL already exists
|
||||
if ACL.objects.filter(user=user, server=server).exists():
|
||||
return JsonResponse({'error': 'User already has access to this server'}, status=400)
|
||||
|
||||
# Create new ACL (with default link)
|
||||
acl = ACL.objects.create(user=user, server=server)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'server_name': server.name,
|
||||
'server_type': server.server_type,
|
||||
'acl_id': acl.id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||
|
||||
def unlink_telegram_view(self, request, user_id):
|
||||
"""Unlink Telegram account from user"""
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
# Store original Telegram info for logging
|
||||
telegram_info = {
|
||||
'user_id': user.telegram_user_id,
|
||||
'username': user.telegram_username,
|
||||
'first_name': user.telegram_first_name,
|
||||
'last_name': user.telegram_last_name
|
||||
}
|
||||
|
||||
# Clear all Telegram fields
|
||||
user.telegram_user_id = None
|
||||
user.telegram_username = ""
|
||||
user.telegram_first_name = ""
|
||||
user.telegram_last_name = ""
|
||||
user.telegram_phone = ""
|
||||
user.save()
|
||||
|
||||
# Also clean up any related access requests
|
||||
try:
|
||||
from telegram_bot.models import AccessRequest
|
||||
AccessRequest.objects.filter(telegram_user_id=telegram_info['user_id']).delete()
|
||||
except:
|
||||
pass # Telegram bot app might not be available
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"Telegram account {'@' + telegram_info['username'] if telegram_info['username'] else telegram_info['user_id']} "
|
||||
f"has been unlinked from user '{user.username}'"
|
||||
)
|
||||
|
||||
return HttpResponseRedirect(reverse('admin:vpn_user_change', args=[user_id]))
|
||||
|
||||
def change_view(self, request, object_id, form_url='', extra_context=None):
|
||||
"""Override change view to add user management data and fix layout"""
|
||||
extra_context = extra_context or {}
|
||||
|
||||
if object_id:
|
||||
try:
|
||||
user = User.objects.get(pk=object_id)
|
||||
extra_context.update({
|
||||
'user_object': user,
|
||||
'external_address': EXTERNAL_ADDRESS,
|
||||
})
|
||||
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
73
vpn/admin_minimal.py
Normal file
73
vpn/admin_minimal.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Minimal admin test to check execution
|
||||
"""
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
import json
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
# Try importing server plugins
|
||||
try:
|
||||
from .server_plugins import (
|
||||
XrayServerV2,
|
||||
XrayServerV2Admin
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"🔴 Failed to import server plugins: {e}")
|
||||
|
||||
# Try importing refactored admin modules
|
||||
try:
|
||||
from .admin import *
|
||||
except Exception as e:
|
||||
logger.error(f"🔴 Failed to import refactored admin modules: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Try importing Xray admin classes
|
||||
try:
|
||||
from .admin_xray import *
|
||||
except Exception as e:
|
||||
logger.error(f"🔴 Failed to import Xray admin classes: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Set custom admin site configuration
|
||||
admin.site.site_title = "VPN Manager"
|
||||
admin.site.site_header = "VPN Manager"
|
||||
admin.site.index_title = "OutFleet"
|
||||
|
||||
# Try adding custom Celery admin interfaces
|
||||
try:
|
||||
from django_celery_results.models import TaskResult
|
||||
|
||||
# Unregister default TaskResult admin if it exists
|
||||
try:
|
||||
admin.site.unregister(TaskResult)
|
||||
except admin.sites.NotRegistered:
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(TaskResult)
|
||||
class CustomTaskResultAdmin(admin.ModelAdmin):
|
||||
list_display = ('task_name_display', 'status', 'date_created')
|
||||
|
||||
@admin.display(description='Task Name', ordering='task_name')
|
||||
def task_name_display(self, obj):
|
||||
return obj.task_name
|
||||
|
||||
|
||||
except ImportError:
|
||||
pass # Celery not available
|
||||
|
||||
# Add subscription management to User admin
|
||||
try:
|
||||
from vpn.admin.user import add_subscription_management_to_user
|
||||
from django.contrib.admin import site
|
||||
for model, admin_instance in site._registry.items():
|
||||
if model.__name__ == 'User' and hasattr(admin_instance, 'fieldsets'):
|
||||
add_subscription_management_to_user(admin_instance.__class__)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add subscription management: {e}")
|
||||
16
vpn/admin_test.py
Normal file
16
vpn/admin_test.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Test admin file to check if code execution works
|
||||
"""
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("🧪 TEST ADMIN FILE EXECUTING!")
|
||||
|
||||
from django.contrib import admin
|
||||
from .models import User
|
||||
|
||||
@admin.register(User)
|
||||
class TestUserAdmin(admin.ModelAdmin):
|
||||
list_display = ('username',)
|
||||
|
||||
logger.info("🧪 TEST ADMIN FILE COMPLETED!")
|
||||
1056
vpn/admin_xray.py
Normal file
1056
vpn/admin_xray.py
Normal file
File diff suppressed because it is too large
Load Diff
103
vpn/apps.py
103
vpn/apps.py
@@ -4,3 +4,106 @@ from django.contrib.auth import get_user_model
|
||||
class VPN(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'vpn'
|
||||
|
||||
def ready(self):
|
||||
"""Import signals when Django starts"""
|
||||
import sys
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info(f"VPN App ready() called in process: {' '.join(sys.argv)}")
|
||||
|
||||
try:
|
||||
import vpn.signals # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Only load admin interfaces in web processes, not in worker/beat
|
||||
skip_admin_load = any([
|
||||
'worker' in sys.argv,
|
||||
'beat' in sys.argv,
|
||||
'makemigrations' in sys.argv,
|
||||
'migrate' in sys.argv,
|
||||
'shell' in sys.argv,
|
||||
'test' in sys.argv,
|
||||
])
|
||||
|
||||
if not skip_admin_load:
|
||||
logger.info("VPN App: Loading admin interfaces in web process")
|
||||
# Force load admin interfaces first
|
||||
self._load_admin_interfaces()
|
||||
|
||||
# Clean up unwanted admin interfaces
|
||||
self._cleanup_admin_interfaces()
|
||||
else:
|
||||
logger.info("VPN App: Skipping admin loading in non-web process")
|
||||
|
||||
def _cleanup_admin_interfaces(self):
|
||||
"""Remove unwanted admin interfaces after all apps are loaded"""
|
||||
from django.contrib import admin
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("VPN App: Starting admin cleanup...")
|
||||
|
||||
try:
|
||||
from django_celery_results.models import GroupResult
|
||||
from django_celery_beat.models import (
|
||||
PeriodicTask,
|
||||
ClockedSchedule,
|
||||
CrontabSchedule,
|
||||
IntervalSchedule,
|
||||
SolarSchedule
|
||||
)
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
# Unregister celery models that we don't want in admin
|
||||
models_to_unregister = [
|
||||
GroupResult, PeriodicTask, ClockedSchedule,
|
||||
CrontabSchedule, IntervalSchedule, SolarSchedule
|
||||
]
|
||||
|
||||
for model in models_to_unregister:
|
||||
try:
|
||||
admin.site.unregister(model)
|
||||
logger.info(f"VPN App: Unregistered {model.__name__}")
|
||||
except admin.sites.NotRegistered:
|
||||
logger.debug(f"VPN App: {model.__name__} was not registered, skipping")
|
||||
|
||||
# Unregister Django's default Group model
|
||||
try:
|
||||
admin.site.unregister(Group)
|
||||
logger.info("VPN App: Unregistered Django Group model")
|
||||
except admin.sites.NotRegistered:
|
||||
logger.debug("VPN App: Django Group was not registered, skipping")
|
||||
|
||||
except ImportError as e:
|
||||
# Celery packages not installed
|
||||
logger.warning(f"VPN App: Celery packages not available: {e}")
|
||||
|
||||
logger.info("VPN App: Admin cleanup completed")
|
||||
|
||||
def _load_admin_interfaces(self):
|
||||
"""Force load admin interfaces to ensure they are registered"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("VPN App: Force loading admin interfaces...")
|
||||
|
||||
try:
|
||||
# Import admin module to trigger registration
|
||||
import sys
|
||||
if 'vpn.admin_minimal' in sys.modules:
|
||||
# Module already imported, remove it to force fresh import
|
||||
del sys.modules['vpn.admin_minimal']
|
||||
logger.info("VPN App: Removed vpn.admin_minimal from cache")
|
||||
|
||||
import vpn.admin_minimal
|
||||
logger.info("VPN App: Successfully loaded vpn.admin_minimal")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"VPN App: Failed to load vpn.admin: {e}")
|
||||
import traceback
|
||||
logger.error(f"VPN App: Traceback: {traceback.format_exc()}")
|
||||
|
||||
logger.info("VPN App: Admin loading completed")
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
from django import forms
|
||||
from .models import User
|
||||
from .server_plugins import Server
|
||||
|
||||
class UserForm(forms.ModelForm):
|
||||
servers = forms.ModelMultipleChoiceField(
|
||||
queryset=Server.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'comment', 'servers']
|
||||
fields = ['username', 'first_name', 'last_name', 'email', 'comment', 'is_active']
|
||||
|
||||
13
vpn/letsencrypt/__init__.py
Normal file
13
vpn/letsencrypt/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Let's Encrypt DNS Challenge Library for OutFleet"""
|
||||
|
||||
from .letsencrypt_dns import (
|
||||
AcmeDnsChallenge,
|
||||
get_certificate,
|
||||
get_certificate_for_domain
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'AcmeDnsChallenge',
|
||||
'get_certificate',
|
||||
'get_certificate_for_domain'
|
||||
]
|
||||
403
vpn/letsencrypt/letsencrypt_dns.py
Normal file
403
vpn/letsencrypt/letsencrypt_dns.py
Normal file
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Let's Encrypt/ZeroSSL Certificate Library with Cloudflare DNS Challenge
|
||||
Generate publicly trusted SSL certificates using ACME DNS-01 challenge
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from acme import client, messages, challenges, errors
|
||||
from acme.client import ClientV2
|
||||
import josepy as jose
|
||||
from cloudflare import Cloudflare
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AcmeDnsChallenge:
|
||||
"""ACME DNS-01 Challenge handler with Cloudflare API"""
|
||||
|
||||
def __init__(self, cloudflare_token: str, acme_directory: str = None):
|
||||
"""
|
||||
Initialize ACME DNS challenge handler
|
||||
|
||||
Args:
|
||||
cloudflare_token: Cloudflare API token with DNS edit permissions
|
||||
acme_directory: ACME directory URL (defaults to Let's Encrypt production)
|
||||
"""
|
||||
self.cf_token = cloudflare_token
|
||||
self.cf = Cloudflare(api_token=cloudflare_token)
|
||||
|
||||
# ACME directory URLs
|
||||
self.acme_directories = {
|
||||
'letsencrypt': 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
'letsencrypt_staging': 'https://acme-staging-v02.api.letsencrypt.org/directory',
|
||||
'zerossl': 'https://acme.zerossl.com/v2/DV90'
|
||||
}
|
||||
|
||||
self.acme_directory = acme_directory or self.acme_directories['letsencrypt']
|
||||
self.acme_client = None
|
||||
self.account_key = None
|
||||
|
||||
def _generate_account_key(self) -> jose.JWKRSA:
|
||||
"""Generate RSA private key for ACME account"""
|
||||
# Generate cryptography key first
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048
|
||||
)
|
||||
# Convert to josepy format for ACME
|
||||
return jose.JWKRSA(key=private_key)
|
||||
|
||||
def _get_zone_id(self, domain: str) -> str:
|
||||
"""Get Cloudflare zone ID for domain"""
|
||||
try:
|
||||
# Get base domain (remove subdomains)
|
||||
parts = domain.split('.')
|
||||
if len(parts) >= 2:
|
||||
base_domain = '.'.join(parts[-2:])
|
||||
else:
|
||||
base_domain = domain
|
||||
|
||||
zones = self.cf.zones.list(name=base_domain)
|
||||
if not zones.result:
|
||||
raise ValueError(f"Domain {base_domain} not found in Cloudflare")
|
||||
|
||||
return zones.result[0].id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get zone ID for {domain}: {e}")
|
||||
raise
|
||||
|
||||
def _create_dns_record(self, domain: str, name: str, content: str) -> str:
|
||||
"""Create DNS TXT record for ACME challenge"""
|
||||
try:
|
||||
zone_id = self._get_zone_id(domain)
|
||||
|
||||
result = self.cf.dns.records.create(
|
||||
zone_id=zone_id,
|
||||
name=name,
|
||||
type='TXT',
|
||||
content=content,
|
||||
ttl=60 # 1 minute TTL for faster propagation
|
||||
)
|
||||
logger.info(f"Created DNS record: {name} = {content}")
|
||||
return result.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create DNS record {name}: {e}")
|
||||
raise
|
||||
|
||||
def _delete_dns_record(self, domain: str, record_id: str):
|
||||
"""Delete DNS TXT record"""
|
||||
try:
|
||||
zone_id = self._get_zone_id(domain)
|
||||
self.cf.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
|
||||
logger.info(f"Deleted DNS record: {record_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete DNS record {record_id}: {e}")
|
||||
|
||||
def _wait_for_dns_propagation(self, record_name: str, expected_value: str, wait_time: int = 20):
|
||||
"""Wait for DNS record to propagate - no local checks, just wait"""
|
||||
logger.info(f"Waiting {wait_time} seconds for DNS propagation of {record_name}...")
|
||||
logger.info(f"Record value: {expected_value}")
|
||||
logger.info("(No local DNS checks - Let's Encrypt servers will verify)")
|
||||
|
||||
time.sleep(wait_time)
|
||||
|
||||
logger.info("DNS propagation wait completed - proceeding with challenge")
|
||||
return True
|
||||
|
||||
def create_acme_client(self, email: str, accept_tos: bool = True) -> ClientV2:
|
||||
"""Create and register ACME client"""
|
||||
if self.acme_client:
|
||||
return self.acme_client
|
||||
|
||||
try:
|
||||
logger.info("Generating ACME account key...")
|
||||
# Generate account key
|
||||
self.account_key = self._generate_account_key()
|
||||
logger.info("Account key generated successfully")
|
||||
|
||||
logger.info(f"Connecting to ACME directory: {self.acme_directory}")
|
||||
# Create ACME client
|
||||
net = client.ClientNetwork(self.account_key, user_agent='letsencrypt-dns-lib/1.0')
|
||||
logger.info("Getting ACME directory...")
|
||||
directory_response = net.get(self.acme_directory)
|
||||
logger.info(f"Directory response status: {directory_response.status_code}")
|
||||
directory = messages.Directory.from_json(directory_response.json())
|
||||
logger.info("ACME directory loaded successfully")
|
||||
|
||||
self.acme_client = ClientV2(directory, net=net)
|
||||
logger.info("ACME client created successfully")
|
||||
|
||||
# Register account
|
||||
logger.info(f"Registering ACME account for email: {email}")
|
||||
try:
|
||||
registration = messages.NewRegistration.from_data(
|
||||
email=email,
|
||||
terms_of_service_agreed=accept_tos
|
||||
)
|
||||
logger.info("Sending account registration...")
|
||||
account = self.acme_client.new_account(registration)
|
||||
logger.info(f"ACME account registered: {account.uri}")
|
||||
|
||||
except errors.ConflictError as e:
|
||||
logger.info(f"Account already exists (ConflictError): {e}")
|
||||
# Account already exists
|
||||
account = self.acme_client.query_registration(messages.NewRegistration())
|
||||
logger.info("Using existing ACME account")
|
||||
except Exception as reg_e:
|
||||
logger.error(f"Account registration failed: {reg_e}")
|
||||
logger.error(f"Registration error type: {type(reg_e).__name__}")
|
||||
raise
|
||||
|
||||
return self.acme_client
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ACME client: {e}")
|
||||
logger.error(f"Error type: {type(e).__name__}")
|
||||
import traceback
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
def request_certificate(self, domains: List[str], email: str,
|
||||
key_size: int = 2048) -> Tuple[str, str]:
|
||||
"""
|
||||
Request certificate using DNS-01 challenge
|
||||
|
||||
Args:
|
||||
domains: List of domain names for certificate
|
||||
email: Email for ACME account registration
|
||||
key_size: RSA key size for certificate
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
logger.info(f"Requesting certificate for domains: {domains}")
|
||||
|
||||
try:
|
||||
# Create ACME client
|
||||
logger.info("Creating ACME client...")
|
||||
acme_client = self.create_acme_client(email)
|
||||
logger.info("ACME client created successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ACME client: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
try:
|
||||
# Generate private key for certificate
|
||||
logger.info(f"Generating {key_size}-bit RSA private key...")
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_size
|
||||
)
|
||||
logger.info("Private key generated successfully")
|
||||
|
||||
# Create CSR
|
||||
logger.info(f"Creating CSR for domains: {domains}")
|
||||
csr_obj = x509.CertificateSigningRequestBuilder().subject_name(
|
||||
x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, domains[0])
|
||||
])
|
||||
).add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.DNSName(domain) for domain in domains
|
||||
]),
|
||||
critical=False
|
||||
).sign(private_key, hashes.SHA256())
|
||||
|
||||
# Convert CSR to PEM format for ACME
|
||||
csr_pem = csr_obj.public_bytes(serialization.Encoding.PEM)
|
||||
logger.info("CSR created successfully")
|
||||
|
||||
# Request certificate
|
||||
logger.info("Requesting certificate order from ACME...")
|
||||
order = acme_client.new_order(csr_pem)
|
||||
logger.info(f"Created ACME order: {order.uri}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed during CSR/order creation: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
# Process challenges - collect all challenges first, then create DNS records
|
||||
dns_records = []
|
||||
challenges_to_answer = []
|
||||
|
||||
try:
|
||||
# First pass: collect all challenges and create DNS records
|
||||
for authorization in order.authorizations:
|
||||
domain = authorization.body.identifier.value
|
||||
logger.info(f"Processing authorization for: {domain}")
|
||||
|
||||
# Find DNS-01 challenge
|
||||
dns_challenge = None
|
||||
for challenge in authorization.body.challenges:
|
||||
if isinstance(challenge.chall, challenges.DNS01):
|
||||
dns_challenge = challenge
|
||||
break
|
||||
|
||||
if not dns_challenge:
|
||||
raise ValueError(f"No DNS-01 challenge found for {domain}")
|
||||
|
||||
# Calculate challenge response
|
||||
response, validation = dns_challenge.response_and_validation(acme_client.net.key)
|
||||
|
||||
# For wildcard domains, use base domain for DNS record
|
||||
if domain.startswith('*.'):
|
||||
dns_domain = domain[2:] # Remove *. prefix
|
||||
else:
|
||||
dns_domain = domain
|
||||
|
||||
# Create DNS record
|
||||
record_name = f"_acme-challenge.{dns_domain}"
|
||||
|
||||
# Check if we already created this DNS record
|
||||
existing_record = None
|
||||
for existing_domain, existing_id, existing_validation in dns_records:
|
||||
if existing_domain == dns_domain:
|
||||
existing_record = (existing_domain, existing_id, existing_validation)
|
||||
break
|
||||
|
||||
if existing_record:
|
||||
logger.info(f"DNS record already exists for {dns_domain}, reusing...")
|
||||
record_id = existing_record[1]
|
||||
# Verify the validation value matches
|
||||
if existing_record[2] != validation:
|
||||
logger.warning(f"Validation values differ for {dns_domain}! This may cause issues.")
|
||||
else:
|
||||
logger.info(f"Creating DNS record for {dns_domain}...")
|
||||
record_id = self._create_dns_record(dns_domain, record_name, validation)
|
||||
dns_records.append((dns_domain, record_id, validation))
|
||||
|
||||
# Store challenge to answer later
|
||||
challenges_to_answer.append((dns_challenge, response, domain, dns_domain))
|
||||
|
||||
# Wait for DNS propagation once for all records
|
||||
if dns_records:
|
||||
logger.info(f"Waiting for DNS propagation for {len(dns_records)} DNS records...")
|
||||
for dns_domain, record_id, validation in dns_records:
|
||||
record_name = f"_acme-challenge.{dns_domain}"
|
||||
self._wait_for_dns_propagation(record_name, validation)
|
||||
|
||||
# Second pass: answer all challenges
|
||||
for dns_challenge, response, domain, dns_domain in challenges_to_answer:
|
||||
logger.info(f"Responding to DNS challenge for {domain}...")
|
||||
challenge_response = acme_client.answer_challenge(dns_challenge, response)
|
||||
logger.info(f"Challenge response sent for {domain}")
|
||||
|
||||
# Finalize order
|
||||
logger.info("Finalizing certificate order...")
|
||||
order = acme_client.poll_and_finalize(order)
|
||||
|
||||
# Get certificate
|
||||
certificate_pem = order.fullchain_pem
|
||||
private_key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
).decode('utf-8')
|
||||
|
||||
logger.info("Certificate obtained successfully!")
|
||||
return certificate_pem, private_key_pem
|
||||
|
||||
finally:
|
||||
# Clean up DNS records
|
||||
for dns_domain, record_id, validation in dns_records:
|
||||
try:
|
||||
self._delete_dns_record(dns_domain, record_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup DNS record for {dns_domain}: {e}")
|
||||
|
||||
def get_certificate(domains: List[str], email: str, cloudflare_token: str,
|
||||
provider: str = 'letsencrypt', staging: bool = False) -> Tuple[str, str]:
|
||||
"""
|
||||
Simple function to get Let's Encrypt/ZeroSSL certificate
|
||||
|
||||
Args:
|
||||
domains: List of domains for certificate
|
||||
email: Email for ACME registration
|
||||
cloudflare_token: Cloudflare API token
|
||||
provider: 'letsencrypt' or 'zerossl'
|
||||
staging: Use staging environment (for testing)
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
# Select ACME directory
|
||||
acme_dns = AcmeDnsChallenge(cloudflare_token)
|
||||
|
||||
if provider == 'letsencrypt':
|
||||
if staging:
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt_staging']
|
||||
else:
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt']
|
||||
elif provider == 'zerossl':
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['zerossl']
|
||||
else:
|
||||
raise ValueError("Provider must be 'letsencrypt' or 'zerossl'")
|
||||
|
||||
return acme_dns.request_certificate(domains, email)
|
||||
|
||||
def get_certificate_for_domain(domain: str, email: str, cloudflare_token: str,
|
||||
include_wildcard: bool = False, **kwargs) -> Tuple[str, str]:
|
||||
"""
|
||||
Helper function to get certificate for single domain (compatible with Cloudflare cert lib)
|
||||
|
||||
Args:
|
||||
domain: Primary domain
|
||||
email: Email for ACME registration
|
||||
cloudflare_token: Cloudflare API token
|
||||
include_wildcard: Include wildcard subdomain
|
||||
**kwargs: Additional arguments (provider, staging)
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
domains = [domain]
|
||||
if include_wildcard:
|
||||
domains.append(f"*.{domain}")
|
||||
|
||||
return get_certificate(domains, email, cloudflare_token, **kwargs)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
import sys
|
||||
|
||||
if len(sys.argv) != 4:
|
||||
print("Usage: python letsencrypt_dns.py <domain> <email> <cloudflare_token>")
|
||||
sys.exit(1)
|
||||
|
||||
domain, email, token = sys.argv[1:4]
|
||||
|
||||
try:
|
||||
cert_pem, key_pem = get_certificate_for_domain(
|
||||
domain=domain,
|
||||
email=email,
|
||||
cloudflare_token=token,
|
||||
include_wildcard=True,
|
||||
staging=True # Use staging for testing
|
||||
)
|
||||
|
||||
print(f"Certificate obtained for {domain}")
|
||||
print(f"Certificate length: {len(cert_pem)} bytes")
|
||||
print(f"Private key length: {len(key_pem)} bytes")
|
||||
|
||||
# Save to files
|
||||
with open(f"{domain}.crt", 'w') as f:
|
||||
f.write(cert_pem)
|
||||
with open(f"{domain}.key", 'w') as f:
|
||||
f.write(key_pem)
|
||||
|
||||
print(f"Saved: {domain}.crt, {domain}.key")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
32
vpn/migrations/0015_remove_old_xray_models.py
Normal file
32
vpn/migrations/0015_remove_old_xray_models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated manually to properly remove old Xray models
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0014_alter_xraycoreserver_client_hostname_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Remove unique_together first to avoid field reference issues
|
||||
migrations.AlterUniqueTogether(
|
||||
name='xrayinbound',
|
||||
unique_together=None,
|
||||
),
|
||||
|
||||
# Remove old models completely
|
||||
migrations.DeleteModel(
|
||||
name='XrayClient',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='XrayInbound',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='XrayInboundServer',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='XrayCoreServer',
|
||||
),
|
||||
]
|
||||
127
vpn/migrations/0016_add_new_xray_models.py
Normal file
127
vpn/migrations/0016_add_new_xray_models.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# Generated manually to add new Xray models
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0015_remove_old_xray_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayConfiguration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('grpc_address', models.CharField(default='127.0.0.1:10085', help_text='Xray gRPC API address (host:port)', max_length=255)),
|
||||
('default_client_hostname', models.CharField(help_text='Default hostname for client connections', max_length=255)),
|
||||
('stats_enabled', models.BooleanField(default=True, help_text='Enable traffic statistics')),
|
||||
('cert_renewal_days', models.IntegerField(default=60, help_text='Renew certificates X days before expiration')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Configuration',
|
||||
'verbose_name_plural': 'Xray Configuration',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Credentials',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Descriptive name for these credentials', max_length=100, unique=True)),
|
||||
('cred_type', models.CharField(choices=[('cloudflare', 'Cloudflare API'), ('dns_provider', 'DNS Provider'), ('email', 'Email SMTP'), ('other', 'Other')], help_text='Type of credentials', max_length=20)),
|
||||
('credentials', models.JSONField(help_text="Credentials data (e.g., {'api_token': '...', 'email': '...'})")),
|
||||
('description', models.TextField(blank=True, help_text='Description of what these credentials are used for')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Credentials',
|
||||
'verbose_name_plural': 'Credentials',
|
||||
'ordering': ['cred_type', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Certificate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('domain', models.CharField(help_text='Domain name for this certificate', max_length=255, unique=True)),
|
||||
('certificate_pem', models.TextField(help_text='Certificate in PEM format')),
|
||||
('private_key_pem', models.TextField(help_text='Private key in PEM format')),
|
||||
('cert_type', models.CharField(choices=[('self_signed', 'Self-Signed'), ('letsencrypt', "Let's Encrypt"), ('custom', 'Custom')], help_text='Type of certificate', max_length=20)),
|
||||
('expires_at', models.DateTimeField(help_text='Certificate expiration date')),
|
||||
('auto_renew', models.BooleanField(default=True, help_text='Automatically renew certificate before expiration')),
|
||||
('last_renewed', models.DateTimeField(blank=True, help_text='Last renewal timestamp', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('credentials', models.ForeignKey(blank=True, help_text="Credentials for Let's Encrypt (Cloudflare API)", null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.credentials')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Certificate',
|
||||
'verbose_name_plural': 'Certificates',
|
||||
'ordering': ['domain'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Inbound',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Unique identifier for this inbound', max_length=100, unique=True)),
|
||||
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], help_text='Protocol type', max_length=20)),
|
||||
('port', models.IntegerField(help_text='Port to listen on')),
|
||||
('network', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('grpc', 'gRPC'), ('http', 'HTTP/2'), ('quic', 'QUIC')], default='tcp', help_text='Transport protocol', max_length=20)),
|
||||
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY')], default='none', help_text='Security type', max_length=20)),
|
||||
('domain', models.CharField(blank=True, help_text='Client connection domain', max_length=255)),
|
||||
('full_config', models.JSONField(default=dict, help_text='Complete configuration for creating inbound on server')),
|
||||
('listen_address', models.CharField(default='0.0.0.0', help_text='IP address to listen on', max_length=45)),
|
||||
('enable_sniffing', models.BooleanField(default=True, help_text='Enable protocol sniffing')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('certificate', models.ForeignKey(blank=True, help_text='Certificate for TLS', null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.certificate')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Inbound',
|
||||
'verbose_name_plural': 'Inbounds',
|
||||
'ordering': ['protocol', 'port'],
|
||||
'unique_together': {('port', 'listen_address')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubscriptionGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="Group name (e.g., 'VLESS Premium', 'VMess Basic')", max_length=100, unique=True)),
|
||||
('description', models.TextField(blank=True, help_text='Description of this subscription group')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this group is active')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('inbounds', models.ManyToManyField(blank=True, help_text='Inbounds included in this group', to='vpn.inbound')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Subscription Group',
|
||||
'verbose_name_plural': 'Subscription Groups',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserSubscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('active', models.BooleanField(default=True, help_text='Whether this subscription is active')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('subscription_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vpn.subscriptiongroup')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='xray_subscriptions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User Subscription',
|
||||
'verbose_name_plural': 'User Subscriptions',
|
||||
'ordering': ['user__username', 'subscription_group__name'],
|
||||
'unique_together': {('user', 'subscription_group')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 13:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0016_add_new_xray_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='XrayServerV2',
|
||||
fields=[
|
||||
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
|
||||
('client_hostname', models.CharField(help_text='Client connection hostname (what users see in their configs)', max_length=255)),
|
||||
('api_address', models.CharField(default='127.0.0.1:10085', help_text='Xray gRPC API address for management', max_length=255)),
|
||||
('api_enabled', models.BooleanField(default=True, help_text='Enable gRPC API for user management')),
|
||||
('stats_enabled', models.BooleanField(default=True, help_text='Enable traffic statistics collection')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Xray Server v2',
|
||||
'verbose_name_plural': 'Xray Servers v2',
|
||||
},
|
||||
bases=('vpn.server',),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='server',
|
||||
name='server_type',
|
||||
field=models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard'), ('xray_core', 'Xray Core'), ('xray_v2', 'Xray Server v2')], editable=False, max_length=50),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ServerInbound',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('active', models.BooleanField(default=True, help_text='Whether this inbound is active on the server')),
|
||||
('deployed_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('deployment_config', models.JSONField(blank=True, default=dict, help_text='Server-specific deployment configuration')),
|
||||
('inbound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployed_servers', to='vpn.inbound')),
|
||||
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployed_inbounds', to='vpn.server')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Server Inbound Deployment',
|
||||
'verbose_name_plural': 'Server Inbound Deployments',
|
||||
'ordering': ['server__name', 'inbound__name'],
|
||||
'unique_together': {('server', 'inbound')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 14:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0017_xrayserverv2_alter_server_server_type_serverinbound'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='certificate',
|
||||
name='certificate_pem',
|
||||
field=models.TextField(blank=True, help_text="Certificate in PEM format (auto-generated for Let's Encrypt)"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='certificate',
|
||||
name='expires_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Certificate expiration date (auto-filled after generation)', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='certificate',
|
||||
name='private_key_pem',
|
||||
field=models.TextField(blank=True, help_text="Private key in PEM format (auto-generated for Let's Encrypt)"),
|
||||
),
|
||||
]
|
||||
18
vpn/migrations/0019_certificate_acme_email.py
Normal file
18
vpn/migrations/0019_certificate_acme_email.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 14:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0018_alter_certificate_certificate_pem_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='certificate',
|
||||
name='acme_email',
|
||||
field=models.EmailField(blank=True, help_text="Email address for ACME account registration (required for Let's Encrypt)", max_length=254),
|
||||
),
|
||||
]
|
||||
18
vpn/migrations/0020_alter_inbound_full_config.py
Normal file
18
vpn/migrations/0020_alter_inbound_full_config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-07 15:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0019_certificate_acme_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='inbound',
|
||||
name='full_config',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Complete configuration for creating inbound on server (auto-generated if empty)'),
|
||||
),
|
||||
]
|
||||
16
vpn/migrations/0021_remove_xray_configuration.py
Normal file
16
vpn/migrations/0021_remove_xray_configuration.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-08 03:33
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0020_alter_inbound_full_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='XrayConfiguration',
|
||||
),
|
||||
]
|
||||
21
vpn/migrations/0022_remove_inbound_domain_field.py
Normal file
21
vpn/migrations/0022_remove_inbound_domain_field.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-08 04:14
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0021_remove_xray_configuration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='inbound',
|
||||
options={'ordering': ['protocol', 'port'], 'verbose_name': 'Inbound Template', 'verbose_name_plural': 'Inbound Templates'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='inbound',
|
||||
name='domain',
|
||||
),
|
||||
]
|
||||
17
vpn/migrations/0023_alter_subscriptiongroup_options.py
Normal file
17
vpn/migrations/0023_alter_subscriptiongroup_options.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-08 04:32
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0022_remove_inbound_domain_field'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='subscriptiongroup',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Subscriptions', 'verbose_name_plural': 'Subscriptions'},
|
||||
),
|
||||
]
|
||||
23
vpn/migrations/0024_add_certificate_to_serverinbound.py
Normal file
23
vpn/migrations/0024_add_certificate_to_serverinbound.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-08 05:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0023_alter_subscriptiongroup_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='inbound',
|
||||
name='certificate',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='serverinbound',
|
||||
name='certificate',
|
||||
field=models.ForeignKey(blank=True, help_text='Certificate for TLS on this specific server (overrides automatic selection)', null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.certificate'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-14 12:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0024_add_certificate_to_serverinbound'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='telegram_first_name',
|
||||
field=models.CharField(blank=True, help_text='First name from Telegram', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='telegram_last_name',
|
||||
field=models.CharField(blank=True, help_text='Last name from Telegram', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='telegram_phone',
|
||||
field=models.CharField(blank=True, help_text='Phone number from Telegram (optional)', max_length=20, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='telegram_user_id',
|
||||
field=models.BigIntegerField(blank=True, help_text='Telegram user ID', null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='telegram_username',
|
||||
field=models.CharField(blank=True, help_text='Telegram username (without @)', max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
17
vpn/migrations/0026_alter_subscriptiongroup_options.py
Normal file
17
vpn/migrations/0026_alter_subscriptiongroup_options.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.7 on 2025-08-15 00:23
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0025_user_telegram_first_name_user_telegram_last_name_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='subscriptiongroup',
|
||||
options={'ordering': ['name'], 'verbose_name': 'XRay-core', 'verbose_name_plural': 'XRay-core'},
|
||||
),
|
||||
]
|
||||
@@ -95,6 +95,38 @@ class User(AbstractUser):
|
||||
last_access = models.DateTimeField(null=True, blank=True)
|
||||
hash = models.CharField(max_length=64, unique=True, help_text="Random user hash. It's using for client config generation.")
|
||||
|
||||
# Telegram fields
|
||||
telegram_user_id = models.BigIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
unique=True,
|
||||
help_text="Telegram user ID"
|
||||
)
|
||||
telegram_username = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Telegram username (without @)"
|
||||
)
|
||||
telegram_first_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="First name from Telegram"
|
||||
)
|
||||
telegram_last_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Last name from Telegram"
|
||||
)
|
||||
telegram_phone = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Phone number from Telegram (optional)"
|
||||
)
|
||||
|
||||
def get_servers(self):
|
||||
return Server.objects.filter(acl__user=self)
|
||||
|
||||
@@ -167,3 +199,10 @@ class ACLLink(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.link
|
||||
|
||||
|
||||
# Import new Xray models
|
||||
from .models_xray import (
|
||||
Credentials, Certificate,
|
||||
Inbound, SubscriptionGroup, UserSubscription
|
||||
)
|
||||
|
||||
464
vpn/models_xray.py
Normal file
464
vpn/models_xray.py
Normal file
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
New Xray models for flexible inbound and subscription management.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Credentials(models.Model):
|
||||
"""Universal credentials storage for various services"""
|
||||
CRED_TYPES = [
|
||||
('cloudflare', 'Cloudflare API'),
|
||||
('dns_provider', 'DNS Provider'),
|
||||
('email', 'Email SMTP'),
|
||||
('other', 'Other')
|
||||
]
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Descriptive name for these credentials"
|
||||
)
|
||||
cred_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=CRED_TYPES,
|
||||
help_text="Type of credentials"
|
||||
)
|
||||
credentials = models.JSONField(
|
||||
help_text="Credentials data (e.g., {'api_token': '...', 'email': '...'})"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of what these credentials are used for"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Credentials"
|
||||
verbose_name_plural = "Credentials"
|
||||
ordering = ['cred_type', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_cred_type_display()})"
|
||||
|
||||
def get_credential(self, key: str, default=None):
|
||||
"""Safely get credential value"""
|
||||
return self.credentials.get(key, default)
|
||||
|
||||
|
||||
class Certificate(models.Model):
|
||||
"""SSL/TLS Certificate management"""
|
||||
CERT_TYPES = [
|
||||
('self_signed', 'Self-Signed'),
|
||||
('letsencrypt', "Let's Encrypt"),
|
||||
('custom', 'Custom')
|
||||
]
|
||||
|
||||
domain = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
help_text="Domain name for this certificate"
|
||||
)
|
||||
certificate_pem = models.TextField(
|
||||
blank=True,
|
||||
help_text="Certificate in PEM format (auto-generated for Let's Encrypt)"
|
||||
)
|
||||
private_key_pem = models.TextField(
|
||||
blank=True,
|
||||
help_text="Private key in PEM format (auto-generated for Let's Encrypt)"
|
||||
)
|
||||
cert_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=CERT_TYPES,
|
||||
help_text="Type of certificate"
|
||||
)
|
||||
expires_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Certificate expiration date (auto-filled after generation)"
|
||||
)
|
||||
credentials = models.ForeignKey(
|
||||
Credentials,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text="Credentials for Let's Encrypt (Cloudflare API)"
|
||||
)
|
||||
acme_email = models.EmailField(
|
||||
blank=True,
|
||||
help_text="Email address for ACME account registration (required for Let's Encrypt)"
|
||||
)
|
||||
auto_renew = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Automatically renew certificate before expiration"
|
||||
)
|
||||
last_renewed = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Last renewal timestamp"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Certificate"
|
||||
verbose_name_plural = "Certificates"
|
||||
ordering = ['domain']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.domain} ({self.get_cert_type_display()})"
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""Check if certificate is expired"""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
@property
|
||||
def days_until_expiration(self):
|
||||
"""Days until certificate expires"""
|
||||
if not self.expires_at:
|
||||
return None
|
||||
delta = self.expires_at - timezone.now()
|
||||
return delta.days
|
||||
|
||||
@property
|
||||
def needs_renewal(self):
|
||||
"""Check if certificate needs renewal"""
|
||||
if not self.auto_renew or not self.expires_at:
|
||||
return False
|
||||
|
||||
# Default renewal period
|
||||
renewal_days = 60
|
||||
|
||||
days_left = self.days_until_expiration
|
||||
if days_left is None:
|
||||
return False
|
||||
|
||||
return days_left <= renewal_days
|
||||
|
||||
|
||||
class Inbound(models.Model):
|
||||
"""Independent inbound configuration"""
|
||||
PROTOCOLS = [
|
||||
('vless', 'VLESS'),
|
||||
('vmess', 'VMess'),
|
||||
('trojan', 'Trojan'),
|
||||
('shadowsocks', 'Shadowsocks')
|
||||
]
|
||||
|
||||
NETWORKS = [
|
||||
('tcp', 'TCP'),
|
||||
('ws', 'WebSocket'),
|
||||
('grpc', 'gRPC'),
|
||||
('http', 'HTTP/2'),
|
||||
('quic', 'QUIC')
|
||||
]
|
||||
|
||||
SECURITIES = [
|
||||
('none', 'None'),
|
||||
('tls', 'TLS'),
|
||||
('reality', 'REALITY')
|
||||
]
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Unique identifier for this inbound"
|
||||
)
|
||||
protocol = models.CharField(
|
||||
max_length=20,
|
||||
choices=PROTOCOLS,
|
||||
help_text="Protocol type"
|
||||
)
|
||||
port = models.IntegerField(
|
||||
help_text="Port to listen on"
|
||||
)
|
||||
network = models.CharField(
|
||||
max_length=20,
|
||||
choices=NETWORKS,
|
||||
default='tcp',
|
||||
help_text="Transport protocol"
|
||||
)
|
||||
security = models.CharField(
|
||||
max_length=20,
|
||||
choices=SECURITIES,
|
||||
default='none',
|
||||
help_text="Security type"
|
||||
)
|
||||
|
||||
# Full configuration for Xray
|
||||
full_config = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Complete configuration for creating inbound on server (auto-generated if empty)"
|
||||
)
|
||||
|
||||
# Additional settings
|
||||
listen_address = models.CharField(
|
||||
max_length=45,
|
||||
default="0.0.0.0",
|
||||
help_text="IP address to listen on"
|
||||
)
|
||||
enable_sniffing = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Enable protocol sniffing"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Inbound Template"
|
||||
verbose_name_plural = "Inbound Templates"
|
||||
ordering = ['protocol', 'port']
|
||||
unique_together = [['port', 'listen_address']]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.protocol.upper()}:{self.port})"
|
||||
|
||||
def generate_tag(self):
|
||||
"""Generate unique tag for inbound"""
|
||||
return f"{self.protocol}-{self.port}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
def build_config(self):
|
||||
"""Build full configuration for Xray"""
|
||||
try:
|
||||
# Build basic Xray inbound configuration
|
||||
config = {
|
||||
"tag": self.name,
|
||||
"port": self.port,
|
||||
"listen": self.listen_address,
|
||||
"protocol": self.protocol,
|
||||
"settings": self._build_protocol_settings(),
|
||||
"streamSettings": self._build_stream_settings(),
|
||||
"sniffing": {
|
||||
"enabled": self.enable_sniffing,
|
||||
"destOverride": ["http", "tls"]
|
||||
} if self.enable_sniffing else {}
|
||||
}
|
||||
|
||||
# Store the built config
|
||||
self.full_config = config
|
||||
return self.full_config
|
||||
|
||||
except Exception as e:
|
||||
# Fallback to basic config if detailed build fails
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Failed to build detailed config for {self.name}: {e}")
|
||||
|
||||
self.full_config = {
|
||||
"tag": self.name,
|
||||
"port": self.port,
|
||||
"listen": self.listen_address,
|
||||
"protocol": self.protocol,
|
||||
"settings": {},
|
||||
"streamSettings": {}
|
||||
}
|
||||
return self.full_config
|
||||
|
||||
def _build_protocol_settings(self):
|
||||
"""Build protocol-specific settings"""
|
||||
settings = {}
|
||||
|
||||
if self.protocol == 'vless':
|
||||
settings = {
|
||||
"clients": [], # Will be populated when users are added
|
||||
"decryption": "none"
|
||||
}
|
||||
elif self.protocol == 'vmess':
|
||||
settings = {
|
||||
"clients": [] # Will be populated when users are added
|
||||
}
|
||||
elif self.protocol == 'trojan':
|
||||
settings = {
|
||||
"clients": [] # Will be populated when users are added
|
||||
}
|
||||
elif self.protocol == 'shadowsocks':
|
||||
settings = {
|
||||
"method": "aes-128-gcm", # Default method
|
||||
"password": "", # Will be set when configured
|
||||
"network": "tcp,udp"
|
||||
}
|
||||
|
||||
return settings
|
||||
|
||||
def _build_stream_settings(self):
|
||||
"""Build stream transport settings"""
|
||||
stream_settings = {
|
||||
"network": self.network
|
||||
}
|
||||
|
||||
# Add network-specific settings
|
||||
if self.network == "ws":
|
||||
stream_settings["wsSettings"] = {
|
||||
"path": f"/{self.name}",
|
||||
"headers": {}
|
||||
}
|
||||
elif self.network == "grpc":
|
||||
stream_settings["grpcSettings"] = {
|
||||
"serviceName": self.name
|
||||
}
|
||||
elif self.network == "http":
|
||||
stream_settings["httpSettings"] = {
|
||||
"path": f"/{self.name}",
|
||||
"host": [] # Will be filled when deployed to server
|
||||
}
|
||||
|
||||
# Add security settings
|
||||
if self.security == "tls":
|
||||
stream_settings["security"] = "tls"
|
||||
tls_settings = {
|
||||
"serverName": "localhost", # Will be replaced with server hostname when deployed
|
||||
"alpn": ["h2", "http/1.1"]
|
||||
}
|
||||
|
||||
# Certificate will be set during deployment based on ServerInbound configuration
|
||||
|
||||
stream_settings["tlsSettings"] = tls_settings
|
||||
|
||||
elif self.security == "reality":
|
||||
stream_settings["security"] = "reality"
|
||||
# Reality settings would be configured here
|
||||
stream_settings["realitySettings"] = {
|
||||
"dest": "example.com:443", # Will be replaced with server hostname when deployed
|
||||
"serverNames": ["example.com"], # Will be replaced with server hostname when deployed
|
||||
"privateKey": "", # Would be generated
|
||||
"shortIds": [""] # Would be generated
|
||||
}
|
||||
|
||||
return stream_settings
|
||||
|
||||
|
||||
class SubscriptionGroup(models.Model):
|
||||
"""Groups of inbounds for subscription management"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text="Group name (e.g., 'VLESS Premium', 'VMess Basic')"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of this subscription group"
|
||||
)
|
||||
inbounds = models.ManyToManyField(
|
||||
Inbound,
|
||||
blank=True,
|
||||
help_text="Inbounds included in this group"
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this group is active"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "XRay-core"
|
||||
verbose_name_plural = "XRay-core"
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def inbound_count(self):
|
||||
"""Number of inbounds in this group"""
|
||||
return self.inbounds.count()
|
||||
|
||||
@property
|
||||
def user_count(self):
|
||||
"""Number of users subscribed to this group"""
|
||||
return self.usersubscription_set.filter(active=True).count()
|
||||
|
||||
|
||||
class UserSubscription(models.Model):
|
||||
"""User subscriptions to groups"""
|
||||
user = models.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='xray_subscriptions'
|
||||
)
|
||||
subscription_group = models.ForeignKey(
|
||||
SubscriptionGroup,
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this subscription is active"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "User Subscription"
|
||||
verbose_name_plural = "User Subscriptions"
|
||||
unique_together = ['user', 'subscription_group']
|
||||
ordering = ['user__username', 'subscription_group__name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.subscription_group.name}"
|
||||
|
||||
|
||||
class ServerInbound(models.Model):
|
||||
"""Many-to-many relationship between servers and inbounds to track deployment"""
|
||||
server = models.ForeignKey('Server', on_delete=models.CASCADE, related_name='deployed_inbounds')
|
||||
inbound = models.ForeignKey(Inbound, on_delete=models.CASCADE, related_name='deployed_servers')
|
||||
active = models.BooleanField(default=True, help_text="Whether this inbound is active on the server")
|
||||
|
||||
deployed_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Certificate for TLS on this specific server deployment
|
||||
certificate = models.ForeignKey(
|
||||
Certificate,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text="Certificate for TLS on this specific server (overrides automatic selection)"
|
||||
)
|
||||
|
||||
# Store deployment-specific configuration if needed
|
||||
deployment_config = models.JSONField(default=dict, blank=True, help_text="Server-specific deployment configuration")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Server Inbound Deployment"
|
||||
verbose_name_plural = "Server Inbound Deployments"
|
||||
ordering = ['server__name', 'inbound__name']
|
||||
unique_together = [('server', 'inbound')]
|
||||
|
||||
def __str__(self):
|
||||
status = "Active" if self.active else "Inactive"
|
||||
return f"{self.server.name} -> {self.inbound.name} ({status})"
|
||||
|
||||
def get_certificate(self):
|
||||
"""Get certificate for this deployment with fallback logic"""
|
||||
# 1. Use explicitly set certificate
|
||||
if self.certificate:
|
||||
return self.certificate
|
||||
|
||||
# 2. Try to find certificate by server's client_hostname
|
||||
if hasattr(self.server.get_real_instance(), 'client_hostname'):
|
||||
server_hostname = self.server.get_real_instance().client_hostname
|
||||
try:
|
||||
return Certificate.objects.get(domain=server_hostname, cert_type='letsencrypt')
|
||||
except Certificate.DoesNotExist:
|
||||
try:
|
||||
return Certificate.objects.get(domain=server_hostname)
|
||||
except Certificate.DoesNotExist:
|
||||
pass
|
||||
|
||||
# 3. No certificate found
|
||||
return None
|
||||
|
||||
def requires_certificate(self):
|
||||
"""Check if this inbound requires a certificate"""
|
||||
return self.inbound.security in ['tls'] or self.inbound.protocol == 'trojan'
|
||||
@@ -1,5 +1,5 @@
|
||||
from .generic import Server
|
||||
from .outline import OutlineServer, OutlineServerAdmin
|
||||
from .wireguard import WireguardServer, WireguardServerAdmin
|
||||
from .xray_core import XrayCoreServer, XrayCoreServerAdmin, XrayInbound, XrayClient, XrayInboundServer, XrayInboundServerAdmin
|
||||
from .xray_v2 import XrayServerV2, XrayServerV2Admin
|
||||
from .urls import urlpatterns
|
||||
@@ -7,6 +7,7 @@ class Server(PolymorphicModel):
|
||||
('Outline', 'Outline'),
|
||||
('Wireguard', 'Wireguard'),
|
||||
('xray_core', 'Xray Core'),
|
||||
('xray_v2', 'Xray Server v2'),
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=100, help_text="Server name")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.urls import path
|
||||
from vpn.views import shadowsocks
|
||||
from vpn.views import shadowsocks, xray_subscription
|
||||
|
||||
urlpatterns = [
|
||||
path('ss/<str:hash_value>/', shadowsocks, name='shadowsocks'),
|
||||
path('xray/<str:user_hash>/', xray_subscription, name='xray_subscription'),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
966
vpn/server_plugins/xray_v2.py
Normal file
966
vpn/server_plugins/xray_v2.py
Normal file
@@ -0,0 +1,966 @@
|
||||
import logging
|
||||
from django.db import models
|
||||
from django.contrib import admin
|
||||
from polymorphic.admin import PolymorphicChildModelAdmin
|
||||
from .generic import Server
|
||||
from vpn.models_xray import Inbound, UserSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class XrayServerV2(Server):
|
||||
"""
|
||||
New Xray server that works with subscription groups and inbounds.
|
||||
This server can host multiple inbounds and users access them through subscription groups.
|
||||
"""
|
||||
client_hostname = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Client connection hostname (what users see in their configs)"
|
||||
)
|
||||
api_address = models.CharField(
|
||||
max_length=255,
|
||||
default="127.0.0.1:10085",
|
||||
help_text="Xray gRPC API address for management"
|
||||
)
|
||||
api_enabled = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Enable gRPC API for user management"
|
||||
)
|
||||
stats_enabled = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Enable traffic statistics collection"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Xray Server v2"
|
||||
verbose_name_plural = "Xray Servers v2"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.server_type:
|
||||
self.server_type = 'xray_v2'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_server_status(self):
|
||||
"""Get server status including active inbounds"""
|
||||
try:
|
||||
# Get basic server information
|
||||
active_inbounds = self.get_active_inbounds()
|
||||
|
||||
# Try to connect to Xray API if enabled
|
||||
api_status = False
|
||||
api_error = None
|
||||
api_stats = {}
|
||||
|
||||
if self.api_enabled:
|
||||
try:
|
||||
# Try different methods to check server status
|
||||
import socket
|
||||
import json
|
||||
|
||||
# Parse API address
|
||||
host, port = self.api_address.split(':')
|
||||
port = int(port)
|
||||
|
||||
# Test basic connection
|
||||
logger.info(f"Testing connection to Xray API at {host}:{port}")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
result = sock.connect_ex((host, port))
|
||||
sock.close()
|
||||
|
||||
if result == 0:
|
||||
api_status = True
|
||||
logger.info(f"Successfully connected to Xray API at {self.api_address}")
|
||||
|
||||
# Try to get stats if library is available
|
||||
try:
|
||||
from vpn.xray_api_v2.server_manager import ServerManager
|
||||
manager = ServerManager(self.api_address)
|
||||
api_stats = manager.get_server_stats()
|
||||
logger.info(f"Got server stats: {api_stats}")
|
||||
except ImportError:
|
||||
logger.info("Xray API v2 library not available, but connection successful")
|
||||
api_stats = {"connection": "ok", "library": "not_available"}
|
||||
except Exception as stats_e:
|
||||
logger.warning(f"Connection OK but stats failed: {stats_e}")
|
||||
api_stats = {"connection": "ok", "stats_error": str(stats_e)}
|
||||
else:
|
||||
api_error = f"Connection failed to {host}:{port}"
|
||||
logger.warning(f"Failed to connect to Xray API at {self.api_address}: {api_error}")
|
||||
|
||||
except Exception as e:
|
||||
api_error = f"Connection test failed: {str(e)}"
|
||||
logger.warning(f"Failed to test connection to Xray API for server {self.name}: {e}")
|
||||
else:
|
||||
api_error = "API disabled in server settings"
|
||||
logger.info(f"API disabled for server {self.name}")
|
||||
|
||||
# Build status response
|
||||
status = {
|
||||
'server_name': self.name,
|
||||
'server_type': 'Xray Server v2',
|
||||
'client_hostname': self.client_hostname,
|
||||
'api_address': self.api_address,
|
||||
'api_enabled': self.api_enabled,
|
||||
'api_connected': api_status,
|
||||
'api_error': api_error,
|
||||
'api_stats': api_stats,
|
||||
'stats_enabled': self.stats_enabled,
|
||||
'total_inbounds': active_inbounds.count(),
|
||||
'inbound_ports': [], # Will be populated when ServerInbound model is fully implemented
|
||||
'accessible': api_status if self.api_enabled else True, # Consider accessible if API disabled
|
||||
'status': 'Connected' if api_status else 'API Issue' if self.api_enabled else 'No API Check'
|
||||
}
|
||||
|
||||
logger.info(f"Server status for {self.name}: {status['status']}")
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get status for Xray server {self.name}: {e}")
|
||||
return {
|
||||
'error': str(e),
|
||||
'server_name': self.name,
|
||||
'server_type': 'Xray Server v2',
|
||||
'accessible': False,
|
||||
'status': 'Error'
|
||||
}
|
||||
|
||||
def get_active_inbounds(self):
|
||||
"""Get all inbounds that are deployed on this server"""
|
||||
try:
|
||||
from vpn.models_xray import ServerInbound
|
||||
return ServerInbound.objects.filter(server=self, active=True).select_related('inbound')
|
||||
except ImportError:
|
||||
# ServerInbound model doesn't exist yet, return empty queryset
|
||||
from django.db.models import QuerySet
|
||||
from vpn.models_xray import Inbound
|
||||
return Inbound.objects.none()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting active inbounds for server {self.name}: {e}")
|
||||
from vpn.models_xray import Inbound
|
||||
return Inbound.objects.none()
|
||||
|
||||
def sync_users(self):
|
||||
"""Sync all users who have subscription groups containing inbounds on this server"""
|
||||
try:
|
||||
from vpn.tasks import sync_server_users
|
||||
task = sync_server_users.delay(self.id)
|
||||
logger.info(f"Scheduled user sync for Xray server {self.name} - task ID: {task.id}")
|
||||
# Return success to indicate task was scheduled
|
||||
return {"status": "scheduled", "task_id": str(task.id)}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule user sync for server {self.name}: {e}")
|
||||
return {"status": "failed", "error": str(e)}
|
||||
|
||||
def sync_inbounds(self, auto_sync_users=True):
|
||||
"""Deploy all required inbounds on this server based on subscription groups"""
|
||||
try:
|
||||
from vpn.tasks import sync_server_inbounds
|
||||
task = sync_server_inbounds.delay(self.id, auto_sync_users)
|
||||
logger.info(f"Scheduled inbound sync for Xray server {self.name} - task ID: {task.id}")
|
||||
return {"task_id": str(task.id), "auto_sync_users": auto_sync_users}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule inbound sync for server {self.name}: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def deploy_inbound(self, inbound, users=None, server_inbound=None):
|
||||
"""Deploy a specific inbound on this server with optional users"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
import uuid
|
||||
|
||||
logger.info(f"Deploying inbound {inbound.name} with protocol {inbound.protocol} on port {inbound.port}")
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# Build user configs if users are provided
|
||||
user_configs = []
|
||||
if users:
|
||||
for user in users:
|
||||
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
|
||||
|
||||
if inbound.protocol == 'vless':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0,
|
||||
"alterId": 0
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"password": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Unsupported protocol {inbound.protocol} for user {user.username}")
|
||||
continue
|
||||
|
||||
user_configs.append(user_config)
|
||||
logger.debug(f"Added user {user.username} to inbound config")
|
||||
|
||||
# Build proper inbound configuration based on protocol
|
||||
if inbound.full_config:
|
||||
inbound_config = inbound.full_config.copy() # Make a copy to modify
|
||||
logger.info(f"Using existing full_config for inbound {inbound.name}")
|
||||
|
||||
# Add users to the config if provided
|
||||
if user_configs:
|
||||
if 'settings' not in inbound_config:
|
||||
inbound_config['settings'] = {}
|
||||
inbound_config['settings']['clients'] = user_configs
|
||||
logger.debug(f"Added {len(user_configs)} users to full_config")
|
||||
|
||||
# Get certificate from ServerInbound or auto-select
|
||||
certificate = None
|
||||
if server_inbound:
|
||||
certificate = server_inbound.get_certificate()
|
||||
|
||||
# If certificate found, update the config to use inline certificates
|
||||
if certificate and certificate.certificate_pem:
|
||||
logger.info(f"Updating full_config with inline certificate for {certificate.domain}")
|
||||
|
||||
# Convert PEM to lines for Xray format
|
||||
cert_lines = certificate.certificate_pem.strip().split('\n')
|
||||
key_lines = certificate.private_key_pem.strip().split('\n')
|
||||
|
||||
# Update streamSettings if it exists
|
||||
if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]:
|
||||
# Remove any existing certificate file paths
|
||||
tls_settings = inbound_config["streamSettings"]["tlsSettings"]
|
||||
if "certificateFile" in tls_settings:
|
||||
del tls_settings["certificateFile"]
|
||||
if "keyFile" in tls_settings:
|
||||
del tls_settings["keyFile"]
|
||||
|
||||
# Set inline certificates
|
||||
inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
logger.debug("Updated existing tlsSettings with inline certificate and removed file paths")
|
||||
else:
|
||||
# Build full config based on protocol
|
||||
inbound_config = {
|
||||
"tag": inbound.name,
|
||||
"port": inbound.port,
|
||||
"protocol": inbound.protocol,
|
||||
"listen": inbound.listen_address or "0.0.0.0",
|
||||
}
|
||||
|
||||
# Add protocol-specific settings
|
||||
if inbound.protocol == 'vless':
|
||||
inbound_config["settings"] = {
|
||||
"clients": user_configs, # Add users during creation
|
||||
"decryption": "none"
|
||||
}
|
||||
if inbound.network == 'ws':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "ws",
|
||||
"wsSettings": {
|
||||
"path": f"/{inbound.name}"
|
||||
}
|
||||
}
|
||||
elif inbound.network == 'tcp':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "tcp"
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
inbound_config["settings"] = {
|
||||
"clients": user_configs # Add users during creation
|
||||
}
|
||||
if inbound.network == 'ws':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "ws",
|
||||
"wsSettings": {
|
||||
"path": f"/{inbound.name}"
|
||||
}
|
||||
}
|
||||
elif inbound.network == 'tcp':
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "tcp"
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
inbound_config["settings"] = {
|
||||
"clients": user_configs # Add users during creation
|
||||
}
|
||||
inbound_config["streamSettings"] = {
|
||||
"network": "tcp",
|
||||
"security": "tls"
|
||||
}
|
||||
|
||||
# Get certificate for Trojan (always required)
|
||||
certificate = None
|
||||
if server_inbound:
|
||||
certificate = server_inbound.get_certificate()
|
||||
|
||||
if certificate and certificate.certificate_pem:
|
||||
logger.info(f"Using certificate for Trojan inbound on domain {certificate.domain}")
|
||||
# Convert PEM to lines for Xray format
|
||||
cert_lines = certificate.certificate_pem.strip().split('\n')
|
||||
key_lines = certificate.private_key_pem.strip().split('\n')
|
||||
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
}
|
||||
else:
|
||||
logger.error(f"Trojan protocol requires certificate, but none found for inbound {inbound.name}!")
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": []
|
||||
}
|
||||
|
||||
# Add TLS if specified
|
||||
if inbound.security == 'tls' and inbound.protocol != 'trojan':
|
||||
if "streamSettings" not in inbound_config:
|
||||
inbound_config["streamSettings"] = {}
|
||||
inbound_config["streamSettings"]["security"] = "tls"
|
||||
|
||||
# Get certificate for TLS
|
||||
certificate = None
|
||||
if server_inbound:
|
||||
certificate = server_inbound.get_certificate()
|
||||
|
||||
if certificate and certificate.certificate_pem:
|
||||
logger.info(f"Using certificate for domain {certificate.domain}")
|
||||
# Convert PEM to lines for Xray format
|
||||
cert_lines = certificate.certificate_pem.strip().split('\n')
|
||||
key_lines = certificate.private_key_pem.strip().split('\n')
|
||||
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
}
|
||||
else:
|
||||
logger.warning(f"No certificate found for inbound {inbound.name}, TLS will not work!")
|
||||
inbound_config["streamSettings"]["tlsSettings"] = {
|
||||
"certificates": []
|
||||
}
|
||||
|
||||
logger.debug(f"Inbound config for {inbound.name}: {len(str(inbound_config))} chars")
|
||||
|
||||
# Check if inbound already exists
|
||||
existing_inbounds = client.list_inbounds()
|
||||
inbound_exists = any(ib.get('tag') == inbound.name for ib in existing_inbounds)
|
||||
|
||||
if inbound_exists:
|
||||
# Inbound already exists, update it instead of recreating
|
||||
logger.info(f"Inbound {inbound.name} already exists, updating it")
|
||||
# First remove the old one
|
||||
client.remove_inbound(inbound.name)
|
||||
# Then add the updated one
|
||||
result = client.add_inbound(inbound_config)
|
||||
else:
|
||||
# Add new inbound
|
||||
logger.info(f"Creating new inbound {inbound.name}")
|
||||
result = client.add_inbound(inbound_config)
|
||||
|
||||
logger.info(f"Deploy inbound result: {result}")
|
||||
|
||||
# Check if command was successful
|
||||
if result is not None and not (isinstance(result, dict) and 'error' in result):
|
||||
# Mark as deployed on this server
|
||||
from vpn.models_xray import ServerInbound
|
||||
ServerInbound.objects.update_or_create(
|
||||
server=self,
|
||||
inbound=inbound,
|
||||
defaults={'active': True}
|
||||
)
|
||||
logger.info(f"Successfully deployed inbound {inbound.name} on server {self.name}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to deploy inbound {inbound.name} on server {self.name}. Result: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deploying inbound {inbound.name} on server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def add_user_to_inbound(self, user, inbound):
|
||||
"""Add a user to a specific inbound on this server using inbound recreation approach"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
from vpn.models_xray import ServerInbound
|
||||
import uuid
|
||||
|
||||
logger.info(f"Adding user {user.username} to inbound {inbound.name} using inbound recreation")
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# Get ServerInbound object for certificate access
|
||||
try:
|
||||
server_inbound = ServerInbound.objects.get(server=self, inbound=inbound)
|
||||
except ServerInbound.DoesNotExist:
|
||||
logger.warning(f"ServerInbound not found for {self.name} -> {inbound.name}, creating one")
|
||||
server_inbound = ServerInbound.objects.create(server=self, inbound=inbound, active=True)
|
||||
|
||||
# Generate user UUID based on username and inbound
|
||||
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
|
||||
logger.debug(f"Generated UUID for user {user.username}: {user_uuid}")
|
||||
|
||||
# Build user config based on protocol
|
||||
if inbound.protocol == 'vless':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0,
|
||||
"alterId": 0
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{self.name}",
|
||||
"password": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
else:
|
||||
logger.error(f"Unsupported protocol: {inbound.protocol}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Get all users who should have access to this inbound from database
|
||||
from vpn.models_xray import UserSubscription
|
||||
|
||||
# Find all users who have subscriptions that include this inbound
|
||||
users_with_access = set()
|
||||
subscriptions = UserSubscription.objects.filter(
|
||||
active=True,
|
||||
subscription_group__inbounds=inbound,
|
||||
subscription_group__is_active=True
|
||||
).select_related('user')
|
||||
|
||||
for subscription in subscriptions:
|
||||
users_with_access.add(subscription.user)
|
||||
|
||||
logger.info(f"Found {len(users_with_access)} users with database access to inbound {inbound.name}")
|
||||
|
||||
# Build user configs for all users who should have access
|
||||
existing_users = []
|
||||
user_already_exists = False
|
||||
|
||||
for db_user in users_with_access:
|
||||
# Generate user UUID and config
|
||||
import uuid
|
||||
db_user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{db_user.username}-{inbound.name}"))
|
||||
|
||||
if db_user.username == user.username:
|
||||
user_already_exists = True
|
||||
|
||||
if inbound.protocol == 'vless':
|
||||
db_user_config = {
|
||||
"email": f"{db_user.username}@{self.name}",
|
||||
"id": db_user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
db_user_config = {
|
||||
"email": f"{db_user.username}@{self.name}",
|
||||
"id": db_user_uuid,
|
||||
"level": 0,
|
||||
"alterId": 0
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
db_user_config = {
|
||||
"email": f"{db_user.username}@{self.name}",
|
||||
"password": db_user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
else:
|
||||
continue
|
||||
|
||||
existing_users.append(db_user_config)
|
||||
|
||||
if user_already_exists:
|
||||
logger.info(f"User {user.username} already has database access to inbound {inbound.name}")
|
||||
# Still proceed to ensure inbound is deployed with all users
|
||||
|
||||
logger.info(f"Creating inbound with {len(existing_users)} users from database including {user.username}")
|
||||
|
||||
# Remove the old inbound
|
||||
logger.info(f"Removing old inbound {inbound.name}")
|
||||
client.remove_inbound(inbound.name)
|
||||
|
||||
# Recreate inbound with updated user list
|
||||
if inbound.full_config:
|
||||
inbound_config = inbound.full_config.copy()
|
||||
if 'settings' not in inbound_config:
|
||||
inbound_config['settings'] = {}
|
||||
inbound_config['settings']['clients'] = existing_users
|
||||
|
||||
# Handle certificate embedding if needed
|
||||
certificate = None
|
||||
if server_inbound:
|
||||
certificate = server_inbound.get_certificate()
|
||||
|
||||
if certificate and certificate.certificate_pem:
|
||||
cert_lines = certificate.certificate_pem.strip().split('\n')
|
||||
key_lines = certificate.private_key_pem.strip().split('\n')
|
||||
|
||||
if "streamSettings" in inbound_config and "tlsSettings" in inbound_config["streamSettings"]:
|
||||
# Remove any existing certificate file paths
|
||||
tls_settings = inbound_config["streamSettings"]["tlsSettings"]
|
||||
if "certificateFile" in tls_settings:
|
||||
del tls_settings["certificateFile"]
|
||||
if "keyFile" in tls_settings:
|
||||
del tls_settings["keyFile"]
|
||||
|
||||
inbound_config["streamSettings"]["tlsSettings"]["certificates"] = [{
|
||||
"certificate": cert_lines,
|
||||
"key": key_lines,
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
else:
|
||||
# Build config from scratch with the users
|
||||
inbound_config = {
|
||||
"tag": inbound.name,
|
||||
"port": inbound.port,
|
||||
"protocol": inbound.protocol,
|
||||
"listen": inbound.listen_address or "0.0.0.0",
|
||||
"settings": {}
|
||||
}
|
||||
|
||||
if inbound.protocol in ['vless', 'vmess']:
|
||||
inbound_config["settings"]["clients"] = existing_users
|
||||
if inbound.protocol == 'vless':
|
||||
inbound_config["settings"]["decryption"] = "none"
|
||||
elif inbound.protocol == 'trojan':
|
||||
inbound_config["settings"]["clients"] = existing_users
|
||||
|
||||
logger.info(f"Deploying inbound with users: {[u.get('email') for u in existing_users]}")
|
||||
result = client.add_inbound(inbound_config)
|
||||
|
||||
if result is not None and not (isinstance(result, dict) and 'error' in result):
|
||||
if user_already_exists:
|
||||
logger.info(f"Successfully ensured user {user.username} exists in inbound {inbound.name}")
|
||||
else:
|
||||
logger.info(f"Successfully added user {user.username} to inbound {inbound.name} via inbound recreation")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to recreate inbound {inbound.name} with users. Result: {result}")
|
||||
return False
|
||||
|
||||
except Exception as cmd_error:
|
||||
logger.error(f"Error during inbound recreation: {cmd_error}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding user {user.username} to inbound {inbound.name} on server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def remove_user_from_inbound(self, user, inbound):
|
||||
"""Remove a user from a specific inbound on this server"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# Remove user using the client's remove_users method
|
||||
user_email = f"{user.username}@{self.name}"
|
||||
logger.info(f"Removing user {user_email} from inbound {inbound.name}")
|
||||
|
||||
result = client.remove_users(inbound.name, user_email)
|
||||
logger.info(f"Remove user result: {result}")
|
||||
|
||||
if result is not None and not (isinstance(result, dict) and 'error' in result):
|
||||
logger.info(f"Successfully removed user {user.username} from inbound {inbound.name} on server {self.name}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to remove user {user.username} from inbound {inbound.name} on server {self.name}. Result: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing user {user.username} from inbound {inbound.name} on server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def get_user_configs(self, user):
|
||||
"""Generate all connection configs for a user on this server"""
|
||||
configs = []
|
||||
|
||||
try:
|
||||
# Get all subscription groups for this user
|
||||
user_subscriptions = UserSubscription.objects.filter(
|
||||
user=user,
|
||||
active=True,
|
||||
subscription_group__is_active=True
|
||||
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
|
||||
|
||||
for subscription in user_subscriptions:
|
||||
group = subscription.subscription_group
|
||||
|
||||
# Check which inbounds from this group are active on this server
|
||||
active_inbounds = self.get_active_inbounds().filter(
|
||||
inbound__in=group.inbounds.all()
|
||||
)
|
||||
|
||||
for server_inbound in active_inbounds:
|
||||
inbound = server_inbound.inbound
|
||||
|
||||
try:
|
||||
# Generate connection string directly
|
||||
from vpn.views import generate_xray_connection_string
|
||||
connection_string = generate_xray_connection_string(user, inbound, self.name, self.client_hostname)
|
||||
|
||||
if connection_string:
|
||||
configs.append({
|
||||
'protocol': inbound.protocol,
|
||||
'inbound_name': inbound.name,
|
||||
'group_name': group.name,
|
||||
'connection_string': connection_string,
|
||||
'port': inbound.port,
|
||||
'network': inbound.network,
|
||||
'security': inbound.security
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate config for user {user.username} on inbound {inbound.name}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Generated {len(configs)} configs for user {user.username} on server {self.name}")
|
||||
return configs
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating user configs for {user.username} on server {self.name}: {e}")
|
||||
return []
|
||||
|
||||
def sync(self):
|
||||
"""Sync server configuration and users"""
|
||||
try:
|
||||
self.sync_inbounds()
|
||||
self.sync_users()
|
||||
logger.info(f"Full sync completed for server {self.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Sync failed for server {self.name}: {e}")
|
||||
|
||||
def add_user(self, user, **kwargs):
|
||||
"""Add user to server - implemented through subscription groups"""
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
client = XrayClient(server=self.api_address)
|
||||
|
||||
# Users are added through subscription groups in the new architecture
|
||||
subscriptions = user.xray_subscriptions.filter(active=True)
|
||||
added_count = 0
|
||||
|
||||
logger.info(f"User {user.username} has {subscriptions.count()} active subscriptions")
|
||||
|
||||
if subscriptions.count() == 0:
|
||||
logger.warning(f"User {user.username} has no active Xray subscriptions - cannot add to server")
|
||||
return False
|
||||
|
||||
# Get all inbounds that this user should have access to
|
||||
inbounds_to_process = []
|
||||
for subscription in subscriptions:
|
||||
logger.info(f"Processing subscription group: {subscription.subscription_group.name}")
|
||||
for inbound in subscription.subscription_group.inbounds.all():
|
||||
if inbound not in inbounds_to_process:
|
||||
inbounds_to_process.append(inbound)
|
||||
logger.info(f"Added inbound {inbound.name} to processing list")
|
||||
|
||||
# Get existing inbounds on server
|
||||
try:
|
||||
existing_result = client.execute_command('lsi') # List inbounds
|
||||
existing_inbound_tags = set()
|
||||
if existing_result and 'inbounds' in existing_result:
|
||||
existing_inbound_tags = {ib.get('tag') for ib in existing_result['inbounds'] if ib.get('tag')}
|
||||
logger.info(f"Existing inbound tags on server: {existing_inbound_tags}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to list inbounds: {e}")
|
||||
existing_inbound_tags = set()
|
||||
|
||||
# Process each inbound
|
||||
for inbound in inbounds_to_process:
|
||||
logger.info(f"Processing inbound: {inbound.name} (protocol: {inbound.protocol})")
|
||||
|
||||
# Check if inbound exists on server
|
||||
if inbound.name not in existing_inbound_tags:
|
||||
logger.info(f"Inbound {inbound.name} doesn't exist on server, creating with all authorized users")
|
||||
|
||||
# Get all users who should have access to this inbound from database
|
||||
from vpn.models_xray import ServerInbound, UserSubscription
|
||||
server_inbound_obj, created = ServerInbound.objects.get_or_create(
|
||||
server=self, inbound=inbound, defaults={'active': True}
|
||||
)
|
||||
|
||||
# Find all users who have subscriptions that include this inbound
|
||||
users_with_access = set()
|
||||
subscriptions_for_inbound = UserSubscription.objects.filter(
|
||||
active=True,
|
||||
subscription_group__inbounds=inbound,
|
||||
subscription_group__is_active=True
|
||||
).select_related('user')
|
||||
|
||||
for subscription in subscriptions_for_inbound:
|
||||
users_with_access.add(subscription.user)
|
||||
|
||||
logger.info(f"Creating inbound {inbound.name} with {len(users_with_access)} authorized users")
|
||||
|
||||
# Create the inbound with all authorized users
|
||||
if self.deploy_inbound(inbound, users=list(users_with_access), server_inbound=server_inbound_obj):
|
||||
logger.info(f"Successfully created inbound {inbound.name} with {len(users_with_access)} users")
|
||||
added_count += 1
|
||||
existing_inbound_tags.add(inbound.name)
|
||||
else:
|
||||
logger.error(f"Failed to create inbound {inbound.name} with users")
|
||||
continue
|
||||
else:
|
||||
# Inbound exists, skip individual user addition to avoid constant recreation
|
||||
# User will be added during the next full inbound sync
|
||||
logger.info(f"Inbound {inbound.name} exists, user {user.username} will be added during next sync")
|
||||
added_count += 1
|
||||
|
||||
logger.info(f"Added user {user.username} to {added_count} inbounds on server {self.name}")
|
||||
return added_count > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add user {user.username} to server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def get_user(self, user, raw=False):
|
||||
"""Get user configurations from server"""
|
||||
try:
|
||||
configs = self.get_user_configs(user)
|
||||
if raw:
|
||||
return {
|
||||
'configs': configs,
|
||||
'total_configs': len(configs)
|
||||
}
|
||||
return configs
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user {user.username} from server {self.name}: {e}")
|
||||
return [] if not raw else {'error': str(e)}
|
||||
|
||||
def delete_user(self, user):
|
||||
"""Remove user from server"""
|
||||
try:
|
||||
removed_count = 0
|
||||
subscriptions = user.xray_subscriptions.filter(active=True)
|
||||
|
||||
for subscription in subscriptions:
|
||||
for inbound in subscription.subscription_group.inbounds.all():
|
||||
if self.remove_user_from_inbound(user, inbound):
|
||||
removed_count += 1
|
||||
|
||||
logger.info(f"Removed user {user.username} from {removed_count} inbounds on server {self.name}")
|
||||
return removed_count > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove user {user.username} from server {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
return f"Xray Server v2: {self.name}"
|
||||
|
||||
|
||||
class ServerInboundInline(admin.TabularInline):
|
||||
"""Inline for managing inbound templates on a server"""
|
||||
from vpn.models_xray import ServerInbound
|
||||
model = ServerInbound
|
||||
extra = 0
|
||||
fields = ('inbound', 'certificate', 'active')
|
||||
verbose_name = "Inbound Template"
|
||||
verbose_name_plural = "Inbound Templates"
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
"""Filter certificates for inbound selection"""
|
||||
if db_field.name == 'certificate':
|
||||
from vpn.models_xray import Certificate
|
||||
kwargs['queryset'] = Certificate.objects.filter(cert_type__in=['letsencrypt', 'custom'])
|
||||
kwargs['empty_label'] = "Auto-select by server hostname"
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
|
||||
|
||||
class XrayServerV2Admin(PolymorphicChildModelAdmin):
|
||||
base_model = XrayServerV2
|
||||
show_in_index = False
|
||||
list_display = ['name', 'client_hostname', 'api_address', 'api_enabled', 'stats_enabled', 'registration_date']
|
||||
list_filter = ['api_enabled', 'stats_enabled', 'registration_date']
|
||||
search_fields = ['name', 'client_hostname', 'comment']
|
||||
readonly_fields = ['server_type', 'registration_date', 'traffic_statistics']
|
||||
inlines = [ServerInboundInline]
|
||||
|
||||
def has_module_permission(self, request):
|
||||
"""Hide this model from the main admin index"""
|
||||
return False
|
||||
|
||||
fieldsets = [
|
||||
('Basic Information', {
|
||||
'fields': ('name', 'comment', 'server_type')
|
||||
}),
|
||||
('Connection Settings', {
|
||||
'fields': ('client_hostname', 'api_address')
|
||||
}),
|
||||
('API Settings', {
|
||||
'fields': ('api_enabled', 'stats_enabled')
|
||||
}),
|
||||
('Traffic Statistics', {
|
||||
'fields': ('traffic_statistics',),
|
||||
'description': 'Real-time traffic statistics from Xray server'
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('registration_date',),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
]
|
||||
|
||||
actions = ['sync_users', 'sync_inbounds', 'get_status']
|
||||
|
||||
def sync_users(self, request, queryset):
|
||||
from vpn.tasks import sync_server_users
|
||||
scheduled_count = 0
|
||||
for server in queryset:
|
||||
# Directly schedule the task instead of calling server.sync_users()
|
||||
# to avoid potential recursion issues
|
||||
sync_server_users.delay(server.id)
|
||||
scheduled_count += 1
|
||||
self.message_user(request, f"Scheduled user sync for {scheduled_count} servers")
|
||||
sync_users.short_description = "Sync users for selected servers"
|
||||
|
||||
def sync_inbounds(self, request, queryset):
|
||||
for server in queryset:
|
||||
server.sync_inbounds()
|
||||
self.message_user(request, f"Scheduled inbound sync for {queryset.count()} servers")
|
||||
sync_inbounds.short_description = "Sync inbounds for selected servers"
|
||||
|
||||
def get_status(self, request, queryset):
|
||||
statuses = []
|
||||
for server in queryset:
|
||||
status = server.get_server_status()
|
||||
statuses.append(f"{server.name}: {status.get('accessible', 'Unknown')}")
|
||||
self.message_user(request, f"Server statuses: {', '.join(statuses)}")
|
||||
get_status.short_description = "Check status of selected servers"
|
||||
|
||||
def traffic_statistics(self, obj):
|
||||
"""Display traffic statistics for this server"""
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
|
||||
if not obj.pk:
|
||||
return "Save server first to see statistics"
|
||||
|
||||
if not obj.api_enabled or not obj.stats_enabled:
|
||||
return "Statistics are disabled. Enable API and stats to see traffic data."
|
||||
|
||||
try:
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
from vpn.xray_api_v2.stats import StatsManager
|
||||
|
||||
client = XrayClient(server=obj.api_address)
|
||||
stats_manager = StatsManager(client)
|
||||
|
||||
# Get traffic summary
|
||||
traffic_summary = stats_manager.get_traffic_summary()
|
||||
|
||||
# Format bytes
|
||||
def format_bytes(bytes_val):
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if bytes_val < 1024.0:
|
||||
return f"{bytes_val:.1f}{unit}"
|
||||
bytes_val /= 1024.0
|
||||
return f"{bytes_val:.1f}PB"
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 15px; border-radius: 5px;">'
|
||||
|
||||
# User statistics
|
||||
users = traffic_summary.get('users', {})
|
||||
if users:
|
||||
html += '<h4 style="margin: 0 0 15px 0; color: #495057;">👥 User Traffic</h4>'
|
||||
html += '<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">'
|
||||
html += '<thead><tr style="background: #e9ecef;">'
|
||||
html += '<th style="padding: 8px; text-align: left; border: 1px solid #dee2e6;">User</th>'
|
||||
html += '<th style="padding: 8px; text-align: right; border: 1px solid #dee2e6;">Upload</th>'
|
||||
html += '<th style="padding: 8px; text-align: right; border: 1px solid #dee2e6;">Download</th>'
|
||||
html += '<th style="padding: 8px; text-align: right; border: 1px solid #dee2e6;">Total</th>'
|
||||
html += '</tr></thead><tbody>'
|
||||
|
||||
# Sort users by total traffic
|
||||
sorted_users = sorted(users.items(),
|
||||
key=lambda x: x[1].get('uplink', 0) + x[1].get('downlink', 0),
|
||||
reverse=True)
|
||||
|
||||
total_up = 0
|
||||
total_down = 0
|
||||
|
||||
for email, stats in sorted_users[:20]: # Show top 20 users
|
||||
up = stats.get('uplink', 0)
|
||||
down = stats.get('downlink', 0)
|
||||
total = up + down
|
||||
total_up += up
|
||||
total_down += down
|
||||
|
||||
html += '<tr>'
|
||||
html += f'<td style="padding: 6px; border: 1px solid #dee2e6;">{email}</td>'
|
||||
html += f'<td style="padding: 6px; text-align: right; border: 1px solid #dee2e6;">↑ {format_bytes(up)}</td>'
|
||||
html += f'<td style="padding: 6px; text-align: right; border: 1px solid #dee2e6;">↓ {format_bytes(down)}</td>'
|
||||
html += f'<td style="padding: 6px; text-align: right; border: 1px solid #dee2e6; font-weight: bold;">{format_bytes(total)}</td>'
|
||||
html += '</tr>'
|
||||
|
||||
if len(users) > 20:
|
||||
html += f'<tr><td colspan="4" style="padding: 6px; text-align: center; border: 1px solid #dee2e6; color: #6c757d;">... and {len(users) - 20} more users</td></tr>'
|
||||
|
||||
# Total row
|
||||
html += '<tr style="background: #e9ecef; font-weight: bold;">'
|
||||
html += f'<td style="padding: 8px; border: 1px solid #dee2e6;">Total ({len(users)} users)</td>'
|
||||
html += f'<td style="padding: 8px; text-align: right; border: 1px solid #dee2e6;">↑ {format_bytes(total_up)}</td>'
|
||||
html += f'<td style="padding: 8px; text-align: right; border: 1px solid #dee2e6;">↓ {format_bytes(total_down)}</td>'
|
||||
html += f'<td style="padding: 8px; text-align: right; border: 1px solid #dee2e6;">{format_bytes(total_up + total_down)}</td>'
|
||||
html += '</tr>'
|
||||
|
||||
html += '</tbody></table>'
|
||||
else:
|
||||
html += '<p style="color: #6c757d;">No user traffic data available</p>'
|
||||
|
||||
# Inbound statistics
|
||||
inbounds = traffic_summary.get('inbounds', {})
|
||||
if inbounds:
|
||||
html += '<h4 style="margin: 20px 0 15px 0; color: #495057;">📡 Inbound Traffic</h4>'
|
||||
html += '<table style="width: 100%; border-collapse: collapse;">'
|
||||
html += '<thead><tr style="background: #e9ecef;">'
|
||||
html += '<th style="padding: 8px; text-align: left; border: 1px solid #dee2e6;">Inbound</th>'
|
||||
html += '<th style="padding: 8px; text-align: right; border: 1px solid #dee2e6;">Upload</th>'
|
||||
html += '<th style="padding: 8px; text-align: right; border: 1px solid #dee2e6;">Download</th>'
|
||||
html += '<th style="padding: 8px; text-align: right; border: 1px solid #dee2e6;">Total</th>'
|
||||
html += '</tr></thead><tbody>'
|
||||
|
||||
for tag, stats in inbounds.items():
|
||||
up = stats.get('uplink', 0)
|
||||
down = stats.get('downlink', 0)
|
||||
total = up + down
|
||||
|
||||
html += '<tr>'
|
||||
html += f'<td style="padding: 6px; border: 1px solid #dee2e6;">{tag}</td>'
|
||||
html += f'<td style="padding: 6px; text-align: right; border: 1px solid #dee2e6;">↑ {format_bytes(up)}</td>'
|
||||
html += f'<td style="padding: 6px; text-align: right; border: 1px solid #dee2e6;">↓ {format_bytes(down)}</td>'
|
||||
html += f'<td style="padding: 6px; text-align: right; border: 1px solid #dee2e6; font-weight: bold;">{format_bytes(total)}</td>'
|
||||
html += '</tr>'
|
||||
|
||||
html += '</tbody></table>'
|
||||
|
||||
html += '</div>'
|
||||
|
||||
return format_html(html)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error fetching statistics: {str(e)}"
|
||||
|
||||
traffic_statistics.short_description = 'Traffic Statistics'
|
||||
|
||||
|
||||
# Register the admin class
|
||||
admin.site.register(XrayServerV2, XrayServerV2Admin)
|
||||
357
vpn/signals.py
Normal file
357
vpn/signals.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Django signals for automatic Xray server synchronization
|
||||
"""
|
||||
import logging
|
||||
from django.db.models.signals import post_save, post_delete, m2m_changed
|
||||
from django.dispatch import receiver
|
||||
from django.db import transaction
|
||||
from celery import group
|
||||
|
||||
from .models_xray import (
|
||||
Inbound,
|
||||
SubscriptionGroup,
|
||||
UserSubscription,
|
||||
Certificate,
|
||||
ServerInbound
|
||||
)
|
||||
from .server_plugins.xray_v2 import XrayServerV2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_active_xray_servers():
|
||||
"""Get all active Xray servers"""
|
||||
from .server_plugins import Server
|
||||
return [
|
||||
server.get_real_instance()
|
||||
for server in Server.objects.all()
|
||||
if hasattr(server.get_real_instance(), 'api_enabled') and
|
||||
server.get_real_instance().api_enabled
|
||||
]
|
||||
|
||||
|
||||
def schedule_inbound_sync_for_servers(inbound, servers=None):
|
||||
"""Schedule inbound deployment on servers"""
|
||||
if servers is None:
|
||||
servers = get_active_xray_servers()
|
||||
|
||||
if not servers:
|
||||
logger.warning("No active Xray servers found for inbound sync")
|
||||
return
|
||||
|
||||
logger.info(f"Scheduling inbound {inbound.name} deployment on {len(servers)} servers")
|
||||
|
||||
# Schedule deployment tasks
|
||||
from .tasks import deploy_inbound_on_server
|
||||
tasks = []
|
||||
for server in servers:
|
||||
task = deploy_inbound_on_server.s(server.id, inbound.id)
|
||||
tasks.append(task)
|
||||
|
||||
# Execute all deployments in parallel
|
||||
job = group(tasks)
|
||||
result = job.apply_async()
|
||||
|
||||
logger.info(f"Scheduled inbound deployment tasks: {result}")
|
||||
return result
|
||||
|
||||
|
||||
def schedule_user_sync_for_servers(servers=None):
|
||||
"""Schedule user sync on servers after inbound changes"""
|
||||
if servers is None:
|
||||
servers = get_active_xray_servers()
|
||||
|
||||
if not servers:
|
||||
logger.warning("No active Xray servers found for user sync")
|
||||
return
|
||||
|
||||
logger.info(f"Scheduling user sync on {len(servers)} servers")
|
||||
|
||||
# Schedule user sync tasks
|
||||
from .tasks import sync_server_users
|
||||
tasks = []
|
||||
for server in servers:
|
||||
task = sync_server_users.s(server.id)
|
||||
tasks.append(task)
|
||||
|
||||
# Execute all user syncs in parallel with delay to allow inbound sync to complete
|
||||
job = group(tasks)
|
||||
result = job.apply_async(countdown=10) # 10 second delay
|
||||
|
||||
logger.info(f"Scheduled user sync tasks: {result}")
|
||||
return result
|
||||
|
||||
|
||||
@receiver(post_save, sender=Inbound)
|
||||
def inbound_created_or_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When an inbound is created or updated, deploy it to all servers
|
||||
where subscription groups contain this inbound
|
||||
"""
|
||||
if created:
|
||||
logger.info(f"New inbound {instance.name} created, will deploy when added to groups")
|
||||
else:
|
||||
logger.info(f"Inbound {instance.name} updated, scheduling redeployment")
|
||||
|
||||
# Get all subscription groups that contain this inbound
|
||||
groups = instance.subscriptiongroup_set.filter(is_active=True)
|
||||
|
||||
if groups.exists():
|
||||
# Get all servers that should have this inbound
|
||||
servers = get_active_xray_servers()
|
||||
|
||||
# Schedule redeployment
|
||||
transaction.on_commit(lambda: schedule_inbound_sync_for_servers(instance, servers))
|
||||
|
||||
# Schedule user sync after inbound update
|
||||
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Inbound)
|
||||
def inbound_deleted(sender, instance, **kwargs):
|
||||
"""
|
||||
When an inbound is deleted, remove it from all servers
|
||||
"""
|
||||
logger.info(f"Inbound {instance.name} deleted, scheduling removal from servers")
|
||||
|
||||
# Schedule removal from all servers
|
||||
from .tasks import remove_inbound_from_server
|
||||
servers = get_active_xray_servers()
|
||||
tasks = []
|
||||
|
||||
for server in servers:
|
||||
task = remove_inbound_from_server.s(server.id, instance.name)
|
||||
tasks.append(task)
|
||||
|
||||
if tasks:
|
||||
job = group(tasks)
|
||||
transaction.on_commit(lambda: job.apply_async())
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=SubscriptionGroup.inbounds.through)
|
||||
def subscription_group_inbounds_changed(sender, instance, action, pk_set, **kwargs):
|
||||
"""
|
||||
When inbounds are added/removed from subscription groups,
|
||||
automatically deploy/remove them on servers
|
||||
"""
|
||||
if action in ['post_add', 'post_remove']:
|
||||
logger.info(f"Subscription group {instance.name} inbounds changed: {action}")
|
||||
|
||||
if action == 'post_add' and pk_set:
|
||||
# Inbounds were added to the group - deploy them
|
||||
inbounds = Inbound.objects.filter(pk__in=pk_set)
|
||||
servers = get_active_xray_servers()
|
||||
|
||||
for inbound in inbounds:
|
||||
logger.info(f"Deploying inbound {inbound.name} (added to group {instance.name})")
|
||||
transaction.on_commit(
|
||||
lambda inb=inbound: schedule_inbound_sync_for_servers(inb, servers)
|
||||
)
|
||||
|
||||
# Schedule user sync after all inbounds are deployed
|
||||
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
|
||||
|
||||
elif action == 'post_remove' and pk_set:
|
||||
# Inbounds were removed from the group
|
||||
inbounds = Inbound.objects.filter(pk__in=pk_set)
|
||||
|
||||
for inbound in inbounds:
|
||||
# Check if inbound is still used by other active groups
|
||||
other_groups = inbound.subscriptiongroup_set.filter(is_active=True).exclude(id=instance.id)
|
||||
|
||||
if not other_groups.exists():
|
||||
# Inbound is not used by any other group - remove from servers
|
||||
logger.info(f"Removing inbound {inbound.name} from servers (no longer in any group)")
|
||||
from .tasks import remove_inbound_from_server
|
||||
servers = get_active_xray_servers()
|
||||
tasks = []
|
||||
|
||||
for server in servers:
|
||||
task = remove_inbound_from_server.s(server.id, inbound.name)
|
||||
tasks.append(task)
|
||||
|
||||
if tasks:
|
||||
job = group(tasks)
|
||||
transaction.on_commit(lambda: job.apply_async())
|
||||
|
||||
|
||||
@receiver(post_save, sender=ServerInbound)
|
||||
def server_inbound_created_or_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When ServerInbound is created or updated, immediately deploy the inbound
|
||||
template to the server (not wait for subscription group changes)
|
||||
"""
|
||||
logger.info(f"ServerInbound {instance.inbound.name} {'created' if created else 'updated'} for server {instance.server.name}")
|
||||
|
||||
if instance.active:
|
||||
# Deploy inbound immediately
|
||||
servers = [instance.server.get_real_instance()]
|
||||
transaction.on_commit(
|
||||
lambda: schedule_inbound_sync_for_servers(instance.inbound, servers)
|
||||
)
|
||||
|
||||
# Schedule user sync after inbound deployment
|
||||
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
|
||||
else:
|
||||
# Remove inbound from server if deactivated
|
||||
logger.info(f"Removing inbound {instance.inbound.name} from server {instance.server.name} (deactivated)")
|
||||
from .tasks import remove_inbound_from_server
|
||||
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
|
||||
transaction.on_commit(lambda: task.apply_async())
|
||||
|
||||
|
||||
@receiver(post_delete, sender=ServerInbound)
|
||||
def server_inbound_deleted(sender, instance, **kwargs):
|
||||
"""
|
||||
When ServerInbound is deleted, remove the inbound from the server
|
||||
"""
|
||||
logger.info(f"ServerInbound {instance.inbound.name} deleted from server {instance.server.name}")
|
||||
|
||||
from .tasks import remove_inbound_from_server
|
||||
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
|
||||
transaction.on_commit(lambda: task.apply_async())
|
||||
|
||||
|
||||
@receiver(post_save, sender=UserSubscription)
|
||||
def user_subscription_created_or_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When user subscription is created or updated, sync the user to servers
|
||||
"""
|
||||
if created:
|
||||
logger.info(f"New subscription created for user {instance.user.username} in group {instance.subscription_group.name}")
|
||||
else:
|
||||
logger.info(f"Subscription updated for user {instance.user.username} in group {instance.subscription_group.name}")
|
||||
|
||||
if instance.active:
|
||||
# Schedule user sync on all servers
|
||||
servers = get_active_xray_servers()
|
||||
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
|
||||
|
||||
|
||||
@receiver(post_delete, sender=UserSubscription)
|
||||
def user_subscription_deleted(sender, instance, **kwargs):
|
||||
"""
|
||||
When user subscription is deleted, remove user from servers if no other subscriptions
|
||||
"""
|
||||
logger.info(f"Subscription deleted for user {instance.user.username} in group {instance.subscription_group.name}")
|
||||
|
||||
# Check if user has other active subscriptions
|
||||
other_subscriptions = UserSubscription.objects.filter(
|
||||
user=instance.user,
|
||||
active=True
|
||||
).exclude(id=instance.id).exists()
|
||||
|
||||
if not other_subscriptions:
|
||||
# User has no more subscriptions - remove from all servers
|
||||
logger.info(f"User {instance.user.username} has no more subscriptions, removing from servers")
|
||||
from .tasks import remove_user_from_server
|
||||
servers = get_active_xray_servers()
|
||||
tasks = []
|
||||
|
||||
for server in servers:
|
||||
task = remove_user_from_server.s(server.id, instance.user.id)
|
||||
tasks.append(task)
|
||||
|
||||
if tasks:
|
||||
job = group(tasks)
|
||||
transaction.on_commit(lambda: job.apply_async())
|
||||
|
||||
|
||||
@receiver(post_save, sender=Certificate)
|
||||
def certificate_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When certificate is updated, redeploy all inbounds that use it
|
||||
"""
|
||||
if not created and instance.certificate_pem: # Only on updates when cert is available
|
||||
logger.info(f"Certificate {instance.domain} updated, redeploying dependent inbounds")
|
||||
|
||||
# Find all ServerInbound deployments that use this certificate
|
||||
from vpn.models_xray import ServerInbound
|
||||
server_inbounds = ServerInbound.objects.filter(certificate=instance).select_related('server', 'inbound')
|
||||
|
||||
# Group by server for efficient syncing
|
||||
servers_to_sync = set()
|
||||
for server_inbound in server_inbounds:
|
||||
servers_to_sync.add(server_inbound.server)
|
||||
|
||||
# Schedule sync for each affected server
|
||||
for server in servers_to_sync:
|
||||
# Only sync if server has sync_inbounds method (Xray servers)
|
||||
real_server = server.get_real_instance()
|
||||
if hasattr(real_server, 'sync_inbounds'):
|
||||
transaction.on_commit(
|
||||
lambda srv=real_server: srv.sync_inbounds()
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Server {server.name} does not support inbound sync")
|
||||
|
||||
|
||||
@receiver(post_save, sender=SubscriptionGroup)
|
||||
def subscription_group_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When subscription group is created/updated, sync its state
|
||||
"""
|
||||
if created:
|
||||
logger.info(f"New subscription group {instance.name} created")
|
||||
else:
|
||||
logger.info(f"Subscription group {instance.name} updated")
|
||||
|
||||
if not instance.is_active:
|
||||
# Group was deactivated - remove its inbounds from servers if not used elsewhere
|
||||
logger.info(f"Subscription group {instance.name} deactivated, checking inbounds")
|
||||
|
||||
for inbound in instance.inbounds.all():
|
||||
# Check if inbound is used by other active groups
|
||||
other_groups = inbound.subscriptiongroup_set.filter(is_active=True).exclude(id=instance.id)
|
||||
|
||||
if not other_groups.exists():
|
||||
# Remove inbound from servers
|
||||
logger.info(f"Removing inbound {inbound.name} from servers (group deactivated)")
|
||||
from .tasks import remove_inbound_from_server
|
||||
servers = get_active_xray_servers()
|
||||
tasks = []
|
||||
|
||||
for server in servers:
|
||||
task = remove_inbound_from_server.s(server.id, inbound.name)
|
||||
tasks.append(task)
|
||||
|
||||
if tasks:
|
||||
job = group(tasks)
|
||||
transaction.on_commit(lambda: job.apply_async())
|
||||
|
||||
|
||||
@receiver(post_save, sender=ServerInbound)
|
||||
def server_inbound_created_or_updated(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When ServerInbound is created or updated, immediately deploy the inbound
|
||||
template to the server (not wait for subscription group changes)
|
||||
"""
|
||||
logger.info(f"ServerInbound {instance.inbound.name} {'created' if created else 'updated'} for server {instance.server.name}")
|
||||
|
||||
if instance.active:
|
||||
# Deploy inbound immediately
|
||||
servers = [instance.server.get_real_instance()]
|
||||
transaction.on_commit(
|
||||
lambda: schedule_inbound_sync_for_servers(instance.inbound, servers)
|
||||
)
|
||||
|
||||
# Schedule user sync after inbound deployment
|
||||
transaction.on_commit(lambda: schedule_user_sync_for_servers(servers))
|
||||
else:
|
||||
# Remove inbound from server if deactivated
|
||||
logger.info(f"Removing inbound {instance.inbound.name} from server {instance.server.name} (deactivated)")
|
||||
from .tasks import remove_inbound_from_server
|
||||
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
|
||||
transaction.on_commit(lambda: task.apply_async())
|
||||
|
||||
|
||||
@receiver(post_delete, sender=ServerInbound)
|
||||
def server_inbound_deleted(sender, instance, **kwargs):
|
||||
"""
|
||||
When ServerInbound is deleted, remove the inbound from the server
|
||||
"""
|
||||
logger.info(f"ServerInbound {instance.inbound.name} deleted from server {instance.server.name}")
|
||||
|
||||
from .tasks import remove_inbound_from_server
|
||||
task = remove_inbound_from_server.s(instance.server.id, instance.inbound.name)
|
||||
transaction.on_commit(lambda: task.apply_async())
|
||||
711
vpn/tasks.py
711
vpn/tasks.py
@@ -54,7 +54,7 @@ def cleanup_task_logs():
|
||||
def sync_xray_inbounds(self, server_id):
|
||||
"""Stage 1: Sync inbounds for Xray server."""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.server_plugins.xray_core import XrayCoreServer
|
||||
from vpn.server_plugins.xray_v2 import XrayServerV2
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
@@ -63,7 +63,7 @@ def sync_xray_inbounds(self, server_id):
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
|
||||
if not isinstance(server.get_real_instance(), XrayCoreServer):
|
||||
if not isinstance(server.get_real_instance(), XrayServerV2):
|
||||
error_message = f"Server {server.name} is not an Xray server"
|
||||
logger.error(error_message)
|
||||
create_task_log(task_id, "sync_xray_inbounds", "Wrong server type", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
@@ -105,7 +105,7 @@ def sync_xray_inbounds(self, server_id):
|
||||
def sync_xray_users(self, server_id):
|
||||
"""Stage 2: Sync users for Xray server."""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.server_plugins.xray_core import XrayCoreServer
|
||||
from vpn.server_plugins.xray_v2 import XrayServerV2
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
@@ -114,7 +114,7 @@ def sync_xray_users(self, server_id):
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
|
||||
if not isinstance(server.get_real_instance(), XrayCoreServer):
|
||||
if not isinstance(server.get_real_instance(), XrayServerV2):
|
||||
error_message = f"Server {server.name} is not an Xray server"
|
||||
logger.error(error_message)
|
||||
create_task_log(task_id, "sync_xray_users", "Wrong server type", 'FAILURE', server=server, message=error_message, execution_time=time.time() - start_time)
|
||||
@@ -123,8 +123,29 @@ def sync_xray_users(self, server_id):
|
||||
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}")
|
||||
|
||||
# Don't call sync_users() which would create another task - directly perform the sync
|
||||
from vpn.models import User
|
||||
from vpn.models_xray import UserSubscription
|
||||
|
||||
# Get all users who should have access to this server
|
||||
users_to_sync = User.objects.filter(
|
||||
xray_subscriptions__active=True,
|
||||
xray_subscriptions__subscription_group__is_active=True
|
||||
).distinct()
|
||||
|
||||
real_server = server.get_real_instance()
|
||||
user_result = real_server.sync_users()
|
||||
|
||||
added_count = 0
|
||||
failed_count = 0
|
||||
for user in users_to_sync:
|
||||
try:
|
||||
if real_server.add_user(user):
|
||||
added_count += 1
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"Failed to sync user {user.username} on server {server.name}: {e}")
|
||||
|
||||
user_result = {"users_added": added_count, "total_users": users_to_sync.count(), "failed": failed_count}
|
||||
|
||||
success_message = f"Successfully synced {user_result.get('users_added', 0)} users for {server.name}"
|
||||
logger.info(f"{success_message}. Result: {user_result}")
|
||||
@@ -247,48 +268,37 @@ def sync_users(self, server_id):
|
||||
|
||||
create_task_log(task_id, "sync_all_users_on_server", f"Found {user_count} users to sync", 'STARTED', server=server, message=f"Users: {user_list}")
|
||||
|
||||
# For Xray servers, use separate staged sync tasks
|
||||
from vpn.server_plugins.xray_core import XrayCoreServer
|
||||
if isinstance(server.get_real_instance(), XrayCoreServer):
|
||||
logger.info(f"Performing staged sync for Xray server {server.name}")
|
||||
# For Xray servers, use the sync_server_users task to avoid recursion
|
||||
from vpn.server_plugins.xray_v2 import XrayServerV2
|
||||
if isinstance(server.get_real_instance(), XrayServerV2):
|
||||
logger.info(f"Using XrayServerV2 sync for server {server.name}")
|
||||
# Call sync_server_users directly to perform the actual sync
|
||||
# Avoid calling server.sync_users() which would create another task
|
||||
from vpn.models import User
|
||||
from vpn.models_xray import UserSubscription
|
||||
|
||||
# Get all users who should have access to this server
|
||||
users_to_sync = User.objects.filter(
|
||||
xray_subscriptions__active=True,
|
||||
xray_subscriptions__subscription_group__is_active=True
|
||||
).distinct()
|
||||
|
||||
added_count = 0
|
||||
failed_count = 0
|
||||
for user in users_to_sync:
|
||||
try:
|
||||
# Stage 1: Sync inbounds first
|
||||
logger.info(f"Stage 1: Syncing inbounds for {server.name}")
|
||||
inbound_task = sync_xray_inbounds.apply_async(args=[server.id])
|
||||
inbound_result = inbound_task.get() # Wait for completion
|
||||
logger.info(f"Inbound sync result for {server.name}: {inbound_result}")
|
||||
|
||||
if "error" in inbound_result:
|
||||
logger.error(f"Inbound sync failed, skipping user sync: {inbound_result['error']}")
|
||||
sync_result = inbound_result
|
||||
else:
|
||||
# Stage 2: Sync users after inbounds are ready
|
||||
logger.info(f"Stage 2: Syncing users for {server.name}")
|
||||
user_task = sync_xray_users.apply_async(args=[server.id])
|
||||
user_result = user_task.get() # Wait for completion
|
||||
logger.info(f"User sync result for {server.name}: {user_result}")
|
||||
|
||||
# Combine results
|
||||
if "error" in user_result:
|
||||
sync_result = {
|
||||
"status": "Staged sync partially failed",
|
||||
"inbounds": inbound_result.get("inbounds", []),
|
||||
"users": f"User sync failed: {user_result['error']}"
|
||||
}
|
||||
else:
|
||||
sync_result = {
|
||||
"status": "Staged sync completed successfully",
|
||||
"inbounds": inbound_result.get("inbounds", []),
|
||||
"users": f"Added {user_result.get('users_added', 0)} users across all inbounds"
|
||||
}
|
||||
|
||||
if server.add_user(user):
|
||||
added_count += 1
|
||||
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()
|
||||
failed_count += 1
|
||||
logger.error(f"Failed to sync user {user.username} on server {server.name}: {e}")
|
||||
|
||||
sync_result = {"users_added": added_count, "total_users": users_to_sync.count(), "failed": failed_count}
|
||||
logger.info(f"Directly synced {added_count} users for Xray server {server.name}")
|
||||
else:
|
||||
# For non-Xray servers, just sync users
|
||||
sync_result = server.sync_users()
|
||||
# For non-Xray servers, sync users directly (non-Xray servers should not create tasks)
|
||||
real_server = server.get_real_instance()
|
||||
sync_result = real_server.sync_users()
|
||||
|
||||
# Check if sync was successful (can be boolean or dict/string)
|
||||
sync_successful = bool(sync_result) and (
|
||||
@@ -567,3 +577,616 @@ def sync_user(self, user_id, server_id):
|
||||
raise TaskFailedException(message=f"Errors during task: {errors}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@shared_task(name="sync_user_xray_access", bind=True)
|
||||
def sync_user_xray_access(self, user_id, server_id):
|
||||
"""
|
||||
Sync user's Xray access based on subscription groups.
|
||||
Creates inbounds on server if needed and adds user to them.
|
||||
"""
|
||||
from .models import User, Server
|
||||
from .models_xray import SubscriptionGroup, Inbound
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
from vpn.server_plugins.xray_v2 import XrayServerV2
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
server = Server.objects.get(id=server_id)
|
||||
|
||||
# Get server instance
|
||||
real_server = server.get_real_instance()
|
||||
if not isinstance(real_server, XrayServerV2):
|
||||
raise ValueError(f"Server {server.name} is not an Xray v2 server")
|
||||
|
||||
create_task_log(
|
||||
task_id, "sync_user_xray_access",
|
||||
f"Starting Xray sync for {user.username} on {server.name}",
|
||||
'STARTED', server=server, user=user
|
||||
)
|
||||
|
||||
# Get user's active subscription groups
|
||||
user_groups = SubscriptionGroup.objects.filter(
|
||||
usersubscription__user=user,
|
||||
usersubscription__active=True,
|
||||
is_active=True
|
||||
).prefetch_related('inbounds')
|
||||
|
||||
if not user_groups.exists():
|
||||
logger.info(f"User {user.username} has no active subscriptions")
|
||||
return {"status": "No active subscriptions"}
|
||||
|
||||
# Collect all inbounds from user's groups
|
||||
user_inbounds = Inbound.objects.filter(
|
||||
subscriptiongroup__in=user_groups
|
||||
).distinct()
|
||||
|
||||
logger.info(f"User {user.username} has access to {user_inbounds.count()} inbounds")
|
||||
|
||||
# Connect to Xray server
|
||||
client = XrayClient(real_server.api_address)
|
||||
|
||||
# Get existing inbounds on server
|
||||
try:
|
||||
existing_result = client.execute_command('lsi') # List inbounds
|
||||
existing_inbounds = existing_result.get('inbounds', []) if existing_result else []
|
||||
existing_tags = {ib.get('tag') for ib in existing_inbounds if ib.get('tag')}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to list existing inbounds: {e}")
|
||||
existing_tags = set()
|
||||
|
||||
results = {
|
||||
'inbounds_created': [],
|
||||
'users_added': [],
|
||||
'errors': []
|
||||
}
|
||||
|
||||
# Process each inbound
|
||||
for inbound in user_inbounds:
|
||||
try:
|
||||
# Check if inbound exists on server
|
||||
if inbound.name not in existing_tags:
|
||||
logger.info(f"Creating inbound {inbound.name} on server")
|
||||
|
||||
# Build inbound configuration
|
||||
if not inbound.full_config:
|
||||
inbound.build_config()
|
||||
inbound.save()
|
||||
|
||||
# Add inbound to server
|
||||
client.execute_command('adi', json_files=[inbound.full_config])
|
||||
results['inbounds_created'].append(inbound.name)
|
||||
|
||||
# Add user to inbound
|
||||
logger.info(f"Adding user {user.username} to inbound {inbound.name}")
|
||||
|
||||
# Create user config based on protocol
|
||||
import uuid
|
||||
|
||||
# Generate user UUID based on username and inbound
|
||||
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
|
||||
|
||||
if inbound.protocol == 'vless':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{server.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
elif inbound.protocol == 'vmess':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{server.name}",
|
||||
"id": user_uuid,
|
||||
"level": 0,
|
||||
"alterId": 0
|
||||
}
|
||||
elif inbound.protocol == 'trojan':
|
||||
user_config = {
|
||||
"email": f"{user.username}@{server.name}",
|
||||
"password": user_uuid,
|
||||
"level": 0
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Unsupported protocol: {inbound.protocol}")
|
||||
continue
|
||||
|
||||
# Add user to inbound
|
||||
add_request = {
|
||||
"inboundTag": inbound.name,
|
||||
"user": user_config
|
||||
}
|
||||
|
||||
client.execute_command('adu', json_files=[add_request])
|
||||
results['users_added'].append(f"{user.username} -> {inbound.name}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error processing inbound {inbound.name}: {e}"
|
||||
logger.error(error_msg)
|
||||
results['errors'].append(error_msg)
|
||||
|
||||
# Log results
|
||||
success_msg = (
|
||||
f"Xray sync completed for {user.username}: "
|
||||
f"Created {len(results['inbounds_created'])} inbounds, "
|
||||
f"Added user to {len(results['users_added'])} inbounds"
|
||||
)
|
||||
|
||||
create_task_log(
|
||||
task_id, "sync_user_xray_access",
|
||||
"Xray sync completed", 'SUCCESS',
|
||||
server=server, user=user,
|
||||
message=success_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error in Xray sync: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
create_task_log(
|
||||
task_id, "sync_user_xray_access",
|
||||
"Xray sync failed", 'FAILURE',
|
||||
message=error_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(name="sync_server_users", bind=True)
|
||||
def sync_server_users(self, server_id):
|
||||
"""
|
||||
Sync all users for a specific Xray server.
|
||||
This is called by XrayServerV2.sync_users()
|
||||
"""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.models import User, ACL
|
||||
from vpn.models_xray import UserSubscription
|
||||
|
||||
start_time = datetime.now()
|
||||
task_id = self.request.id
|
||||
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
real_server = server.get_real_instance()
|
||||
|
||||
# Create initial log entry
|
||||
create_task_log(
|
||||
task_id=task_id,
|
||||
task_name='sync_server_users',
|
||||
action=f'Starting user sync for server {server.name}',
|
||||
status='STARTED',
|
||||
server=server
|
||||
)
|
||||
|
||||
# Get all users who should have access to this server
|
||||
# For Xray v2, users access through subscription groups
|
||||
users_to_sync = User.objects.filter(
|
||||
xray_subscriptions__active=True,
|
||||
xray_subscriptions__subscription_group__is_active=True
|
||||
).distinct()
|
||||
|
||||
logger.info(f"Syncing {users_to_sync.count()} users for Xray server {server.name}")
|
||||
|
||||
added_count = 0
|
||||
failed_count = 0
|
||||
for user in users_to_sync:
|
||||
try:
|
||||
if real_server.add_user(user):
|
||||
added_count += 1
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"Failed to sync user {user.username} on server {server.name}: {e}")
|
||||
|
||||
# Calculate execution time
|
||||
execution_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# Create success log
|
||||
create_task_log(
|
||||
task_id=task_id,
|
||||
task_name='sync_server_users',
|
||||
action=f'Completed user sync for server {server.name}',
|
||||
status='SUCCESS',
|
||||
server=server,
|
||||
message=f'Synced {added_count} of {users_to_sync.count()} users. Failed: {failed_count}',
|
||||
execution_time=execution_time
|
||||
)
|
||||
|
||||
logger.info(f"Successfully synced {added_count} users for server {server.name}")
|
||||
return {"users_added": added_count, "total_users": users_to_sync.count(), "failed": failed_count}
|
||||
|
||||
except Exception as e:
|
||||
# Calculate execution time
|
||||
execution_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# Create failure log
|
||||
create_task_log(
|
||||
task_id=task_id,
|
||||
task_name='sync_server_users',
|
||||
action=f'Failed to sync users for server {server_id}',
|
||||
status='FAILURE',
|
||||
message=str(e),
|
||||
execution_time=execution_time
|
||||
)
|
||||
|
||||
logger.error(f"Error syncing users for server {server_id}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(name="sync_server_inbounds", bind=True)
|
||||
def sync_server_inbounds(self, server_id, auto_sync_users=True):
|
||||
"""
|
||||
Sync all inbounds for a specific Xray server.
|
||||
This is called by XrayServerV2.sync_inbounds()
|
||||
"""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.models_xray import SubscriptionGroup, ServerInbound, UserSubscription
|
||||
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
real_server = server.get_real_instance()
|
||||
|
||||
# Get all subscription groups
|
||||
groups = SubscriptionGroup.objects.filter(is_active=True).prefetch_related('inbounds')
|
||||
|
||||
deployed_count = 0
|
||||
for group in groups:
|
||||
for inbound in group.inbounds.all():
|
||||
try:
|
||||
# Get users for this inbound
|
||||
users_with_access = []
|
||||
group_users = [
|
||||
sub.user for sub in
|
||||
UserSubscription.objects.filter(
|
||||
subscription_group=group,
|
||||
active=True
|
||||
).select_related('user')
|
||||
]
|
||||
users_with_access.extend(group_users)
|
||||
|
||||
# Remove duplicates
|
||||
users_with_access = list(set(users_with_access))
|
||||
|
||||
# Deploy inbound with users
|
||||
if real_server.deploy_inbound(inbound, users=users_with_access):
|
||||
deployed_count += 1
|
||||
|
||||
# Mark as deployed
|
||||
ServerInbound.objects.update_or_create(
|
||||
server=server,
|
||||
inbound=inbound,
|
||||
defaults={'active': True}
|
||||
)
|
||||
|
||||
logger.info(f"Deployed inbound {inbound.name} with {len(users_with_access)} users on server {server.name}")
|
||||
else:
|
||||
logger.error(f"Failed to deploy inbound {inbound.name} on server {server.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to deploy inbound {inbound.name} on server {server.name}: {e}")
|
||||
|
||||
logger.info(f"Successfully deployed {deployed_count} inbounds on server {server.name}")
|
||||
|
||||
# Don't automatically sync users to avoid loops
|
||||
# Users are already added when deploying inbounds
|
||||
logger.info(f"Inbound sync completed for server {server.name}, users were already synced during deployment")
|
||||
|
||||
return {"inbounds_deployed": deployed_count, "auto_sync_users": auto_sync_users}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing inbounds for server {server_id}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(name="deploy_inbound_on_server", bind=True)
|
||||
def deploy_inbound_on_server(self, server_id, inbound_id):
|
||||
"""
|
||||
Deploy a specific inbound on a specific server
|
||||
"""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.models_xray import Inbound
|
||||
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
real_server = server.get_real_instance()
|
||||
inbound = Inbound.objects.get(id=inbound_id)
|
||||
|
||||
logger.info(f"Deploying inbound {inbound.name} on server {server.name}")
|
||||
|
||||
# Get all users that should have access to this inbound
|
||||
from vpn.models_xray import UserSubscription
|
||||
users_with_access = []
|
||||
|
||||
# Find users through subscription groups
|
||||
for group in inbound.subscriptiongroup_set.filter(is_active=True):
|
||||
group_users = [
|
||||
sub.user for sub in
|
||||
UserSubscription.objects.filter(
|
||||
subscription_group=group,
|
||||
active=True
|
||||
).select_related('user')
|
||||
]
|
||||
users_with_access.extend(group_users)
|
||||
|
||||
# Remove duplicates
|
||||
users_with_access = list(set(users_with_access))
|
||||
|
||||
logger.info(f"Deploying inbound {inbound.name} with {len(users_with_access)} users")
|
||||
|
||||
# Deploy inbound with users
|
||||
if real_server.deploy_inbound(inbound, users=users_with_access):
|
||||
# Mark as deployed
|
||||
from vpn.models_xray import ServerInbound
|
||||
ServerInbound.objects.update_or_create(
|
||||
server=server,
|
||||
inbound=inbound,
|
||||
defaults={'active': True}
|
||||
)
|
||||
logger.info(f"Successfully deployed inbound {inbound.name} on server {server.name}")
|
||||
return {"success": True, "inbound": inbound.name, "server": server.name, "users": len(users_with_access)}
|
||||
else:
|
||||
logger.error(f"Failed to deploy inbound {inbound.name} on server {server.name}")
|
||||
return {"success": False, "inbound": inbound.name, "server": server.name, "error": "Deployment failed"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deploying inbound {inbound_id} on server {server_id}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@shared_task(name="remove_inbound_from_server", bind=True)
|
||||
def remove_inbound_from_server(self, server_id, inbound_name):
|
||||
"""
|
||||
Remove a specific inbound from a specific server
|
||||
"""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.xray_api_v2.client import XrayClient
|
||||
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
real_server = server.get_real_instance()
|
||||
|
||||
logger.info(f"Removing inbound {inbound_name} from server {server.name}")
|
||||
|
||||
# Remove inbound using Xray API
|
||||
client = XrayClient(server=real_server.api_address)
|
||||
result = client.remove_inbound(inbound_name)
|
||||
|
||||
# Remove from ServerInbound tracking
|
||||
from vpn.models_xray import ServerInbound, Inbound
|
||||
try:
|
||||
inbound = Inbound.objects.get(name=inbound_name)
|
||||
ServerInbound.objects.filter(server=server, inbound=inbound).delete()
|
||||
except Inbound.DoesNotExist:
|
||||
pass # Inbound was already deleted from Django
|
||||
|
||||
logger.info(f"Successfully removed inbound {inbound_name} from server {server.name}")
|
||||
return {"success": True, "inbound": inbound_name, "server": server.name}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing inbound {inbound_name} from server {server_id}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@shared_task(name="remove_user_from_server", bind=True)
|
||||
def remove_user_from_server(self, server_id, user_id):
|
||||
"""
|
||||
Remove a specific user from a specific server
|
||||
"""
|
||||
from vpn.server_plugins import Server
|
||||
from vpn.models import User
|
||||
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
real_server = server.get_real_instance()
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
logger.info(f"Removing user {user.username} from server {server.name}")
|
||||
|
||||
result = real_server.delete_user(user)
|
||||
|
||||
if result:
|
||||
logger.info(f"Successfully removed user {user.username} from server {server.name}")
|
||||
return {"success": True, "user": user.username, "server": server.name}
|
||||
else:
|
||||
logger.warning(f"Failed to remove user {user.username} from server {server.name}")
|
||||
return {"success": False, "user": user.username, "server": server.name, "error": "Removal failed"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing user {user_id} from server {server_id}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@shared_task(name="generate_certificate_task", bind=True)
|
||||
def generate_certificate_task(self, certificate_id):
|
||||
"""
|
||||
Generate Let's Encrypt certificate for a domain
|
||||
"""
|
||||
from .models_xray import Certificate
|
||||
from vpn.letsencrypt.letsencrypt_dns import get_certificate_for_domain
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
|
||||
try:
|
||||
cert = Certificate.objects.get(id=certificate_id)
|
||||
|
||||
create_task_log(
|
||||
task_id, "generate_certificate_task",
|
||||
f"Starting certificate generation for {cert.domain}",
|
||||
'STARTED'
|
||||
)
|
||||
|
||||
# Check if we have credentials
|
||||
if not cert.credentials:
|
||||
raise ValueError(f"No credentials configured for {cert.domain}")
|
||||
|
||||
# Get Cloudflare token from credentials
|
||||
cf_token = cert.credentials.get_credential('api_token')
|
||||
if not cf_token:
|
||||
raise ValueError(f"No Cloudflare API token found for {cert.domain}")
|
||||
|
||||
logger.info(f"Generating certificate for {cert.domain} using email {cert.acme_email}")
|
||||
|
||||
# Request certificate using the library function
|
||||
cert_pem, key_pem = get_certificate_for_domain(
|
||||
domain=cert.domain,
|
||||
email=cert.acme_email,
|
||||
cloudflare_token=cf_token,
|
||||
staging=False # Production certificate
|
||||
)
|
||||
|
||||
# Update certificate object
|
||||
cert.certificate_pem = cert_pem
|
||||
cert.private_key_pem = key_pem
|
||||
cert.expires_at = timezone.now() + timedelta(days=90) # Let's Encrypt certs are valid for 90 days
|
||||
cert.last_renewed = timezone.now()
|
||||
cert.save()
|
||||
|
||||
success_msg = f"Certificate for {cert.domain} generated successfully"
|
||||
logger.info(success_msg)
|
||||
|
||||
create_task_log(
|
||||
task_id, "generate_certificate_task",
|
||||
"Certificate generated", 'SUCCESS',
|
||||
message=success_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
return {"status": "success", "domain": cert.domain}
|
||||
|
||||
except Certificate.DoesNotExist:
|
||||
error_msg = f"Certificate with id {certificate_id} not found"
|
||||
logger.error(error_msg)
|
||||
|
||||
create_task_log(
|
||||
task_id, "generate_certificate_task",
|
||||
"Certificate not found", 'FAILURE',
|
||||
message=error_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to generate certificate: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
create_task_log(
|
||||
task_id, "generate_certificate_task",
|
||||
"Certificate generation failed", 'FAILURE',
|
||||
message=error_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(name="renew_certificates", bind=True)
|
||||
def renew_certificates(self):
|
||||
"""
|
||||
Check and renew certificates that are about to expire.
|
||||
"""
|
||||
from .models_xray import Certificate
|
||||
from .letsencrypt import get_certificate_for_domain
|
||||
from datetime import datetime
|
||||
|
||||
start_time = time.time()
|
||||
task_id = self.request.id
|
||||
|
||||
create_task_log(task_id, "renew_certificates", "Starting certificate renewal check", 'STARTED')
|
||||
|
||||
try:
|
||||
# Get certificates that need renewal
|
||||
certs_to_renew = Certificate.objects.filter(
|
||||
auto_renew=True,
|
||||
cert_type='letsencrypt'
|
||||
)
|
||||
|
||||
renewed_count = 0
|
||||
errors = []
|
||||
|
||||
for cert in certs_to_renew:
|
||||
if not cert.needs_renewal:
|
||||
continue
|
||||
|
||||
try:
|
||||
logger.info(f"Renewing certificate for {cert.domain}")
|
||||
|
||||
# Check if we have credentials
|
||||
if not cert.credentials:
|
||||
logger.warning(f"No credentials configured for {cert.domain}")
|
||||
continue
|
||||
|
||||
# Get Cloudflare token from credentials
|
||||
cf_token = cert.credentials.get_credential('api_token')
|
||||
cf_email = cert.credentials.get_credential('email', 'admin@example.com')
|
||||
|
||||
if not cf_token:
|
||||
logger.error(f"No Cloudflare API token found for {cert.domain}")
|
||||
continue
|
||||
|
||||
# Renew certificate
|
||||
cert_pem, key_pem = get_certificate_for_domain(
|
||||
domain=cert.domain,
|
||||
email=cf_email,
|
||||
cloudflare_token=cf_token,
|
||||
staging=False # Production certificate
|
||||
)
|
||||
|
||||
# Update certificate
|
||||
cert.certificate_pem = cert_pem
|
||||
cert.private_key_pem = key_pem
|
||||
cert.last_renewed = datetime.now()
|
||||
cert.expires_at = datetime.now() + timedelta(days=90) # Let's Encrypt certs are valid for 90 days
|
||||
cert.save()
|
||||
|
||||
renewed_count += 1
|
||||
logger.info(f"Successfully renewed certificate for {cert.domain}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to renew certificate for {cert.domain}: {e}"
|
||||
logger.error(error_msg)
|
||||
errors.append(error_msg)
|
||||
|
||||
# Summary
|
||||
if renewed_count > 0 or errors:
|
||||
summary = f"Renewed {renewed_count} certificates"
|
||||
if errors:
|
||||
summary += f", {len(errors)} errors"
|
||||
|
||||
create_task_log(
|
||||
task_id, "renew_certificates",
|
||||
"Certificate renewal completed",
|
||||
'SUCCESS' if not errors else 'PARTIAL',
|
||||
message=summary,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
else:
|
||||
create_task_log(
|
||||
task_id, "renew_certificates",
|
||||
"No certificates need renewal",
|
||||
'SUCCESS',
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
return {
|
||||
'renewed': renewed_count,
|
||||
'errors': errors
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Certificate renewal task failed: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
create_task_log(
|
||||
task_id, "renew_certificates",
|
||||
"Certificate renewal failed",
|
||||
'FAILURE',
|
||||
message=error_msg,
|
||||
execution_time=time.time() - start_time
|
||||
)
|
||||
|
||||
raise
|
||||
304
vpn/templates/admin/purge_users.html
Normal file
304
vpn/templates/admin/purge_users.html
Normal file
@@ -0,0 +1,304 @@
|
||||
<!-- vpn/templates/admin/purge_users.html -->
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/server_actions.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content-main">
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
<!-- Context Information -->
|
||||
<div class="alert alert-info" style="margin: 10px 0; padding: 10px; border-radius: 4px; background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;">
|
||||
{% if servers_info|length == 1 %}
|
||||
<strong>🎯 Single Server Operation:</strong> You are managing users for server "{{ servers_info.0.server.name }}"
|
||||
{% elif servers_info|length > 10 %}
|
||||
<strong>🌐 Bulk Operation:</strong> You are managing users for {{ servers_info|length }} servers (all available servers)
|
||||
{% else %}
|
||||
<strong>📋 Multi-Server Operation:</strong> You are managing users for {{ servers_info|length }} selected servers
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" style="margin: 10px 0; padding: 15px; border-radius: 4px; background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404;">
|
||||
<strong>⚠️ WARNING:</strong> This operation will permanently delete users directly from the VPN servers.
|
||||
This action cannot be undone and may affect active VPN connections.
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}" style="margin: 10px 0; padding: 10px; border-radius: 4px;
|
||||
{% if message.tags == 'error' %}background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24;
|
||||
{% elif message.tags == 'success' %}background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724;
|
||||
{% elif message.tags == 'warning' %}background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404;
|
||||
{% elif message.tags == 'info' %}background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460;
|
||||
{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" id="purge-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-row" style="margin-bottom: 20px;">
|
||||
<h3>Select Servers and Purge Mode:</h3>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label for="purge_mode"><strong>Purge Mode:</strong></label>
|
||||
<div style="margin-top: 5px;">
|
||||
<input type="radio" id="purge_unmanaged" name="purge_mode" value="unmanaged" checked onchange="updatePurgeDescription()">
|
||||
<label for="purge_unmanaged" style="font-weight: normal; margin-left: 5px; margin-right: 20px;">
|
||||
<span style="color: #28a745;">Safe Purge</span> - Only unmanaged users
|
||||
</label>
|
||||
|
||||
<input type="radio" id="purge_all" name="purge_mode" value="all" onchange="updatePurgeDescription()">
|
||||
<label for="purge_all" style="font-weight: normal; margin-left: 5px; color: #dc3545;">
|
||||
<span style="color: #dc3545;">⚠️ Full Purge</span> - ALL users (including OutFleet managed)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="purge-description" style="padding: 10px; border-radius: 5px; margin-bottom: 15px;">
|
||||
<!-- Description will be updated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-bottom: 20px;">
|
||||
<h3>Select Servers to Purge:</h3>
|
||||
<div style="max-height: 400px; overflow-y: auto; border: 1px solid #ddd; padding: 10px;">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<button type="button" onclick="toggleAllServers()" style="padding: 5px 10px; margin-right: 10px;">Select All</button>
|
||||
<button type="button" onclick="toggleAllServers(false)" style="padding: 5px 10px;">Deselect All</button>
|
||||
</div>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background-color: #f5f5f5;">
|
||||
<th style="padding: 8px; border: 1px solid #ddd; width: 50px;">Select</th>
|
||||
<th style="padding: 8px; border: 1px solid #ddd;">Server Name</th>
|
||||
<th style="padding: 8px; border: 1px solid #ddd;">Type</th>
|
||||
<th style="padding: 8px; border: 1px solid #ddd;">Status</th>
|
||||
<th style="padding: 8px; border: 1px solid #ddd;">Users on Server</th>
|
||||
<th style="padding: 8px; border: 1px solid #ddd;">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server_info in servers_info %}
|
||||
<tr class="server-row" data-server-id="{{ server_info.server.id }}">
|
||||
<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">
|
||||
{% if server_info.status == 'online' %}
|
||||
<input type="checkbox" name="selected_servers" value="{{ server_info.server.id }}"
|
||||
class="server-checkbox" onchange="updateSubmitButton()">
|
||||
{% else %}
|
||||
<span style="color: #ccc;" title="Server unavailable">❌</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 8px; border: 1px solid #ddd;">
|
||||
<strong>{{ server_info.server.name }}</strong>
|
||||
</td>
|
||||
<td style="padding: 8px; border: 1px solid #ddd;">
|
||||
{{ server_info.server.server_type }}
|
||||
</td>
|
||||
<td style="padding: 8px; border: 1px solid #ddd;">
|
||||
{% if server_info.status == 'online' %}
|
||||
<span style="color: #28a745;">✅ Online</span>
|
||||
{% else %}
|
||||
<span style="color: #dc3545;">❌ Error</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">
|
||||
{% if server_info.status == 'online' %}
|
||||
<strong>{{ server_info.user_count }}</strong>
|
||||
{% else %}
|
||||
<span style="color: #ccc;">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 8px; border: 1px solid #ddd; font-size: 12px;">
|
||||
{% if server_info.status == 'online' %}
|
||||
{% if server_info.user_count > 0 %}
|
||||
<details>
|
||||
<summary style="cursor: pointer;">View users ({{ server_info.user_count }})</summary>
|
||||
<div style="margin-top: 5px; max-height: 150px; overflow-y: auto; background-color: #f8f9fa; padding: 5px; border-radius: 3px;">
|
||||
{% for user in server_info.users %}
|
||||
<div style="margin: 2px 0; font-family: monospace; font-size: 11px;">
|
||||
<strong>{{ user.name }}</strong> (ID: {{ user.key_id }})
|
||||
<br><span style="color: #666;">Pass: {{ user.password|slice:":8" }}...</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<span style="color: #666;">No users</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span style="color: #dc3545;">{{ server_info.error }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit-row">
|
||||
<input type="submit" value="🗑️ Purge Selected Servers" class="default" id="submit-btn" disabled
|
||||
style="background-color: #dc3545; border-color: #dc3545;">
|
||||
<a href="{% url 'admin:vpn_server_changelist' %}" class="button cancel">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updatePurgeDescription() {
|
||||
var purgeMode = document.querySelector('input[name="purge_mode"]:checked').value;
|
||||
var descriptionDiv = document.getElementById('purge-description');
|
||||
|
||||
if (purgeMode === 'unmanaged') {
|
||||
descriptionDiv.innerHTML = `
|
||||
<div style="background-color: #d4edda; border-left: 4px solid #28a745; color: #155724;">
|
||||
<h4 style="margin: 0 0 5px 0;">Safe Purge Mode</h4>
|
||||
<p style="margin: 0;">
|
||||
• Only removes users that are <strong>NOT</strong> managed by OutFleet<br>
|
||||
• Preserves all users that exist in the OutFleet database<br>
|
||||
• Safe to use - will not affect your managed users<br>
|
||||
• Recommended for cleaning up orphaned or manually created users
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
descriptionDiv.innerHTML = `
|
||||
<div style="background-color: #f8d7da; border-left: 4px solid #dc3545; color: #721c24;">
|
||||
<h4 style="margin: 0 0 5px 0;">⚠️ DANGEROUS: Full Purge Mode</h4>
|
||||
<p style="margin: 0;">
|
||||
• <strong>REMOVES ALL USERS</strong> from the server, including OutFleet managed users<br>
|
||||
• <strong>WILL DISCONNECT ALL ACTIVE VPN SESSIONS</strong><br>
|
||||
• OutFleet managed users will be recreated during next sync<br>
|
||||
• Use only if you want to completely reset the server<br>
|
||||
• <strong>THIS ACTION CANNOT BE UNDONE</strong>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAllServers(selectAll = true) {
|
||||
var checkboxes = document.getElementsByClassName('server-checkbox');
|
||||
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
checkboxes[i].checked = selectAll;
|
||||
}
|
||||
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
function updateSubmitButton() {
|
||||
var checkboxes = document.getElementsByClassName('server-checkbox');
|
||||
var submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
var hasSelected = false;
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
if (checkboxes[i].checked) {
|
||||
hasSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.disabled = !hasSelected;
|
||||
|
||||
// Update button text based on purge mode
|
||||
var purgeMode = document.querySelector('input[name="purge_mode"]:checked').value;
|
||||
if (purgeMode === 'all') {
|
||||
submitBtn.innerHTML = '<i class="fas fa-exclamation-triangle mr-2"></i>PURGE ALL USERS';
|
||||
submitBtn.className = 'btn btn-danger';
|
||||
} else {
|
||||
submitBtn.innerHTML = '<i class="fas fa-trash mr-2"></i>Purge Unmanaged Users';
|
||||
submitBtn.className = 'btn btn-warning';
|
||||
}
|
||||
}
|
||||
|
||||
// Form submission confirmation
|
||||
document.getElementById('purge-form').addEventListener('submit', function(e) {
|
||||
var checkboxes = document.getElementsByClassName('server-checkbox');
|
||||
var selectedCount = 0;
|
||||
var selectedServers = [];
|
||||
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
if (checkboxes[i].checked) {
|
||||
selectedCount++;
|
||||
var row = checkboxes[i].closest('tr');
|
||||
var serverName = row.querySelector('td:nth-child(2) strong').textContent;
|
||||
selectedServers.push(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
var purgeMode = document.querySelector('input[name="purge_mode"]:checked').value;
|
||||
var totalUsers = 0;
|
||||
|
||||
// Calculate total users that will be affected
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
if (checkboxes[i].checked) {
|
||||
var row = checkboxes[i].closest('tr');
|
||||
var userCountBadge = row.querySelector('td:nth-child(5) .badge');
|
||||
if (userCountBadge) {
|
||||
totalUsers += parseInt(userCountBadge.textContent) || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var confirmMessage = '';
|
||||
|
||||
if (purgeMode === 'all') {
|
||||
confirmMessage = `⚠️ DANGER: FULL PURGE CONFIRMATION ⚠️\n\n`;
|
||||
confirmMessage += `You are about to PERMANENTLY DELETE ALL USERS from ${selectedCount} server(s):\n`;
|
||||
confirmMessage += `${selectedServers.join(', ')}\n\n`;
|
||||
confirmMessage += `This will:\n`;
|
||||
confirmMessage += `• DELETE ALL ${totalUsers} users from selected servers\n`;
|
||||
confirmMessage += `• DISCONNECT ALL ACTIVE VPN SESSIONS\n`;
|
||||
confirmMessage += `• REMOVE BOTH managed and unmanaged users\n`;
|
||||
confirmMessage += `• Cannot be undone\n\n`;
|
||||
confirmMessage += `OutFleet managed users will be recreated during next sync.\n\n`;
|
||||
confirmMessage += `Type "PURGE ALL" to confirm this dangerous operation:`;
|
||||
|
||||
var userInput = prompt(confirmMessage);
|
||||
if (userInput !== "PURGE ALL") {
|
||||
e.preventDefault();
|
||||
alert("Operation cancelled. You must type exactly 'PURGE ALL' to confirm.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
confirmMessage = `Safe Purge Confirmation\n\n`;
|
||||
confirmMessage += `You are about to purge unmanaged users from ${selectedCount} server(s):\n`;
|
||||
confirmMessage += `${selectedServers.join(', ')}\n\n`;
|
||||
confirmMessage += `This will:\n`;
|
||||
confirmMessage += `• Remove only users NOT managed by OutFleet\n`;
|
||||
confirmMessage += `• Preserve all OutFleet managed users\n`;
|
||||
confirmMessage += `• Help clean up orphaned users\n\n`;
|
||||
confirmMessage += `Are you sure you want to continue?`;
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize page
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updatePurgeDescription();
|
||||
updateSubmitButton();
|
||||
|
||||
// Add event listeners to purge mode radio buttons
|
||||
document.querySelectorAll('input[name="purge_mode"]').forEach(function(radio) {
|
||||
radio.addEventListener('change', function() {
|
||||
updatePurgeDescription();
|
||||
updateSubmitButton();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
18
vpn/templates/admin/vpn/certificate/change_form.html
Normal file
18
vpn/templates/admin/vpn/certificate/change_form.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if show_tab_navigation %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
{% for tab in tabs %}
|
||||
<a href="{{ tab.url }}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
|
||||
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
18
vpn/templates/admin/vpn/certificate/change_list.html
Normal file
18
vpn/templates/admin/vpn/certificate/change_list.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if show_tab_navigation %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
{% for tab in tabs %}
|
||||
<a href="{{ tab.url }}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
|
||||
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
18
vpn/templates/admin/vpn/inbound/change_form.html
Normal file
18
vpn/templates/admin/vpn/inbound/change_form.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if show_tab_navigation %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
{% for tab in tabs %}
|
||||
<a href="{{ tab.url }}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
|
||||
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
18
vpn/templates/admin/vpn/inbound/change_list.html
Normal file
18
vpn/templates/admin/vpn/inbound/change_list.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if show_tab_navigation %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
{% for tab in tabs %}
|
||||
<a href="{{ tab.url }}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
|
||||
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
39
vpn/templates/admin/vpn/server/change_form.html
Normal file
39
vpn/templates/admin/vpn/server/change_form.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!-- vpn/templates/admin/vpn/server/change_form.html -->
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/main.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block submit_buttons_bottom %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if original %}
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
<i class="fas fa-tools mr-2"></i>Server Actions
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'admin:server_move_clients' %}?servers={{ original.id }}"
|
||||
class="btn btn-primary">
|
||||
<i class="fas fa-exchange-alt mr-2"></i>Move Links
|
||||
</a>
|
||||
<a href="{% url 'admin:server_purge_users' %}?servers={{ original.id }}"
|
||||
class="btn btn-danger"
|
||||
onclick="return confirm('Open purge interface for {{ original.name }}?')">
|
||||
<i class="fas fa-trash mr-2"></i>Purge Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
166
vpn/templates/admin/vpn/server/change_list.html
Normal file
166
vpn/templates/admin/vpn/server/change_list.html
Normal file
@@ -0,0 +1,166 @@
|
||||
<!-- vpn/templates/admin/vpn/server/change_list.html -->
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
<!-- Bulk Action Buttons -->
|
||||
<div class="bulk-actions-section" style="margin: 20px 0; padding: 15px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 5px;">
|
||||
<h3 style="margin-top: 0; color: #495057;">🚀 Bulk Server Operations</h3>
|
||||
<p style="margin-bottom: 15px; color: #6c757d;">
|
||||
Perform operations on all available servers at once. These actions will include all servers in the system.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<a href="{{ bulk_move_clients_url }}"
|
||||
class="bulk-action-btn btn-move-clients"
|
||||
style="background-color: #007cba; color: white; padding: 10px 15px; text-decoration: none; border-radius: 4px; display: inline-flex; align-items: center; gap: 5px;">
|
||||
📦 <span>Bulk Move Clients</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ bulk_purge_users_url }}"
|
||||
class="bulk-action-btn btn-purge-users"
|
||||
style="background-color: #dc3545; color: white; padding: 10px 15px; text-decoration: none; border-radius: 4px; display: inline-flex; align-items: center; gap: 5px;"
|
||||
onclick="return confirm('⚠️ Warning: This will open the purge interface for ALL servers. Continue?')">
|
||||
🗑️ <span>Bulk Purge Users</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="tip-section" style="margin-top: 10px; padding: 10px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 3px;">
|
||||
<small style="color: #856404;">
|
||||
<strong>💡 Tip:</strong> You can also select specific servers below and use the "Actions" dropdown,
|
||||
or click individual action buttons in the "Actions" column for single-server operations.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
{% load static %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/server_actions.css' %}">
|
||||
<style>
|
||||
/* Style for action buttons in the list */
|
||||
.field-server_actions .button {
|
||||
background-color: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin: 2px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.field-server_actions .button:hover {
|
||||
background-color: #005a8b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.field-server_actions .button[style*="background-color: #dc3545"] {
|
||||
background-color: #dc3545 !important;
|
||||
}
|
||||
|
||||
.field-server_actions .button[style*="background-color: #dc3545"]:hover {
|
||||
background-color: #c82333 !important;
|
||||
}
|
||||
|
||||
/* Make action column wider */
|
||||
.field-server_actions {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Responsive design for action buttons */
|
||||
@media (max-width: 768px) {
|
||||
.field-server_actions > div {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.field-server_actions .button {
|
||||
margin: 1px 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced bulk action section styling */
|
||||
.bulk-actions-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-left: 4px solid #007cba;
|
||||
}
|
||||
|
||||
.bulk-actions-section h3 {
|
||||
color: #007cba;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Hover effects for bulk buttons */
|
||||
.bulk-action-btn {
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.bulk-action-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.server-status-online {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.server-status-error {
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Add loading states to bulk action buttons
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const bulkButtons = document.querySelectorAll('.bulk-action-btn');
|
||||
|
||||
bulkButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
// Don't prevent default, but add loading state
|
||||
this.classList.add('loading');
|
||||
this.style.pointerEvents = 'none';
|
||||
|
||||
// Remove loading state after a delay (in case user navigates back)
|
||||
setTimeout(() => {
|
||||
this.classList.remove('loading');
|
||||
this.style.pointerEvents = 'auto';
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block result_list %}
|
||||
<!-- Server Statistics -->
|
||||
<div class="server-stats-section" style="margin: 15px 0; padding: 10px; background-color: #e8f4fd; border: 1px solid #bee5eb; border-radius: 4px;">
|
||||
<div class="server-stats-grid" style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;">
|
||||
<div class="stat-item">
|
||||
<strong>📊 Server Overview:</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label" style="color: #007cba;">Total Servers:</span>
|
||||
<strong class="stat-value">{{ cl.result_count }}</strong>
|
||||
</div>
|
||||
{% if cl.result_count > 0 %}
|
||||
<div class="stat-item">
|
||||
<span class="stat-label" style="color: #28a745;">Available Operations:</span>
|
||||
<strong class="stat-value">Move Links, Purge Users, Bulk Actions</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
18
vpn/templates/admin/vpn/subscriptiongroup/change_form.html
Normal file
18
vpn/templates/admin/vpn/subscriptiongroup/change_form.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if show_tab_navigation %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
{% for tab in tabs %}
|
||||
<a href="{{ tab.url }}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
|
||||
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
18
vpn/templates/admin/vpn/subscriptiongroup/change_list.html
Normal file
18
vpn/templates/admin/vpn/subscriptiongroup/change_list.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if show_tab_navigation %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
{% for tab in tabs %}
|
||||
<a href="{{ tab.url }}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
|
||||
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
18
vpn/templates/admin/vpn/usersubscription/change_form.html
Normal file
18
vpn/templates/admin/vpn/usersubscription/change_form.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if show_tab_navigation %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
{% for tab in tabs %}
|
||||
<a href="{{ tab.url }}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
|
||||
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
18
vpn/templates/admin/vpn/usersubscription/change_list.html
Normal file
18
vpn/templates/admin/vpn/usersubscription/change_list.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if show_tab_navigation %}
|
||||
<div class="module" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; border-bottom: 1px solid #ddd;">
|
||||
{% for tab in tabs %}
|
||||
<a href="{{ tab.url }}"
|
||||
style="padding: 10px 20px; text-decoration: none; border-bottom: 3px solid {% if tab.active %}#417690{% else %}transparent{% endif %}; color: {% if tab.active %}#417690{% else %}#666{% endif %};">
|
||||
{% if tab.name == 'subscription_groups' %}📋{% elif tab.name == 'user_subscriptions' %}👥{% elif tab.name == 'certificates' %}🔒{% elif tab.name == 'inbound_templates' %}⚙️{% endif %} {{ tab.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
@@ -450,12 +450,12 @@
|
||||
<div class="subtitle">Welcome back, <strong>{{ user.username }}</strong></div>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ total_servers }}</span>
|
||||
<span class="stat-label">Available Servers</span>
|
||||
<span class="stat-number">{{ total_groups }}</span>
|
||||
<span class="stat-label">Subscription Groups</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ total_links }}</span>
|
||||
<span class="stat-label">Active Links</span>
|
||||
<span class="stat-number">{{ total_inbounds }}</span>
|
||||
<span class="stat-label">Available Inbounds</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ total_connections }}</span>
|
||||
@@ -475,84 +475,122 @@
|
||||
</div>
|
||||
|
||||
<!-- Xray Subscription Link -->
|
||||
{% if has_xray_servers and user_links %}
|
||||
{% if has_xray_access %}
|
||||
<div class="xray-subscription" style="margin-top: 20px; padding: 15px; background: rgba(148, 163, 184, 0.1); border: 1px solid rgba(148, 163, 184, 0.3); border-radius: 12px;">
|
||||
<h3 style="color: #94a3b8; margin-bottom: 10px; font-size: 1.1rem;">🚀 Xray Universal Subscription</h3>
|
||||
<p style="color: #64748b; font-size: 0.9rem; margin-bottom: 10px;">
|
||||
One link for all your Xray protocols (VLESS, VMess, Trojan)
|
||||
</p>
|
||||
<div class="link-url" style="margin-bottom: 0;">
|
||||
{% url 'xray_subscription' user_links.0.link as xray_url %}{{ request.scheme }}://{{ request.get_host }}{{ xray_url }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ request.scheme }}://{{ request.get_host }}{{ xray_url }}')">Copy</button>
|
||||
{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if servers_data %}
|
||||
{% if groups_data %}
|
||||
<div class="servers-grid">
|
||||
{% for server_name, server_data in servers_data.items %}
|
||||
{% for group_name, group_data in groups_data.items %}
|
||||
<div class="server-card">
|
||||
<div class="server-header">
|
||||
<div class="server-info">
|
||||
<div class="server-name">{{ server_name }}</div>
|
||||
<div class="server-name">{{ group_name }}</div>
|
||||
<div class="server-stats">
|
||||
<span class="connection-count">📊 {{ server_data.total_connections }} uses</span>
|
||||
<span class="connection-count">🔗 {{ group_data.deployed_count }} inbound(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-type">{{ server_data.server_type }}</div>
|
||||
<div class="server-type">Xray Group</div>
|
||||
</div>
|
||||
|
||||
<div class="server-status">
|
||||
{% if server_data.accessible %}
|
||||
<div class="status-indicator status-online">
|
||||
<div class="status-dot"></div>
|
||||
Online & Ready
|
||||
Active Subscription
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="status-indicator status-offline">
|
||||
<div class="status-dot"></div>
|
||||
Connection Issues
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Individual Subscription Link for this Group -->
|
||||
<div class="links-container">
|
||||
{% for link_data in server_data.links %}
|
||||
<div class="link-item">
|
||||
<div class="link-header">
|
||||
<div class="link-info">
|
||||
<div class="link-comment">📱 {{ link_data.comment }}</div>
|
||||
<div class="link-comment">🚀 {{ group_name }} Subscription</div>
|
||||
<div class="link-stats">
|
||||
<span class="usage-count">✨ {{ link_data.connections }} uses</span>
|
||||
<span class="recent-count">📅 {{ link_data.recent_connections }} last 30 days</span>
|
||||
<span class="last-used">🕒 {{ link_data.last_access_display }}</span>
|
||||
<span class="last-used">🔗 {{ group_data.deployed_count }} inbound(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-chart" data-usage="{{ link_data.daily_usage|join:',' }}" data-max="{{ link_data.max_daily }}">
|
||||
<div class="chart-title">30-day activity</div>
|
||||
<div class="chart-bars">
|
||||
{% for day_usage in link_data.daily_usage %}
|
||||
<div class="chart-bar" data-height="{{ day_usage }}" data-max="{{ link_data.max_daily }}"></div>
|
||||
<div class="usage-chart">
|
||||
<div class="chart-title">Protocols</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px; align-items: center;">
|
||||
{% for inbound_data in group_data.inbounds %}
|
||||
<div style="background: rgba(74, 222, 128, 0.2); padding: 3px 8px; border-radius: 8px; font-size: 0.7rem; color: #4ade80;">
|
||||
{{ inbound_data.server_name }}: {{ inbound_data.protocol|upper }}:{{ inbound_data.port }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="link-url">
|
||||
{{ link_data.url }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ link_data.url }}')">Copy</button>
|
||||
{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}?group={{ group_name }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}?group={{ group_name }}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-servers">
|
||||
<h3>No VPN Access Available</h3>
|
||||
<p>You don't have access to any VPN servers yet. Please contact your administrator.</p>
|
||||
<h3>No Xray Subscriptions Available</h3>
|
||||
<p>You don't have access to any subscription groups yet. Please contact your administrator.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Show old ACL links for backwards compatibility -->
|
||||
{% if has_old_links %}
|
||||
<h2 style="color: #9ca3af; margin: 40px 0 20px 0; text-align: center;">Legacy Shadowsocks Access</h2>
|
||||
<div class="servers-grid">
|
||||
{% for acl_link in acl_links %}
|
||||
<div class="server-card" style="opacity: 0.7;">
|
||||
<div class="server-header">
|
||||
<div class="server-info">
|
||||
<div class="server-name">{{ acl_link.acl.server.name }}</div>
|
||||
<div class="server-stats">
|
||||
<span class="connection-count">📊 Legacy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-type">Shadowsocks</div>
|
||||
</div>
|
||||
|
||||
<div class="server-status">
|
||||
<div class="status-indicator status-online">
|
||||
<div class="status-dot"></div>
|
||||
Legacy Access
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links-container">
|
||||
<div class="link-item">
|
||||
<div class="link-header">
|
||||
<div class="link-info">
|
||||
<div class="link-comment">📱 {{ acl_link.comment }}</div>
|
||||
<div class="link-stats">
|
||||
<span class="last-used">🕒 {{ acl_link.last_access_time|default:"Never used" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="link-url">
|
||||
{{ external_address }}/ss/{{ acl_link.link }}#{{ acl_link.acl.server.name }}
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ external_address }}/ss/{{ acl_link.link }}#{{ acl_link.acl.server.name }}')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
21
vpn/utils.py
Normal file
21
vpn/utils.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Utility functions for VPN application
|
||||
"""
|
||||
import json
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
|
||||
def format_object(data):
|
||||
"""
|
||||
Format various data types for display in Django admin interface
|
||||
"""
|
||||
try:
|
||||
if isinstance(data, dict):
|
||||
formatted_data = json.dumps(data, indent=2)
|
||||
return mark_safe(f"<pre>{formatted_data}</pre>")
|
||||
elif isinstance(data, str):
|
||||
return mark_safe(f"<pre>{data}</pre>")
|
||||
else:
|
||||
return mark_safe(f"<pre>{str(data)}</pre>")
|
||||
except Exception as e:
|
||||
return mark_safe(f"<span style='color: red;'>Error: {e}</span>")
|
||||
615
vpn/views.py
615
vpn/views.py
@@ -1,6 +1,7 @@
|
||||
def userPortal(request, user_hash):
|
||||
"""HTML portal for user to view their VPN access links and server information"""
|
||||
from .models import User, ACLLink, UserStatistics, AccessLog
|
||||
"""HTML portal for user to view their VPN access links and subscription groups"""
|
||||
from .models import User
|
||||
from .models_xray import UserSubscription, SubscriptionGroup, Inbound
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
@@ -18,155 +19,166 @@ def userPortal(request, user_hash):
|
||||
}, status=403)
|
||||
|
||||
try:
|
||||
# Get all ACL links for the user with server information
|
||||
acl_links = ACLLink.objects.filter(acl__user=user).select_related('acl__server', 'acl')
|
||||
logger.info(f"Found {acl_links.count()} ACL links for user {user.username}")
|
||||
|
||||
# Calculate overall statistics from cached data (only where cache exists)
|
||||
user_stats = UserStatistics.objects.filter(user=user)
|
||||
if user_stats.exists():
|
||||
total_connections = sum(stat.total_connections for stat in user_stats)
|
||||
recent_connections = sum(stat.recent_connections for stat in user_stats)
|
||||
logger.info(f"User {user.username} cached stats: total_connections={total_connections}, recent_connections={recent_connections}")
|
||||
else:
|
||||
# No cache available, set to zero and suggest cache update
|
||||
total_connections = 0
|
||||
recent_connections = 0
|
||||
logger.warning(f"No cached statistics found for user {user.username}. Run statistics update task.")
|
||||
|
||||
# Group links by server
|
||||
servers_data = {}
|
||||
total_links = 0
|
||||
|
||||
for link in acl_links:
|
||||
server = link.acl.server
|
||||
server_name = server.name
|
||||
logger.debug(f"Processing link {link.link} for server {server_name}")
|
||||
|
||||
if server_name not in servers_data:
|
||||
# Get server status and info
|
||||
try:
|
||||
server_status = server.get_server_status()
|
||||
server_accessible = True
|
||||
server_error = None
|
||||
logger.debug(f"Server {server_name} status retrieved successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get status for server {server_name}: {e}")
|
||||
server_status = {}
|
||||
server_accessible = False
|
||||
server_error = str(e)
|
||||
|
||||
# Calculate server-level totals from cached stats (only where cache exists)
|
||||
server_stats = user_stats.filter(server_name=server_name)
|
||||
if server_stats.exists():
|
||||
server_total_connections = sum(stat.total_connections for stat in server_stats)
|
||||
else:
|
||||
server_total_connections = 0
|
||||
|
||||
servers_data[server_name] = {
|
||||
'server': server,
|
||||
'status': server_status,
|
||||
'accessible': server_accessible,
|
||||
'error': server_error,
|
||||
'links': [],
|
||||
'server_type': server.server_type,
|
||||
'total_connections': server_total_connections,
|
||||
}
|
||||
logger.debug(f"Created server data for {server_name} with {server_total_connections} cached connections")
|
||||
|
||||
# Calculate time since last access
|
||||
last_access_display = "Never used"
|
||||
if link.last_access_time:
|
||||
time_diff = timezone.now() - link.last_access_time
|
||||
if time_diff.days > 0:
|
||||
last_access_display = f"{time_diff.days} days ago"
|
||||
elif time_diff.seconds > 3600:
|
||||
hours = time_diff.seconds // 3600
|
||||
last_access_display = f"{hours} hours ago"
|
||||
elif time_diff.seconds > 60:
|
||||
minutes = time_diff.seconds // 60
|
||||
last_access_display = f"{minutes} minutes ago"
|
||||
else:
|
||||
last_access_display = "Just now"
|
||||
|
||||
# Get cached statistics for this specific link
|
||||
try:
|
||||
link_stats = UserStatistics.objects.get(
|
||||
# Get all active subscription groups for the user
|
||||
user_subscriptions = UserSubscription.objects.filter(
|
||||
user=user,
|
||||
server_name=server_name,
|
||||
acl_link_id=link.link
|
||||
)
|
||||
logger.debug(f"Found cached stats for link {link.link}: {link_stats.total_connections} connections, max_daily={link_stats.max_daily}")
|
||||
active=True,
|
||||
subscription_group__is_active=True
|
||||
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
|
||||
|
||||
link_connections = link_stats.total_connections
|
||||
link_recent_connections = link_stats.recent_connections
|
||||
daily_usage = link_stats.daily_usage or []
|
||||
max_daily = link_stats.max_daily
|
||||
logger.info(f"Found {user_subscriptions.count()} active subscription groups for user {user.username}")
|
||||
|
||||
except UserStatistics.DoesNotExist:
|
||||
logger.warning(f"No cached stats found for link {link.link} on server {server_name}, using fallback")
|
||||
# Calculate overall Xray subscription statistics
|
||||
from .models import AccessLog
|
||||
total_connections = AccessLog.objects.filter(
|
||||
user=user.username,
|
||||
action='Success',
|
||||
server='Xray-Subscription'
|
||||
).count()
|
||||
|
||||
# Fallback: Since AccessLog doesn't track specific links, show zero for link-specific stats
|
||||
# but keep server-level stats for context
|
||||
link_connections = 0
|
||||
link_recent_connections = 0
|
||||
daily_usage = [0] * 30 # Empty 30-day chart
|
||||
max_daily = 0
|
||||
recent_connections = AccessLog.objects.filter(
|
||||
user=user.username,
|
||||
action='Success',
|
||||
server='Xray-Subscription',
|
||||
timestamp__gte=timezone.now() - timedelta(days=30)
|
||||
).count()
|
||||
|
||||
logger.warning(f"Using zero stats for uncached link {link.link} - AccessLog doesn't track individual links")
|
||||
logger.info(f"Xray statistics for user {user.username}: total={total_connections}, recent={recent_connections}")
|
||||
|
||||
logger.debug(f"Link {link.link} stats: connections={link_connections}, recent={link_recent_connections}, max_daily={max_daily}")
|
||||
# Determine protocol scheme
|
||||
scheme = 'https' # Always use HTTPS as SSL is handled by ingress
|
||||
|
||||
# Add link information with statistics
|
||||
link_url = f"{EXTERNAL_ADDRESS}/ss/{link.link}#{server_name}"
|
||||
# Group inbounds by subscription group
|
||||
groups_data = {}
|
||||
total_inbounds = 0
|
||||
|
||||
link_data = {
|
||||
'link': link,
|
||||
'url': link_url,
|
||||
'comment': link.comment or 'Default',
|
||||
'last_access': link.last_access_time,
|
||||
'last_access_display': last_access_display,
|
||||
'connections': link_connections,
|
||||
'recent_connections': link_recent_connections,
|
||||
'daily_usage': daily_usage,
|
||||
'max_daily': max_daily,
|
||||
for subscription in user_subscriptions:
|
||||
group = subscription.subscription_group
|
||||
group_name = group.name
|
||||
logger.debug(f"Processing subscription group {group_name}")
|
||||
|
||||
# Get all deployed inbounds for this group (count actual server deployments)
|
||||
from .models_xray import ServerInbound
|
||||
deployed_inbounds = ServerInbound.objects.filter(
|
||||
inbound__in=group.inbounds.all(),
|
||||
active=True
|
||||
).select_related('inbound', 'server')
|
||||
|
||||
# Calculate connections for this specific group
|
||||
group_connections = AccessLog.objects.filter(
|
||||
user=user.username,
|
||||
action='Success',
|
||||
server='Xray-Subscription',
|
||||
data__icontains=f'"group": "{group_name}"'
|
||||
).count()
|
||||
|
||||
groups_data[group_name] = {
|
||||
'group': group,
|
||||
'subscription': subscription,
|
||||
'inbounds': [],
|
||||
'total_connections': group_connections,
|
||||
'deployed_count': deployed_inbounds.count(), # Actual deployed inbounds count
|
||||
}
|
||||
|
||||
servers_data[server_name]['links'].append(link_data)
|
||||
total_links += 1
|
||||
# Process each deployed inbound (each server-inbound combination)
|
||||
for server_inbound in deployed_inbounds:
|
||||
inbound = server_inbound.inbound
|
||||
logger.debug(f"Processing inbound {inbound.name} in group {group_name}")
|
||||
|
||||
logger.debug(f"Added comprehensive link data for {link.link}")
|
||||
# Generate connection URLs based on protocol
|
||||
connection_urls = []
|
||||
|
||||
logger.info(f"Prepared data for {len(servers_data)} servers and {total_links} total links")
|
||||
if inbound.protocol == 'vless':
|
||||
# Generate VLESS URL - this is a placeholder implementation
|
||||
# In the real implementation, you'd generate proper VLESS URLs with user UUID
|
||||
connection_url = f"vless://user-uuid@{get_external_host()}:{inbound.port}#{inbound.name}"
|
||||
connection_urls.append({
|
||||
'url': connection_url,
|
||||
'protocol': 'VLESS',
|
||||
'name': inbound.name
|
||||
})
|
||||
elif inbound.protocol == 'vmess':
|
||||
# Generate VMess URL - placeholder
|
||||
connection_url = f"vmess://user-config@{get_external_host()}:{inbound.port}#{inbound.name}"
|
||||
connection_urls.append({
|
||||
'url': connection_url,
|
||||
'protocol': 'VMess',
|
||||
'name': inbound.name
|
||||
})
|
||||
elif inbound.protocol == 'trojan':
|
||||
# Generate Trojan URL - placeholder
|
||||
connection_url = f"trojan://user-password@{get_external_host()}:{inbound.port}#{inbound.name}"
|
||||
connection_urls.append({
|
||||
'url': connection_url,
|
||||
'protocol': 'Trojan',
|
||||
'name': inbound.name
|
||||
})
|
||||
elif inbound.protocol == 'shadowsocks':
|
||||
# Generate Shadowsocks URL - placeholder
|
||||
connection_url = f"ss://user-config@{get_external_host()}:{inbound.port}#{inbound.name}"
|
||||
connection_urls.append({
|
||||
'url': connection_url,
|
||||
'protocol': 'Shadowsocks',
|
||||
'name': inbound.name
|
||||
})
|
||||
|
||||
inbound_data = {
|
||||
'inbound': inbound,
|
||||
'connection_urls': connection_urls,
|
||||
'protocol': inbound.protocol.upper(),
|
||||
'port': inbound.port,
|
||||
'domain': get_external_host(),
|
||||
'network': inbound.network,
|
||||
'security': inbound.security,
|
||||
'connections': 0, # Placeholder during transition
|
||||
'last_access_display': "Never used", # Placeholder
|
||||
'server': server_inbound.server, # Server that deployed this inbound
|
||||
'server_name': server_inbound.server.name, # Server name for display
|
||||
}
|
||||
|
||||
groups_data[group_name]['inbounds'].append(inbound_data)
|
||||
total_inbounds += 1
|
||||
|
||||
logger.debug(f"Added inbound data for {inbound.name}")
|
||||
|
||||
logger.info(f"Prepared data for {len(groups_data)} subscription groups and {total_inbounds} total inbounds")
|
||||
logger.info(f"Portal statistics: total_connections={total_connections}, recent_connections={recent_connections}")
|
||||
|
||||
# Check if user has access to any Xray servers
|
||||
from vpn.server_plugins import XrayCoreServer, XrayInboundServer
|
||||
has_xray_servers = any(
|
||||
isinstance(acl_link.acl.server.get_real_instance(), (XrayCoreServer, XrayInboundServer))
|
||||
for acl_link in acl_links
|
||||
)
|
||||
# Check if user has any Xray subscription groups
|
||||
has_xray_access = user_subscriptions.exists()
|
||||
|
||||
# Also get old-style ACL links for backwards compatibility
|
||||
acl_links = []
|
||||
try:
|
||||
from .models import ACLLink
|
||||
acl_links = ACLLink.objects.filter(acl__user=user).select_related('acl__server', 'acl')
|
||||
except:
|
||||
pass
|
||||
|
||||
context = {
|
||||
'user': user,
|
||||
'user_links': acl_links, # For accessing user's links in template
|
||||
'servers_data': servers_data,
|
||||
'total_servers': len(servers_data),
|
||||
'total_links': total_links,
|
||||
'user_subscriptions': user_subscriptions, # For accessing user's subscriptions in template
|
||||
'groups_data': groups_data,
|
||||
'total_groups': len(groups_data),
|
||||
'total_inbounds': total_inbounds,
|
||||
'total_connections': total_connections,
|
||||
'recent_connections': recent_connections,
|
||||
'external_address': EXTERNAL_ADDRESS,
|
||||
'has_xray_servers': has_xray_servers,
|
||||
'external_address': get_external_host(),
|
||||
'has_xray_access': has_xray_access,
|
||||
'force_scheme': scheme, # Override request.scheme in template
|
||||
'acl_links': acl_links, # For backwards compatibility
|
||||
'has_old_links': len(acl_links) > 0,
|
||||
'xray_subscription_url': f"https://{request.get_host()}/xray/{user.hash}",
|
||||
}
|
||||
|
||||
logger.debug(f"Context prepared with keys: {list(context.keys())}")
|
||||
logger.debug(f"Servers in context: {list(servers_data.keys())}")
|
||||
logger.debug(f"Groups in context: {list(groups_data.keys())}")
|
||||
|
||||
# Log sample server data for debugging
|
||||
for server_name, server_data in servers_data.items():
|
||||
logger.debug(f"Server {server_name}: total_connections={server_data['total_connections']}, links_count={len(server_data['links'])}")
|
||||
for i, link_data in enumerate(server_data['links']):
|
||||
logger.debug(f" Link {i}: connections={link_data['connections']}, recent={link_data['recent_connections']}, last_access='{link_data['last_access_display']}'")
|
||||
# Log sample group data for debugging
|
||||
for group_name, group_data in groups_data.items():
|
||||
logger.debug(f"Group {group_name}: total_connections={group_data['total_connections']}, inbounds_count={len(group_data['inbounds'])}")
|
||||
for i, inbound_data in enumerate(group_data['inbounds']):
|
||||
logger.debug(f" Inbound {i}: protocol={inbound_data['protocol']}, port={inbound_data['port']}, connections={inbound_data['connections']}")
|
||||
|
||||
return render(request, 'vpn/user_portal.html', context)
|
||||
|
||||
@@ -182,6 +194,15 @@ import logging
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.http import JsonResponse, HttpResponse, Http404
|
||||
from mysite.settings import EXTERNAL_ADDRESS
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def get_external_host():
|
||||
"""Extract hostname from EXTERNAL_ADDRESS, removing scheme"""
|
||||
parsed = urlparse(EXTERNAL_ADDRESS)
|
||||
if parsed.hostname:
|
||||
return parsed.hostname
|
||||
# If no scheme, assume it's just a hostname
|
||||
return EXTERNAL_ADDRESS
|
||||
|
||||
def userFrontend(request, user_hash):
|
||||
from .models import User, ACLLink
|
||||
@@ -195,7 +216,7 @@ def userFrontend(request, user_hash):
|
||||
server_name = link.acl.server.name
|
||||
if server_name not in acl_links:
|
||||
acl_links[server_name] = []
|
||||
acl_links[server_name].append({"link": f"{EXTERNAL_ADDRESS}/ss/{link.link}#{link.acl.server.name}", "comment": link.comment})
|
||||
acl_links[server_name].append({"link": f"https://{request.get_host()}/ss/{link.link}#{link.acl.server.name}", "comment": link.comment})
|
||||
|
||||
return JsonResponse(acl_links)
|
||||
|
||||
@@ -235,15 +256,27 @@ def shadowsocks(request, link):
|
||||
)
|
||||
return JsonResponse({"error": f"Couldn't get credentials from server. {e}"}, status=500)
|
||||
|
||||
# Handle both dict and object formats for server_user
|
||||
if isinstance(server_user, dict):
|
||||
password = server_user.get('password', '')
|
||||
method = server_user.get('method', 'aes-128-gcm')
|
||||
port = server_user.get('port', 8080)
|
||||
access_url = server_user.get('access_url', '')
|
||||
else:
|
||||
password = getattr(server_user, 'password', '')
|
||||
method = getattr(server_user, 'method', 'aes-128-gcm')
|
||||
port = getattr(server_user, 'port', 8080)
|
||||
access_url = getattr(server_user, 'access_url', '')
|
||||
|
||||
if request.GET.get('mode') == 'json':
|
||||
config = {
|
||||
"info": "Managed by OutFleet_2 [github.com/house-of-vanity/OutFleet/]",
|
||||
"password": server_user.password,
|
||||
"method": server_user.method,
|
||||
"password": password,
|
||||
"method": method,
|
||||
"prefix": "\u0005\u00dc_\u00e0\u0001",
|
||||
"server": acl.server.client_hostname,
|
||||
"server_port": server_user.port,
|
||||
"access_url": server_user.access_url,
|
||||
"server_port": port,
|
||||
"access_url": access_url,
|
||||
"outfleet": {
|
||||
"acl_link": link,
|
||||
"server_name": acl.server.name,
|
||||
@@ -257,16 +290,16 @@ def shadowsocks(request, link):
|
||||
"$type": "tcpudp",
|
||||
"tcp": {
|
||||
"$type": "shadowsocks",
|
||||
"endpoint": f"{acl.server.client_hostname}:{server_user.port}",
|
||||
"cipher": f"{server_user.method}",
|
||||
"secret": f"{server_user.password}",
|
||||
"endpoint": f"{acl.server.client_hostname}:{port}",
|
||||
"cipher": f"{method}",
|
||||
"secret": f"{password}",
|
||||
"prefix": "\u0005\u00dc_\u00e0\u0001"
|
||||
},
|
||||
"udp": {
|
||||
"$type": "shadowsocks",
|
||||
"endpoint": f"{acl.server.client_hostname}:{server_user.port}",
|
||||
"cipher": f"{server_user.method}",
|
||||
"secret": f"{server_user.password}",
|
||||
"endpoint": f"{acl.server.client_hostname}:{port}",
|
||||
"cipher": f"{method}",
|
||||
"secret": f"{password}",
|
||||
"prefix": "\u0005\u00dc_\u00e0\u0001"
|
||||
}
|
||||
}
|
||||
@@ -289,112 +322,314 @@ def shadowsocks(request, link):
|
||||
return HttpResponse(response, content_type=f"{ 'application/json; charset=utf-8' if request.GET.get('mode') == 'json' else 'text/html' }")
|
||||
|
||||
|
||||
def xray_subscription(request, link):
|
||||
def xray_subscription(request, user_hash):
|
||||
"""
|
||||
Return Xray subscription with all available protocols for the user.
|
||||
This generates a single subscription link that includes all inbounds the user has access to.
|
||||
This generates configs based on user's subscription groups.
|
||||
"""
|
||||
from .models import ACLLink, AccessLog
|
||||
from vpn.server_plugins import XrayCoreServer, XrayInboundServer
|
||||
from .models import User, AccessLog
|
||||
from .models_xray import UserSubscription
|
||||
import logging
|
||||
from django.utils import timezone
|
||||
import base64
|
||||
import uuid
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Clean user_hash from any trailing slashes
|
||||
user_hash = user_hash.rstrip('/')
|
||||
|
||||
try:
|
||||
acl_link = get_object_or_404(ACLLink, link=link)
|
||||
acl = acl_link.acl
|
||||
logger.info(f"Found ACL link for user {acl.user.username} on server {acl.server.name}")
|
||||
user = get_object_or_404(User, hash=user_hash)
|
||||
logger.info(f"Found user {user.username} for Xray subscription generation")
|
||||
except Http404:
|
||||
logger.warning(f"ACL link not found: {link}")
|
||||
logger.warning(f"User not found for hash: {user_hash}")
|
||||
AccessLog.objects.create(
|
||||
user=None,
|
||||
server="Unknown",
|
||||
acl_link_id=link,
|
||||
acl_link_id=user_hash,
|
||||
action="Failed",
|
||||
data=f"ACL not found for link: {link}"
|
||||
data=f"User not found for hash: {user_hash}"
|
||||
)
|
||||
return HttpResponse("Not found", status=404)
|
||||
|
||||
# Check if this is a JSON request for web display
|
||||
if request.GET.get('format') == 'json':
|
||||
return xray_subscription_json(request, user, user_hash)
|
||||
|
||||
try:
|
||||
# Get all servers this user has access to
|
||||
user_acls = acl.user.acl_set.all()
|
||||
# Check if specific group is requested
|
||||
group_filter = request.GET.get('group')
|
||||
|
||||
# Get subscription groups for the user
|
||||
user_subscriptions = UserSubscription.objects.filter(
|
||||
user=user,
|
||||
active=True,
|
||||
subscription_group__is_active=True
|
||||
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
|
||||
|
||||
# Filter by specific group if requested
|
||||
if group_filter:
|
||||
user_subscriptions = user_subscriptions.filter(subscription_group__name=group_filter)
|
||||
logger.info(f"Filtering subscription for group: {group_filter}")
|
||||
|
||||
subscription_configs = []
|
||||
|
||||
for user_acl in user_acls:
|
||||
server = user_acl.server.get_real_instance()
|
||||
for subscription in user_subscriptions:
|
||||
group = subscription.subscription_group
|
||||
logger.info(f"Processing subscription group {group.name} for user {user.username}")
|
||||
|
||||
# Handle XrayInboundServer (individual inbounds)
|
||||
if isinstance(server, XrayInboundServer):
|
||||
if server.xray_inbound:
|
||||
config = server.get_user(acl.user, raw=True)
|
||||
if config and 'connection_string' in config:
|
||||
subscription_configs.append(config['connection_string'])
|
||||
logger.info(f"Added XrayInboundServer config for {server.name}")
|
||||
|
||||
# Handle XrayCoreServer (parent server with multiple inbounds)
|
||||
elif isinstance(server, XrayCoreServer):
|
||||
# Get all inbounds from this group
|
||||
for inbound in group.inbounds.all():
|
||||
try:
|
||||
# Get all inbounds for this server that have this user
|
||||
for inbound in server.inbounds.filter(enabled=True):
|
||||
# Check if user has a client in this inbound
|
||||
client = inbound.clients.filter(user=acl.user).first()
|
||||
if client:
|
||||
connection_string = server._generate_connection_string(client)
|
||||
# Find all servers where this inbound is deployed
|
||||
from .models_xray import ServerInbound
|
||||
deployed_servers = ServerInbound.objects.filter(
|
||||
inbound=inbound,
|
||||
active=True
|
||||
).select_related('server')
|
||||
|
||||
# Generate connection string for each server where inbound is deployed
|
||||
for server_inbound in deployed_servers:
|
||||
server = server_inbound.server
|
||||
# Get server's client_hostname for XrayServerV2
|
||||
server_hostname = getattr(server.get_real_instance(), 'client_hostname', None)
|
||||
connection_string = generate_xray_connection_string(user, inbound, server.name, server_hostname)
|
||||
|
||||
if connection_string:
|
||||
subscription_configs.append(connection_string)
|
||||
logger.info(f"Added inbound {inbound.tag} config for user {acl.user.username}")
|
||||
logger.info(f"Added {inbound.protocol} config for inbound {inbound.name} on server {server.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get configs from XrayCoreServer {server.name}: {e}")
|
||||
logger.warning(f"Failed to generate config for inbound {inbound.name}: {e}")
|
||||
continue
|
||||
|
||||
if not subscription_configs:
|
||||
logger.warning(f"No Xray configurations found for user {acl.user.username}")
|
||||
group_msg = f" for group '{group_filter}'" if group_filter else ""
|
||||
logger.warning(f"No Xray configurations found for user {user.username}{group_msg}")
|
||||
AccessLog.objects.create(
|
||||
user=acl.user.username,
|
||||
server="Multiple",
|
||||
acl_link_id=acl_link.link,
|
||||
user=user.username,
|
||||
server="Xray-Subscription",
|
||||
acl_link_id=user_hash,
|
||||
action="Failed",
|
||||
data="No Xray configurations available"
|
||||
data=f"No Xray configurations available{group_msg}"
|
||||
)
|
||||
return HttpResponse("No configurations available", status=404)
|
||||
return HttpResponse(f"No configurations available{group_msg}", status=404)
|
||||
|
||||
# Join all configs with newlines and encode in base64 for subscription format
|
||||
subscription_content = '\n'.join(subscription_configs)
|
||||
logger.info(f"Raw subscription content for {acl.user.username}:\n{subscription_content}")
|
||||
logger.info(f"Raw subscription content for {user.username}: {len(subscription_configs)} configs")
|
||||
|
||||
subscription_b64 = base64.b64encode(subscription_content.encode('utf-8')).decode('utf-8')
|
||||
logger.info(f"Base64 subscription length: {len(subscription_b64)}")
|
||||
|
||||
# Update last access time
|
||||
acl_link.last_access_time = timezone.now()
|
||||
acl_link.save(update_fields=['last_access_time'])
|
||||
|
||||
# Create access log
|
||||
group_msg = f" for group '{group_filter}'" if group_filter else ""
|
||||
AccessLog.objects.create(
|
||||
user=acl.user.username,
|
||||
user=user.username,
|
||||
server="Xray-Subscription",
|
||||
acl_link_id=acl_link.link,
|
||||
acl_link_id=user_hash,
|
||||
action="Success",
|
||||
data=f"Generated subscription with {len(subscription_configs)} configs. Content: {subscription_content[:200]}..."
|
||||
data=f"Generated subscription with {len(subscription_configs)} configs from {user_subscriptions.count()} groups{group_msg}"
|
||||
)
|
||||
|
||||
logger.info(f"Generated Xray subscription for {acl.user.username} with {len(subscription_configs)} configs")
|
||||
logger.info(f"Generated Xray subscription for {user.username} with {len(subscription_configs)} configs{group_msg}")
|
||||
|
||||
# Return with proper headers for subscription
|
||||
response = HttpResponse(subscription_b64, content_type="text/plain; charset=utf-8")
|
||||
response['Content-Disposition'] = 'attachment; filename="xray_subscription.txt"'
|
||||
response['Content-Disposition'] = f'attachment; filename="{user.username}"'
|
||||
response['Cache-Control'] = 'no-cache'
|
||||
|
||||
# Add subscription-specific headers like other providers
|
||||
import base64 as b64
|
||||
profile_title_b64 = b64.b64encode("OutFleet VPN".encode('utf-8')).decode('utf-8')
|
||||
response['profile-title'] = f'base64:{profile_title_b64}'
|
||||
response['profile-update-interval'] = '24' # Update every 24 hours
|
||||
response['profile-web-page-url'] = f'https://{request.get_host()}/u/{user_hash}'
|
||||
response['support-url'] = f'https://{request.get_host()}/admin/'
|
||||
|
||||
# Add user info without limits (unlimited service)
|
||||
# Set very high limits to indicate "unlimited"
|
||||
import time
|
||||
expire_timestamp = int(time.time()) + (365 * 24 * 60 * 60) # 1 year from now
|
||||
response['subscription-userinfo'] = f'upload=0; download=0; total=1099511627776; expire={expire_timestamp}'
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate Xray subscription for {acl.user.username}: {e}")
|
||||
logger.error(f"Failed to generate Xray subscription for {user.username}: {e}")
|
||||
AccessLog.objects.create(
|
||||
user=acl.user.username,
|
||||
user=user.username,
|
||||
server="Xray-Subscription",
|
||||
acl_link_id=acl_link.link,
|
||||
acl_link_id=user_hash,
|
||||
action="Failed",
|
||||
data=f"Failed to generate subscription: {e}"
|
||||
)
|
||||
return HttpResponse(f"Error generating subscription: {e}", status=500)
|
||||
|
||||
|
||||
def xray_subscription_json(request, user, user_hash):
|
||||
"""Return Xray subscription in JSON format for web display"""
|
||||
from .models_xray import UserSubscription
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# Get all active subscription groups for the user
|
||||
user_subscriptions = UserSubscription.objects.filter(
|
||||
user=user,
|
||||
active=True,
|
||||
subscription_group__is_active=True
|
||||
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
|
||||
|
||||
groups_data = {}
|
||||
|
||||
for subscription in user_subscriptions:
|
||||
group = subscription.subscription_group
|
||||
group_configs = []
|
||||
|
||||
# Get all inbounds from this group
|
||||
for inbound in group.inbounds.all():
|
||||
try:
|
||||
# Find all servers where this inbound is deployed
|
||||
from .models_xray import ServerInbound
|
||||
deployed_servers = ServerInbound.objects.filter(
|
||||
inbound=inbound,
|
||||
active=True
|
||||
).select_related('server')
|
||||
|
||||
# Generate connection string for each server where inbound is deployed
|
||||
for server_inbound in deployed_servers:
|
||||
server = server_inbound.server
|
||||
# Get server's client_hostname for XrayServerV2
|
||||
server_hostname = getattr(server.get_real_instance(), 'client_hostname', None)
|
||||
connection_string = generate_xray_connection_string(user, inbound, server.name, server_hostname)
|
||||
|
||||
if connection_string:
|
||||
config_name = f"{server.name} {inbound.name}"
|
||||
group_configs.append({
|
||||
'name': config_name,
|
||||
'protocol': inbound.protocol.upper(),
|
||||
'port': inbound.port,
|
||||
'network': inbound.network,
|
||||
'security': inbound.security,
|
||||
'domain': host,
|
||||
'connection_string': connection_string
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate config for inbound {inbound.name}: {e}")
|
||||
continue
|
||||
|
||||
if group_configs:
|
||||
groups_data[group.name] = {
|
||||
'group_name': group.name,
|
||||
'description': group.description,
|
||||
'configs': group_configs
|
||||
}
|
||||
|
||||
return JsonResponse(groups_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate Xray JSON for {user.username}: {e}")
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
|
||||
def generate_xray_connection_string(user, inbound, server_name=None, server_hostname=None):
|
||||
"""Generate Xray connection string for user and inbound"""
|
||||
import uuid
|
||||
import base64
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
# Generate user UUID based on user ID and inbound
|
||||
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{user.username}-{inbound.name}"))
|
||||
|
||||
# Get host (use server's client_hostname if available, fallback to external host)
|
||||
host = server_hostname if server_hostname else get_external_host()
|
||||
|
||||
if inbound.protocol == 'vless':
|
||||
# VLESS URL format: vless://uuid@host:port?params#name
|
||||
params = []
|
||||
|
||||
# Always add transport type for VLESS
|
||||
params.append(f"type={inbound.network}")
|
||||
|
||||
if inbound.security != 'none':
|
||||
params.append(f"security={inbound.security}")
|
||||
if inbound.security == 'tls' and host:
|
||||
params.append(f"sni={host}")
|
||||
|
||||
if inbound.network == 'ws':
|
||||
params.append(f"path=/{inbound.name}")
|
||||
elif inbound.network == 'grpc':
|
||||
params.append(f"serviceName={inbound.name}")
|
||||
|
||||
param_string = '&'.join(params)
|
||||
query_part = f"?{param_string}" if param_string else ""
|
||||
|
||||
# Generate config name: ServerName InboundName (e.g., "Israel VLESS-Premium")
|
||||
config_name = f"{server_name} {inbound.name}" if server_name else inbound.name
|
||||
connection_string = f"vless://{user_uuid}@{host}:{inbound.port}{query_part}#{quote(config_name)}"
|
||||
|
||||
elif inbound.protocol == 'vmess':
|
||||
# VMess JSON format encoded in base64
|
||||
# Generate config name: ServerName InboundName (e.g., "Israel VMESS-Premium")
|
||||
config_name = f"{server_name} {inbound.name}" if server_name else inbound.name
|
||||
vmess_config = {
|
||||
"v": "2",
|
||||
"ps": config_name,
|
||||
"add": host,
|
||||
"port": str(inbound.port),
|
||||
"id": user_uuid,
|
||||
"aid": "0",
|
||||
"scy": "auto",
|
||||
"net": inbound.network,
|
||||
"type": "none",
|
||||
"host": host if host else "",
|
||||
"path": f"/{inbound.name}" if inbound.network == 'ws' else "",
|
||||
"tls": inbound.security if inbound.security != 'none' else ""
|
||||
}
|
||||
|
||||
vmess_json = json.dumps(vmess_config)
|
||||
vmess_b64 = base64.b64encode(vmess_json.encode()).decode()
|
||||
connection_string = f"vmess://{vmess_b64}"
|
||||
|
||||
elif inbound.protocol == 'trojan':
|
||||
# Trojan URL format: trojan://password@host:port?params#name
|
||||
# Use user UUID as password
|
||||
params = []
|
||||
|
||||
if inbound.security != 'none' and host:
|
||||
params.append(f"sni={host}")
|
||||
|
||||
if inbound.network != 'tcp':
|
||||
params.append(f"type={inbound.network}")
|
||||
if inbound.network == 'ws':
|
||||
params.append(f"path=/{inbound.name}")
|
||||
elif inbound.network == 'grpc':
|
||||
params.append(f"serviceName={inbound.name}")
|
||||
|
||||
param_string = '&'.join(params)
|
||||
query_part = f"?{param_string}" if param_string else ""
|
||||
|
||||
# Generate config name: ServerName InboundName (e.g., "Israel TROJAN-Premium")
|
||||
config_name = f"{server_name} {inbound.name}" if server_name else inbound.name
|
||||
connection_string = f"trojan://{user_uuid}@{host}:{inbound.port}{query_part}#{quote(config_name)}"
|
||||
|
||||
else:
|
||||
# Fallback for unknown protocols
|
||||
config_name = f"{server_name} {inbound.name}" if server_name else inbound.name
|
||||
connection_string = f"{inbound.protocol}://{user_uuid}@{host}:{inbound.port}#{quote(config_name)}"
|
||||
|
||||
return connection_string
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to generate connection string for {inbound.name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
Xray Manager - Python library for managing Xray proxy server via gRPC API.
|
||||
|
||||
Supports VLESS, VMess, and Trojan protocols.
|
||||
"""
|
||||
|
||||
from .client import XrayClient
|
||||
from .models import User, VlessUser, VmessUser, TrojanUser, Stats
|
||||
from .exceptions import XrayError, APIError, InboundNotFoundError, UserNotFoundError
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__all__ = [
|
||||
"XrayClient",
|
||||
"User",
|
||||
"VlessUser",
|
||||
"VmessUser",
|
||||
"TrojanUser",
|
||||
"Stats",
|
||||
"XrayError",
|
||||
"APIError",
|
||||
"InboundNotFoundError",
|
||||
"UserNotFoundError"
|
||||
]
|
||||
@@ -1,577 +0,0 @@
|
||||
"""
|
||||
Main Xray client for managing proxy server via gRPC API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .exceptions import APIError, InboundNotFoundError, UserNotFoundError
|
||||
from .models import Stats, TrojanUser, User, VlessUser, VmessUser
|
||||
from .protocols import TrojanProtocol, VlessProtocol, VmessProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class XrayClient:
|
||||
"""Main client for Xray server management."""
|
||||
|
||||
def __init__(self, server: str):
|
||||
"""
|
||||
Initialize Xray client.
|
||||
|
||||
Args:
|
||||
server: Xray gRPC API server address (host:port)
|
||||
"""
|
||||
self.server = server
|
||||
self.hostname = server.split(':')[0] # Extract hostname for client links
|
||||
|
||||
# Protocol handlers
|
||||
self._protocols = {}
|
||||
|
||||
# Inbound management
|
||||
|
||||
def add_vless_inbound(self, port: int, users: List[VlessUser], tag: Optional[str] = None,
|
||||
listen: str = "0.0.0.0", network: str = "tcp") -> None:
|
||||
"""Add VLESS inbound with users."""
|
||||
protocol = VlessProtocol(port, tag, listen, network)
|
||||
self._protocols[protocol.tag] = protocol
|
||||
config = protocol.create_inbound_config(users)
|
||||
self._add_inbound(config)
|
||||
|
||||
def add_vmess_inbound(self, port: int, users: List[VmessUser], tag: Optional[str] = None,
|
||||
listen: str = "0.0.0.0", network: str = "tcp") -> None:
|
||||
"""Add VMess inbound with users."""
|
||||
protocol = VmessProtocol(port, tag, listen, network)
|
||||
self._protocols[protocol.tag] = protocol
|
||||
config = protocol.create_inbound_config(users)
|
||||
self._add_inbound(config)
|
||||
|
||||
def add_trojan_inbound(self, port: int, users: List[TrojanUser], tag: Optional[str] = None,
|
||||
listen: str = "0.0.0.0", network: str = "tcp",
|
||||
cert_pem: Optional[str] = None, key_pem: Optional[str] = None,
|
||||
hostname: Optional[str] = None) -> None:
|
||||
"""Add Trojan inbound with users and optional custom certificates."""
|
||||
hostname = hostname or self.hostname
|
||||
protocol = TrojanProtocol(port, tag, listen, network, cert_pem, key_pem, hostname)
|
||||
self._protocols[protocol.tag] = protocol
|
||||
config = protocol.create_inbound_config(users)
|
||||
self._add_inbound(config)
|
||||
|
||||
|
||||
def remove_inbound(self, protocol_type_or_tag: str) -> None:
|
||||
"""
|
||||
Remove inbound by protocol type or tag.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
"""
|
||||
# Try to find by protocol type first
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
|
||||
|
||||
config = {"tag": tag}
|
||||
self._remove_inbound(config)
|
||||
|
||||
if tag in self._protocols:
|
||||
del self._protocols[tag]
|
||||
|
||||
def list_inbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List all inbounds."""
|
||||
return self._list_inbounds()
|
||||
|
||||
# User management
|
||||
|
||||
def add_user(self, protocol_type_or_tag: str, user: User) -> None:
|
||||
"""
|
||||
Add user to existing inbound by recreating it with updated users.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
user: User object matching the protocol type
|
||||
"""
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
# Check if it's a protocol type or direct tag
|
||||
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
|
||||
|
||||
# If protocol not registered, we need to get inbound info first
|
||||
if tag not in self._protocols:
|
||||
logger.debug(f"Protocol for tag '{tag}' not registered, attempting to retrieve inbound info")
|
||||
|
||||
# Try to get inbound info to determine protocol
|
||||
try:
|
||||
inbounds = self._list_inbounds()
|
||||
if isinstance(inbounds, dict) and 'inbounds' in inbounds:
|
||||
inbound_list = inbounds['inbounds']
|
||||
else:
|
||||
inbound_list = inbounds if isinstance(inbounds, list) else []
|
||||
|
||||
# Find the inbound by tag
|
||||
for inbound in inbound_list:
|
||||
if inbound.get('tag') == tag:
|
||||
# Determine protocol from proxySettings
|
||||
proxy_settings = inbound.get('proxySettings', {})
|
||||
typed_message = proxy_settings.get('_TypedMessage_', '')
|
||||
|
||||
if 'vless' in typed_message.lower():
|
||||
from .protocols import VlessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VlessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'vmess' in typed_message.lower():
|
||||
from .protocols import VmessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VmessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'trojan' in typed_message.lower():
|
||||
from .protocols import TrojanProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = TrojanProtocol(port, tag, listen, network, hostname=self.hostname)
|
||||
self._protocols[tag] = protocol
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve inbound info for tag '{tag}': {e}")
|
||||
|
||||
if tag not in self._protocols:
|
||||
raise InboundNotFoundError(f"Inbound {protocol_type_or_tag} not found or could not determine protocol")
|
||||
|
||||
protocol = self._protocols[tag]
|
||||
|
||||
# Use the recreate method since direct API doesn't work reliably
|
||||
self._recreate_inbound_with_user(protocol, user)
|
||||
|
||||
def remove_user(self, protocol_type_or_tag: str, email: str) -> None:
|
||||
"""
|
||||
Remove user from inbound by recreating it without the user.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
email: User email to remove
|
||||
"""
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
|
||||
|
||||
# Use same logic as add_user to find/register protocol
|
||||
if tag not in self._protocols:
|
||||
logger.debug(f"Protocol for tag '{tag}' not registered, attempting to retrieve inbound info")
|
||||
|
||||
# Try to get inbound info to determine protocol
|
||||
try:
|
||||
inbounds = self._list_inbounds()
|
||||
if isinstance(inbounds, dict) and 'inbounds' in inbounds:
|
||||
inbound_list = inbounds['inbounds']
|
||||
else:
|
||||
inbound_list = inbounds if isinstance(inbounds, list) else []
|
||||
|
||||
# Find the inbound by tag
|
||||
for inbound in inbound_list:
|
||||
if inbound.get('tag') == tag:
|
||||
# Determine protocol from proxySettings
|
||||
proxy_settings = inbound.get('proxySettings', {})
|
||||
typed_message = proxy_settings.get('_TypedMessage_', '')
|
||||
|
||||
if 'vless' in typed_message.lower():
|
||||
from .protocols import VlessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VlessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'vmess' in typed_message.lower():
|
||||
from .protocols import VmessProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = VmessProtocol(port, tag, listen, network)
|
||||
self._protocols[tag] = protocol
|
||||
elif 'trojan' in typed_message.lower():
|
||||
from .protocols import TrojanProtocol
|
||||
port = inbound.get('receiverSettings', {}).get('portList', 443)
|
||||
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
|
||||
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
|
||||
protocol = TrojanProtocol(port, tag, listen, network, hostname=self.hostname)
|
||||
self._protocols[tag] = protocol
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve inbound info for tag '{tag}': {e}")
|
||||
|
||||
if tag not in self._protocols:
|
||||
raise InboundNotFoundError(f"Inbound {protocol_type_or_tag} not found or could not determine protocol")
|
||||
|
||||
protocol = self._protocols[tag]
|
||||
|
||||
# Use the recreate method
|
||||
self._recreate_inbound_without_user(protocol, email)
|
||||
|
||||
# Client link generation
|
||||
|
||||
def generate_client_link(self, protocol_type_or_tag: str, user: User) -> str:
|
||||
"""
|
||||
Generate client connection link.
|
||||
|
||||
Args:
|
||||
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
|
||||
user: User object
|
||||
|
||||
Returns:
|
||||
Client connection link (vless://, vmess://, trojan://)
|
||||
"""
|
||||
# First try to find by protocol type
|
||||
tag_map = {
|
||||
'vless': 'vless-inbound',
|
||||
'vmess': 'vmess-inbound',
|
||||
'trojan': 'trojan-inbound'
|
||||
}
|
||||
|
||||
# Check if it's a protocol type or direct tag
|
||||
tag = tag_map.get(protocol_type_or_tag)
|
||||
if tag and tag in self._protocols:
|
||||
protocol = self._protocols[tag]
|
||||
elif protocol_type_or_tag in self._protocols:
|
||||
protocol = self._protocols[protocol_type_or_tag]
|
||||
else:
|
||||
# Try to find any protocol matching the type
|
||||
for stored_tag, stored_protocol in self._protocols.items():
|
||||
if protocol_type_or_tag in ['vless', 'vmess', 'trojan']:
|
||||
protocol_class_name = f"{protocol_type_or_tag.title()}Protocol"
|
||||
if stored_protocol.__class__.__name__ == protocol_class_name:
|
||||
protocol = stored_protocol
|
||||
break
|
||||
else:
|
||||
raise InboundNotFoundError(f"Protocol {protocol_type_or_tag} not configured")
|
||||
|
||||
return protocol.generate_client_link(user, self.hostname)
|
||||
|
||||
# Statistics
|
||||
|
||||
def get_server_stats(self) -> Dict[str, Any]:
|
||||
"""Get server system statistics."""
|
||||
return self._get_stats_sys()
|
||||
|
||||
def get_user_stats(self, protocol_type: str, email: str) -> Stats:
|
||||
"""
|
||||
Get user traffic statistics.
|
||||
|
||||
Args:
|
||||
protocol_type: Protocol type
|
||||
email: User email
|
||||
|
||||
Returns:
|
||||
Stats object with uplink/downlink data
|
||||
"""
|
||||
# Implementation would require stats queries
|
||||
# This is a placeholder for the interface
|
||||
return Stats(uplink=0, downlink=0)
|
||||
|
||||
# Private API methods
|
||||
|
||||
def _add_inbound(self, config: Dict[str, Any]) -> None:
|
||||
"""Add inbound via API."""
|
||||
result = self._run_api_command("adi", stdin_data=json.dumps(config))
|
||||
if "error" in result.get("stderr", "").lower():
|
||||
raise APIError(f"Failed to add inbound: {result['stderr']}")
|
||||
|
||||
def _remove_inbound(self, config: Dict[str, Any]) -> None:
|
||||
"""Remove inbound via API."""
|
||||
tag = config.get("tag")
|
||||
if tag:
|
||||
# Use tag directly as argument instead of JSON
|
||||
result = self._run_api_command("rmi", args=[tag])
|
||||
else:
|
||||
# Fallback to JSON if no tag
|
||||
result = self._run_api_command("rmi", stdin_data=json.dumps(config))
|
||||
|
||||
if result["returncode"] != 0 and "no inbound to remove" not in result.get("stderr", ""):
|
||||
raise APIError(f"Failed to remove inbound: {result['stderr']}")
|
||||
|
||||
def _list_inbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List inbounds via API."""
|
||||
result = self._run_api_command("lsi")
|
||||
if result["returncode"] != 0:
|
||||
raise APIError(f"Failed to list inbounds: {result['stderr']}")
|
||||
|
||||
try:
|
||||
return json.loads(result["stdout"])
|
||||
except json.JSONDecodeError:
|
||||
raise APIError("Invalid JSON response from API")
|
||||
|
||||
def _add_user(self, config: Dict[str, Any]) -> None:
|
||||
"""Add user via API."""
|
||||
logger.debug(f"Adding user with config: {json.dumps(config, indent=2)}")
|
||||
result = self._run_api_command("adu", stdin_data=json.dumps(config))
|
||||
|
||||
if result["returncode"] != 0 or "error" in result.get("stderr", "").lower():
|
||||
logger.error(f"Failed to add user. stdout: {result['stdout']}, stderr: {result['stderr']}")
|
||||
raise APIError(f"Failed to add user: {result['stderr'] or result['stdout']}")
|
||||
|
||||
logger.info(f"User {config.get('email')} added successfully to inbound {config.get('tag')}")
|
||||
|
||||
def _remove_user(self, inbound_tag: str, email: str) -> None:
|
||||
"""Remove user via API."""
|
||||
result = self._run_api_command("rmu", args=[f"--email={email}", inbound_tag])
|
||||
if "error" in result.get("stderr", "").lower():
|
||||
raise APIError(f"Failed to remove user: {result['stderr']}")
|
||||
|
||||
def _get_stats_sys(self) -> Dict[str, Any]:
|
||||
"""Get system stats via API."""
|
||||
result = self._run_api_command("statssys")
|
||||
if result["returncode"] != 0:
|
||||
raise APIError(f"Failed to get stats: {result['stderr']}")
|
||||
|
||||
try:
|
||||
return json.loads(result["stdout"])
|
||||
except json.JSONDecodeError:
|
||||
raise APIError("Invalid JSON response from API")
|
||||
|
||||
def _build_user_config(self, tag: str, user: User, protocol) -> Dict[str, Any]:
|
||||
"""
|
||||
Build user configuration for Xray API.
|
||||
|
||||
Args:
|
||||
tag: Inbound tag
|
||||
user: User object (VlessUser, VmessUser, or TrojanUser)
|
||||
protocol: Protocol handler
|
||||
|
||||
Returns:
|
||||
User configuration dict for Xray API
|
||||
"""
|
||||
from .models import VlessUser, VmessUser, TrojanUser
|
||||
|
||||
base_config = {
|
||||
"tag": tag,
|
||||
"email": user.email
|
||||
}
|
||||
|
||||
if isinstance(user, VlessUser):
|
||||
base_config["account"] = {
|
||||
"_TypedMessage_": "xray.proxy.vless.Account",
|
||||
"id": user.uuid
|
||||
}
|
||||
elif isinstance(user, VmessUser):
|
||||
base_config["account"] = {
|
||||
"_TypedMessage_": "xray.proxy.vmess.Account",
|
||||
"id": user.uuid,
|
||||
"alterId": getattr(user, 'alter_id', 0)
|
||||
}
|
||||
elif isinstance(user, TrojanUser):
|
||||
base_config["account"] = {
|
||||
"_TypedMessage_": "xray.proxy.trojan.Account",
|
||||
"password": user.password
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unsupported user type: {type(user)}")
|
||||
|
||||
return base_config
|
||||
|
||||
def _recreate_inbound_without_user(self, protocol, email: str) -> None:
|
||||
"""
|
||||
Recreate inbound without specified user.
|
||||
"""
|
||||
# Get existing users from the inbound
|
||||
existing_users = self._get_existing_users(protocol.tag)
|
||||
|
||||
# Filter out the user to remove
|
||||
all_users = [user for user in existing_users if user.email != email]
|
||||
|
||||
if len(all_users) == len(existing_users):
|
||||
logger.warning(f"User {email} not found in inbound {protocol.tag}")
|
||||
return
|
||||
|
||||
# Remove existing inbound
|
||||
try:
|
||||
self.remove_inbound(protocol.tag)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove inbound {protocol.tag}: {e}")
|
||||
|
||||
# Recreate inbound with remaining users
|
||||
if hasattr(protocol, '__class__') and 'Vless' in protocol.__class__.__name__:
|
||||
self.add_vless_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Vmess' in protocol.__class__.__name__:
|
||||
self.add_vmess_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Trojan' in protocol.__class__.__name__:
|
||||
self.add_trojan_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network,
|
||||
hostname=getattr(protocol, 'hostname', 'localhost')
|
||||
)
|
||||
|
||||
def _recreate_inbound_with_user(self, protocol, new_user: User) -> None:
|
||||
"""
|
||||
Recreate inbound with existing users plus new user.
|
||||
This is a workaround since Xray API doesn't support reliable dynamic user addition.
|
||||
"""
|
||||
# Get existing users from the inbound
|
||||
existing_users = self._get_existing_users(protocol.tag)
|
||||
|
||||
# Check if user already exists
|
||||
for existing_user in existing_users:
|
||||
if existing_user.email == new_user.email:
|
||||
return # User already exists, no need to recreate
|
||||
|
||||
# Add new user to existing users list
|
||||
all_users = existing_users + [new_user]
|
||||
|
||||
# Remove existing inbound
|
||||
try:
|
||||
self.remove_inbound(protocol.tag)
|
||||
except Exception as e:
|
||||
# If removal fails, log but continue - inbound might not exist
|
||||
pass
|
||||
|
||||
# Recreate inbound with all users
|
||||
if hasattr(protocol, '__class__') and 'Vless' in protocol.__class__.__name__:
|
||||
self.add_vless_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Vmess' in protocol.__class__.__name__:
|
||||
self.add_vmess_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network
|
||||
)
|
||||
elif hasattr(protocol, '__class__') and 'Trojan' in protocol.__class__.__name__:
|
||||
self.add_trojan_inbound(
|
||||
port=protocol.port,
|
||||
users=all_users,
|
||||
tag=protocol.tag,
|
||||
listen=protocol.listen,
|
||||
network=protocol.network,
|
||||
hostname=getattr(protocol, 'hostname', 'localhost')
|
||||
)
|
||||
|
||||
def _get_existing_users(self, tag: str) -> List[User]:
|
||||
"""
|
||||
Get existing users from an inbound.
|
||||
"""
|
||||
from .models import VlessUser, VmessUser, TrojanUser
|
||||
|
||||
try:
|
||||
# Use inbounduser API command to get existing users
|
||||
result = self._run_api_command("inbounduser", args=[f"-tag={tag}"])
|
||||
|
||||
if result["returncode"] != 0:
|
||||
return [] # No users or inbound doesn't exist
|
||||
|
||||
import json
|
||||
user_data = json.loads(result["stdout"])
|
||||
users = []
|
||||
|
||||
if "users" in user_data:
|
||||
for user_info in user_data["users"]:
|
||||
email = user_info.get("email", "")
|
||||
account = user_info.get("account", {})
|
||||
|
||||
# Determine protocol based on account type
|
||||
account_type = account.get("_TypedMessage_", "")
|
||||
|
||||
if "vless" in account_type.lower():
|
||||
users.append(VlessUser(
|
||||
email=email,
|
||||
uuid=account.get("id", "")
|
||||
))
|
||||
elif "vmess" in account_type.lower():
|
||||
users.append(VmessUser(
|
||||
email=email,
|
||||
uuid=account.get("id", ""),
|
||||
alter_id=account.get("alterId", 0)
|
||||
))
|
||||
elif "trojan" in account_type.lower():
|
||||
users.append(TrojanUser(
|
||||
email=email,
|
||||
password=account.get("password", "")
|
||||
))
|
||||
|
||||
return users
|
||||
|
||||
except Exception as e:
|
||||
# If we can't get existing users, return empty list
|
||||
return []
|
||||
|
||||
def _run_api_command(self, command: str, args: List[str] = None, stdin_data: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Run xray api command.
|
||||
|
||||
Args:
|
||||
command: API command (adi, rmi, lsi, etc.)
|
||||
args: Additional command arguments
|
||||
stdin_data: Data to pass via stdin
|
||||
|
||||
Returns:
|
||||
Dict with stdout, stderr, returncode
|
||||
"""
|
||||
cmd = ["xray", "api", command, f"--server={self.server}"]
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
logger.debug(f"Running command: {' '.join(cmd)}")
|
||||
if stdin_data:
|
||||
logger.debug(f"With stdin data: {stdin_data}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=stdin_data,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
logger.debug(f"Command result - returncode: {result.returncode}, stdout: {result.stdout}, stderr: {result.stderr}")
|
||||
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"returncode": result.returncode
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"API command timeout for: {' '.join(cmd)}")
|
||||
raise APIError("API command timeout")
|
||||
except FileNotFoundError:
|
||||
logger.error("xray command not found in PATH")
|
||||
raise APIError("xray command not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error running command: {e}")
|
||||
raise APIError(f"Failed to run command: {e}")
|
||||
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
Custom exceptions for Xray Manager.
|
||||
"""
|
||||
|
||||
|
||||
class XrayError(Exception):
|
||||
"""Base exception for all Xray-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class APIError(XrayError):
|
||||
"""Error occurred during API communication."""
|
||||
pass
|
||||
|
||||
|
||||
class InboundNotFoundError(XrayError):
|
||||
"""Inbound with specified tag not found."""
|
||||
pass
|
||||
|
||||
|
||||
class UserNotFoundError(XrayError):
|
||||
"""User with specified email not found."""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(XrayError):
|
||||
"""Error in Xray configuration."""
|
||||
pass
|
||||
|
||||
|
||||
class CertificateError(XrayError):
|
||||
"""Error related to TLS certificates."""
|
||||
pass
|
||||
@@ -1,93 +0,0 @@
|
||||
"""
|
||||
Data models for Xray Manager.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any
|
||||
from .utils import generate_uuid
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Base user model."""
|
||||
email: str
|
||||
level: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert user to dictionary representation."""
|
||||
return {
|
||||
"email": self.email,
|
||||
"level": self.level
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class VlessUser(User):
|
||||
"""VLESS protocol user."""
|
||||
uuid: str = field(default_factory=generate_uuid)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"id": self.uuid
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class VmessUser(User):
|
||||
"""VMess protocol user."""
|
||||
uuid: str = field(default_factory=generate_uuid)
|
||||
alter_id: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"id": self.uuid,
|
||||
"alterId": self.alter_id
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanUser(User):
|
||||
"""Trojan protocol user."""
|
||||
password: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"password": self.password
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class Inbound:
|
||||
"""Inbound configuration."""
|
||||
tag: str
|
||||
protocol: str
|
||||
port: int
|
||||
listen: str = "0.0.0.0"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"tag": self.tag,
|
||||
"protocol": self.protocol,
|
||||
"port": self.port,
|
||||
"listen": self.listen
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
"""Statistics data."""
|
||||
uplink: int = 0
|
||||
downlink: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total traffic (uplink + downlink)."""
|
||||
return self.uplink + self.downlink
|
||||
@@ -1,15 +0,0 @@
|
||||
"""
|
||||
Protocol-specific implementations for Xray Manager.
|
||||
"""
|
||||
|
||||
from .base import BaseProtocol
|
||||
from .vless import VlessProtocol
|
||||
from .vmess import VmessProtocol
|
||||
from .trojan import TrojanProtocol
|
||||
|
||||
__all__ = [
|
||||
"BaseProtocol",
|
||||
"VlessProtocol",
|
||||
"VmessProtocol",
|
||||
"TrojanProtocol"
|
||||
]
|
||||
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
Base protocol implementation.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Any, Optional
|
||||
from ..models import User
|
||||
|
||||
|
||||
class BaseProtocol(ABC):
|
||||
"""Base class for all protocol implementations."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"):
|
||||
self.port = port
|
||||
self.tag = tag or self._default_tag()
|
||||
self.listen = listen
|
||||
self.network = network
|
||||
|
||||
@abstractmethod
|
||||
def _default_tag(self) -> str:
|
||||
"""Return default tag for this protocol."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_inbound_config(self, users: List[User]) -> Dict[str, Any]:
|
||||
"""Create inbound configuration for this protocol."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_user_config(self, user: User) -> Dict[str, Any]:
|
||||
"""Create user configuration for adding to existing inbound."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_client_link(self, user: User, hostname: str) -> str:
|
||||
"""Generate client connection link."""
|
||||
pass
|
||||
|
||||
def _base_inbound_config(self) -> Dict[str, Any]:
|
||||
"""Common inbound configuration."""
|
||||
return {
|
||||
"listen": self.listen,
|
||||
"port": self.port,
|
||||
"tag": self.tag
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
"""
|
||||
Trojan protocol implementation.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .base import BaseProtocol
|
||||
from ..models import User, TrojanUser
|
||||
from ..utils import generate_self_signed_cert, pem_to_lines
|
||||
from ..exceptions import CertificateError
|
||||
|
||||
|
||||
class TrojanProtocol(BaseProtocol):
|
||||
"""Trojan protocol handler."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0",
|
||||
network: str = "tcp", cert_pem: Optional[str] = None,
|
||||
key_pem: Optional[str] = None, hostname: str = "localhost"):
|
||||
super().__init__(port, tag, listen, network)
|
||||
self.hostname = hostname
|
||||
|
||||
if cert_pem and key_pem:
|
||||
self.cert_pem = cert_pem
|
||||
self.key_pem = key_pem
|
||||
else:
|
||||
# Generate self-signed certificate
|
||||
self.cert_pem, self.key_pem = generate_self_signed_cert(hostname)
|
||||
|
||||
def _default_tag(self) -> str:
|
||||
return "trojan-inbound"
|
||||
|
||||
def create_inbound_config(self, users: List[TrojanUser]) -> Dict[str, Any]:
|
||||
"""Create Trojan inbound configuration."""
|
||||
config = self._base_inbound_config()
|
||||
config.update({
|
||||
"protocol": "trojan",
|
||||
"settings": {
|
||||
"_TypedMessage_": "xray.proxy.trojan.Config",
|
||||
"clients": [self._user_to_client(user) for user in users],
|
||||
"fallbacks": [{"dest": 80}]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": self.network,
|
||||
"security": "tls",
|
||||
"tlsSettings": {
|
||||
"alpn": ["http/1.1"],
|
||||
"certificates": [{
|
||||
"certificate": pem_to_lines(self.cert_pem),
|
||||
"key": pem_to_lines(self.key_pem),
|
||||
"usage": "encipherment"
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
return {"inbounds": [config]}
|
||||
|
||||
def create_user_config(self, user: TrojanUser) -> Dict[str, Any]:
|
||||
"""Create user configuration for Trojan."""
|
||||
return {
|
||||
"inboundTag": self.tag,
|
||||
"proxySettings": {
|
||||
"_TypedMessage_": "xray.proxy.trojan.Config",
|
||||
"clients": [self._user_to_client(user)]
|
||||
}
|
||||
}
|
||||
|
||||
def generate_client_link(self, user: TrojanUser, hostname: str) -> 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
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
"""
|
||||
VLESS protocol implementation.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .base import BaseProtocol
|
||||
from ..models import User, VlessUser
|
||||
|
||||
|
||||
class VlessProtocol(BaseProtocol):
|
||||
"""VLESS protocol handler."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"):
|
||||
super().__init__(port, tag, listen, network)
|
||||
|
||||
def _default_tag(self) -> str:
|
||||
return "vless-inbound"
|
||||
|
||||
def create_inbound_config(self, users: List[VlessUser]) -> Dict[str, Any]:
|
||||
"""Create VLESS inbound configuration."""
|
||||
config = self._base_inbound_config()
|
||||
config.update({
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"_TypedMessage_": "xray.proxy.vless.inbound.Config",
|
||||
"clients": [self._user_to_client(user) for user in users],
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": self.network
|
||||
}
|
||||
})
|
||||
return {"inbounds": [config]}
|
||||
|
||||
def create_user_config(self, user: VlessUser) -> Dict[str, Any]:
|
||||
"""Create user configuration for VLESS."""
|
||||
return {
|
||||
"inboundTag": self.tag,
|
||||
"proxySettings": {
|
||||
"_TypedMessage_": "xray.proxy.vless.inbound.Config",
|
||||
"clients": [self._user_to_client(user)]
|
||||
}
|
||||
}
|
||||
|
||||
def generate_client_link(self, user: VlessUser, hostname: str) -> 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
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
VMess protocol implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .base import BaseProtocol
|
||||
from ..models import User, VmessUser
|
||||
|
||||
|
||||
class VmessProtocol(BaseProtocol):
|
||||
"""VMess protocol handler."""
|
||||
|
||||
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"):
|
||||
super().__init__(port, tag, listen, network)
|
||||
|
||||
def _default_tag(self) -> str:
|
||||
return "vmess-inbound"
|
||||
|
||||
def create_inbound_config(self, users: List[VmessUser]) -> Dict[str, Any]:
|
||||
"""Create VMess inbound configuration."""
|
||||
config = self._base_inbound_config()
|
||||
config.update({
|
||||
"protocol": "vmess",
|
||||
"settings": {
|
||||
"_TypedMessage_": "xray.proxy.vmess.inbound.Config",
|
||||
"clients": [self._user_to_client(user) for user in users]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": self.network
|
||||
}
|
||||
})
|
||||
return {"inbounds": [config]}
|
||||
|
||||
def create_user_config(self, user: VmessUser) -> Dict[str, Any]:
|
||||
"""Create user configuration for VMess."""
|
||||
return {
|
||||
"inboundTag": self.tag,
|
||||
"proxySettings": {
|
||||
"_TypedMessage_": "xray.proxy.vmess.inbound.Config",
|
||||
"clients": [self._user_to_client(user)]
|
||||
}
|
||||
}
|
||||
|
||||
def generate_client_link(self, user: VmessUser, hostname: str) -> 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
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
"""
|
||||
Utility functions for Xray Manager.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import base64
|
||||
import secrets
|
||||
from typing import List
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
import datetime
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
"""Generate a random UUID for VLESS/VMess users."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
|
||||
|
||||
def generate_self_signed_cert(hostname: str = "localhost") -> tuple[str, str]:
|
||||
"""
|
||||
Generate self-signed certificate for Trojan.
|
||||
|
||||
Args:
|
||||
hostname: Common name for certificate
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
# Generate private key
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048
|
||||
)
|
||||
|
||||
# Create certificate
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||
])
|
||||
|
||||
cert = x509.CertificateBuilder().subject_name(
|
||||
subject
|
||||
).issuer_name(
|
||||
issuer
|
||||
).public_key(
|
||||
private_key.public_key()
|
||||
).serial_number(
|
||||
x509.random_serial_number()
|
||||
).not_valid_before(
|
||||
datetime.datetime.utcnow()
|
||||
).not_valid_after(
|
||||
datetime.datetime.utcnow() + datetime.timedelta(days=365)
|
||||
).add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.DNSName(hostname),
|
||||
]),
|
||||
critical=False,
|
||||
).sign(private_key, hashes.SHA256())
|
||||
|
||||
# Convert to PEM format
|
||||
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
|
||||
key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
return cert_pem.decode(), key_pem.decode()
|
||||
|
||||
|
||||
def pem_to_lines(pem_data: str) -> List[str]:
|
||||
"""Convert PEM data to list of lines for Xray JSON format."""
|
||||
return pem_data.strip().split('\n')
|
||||
62
vpn/xray_api_v2/__init__.py
Normal file
62
vpn/xray_api_v2/__init__.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Xray API Python Library"""
|
||||
from .client import XrayClient
|
||||
from .stats import StatsManager, StatItem, SystemStats
|
||||
from .subscription import SubscriptionLinkGenerator
|
||||
from .exceptions import (
|
||||
XrayAPIError, XrayConnectionError, XrayCommandError,
|
||||
XrayConfigError, XrayNotFoundError, XrayValidationError
|
||||
)
|
||||
from .models import (
|
||||
# Base
|
||||
XrayProtocol, TransportProtocol, SecurityType,
|
||||
|
||||
# Protocols
|
||||
VLESSClient, VMeSSUser, TrojanUser, TrojanFallback,
|
||||
VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig,
|
||||
generate_uuid, validate_uuid,
|
||||
|
||||
# Transports
|
||||
StreamSettings, create_tcp_stream, create_ws_stream,
|
||||
create_grpc_stream, create_http_stream, create_xhttp_stream,
|
||||
|
||||
# Security
|
||||
TLSConfig, REALITYConfig,
|
||||
create_tls_config, create_reality_config,
|
||||
generate_self_signed_certificate, create_tls_config_with_self_signed,
|
||||
|
||||
# Inbound
|
||||
InboundConfig, InboundBuilder, SniffingConfig
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
# Client
|
||||
'XrayClient',
|
||||
|
||||
# Stats
|
||||
'StatsManager', 'StatItem', 'SystemStats',
|
||||
|
||||
# Subscription
|
||||
'SubscriptionLinkGenerator',
|
||||
|
||||
# Exceptions
|
||||
'XrayAPIError', 'XrayConnectionError', 'XrayCommandError',
|
||||
'XrayConfigError', 'XrayNotFoundError', 'XrayValidationError',
|
||||
|
||||
# Enums
|
||||
'XrayProtocol', 'TransportProtocol', 'SecurityType',
|
||||
|
||||
# Models
|
||||
'VLESSClient', 'VMeSSUser', 'TrojanUser', 'TrojanFallback',
|
||||
'VLESSInboundConfig', 'VMeSSInboundConfig', 'TrojanServerConfig',
|
||||
'StreamSettings', 'TLSConfig', 'REALITYConfig',
|
||||
'InboundConfig', 'InboundBuilder', 'SniffingConfig',
|
||||
|
||||
# Utils
|
||||
'generate_uuid', 'validate_uuid',
|
||||
'create_tcp_stream', 'create_ws_stream',
|
||||
'create_grpc_stream', 'create_http_stream', 'create_xhttp_stream',
|
||||
'create_tls_config', 'create_reality_config',
|
||||
'generate_self_signed_certificate', 'create_tls_config_with_self_signed',
|
||||
]
|
||||
235
vpn/xray_api_v2/client.py
Normal file
235
vpn/xray_api_v2/client.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Xray API client implementation"""
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from .exceptions import (
|
||||
XrayConnectionError,
|
||||
XrayCommandError,
|
||||
XrayConfigError
|
||||
)
|
||||
from .models.base import BaseXrayModel
|
||||
|
||||
|
||||
class XrayClient:
|
||||
"""Client for interacting with Xray API via CLI commands"""
|
||||
|
||||
def __init__(self, server: str = "127.0.0.1:8080", timeout: int = 3):
|
||||
"""
|
||||
Initialize Xray client
|
||||
|
||||
Args:
|
||||
server: API server address (host:port)
|
||||
timeout: Command timeout in seconds
|
||||
"""
|
||||
self.server = server
|
||||
self.timeout = timeout
|
||||
self._xray_binary = "xray"
|
||||
|
||||
def execute_command(self,
|
||||
command: str,
|
||||
args: List[str] = None,
|
||||
json_files: List[Union[str, Dict, BaseXrayModel]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute xray API command
|
||||
|
||||
Args:
|
||||
command: API command (e.g., 'adi', 'adu', 'lsi')
|
||||
args: Additional command arguments
|
||||
json_files: JSON configurations (paths, dicts, or models)
|
||||
|
||||
Returns:
|
||||
Command output as dictionary
|
||||
"""
|
||||
cmd = [self._xray_binary, "api", command]
|
||||
cmd.extend([f"--server={self.server}", f"--timeout={self.timeout}"])
|
||||
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
temp_files = []
|
||||
try:
|
||||
# Handle JSON configurations
|
||||
if json_files:
|
||||
for config in json_files:
|
||||
if isinstance(config, str):
|
||||
# File path provided
|
||||
cmd.append(config)
|
||||
else:
|
||||
# Create temporary file for dict or model
|
||||
temp_file = self._create_temp_json(config)
|
||||
temp_files.append(temp_file)
|
||||
cmd.append(temp_file.name)
|
||||
|
||||
# Execute command
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.timeout + 5 # Add buffer to subprocess timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise XrayCommandError(f"Command failed: {result.stderr}")
|
||||
|
||||
# Parse output
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
# Some commands return plain text
|
||||
return {"output": output}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
raise XrayConnectionError(f"Command timed out after {self.timeout} seconds")
|
||||
except Exception as e:
|
||||
raise XrayCommandError(f"Command execution failed: {str(e)}")
|
||||
finally:
|
||||
# Cleanup temporary files
|
||||
for temp_file in temp_files:
|
||||
try:
|
||||
os.unlink(temp_file.name)
|
||||
except:
|
||||
pass
|
||||
|
||||
def _create_temp_json(self, config: Union[Dict, BaseXrayModel]) -> tempfile.NamedTemporaryFile:
|
||||
"""Create temporary JSON file from config"""
|
||||
if isinstance(config, BaseXrayModel):
|
||||
data = config.to_xray_json()
|
||||
else:
|
||||
data = config
|
||||
|
||||
temp_file = tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
suffix='.json',
|
||||
delete=False
|
||||
)
|
||||
json.dump(data, temp_file, indent=2)
|
||||
temp_file.close()
|
||||
return temp_file
|
||||
|
||||
# Inbound management
|
||||
def add_inbound(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add one or more inbounds"""
|
||||
# Wrap inbound configs in the required format
|
||||
wrapped_configs = []
|
||||
for config in configs:
|
||||
if isinstance(config, BaseXrayModel):
|
||||
config_dict = config.to_xray_json()
|
||||
else:
|
||||
config_dict = config
|
||||
|
||||
# Wrap in inbounds array if not already wrapped
|
||||
if "inbounds" not in config_dict:
|
||||
wrapped_config = {"inbounds": [config_dict]}
|
||||
else:
|
||||
wrapped_config = config_dict
|
||||
|
||||
wrapped_configs.append(wrapped_config)
|
||||
|
||||
return self.execute_command("adi", json_files=wrapped_configs)
|
||||
|
||||
def remove_inbound(self, tag: str) -> Dict[str, Any]:
|
||||
"""Remove inbound by tag"""
|
||||
return self.execute_command("rmi", args=[tag])
|
||||
|
||||
def list_inbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List all inbounds"""
|
||||
result = self.execute_command("lsi")
|
||||
return result.get("inbounds", [])
|
||||
|
||||
# Outbound management
|
||||
def add_outbound(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add one or more outbounds"""
|
||||
return self.execute_command("ado", json_files=list(configs))
|
||||
|
||||
def remove_outbound(self, tag: str) -> Dict[str, Any]:
|
||||
"""Remove outbound by tag"""
|
||||
return self.execute_command("rmo", args=[tag])
|
||||
|
||||
def list_outbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List all outbounds"""
|
||||
result = self.execute_command("lso")
|
||||
return result.get("outbounds", [])
|
||||
|
||||
# User management
|
||||
def add_users(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add users to inbounds using JSON files"""
|
||||
return self.execute_command("adu", json_files=list(configs))
|
||||
|
||||
def remove_users(self, tag: str, *emails: str) -> Dict[str, Any]:
|
||||
"""Remove users from inbounds using tag and email list"""
|
||||
args = [f"-tag={tag}"] + list(emails)
|
||||
return self.execute_command("rmu", args=args)
|
||||
|
||||
def get_inbound_user(self, tag: str, email: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get inbound user(s) information using -tag flag"""
|
||||
args = [f"-tag={tag}"]
|
||||
if email:
|
||||
args.append(f"-email={email}")
|
||||
return self.execute_command("inbounduser", args=args)
|
||||
|
||||
def get_inbound_user_count(self, tag: str) -> int:
|
||||
"""Get inbound user count using -tag flag"""
|
||||
args = [f"-tag={tag}"]
|
||||
result = self.execute_command("inboundusercount", args=args)
|
||||
# Parse the result - might be in output field or direct number
|
||||
if isinstance(result, dict):
|
||||
return result.get("count", 0)
|
||||
return 0
|
||||
|
||||
# Statistics
|
||||
def get_stats(self, pattern: str = "", reset: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Get statistics"""
|
||||
args = [pattern]
|
||||
if reset:
|
||||
args.append("-reset")
|
||||
if pattern:
|
||||
args.extend(["-json"])
|
||||
result = self.execute_command("statsquery", args=args)
|
||||
return result.get("stat", [])
|
||||
|
||||
def get_system_stats(self) -> Dict[str, Any]:
|
||||
"""Get system statistics"""
|
||||
return self.execute_command("statssys")
|
||||
|
||||
def get_online_stats(self, email: str) -> Dict[str, Any]:
|
||||
"""Get online session count for user"""
|
||||
return self.execute_command("statsonline", args=[email])
|
||||
|
||||
def get_online_ips(self, email: str) -> List[Dict[str, Any]]:
|
||||
"""Get user's online IP addresses"""
|
||||
result = self.execute_command("statsonlineiplist", args=[email])
|
||||
return result.get("ips", [])
|
||||
|
||||
# Routing rules
|
||||
def add_routing_rules(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add routing rules"""
|
||||
return self.execute_command("adrules", json_files=list(configs))
|
||||
|
||||
def remove_routing_rules(self, *tags: str) -> Dict[str, Any]:
|
||||
"""Remove routing rules by tags"""
|
||||
return self.execute_command("rmrules", args=list(tags))
|
||||
|
||||
# Other operations
|
||||
def restart_logger(self) -> Dict[str, Any]:
|
||||
"""Restart the logger"""
|
||||
return self.execute_command("restartlogger")
|
||||
|
||||
def get_balancer_info(self, tag: str) -> Dict[str, Any]:
|
||||
"""Get balancer information"""
|
||||
return self.execute_command("bi", args=[tag])
|
||||
|
||||
def override_balancer(self, tag: str, selectors: List[str]) -> Dict[str, Any]:
|
||||
"""Override balancer selection"""
|
||||
return self.execute_command("bo", args=[tag] + selectors)
|
||||
|
||||
def block_connection(self, source_ip: str, seconds: int) -> Dict[str, Any]:
|
||||
"""Block connections from source IP"""
|
||||
return self.execute_command("sib", args=[source_ip, str(seconds)])
|
||||
0
vpn/xray_api_v2/commands/__init__.py
Normal file
0
vpn/xray_api_v2/commands/__init__.py
Normal file
33
vpn/xray_api_v2/commands/user.py
Normal file
33
vpn/xray_api_v2/commands/user.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""User management command wrappers"""
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from ..models.base import BaseXrayModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class AddUserRequest(BaseXrayModel):
|
||||
"""Request to add user to inbound"""
|
||||
inboundTag: str
|
||||
user: Dict[str, Any] # Protocol-specific user config
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoveUserRequest(BaseXrayModel):
|
||||
"""Request to remove user from inbound"""
|
||||
inboundTag: str
|
||||
email: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserStats(BaseXrayModel):
|
||||
"""User statistics data"""
|
||||
email: str
|
||||
uplink: int = 0
|
||||
downlink: int = 0
|
||||
online: bool = False
|
||||
ips: List[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.ips is None:
|
||||
self.ips = []
|
||||
31
vpn/xray_api_v2/exceptions.py
Normal file
31
vpn/xray_api_v2/exceptions.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Exceptions for xray-api library"""
|
||||
|
||||
|
||||
class XrayAPIError(Exception):
|
||||
"""Base exception for all xray-api errors"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayConnectionError(XrayAPIError):
|
||||
"""Connection to Xray API server failed"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayCommandError(XrayAPIError):
|
||||
"""Xray command execution failed"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayConfigError(XrayAPIError):
|
||||
"""Invalid configuration"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayNotFoundError(XrayAPIError):
|
||||
"""Resource not found"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayValidationError(XrayAPIError):
|
||||
"""Validation error"""
|
||||
pass
|
||||
55
vpn/xray_api_v2/models/__init__.py
Normal file
55
vpn/xray_api_v2/models/__init__.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Xray API models"""
|
||||
from .base import (
|
||||
BaseXrayModel, XrayConfig, XrayProtocol,
|
||||
TransportProtocol, SecurityType
|
||||
)
|
||||
from .protocols import (
|
||||
# VLESS
|
||||
VLESSAccount, VLESSClient, VLESSInboundConfig,
|
||||
# VMess
|
||||
VMeSSAccount, VMeSSUser, VMeSSInboundConfig, VMeSSSecurityConfig,
|
||||
# Trojan
|
||||
TrojanAccount, TrojanUser, TrojanServerConfig, TrojanFallback,
|
||||
# Shadowsocks
|
||||
ShadowsocksAccount, ShadowsocksUser, ShadowsocksServerConfig,
|
||||
# Utils
|
||||
generate_uuid, validate_uuid, create_protocol_config
|
||||
)
|
||||
from .transports import (
|
||||
StreamSettings, TCPSettings, WebSocketSettings, GRPCSettings,
|
||||
HTTPSettings, XHTTPSettings, KCPSettings, QUICSettings, DomainSocketSettings,
|
||||
create_tcp_stream, create_ws_stream, create_grpc_stream, create_http_stream, create_xhttp_stream
|
||||
)
|
||||
from .security import (
|
||||
TLSConfig, REALITYConfig, XTLSConfig, Certificate,
|
||||
create_tls_config, create_reality_config, create_reality_client_config,
|
||||
generate_self_signed_certificate, create_tls_config_with_self_signed
|
||||
)
|
||||
from .inbound import (
|
||||
InboundConfig, ReceiverConfig, SniffingConfig, InboundBuilder
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
'BaseXrayModel', 'XrayConfig', 'XrayProtocol', 'TransportProtocol', 'SecurityType',
|
||||
|
||||
# Protocols
|
||||
'VLESSAccount', 'VLESSClient', 'VLESSInboundConfig',
|
||||
'VMeSSAccount', 'VMeSSUser', 'VMeSSInboundConfig', 'VMeSSSecurityConfig',
|
||||
'TrojanAccount', 'TrojanUser', 'TrojanServerConfig', 'TrojanFallback',
|
||||
'ShadowsocksAccount', 'ShadowsocksUser', 'ShadowsocksServerConfig',
|
||||
'generate_uuid', 'validate_uuid', 'create_protocol_config',
|
||||
|
||||
# Transports
|
||||
'StreamSettings', 'TCPSettings', 'WebSocketSettings', 'GRPCSettings',
|
||||
'HTTPSettings', 'XHTTPSettings', 'KCPSettings', 'QUICSettings', 'DomainSocketSettings',
|
||||
'create_tcp_stream', 'create_ws_stream', 'create_grpc_stream', 'create_http_stream', 'create_xhttp_stream',
|
||||
|
||||
# Security
|
||||
'TLSConfig', 'REALITYConfig', 'XTLSConfig', 'Certificate',
|
||||
'create_tls_config', 'create_reality_config', 'create_reality_client_config',
|
||||
'generate_self_signed_certificate', 'create_tls_config_with_self_signed',
|
||||
|
||||
# Inbound
|
||||
'InboundConfig', 'ReceiverConfig', 'SniffingConfig', 'InboundBuilder',
|
||||
]
|
||||
97
vpn/xray_api_v2/models/base.py
Normal file
97
vpn/xray_api_v2/models/base.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Base models for xray-api library"""
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from typing import Dict, Any, Optional, Type, TypeVar
|
||||
import json
|
||||
from enum import Enum
|
||||
|
||||
T = TypeVar('T', bound='BaseXrayModel')
|
||||
|
||||
|
||||
class BaseXrayModel(ABC):
|
||||
"""Base class for all Xray configuration models"""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for storage"""
|
||||
if hasattr(self, '__dataclass_fields__'):
|
||||
return self._clean_dict(asdict(self))
|
||||
return self._clean_dict(self.__dict__.copy())
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert model to Xray API format"""
|
||||
return self.to_dict()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
|
||||
"""Create model instance from dictionary"""
|
||||
return cls(**data)
|
||||
|
||||
def _clean_dict(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Remove None values and empty collections"""
|
||||
cleaned = {}
|
||||
for key, value in data.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (list, dict)) and not value:
|
||||
continue
|
||||
if isinstance(value, BaseXrayModel):
|
||||
cleaned[key] = value.to_dict()
|
||||
elif isinstance(value, list):
|
||||
cleaned[key] = [
|
||||
item.to_dict() if isinstance(item, BaseXrayModel) else item
|
||||
for item in value
|
||||
]
|
||||
elif isinstance(value, Enum):
|
||||
cleaned[key] = value.value
|
||||
else:
|
||||
cleaned[key] = value
|
||||
return cleaned
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Convert to JSON string"""
|
||||
return json.dumps(self.to_dict(), indent=2)
|
||||
|
||||
|
||||
@dataclass
|
||||
class XrayConfig(BaseXrayModel):
|
||||
"""Base configuration class"""
|
||||
_TypedMessage_: Optional[str] = field(default=None, init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Set TypedMessage after initialization"""
|
||||
if self._TypedMessage_ is None and hasattr(self, '__xray_type__'):
|
||||
self._TypedMessage_ = self.__xray_type__
|
||||
|
||||
|
||||
class XrayProtocol(str, Enum):
|
||||
"""Supported Xray protocols"""
|
||||
VLESS = "vless"
|
||||
VMESS = "vmess"
|
||||
TROJAN = "trojan"
|
||||
SHADOWSOCKS = "shadowsocks"
|
||||
DOKODEMO = "dokodemo-door"
|
||||
FREEDOM = "freedom"
|
||||
BLACKHOLE = "blackhole"
|
||||
DNS = "dns"
|
||||
HTTP = "http"
|
||||
SOCKS = "socks"
|
||||
|
||||
|
||||
class TransportProtocol(str, Enum):
|
||||
"""Transport protocols"""
|
||||
TCP = "tcp"
|
||||
KCP = "kcp"
|
||||
WS = "ws"
|
||||
HTTP = "http"
|
||||
XHTTP = "xhttp"
|
||||
DOMAINSOCKET = "domainsocket"
|
||||
QUIC = "quic"
|
||||
GRPC = "grpc"
|
||||
|
||||
|
||||
class SecurityType(str, Enum):
|
||||
"""Security types"""
|
||||
NONE = "none"
|
||||
TLS = "tls"
|
||||
REALITY = "reality"
|
||||
XTLS = "xtls"
|
||||
176
vpn/xray_api_v2/models/inbound.py
Normal file
176
vpn/xray_api_v2/models/inbound.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Inbound configuration models"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, XrayProtocol
|
||||
from .protocols import (
|
||||
VLESSInboundConfig, VMeSSInboundConfig,
|
||||
TrojanServerConfig, ShadowsocksServerConfig
|
||||
)
|
||||
from .transports import StreamSettings
|
||||
|
||||
|
||||
@dataclass
|
||||
class SniffingConfig(BaseXrayModel):
|
||||
"""Traffic sniffing configuration"""
|
||||
enabled: bool = True
|
||||
destOverride: Optional[List[str]] = None
|
||||
metadataOnly: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
if self.destOverride is None:
|
||||
self.destOverride = ["http", "tls"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReceiverConfig(XrayConfig):
|
||||
"""Receiver configuration for inbound"""
|
||||
__xray_type__ = "xray.app.proxyman.ReceiverConfig"
|
||||
|
||||
listen: str = "0.0.0.0"
|
||||
port: Optional[int] = None
|
||||
portList: Optional[Union[int, str]] = None # Can be int or range like "10000-20000"
|
||||
streamSettings: Optional[StreamSettings] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format"""
|
||||
config = {"listen": self.listen}
|
||||
|
||||
# Either port or portList must be set
|
||||
if self.port is not None:
|
||||
config["port"] = self.port
|
||||
elif self.portList is not None:
|
||||
config["portList"] = self.portList
|
||||
else:
|
||||
raise ValueError("Either port or portList must be specified")
|
||||
|
||||
if self.streamSettings:
|
||||
config["streamSettings"] = self.streamSettings.to_xray_json()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@dataclass
|
||||
class InboundConfig(BaseXrayModel):
|
||||
"""Complete inbound configuration"""
|
||||
tag: str
|
||||
protocol: XrayProtocol
|
||||
settings: Union[VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig, ShadowsocksServerConfig]
|
||||
listen: str = "0.0.0.0"
|
||||
port: Optional[int] = None
|
||||
portList: Optional[Union[int, str]] = None
|
||||
streamSettings: Optional[StreamSettings] = None
|
||||
sniffing: Optional[SniffingConfig] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper field order and structure"""
|
||||
config = {
|
||||
"listen": self.listen,
|
||||
"tag": self.tag,
|
||||
"protocol": self.protocol.value if hasattr(self.protocol, 'value') else str(self.protocol),
|
||||
}
|
||||
|
||||
# Add port or portList (port comes before protocol in working format)
|
||||
if self.port is not None:
|
||||
config["port"] = self.port
|
||||
elif self.portList is not None:
|
||||
config["portList"] = self.portList
|
||||
else:
|
||||
raise ValueError("Either port or portList must be specified")
|
||||
|
||||
# Add protocol settings with _TypedMessage_
|
||||
settings = self.settings.to_xray_json()
|
||||
if "_TypedMessage_" not in settings:
|
||||
# Add _TypedMessage_ based on protocol
|
||||
protocol_type_map = {
|
||||
"vless": "xray.proxy.vless.inbound.Config",
|
||||
"vmess": "xray.proxy.vmess.inbound.Config",
|
||||
"trojan": "xray.proxy.trojan.inbound.Config",
|
||||
"shadowsocks": "xray.proxy.shadowsocks.inbound.Config"
|
||||
}
|
||||
protocol_name = self.protocol.value if hasattr(self.protocol, 'value') else str(self.protocol)
|
||||
if protocol_name in protocol_type_map:
|
||||
settings["_TypedMessage_"] = protocol_type_map[protocol_name]
|
||||
|
||||
config["settings"] = settings
|
||||
|
||||
# Add stream settings
|
||||
if self.streamSettings:
|
||||
config["streamSettings"] = self.streamSettings.to_xray_json()
|
||||
else:
|
||||
config["streamSettings"] = {"network": "tcp"} # Default TCP
|
||||
|
||||
# Add sniffing
|
||||
if self.sniffing:
|
||||
config["sniffing"] = self.sniffing.to_dict()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Builder for easier configuration
|
||||
class InboundBuilder:
|
||||
"""Builder for creating inbound configurations"""
|
||||
|
||||
def __init__(self, tag: str, protocol: XrayProtocol):
|
||||
self.tag = tag
|
||||
self.protocol = protocol
|
||||
self._settings = None
|
||||
self._listen = "0.0.0.0"
|
||||
self._port = None
|
||||
self._port_list = None
|
||||
self._stream_settings = None
|
||||
self._sniffing = None
|
||||
|
||||
def listen(self, address: str) -> 'InboundBuilder':
|
||||
"""Set listen address"""
|
||||
self._listen = address
|
||||
return self
|
||||
|
||||
def port(self, port: int) -> 'InboundBuilder':
|
||||
"""Set single port"""
|
||||
self._port = port
|
||||
self._port_list = None
|
||||
return self
|
||||
|
||||
def port_range(self, start: int, end: int) -> 'InboundBuilder':
|
||||
"""Set port range"""
|
||||
self._port_list = f"{start}-{end}"
|
||||
self._port = None
|
||||
return self
|
||||
|
||||
def stream_settings(self, settings: StreamSettings) -> 'InboundBuilder':
|
||||
"""Set stream settings"""
|
||||
self._stream_settings = settings
|
||||
return self
|
||||
|
||||
def sniffing(self, enabled: bool = True, dest_override: Optional[List[str]] = None) -> 'InboundBuilder':
|
||||
"""Configure sniffing"""
|
||||
self._sniffing = SniffingConfig(
|
||||
enabled=enabled,
|
||||
destOverride=dest_override
|
||||
)
|
||||
return self
|
||||
|
||||
def protocol_settings(self, settings: Union[VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig, ShadowsocksServerConfig]) -> 'InboundBuilder':
|
||||
"""Set protocol-specific settings"""
|
||||
self._settings = settings
|
||||
return self
|
||||
|
||||
def build(self) -> InboundConfig:
|
||||
"""Build the inbound configuration"""
|
||||
if not self._settings:
|
||||
raise ValueError("Protocol settings must be specified")
|
||||
|
||||
if not self._port and not self._port_list:
|
||||
raise ValueError("Either port or port range must be specified")
|
||||
|
||||
return InboundConfig(
|
||||
tag=self.tag,
|
||||
protocol=self.protocol,
|
||||
settings=self._settings,
|
||||
listen=self._listen,
|
||||
port=self._port,
|
||||
portList=self._port_list,
|
||||
streamSettings=self._stream_settings,
|
||||
sniffing=self._sniffing
|
||||
)
|
||||
266
vpn/xray_api_v2/models/protocols.py
Normal file
266
vpn/xray_api_v2/models/protocols.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Protocol models for Xray"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import uuid4
|
||||
import re
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, XrayProtocol
|
||||
|
||||
|
||||
def validate_uuid(uuid_str: str) -> bool:
|
||||
"""Validate UUID format"""
|
||||
pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I)
|
||||
return bool(pattern.match(uuid_str))
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
"""Generate new UUID"""
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
# VLESS Protocol
|
||||
@dataclass
|
||||
class VLESSAccount(XrayConfig):
|
||||
"""VLESS account configuration"""
|
||||
__xray_type__ = "xray.proxy.vless.Account"
|
||||
|
||||
id: str
|
||||
flow: Optional[str] = None
|
||||
encryption: str = "none"
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
if not validate_uuid(self.id):
|
||||
raise ValueError(f"Invalid UUID: {self.id}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class VLESSClient(BaseXrayModel):
|
||||
"""VLESS client configuration"""
|
||||
email: str
|
||||
account: VLESSAccount
|
||||
level: int = 0
|
||||
|
||||
@classmethod
|
||||
def create(cls, email: str, uuid: Optional[str] = None, flow: Optional[str] = None) -> 'VLESSClient':
|
||||
"""Create VLESS client with optional UUID generation"""
|
||||
if uuid is None:
|
||||
uuid = generate_uuid()
|
||||
account = VLESSAccount(id=uuid, flow=flow)
|
||||
return cls(email=email, account=account)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VLESSInboundConfig(XrayConfig):
|
||||
"""VLESS inbound configuration"""
|
||||
__xray_type__ = "xray.proxy.vless.inbound.Config"
|
||||
|
||||
clients: List[VLESSClient] = field(default_factory=list)
|
||||
decryption: str = "none"
|
||||
fallbacks: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper structure"""
|
||||
config = {
|
||||
"_TypedMessage_": self.__xray_type__,
|
||||
"clients": [],
|
||||
"decryption": self.decryption
|
||||
}
|
||||
|
||||
# Convert clients to proper format
|
||||
for client in self.clients:
|
||||
client_data = {
|
||||
"id": client.account.id,
|
||||
"level": client.level,
|
||||
"email": client.email
|
||||
}
|
||||
if client.account.flow:
|
||||
client_data["flow"] = client.account.flow
|
||||
config["clients"].append(client_data)
|
||||
|
||||
if self.fallbacks:
|
||||
config["fallbacks"] = self.fallbacks
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# VMess Protocol
|
||||
@dataclass
|
||||
class VMeSSSecurityConfig(BaseXrayModel):
|
||||
"""VMess security configuration"""
|
||||
type: str = "AUTO" # AUTO, AES-128-GCM, CHACHA20-POLY1305, NONE
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMeSSAccount(XrayConfig):
|
||||
"""VMess account configuration"""
|
||||
__xray_type__ = "xray.proxy.vmess.Account"
|
||||
|
||||
id: str
|
||||
securitySettings: Optional[VMeSSSecurityConfig] = None
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
if not validate_uuid(self.id):
|
||||
raise ValueError(f"Invalid UUID: {self.id}")
|
||||
if self.securitySettings is None:
|
||||
self.securitySettings = VMeSSSecurityConfig()
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMeSSUser(BaseXrayModel):
|
||||
"""VMess user configuration"""
|
||||
email: str
|
||||
account: VMeSSAccount
|
||||
level: int = 0
|
||||
|
||||
@classmethod
|
||||
def create(cls, email: str, uuid: Optional[str] = None, security: str = "AUTO") -> 'VMeSSUser':
|
||||
"""Create VMess user with optional UUID generation"""
|
||||
if uuid is None:
|
||||
uuid = generate_uuid()
|
||||
account = VMeSSAccount(
|
||||
id=uuid,
|
||||
securitySettings=VMeSSSecurityConfig(type=security)
|
||||
)
|
||||
return cls(email=email, account=account)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMeSSInboundConfig(XrayConfig):
|
||||
"""VMess inbound configuration"""
|
||||
__xray_type__ = "xray.proxy.vmess.inbound.Config"
|
||||
|
||||
user: List[VMeSSUser] = field(default_factory=list)
|
||||
disableInsecureEncryption: bool = False
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper structure"""
|
||||
config = {
|
||||
"_TypedMessage_": self.__xray_type__,
|
||||
"clients": []
|
||||
}
|
||||
|
||||
# Convert users to proper format
|
||||
for user in self.user:
|
||||
client_data = {
|
||||
"id": user.account.id,
|
||||
"level": user.level,
|
||||
"email": user.email,
|
||||
"alterId": 0 # VMess specific
|
||||
}
|
||||
config["clients"].append(client_data)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Trojan Protocol
|
||||
@dataclass
|
||||
class TrojanAccount(XrayConfig):
|
||||
"""Trojan account configuration"""
|
||||
__xray_type__ = "xray.proxy.trojan.Account"
|
||||
|
||||
password: str
|
||||
|
||||
@classmethod
|
||||
def generate_password(cls) -> str:
|
||||
"""Generate secure password"""
|
||||
return generate_uuid()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanUser(BaseXrayModel):
|
||||
"""Trojan user configuration"""
|
||||
email: str
|
||||
account: TrojanAccount
|
||||
level: int = 0
|
||||
|
||||
@classmethod
|
||||
def create(cls, email: str, password: Optional[str] = None) -> 'TrojanUser':
|
||||
"""Create Trojan user with optional password generation"""
|
||||
if password is None:
|
||||
password = TrojanAccount.generate_password()
|
||||
account = TrojanAccount(password=password)
|
||||
return cls(email=email, account=account)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanFallback(BaseXrayModel):
|
||||
"""Trojan fallback configuration"""
|
||||
dest: str
|
||||
type: str = "tcp"
|
||||
xver: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanServerConfig(XrayConfig):
|
||||
"""Trojan server configuration"""
|
||||
__xray_type__ = "xray.proxy.trojan.ServerConfig"
|
||||
|
||||
users: List[TrojanUser] = field(default_factory=list)
|
||||
fallbacks: Optional[List[TrojanFallback]] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper structure"""
|
||||
config = {
|
||||
"_TypedMessage_": self.__xray_type__,
|
||||
"clients": []
|
||||
}
|
||||
|
||||
# Convert users to proper format
|
||||
for user in self.users:
|
||||
client_data = {
|
||||
"password": user.account.password,
|
||||
"level": user.level,
|
||||
"email": user.email
|
||||
}
|
||||
config["clients"].append(client_data)
|
||||
|
||||
if self.fallbacks:
|
||||
config["fallbacks"] = [fb.to_dict() for fb in self.fallbacks]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Shadowsocks Protocol
|
||||
@dataclass
|
||||
class ShadowsocksAccount(XrayConfig):
|
||||
"""Shadowsocks account configuration"""
|
||||
__xray_type__ = "xray.proxy.shadowsocks.Account"
|
||||
|
||||
method: str # aes-256-gcm, aes-128-gcm, chacha20-poly1305, etc.
|
||||
password: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShadowsocksUser(BaseXrayModel):
|
||||
"""Shadowsocks user configuration"""
|
||||
email: str
|
||||
account: ShadowsocksAccount
|
||||
level: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShadowsocksServerConfig(XrayConfig):
|
||||
"""Shadowsocks server configuration"""
|
||||
__xray_type__ = "xray.proxy.shadowsocks.ServerConfig"
|
||||
|
||||
users: List[ShadowsocksUser] = field(default_factory=list)
|
||||
network: str = "tcp,udp"
|
||||
|
||||
|
||||
# Protocol config factory
|
||||
def create_protocol_config(protocol: XrayProtocol, **kwargs) -> XrayConfig:
|
||||
"""Factory to create protocol configurations"""
|
||||
protocol_map = {
|
||||
XrayProtocol.VLESS: VLESSInboundConfig,
|
||||
XrayProtocol.VMESS: VMeSSInboundConfig,
|
||||
XrayProtocol.TROJAN: TrojanServerConfig,
|
||||
XrayProtocol.SHADOWSOCKS: ShadowsocksServerConfig,
|
||||
}
|
||||
|
||||
config_class = protocol_map.get(protocol)
|
||||
if not config_class:
|
||||
raise ValueError(f"Unsupported protocol: {protocol}")
|
||||
|
||||
return config_class(**kwargs)
|
||||
389
vpn/xray_api_v2/models/security.py
Normal file
389
vpn/xray_api_v2/models/security.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""Security configuration models for Xray"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, SecurityType
|
||||
|
||||
|
||||
# TLS Configuration
|
||||
@dataclass
|
||||
class Certificate(BaseXrayModel):
|
||||
"""TLS certificate configuration"""
|
||||
certificateFile: Optional[str] = None
|
||||
keyFile: Optional[str] = None
|
||||
certificate: Optional[List[str]] = None # PEM format lines
|
||||
key: Optional[List[str]] = None # PEM format lines
|
||||
usage: str = "encipherment"
|
||||
ocspStapling: int = 3600
|
||||
oneTimeLoading: bool = False
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format"""
|
||||
config = {}
|
||||
|
||||
if self.certificateFile and self.keyFile:
|
||||
config["certificateFile"] = self.certificateFile
|
||||
config["keyFile"] = self.keyFile
|
||||
elif self.certificate and self.key:
|
||||
config["certificate"] = self.certificate
|
||||
config["key"] = self.key
|
||||
|
||||
config["usage"] = self.usage
|
||||
|
||||
if self.ocspStapling:
|
||||
config["ocspStapling"] = self.ocspStapling
|
||||
|
||||
if self.oneTimeLoading:
|
||||
config["OneTimeLoading"] = self.oneTimeLoading
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@dataclass
|
||||
class TLSConfig(XrayConfig):
|
||||
"""TLS configuration"""
|
||||
__xray_type__ = "xray.transport.internet.tls.Config"
|
||||
|
||||
serverName: Optional[str] = None
|
||||
allowInsecure: bool = False
|
||||
alpn: Optional[List[str]] = None
|
||||
minVersion: str = "1.2"
|
||||
maxVersion: str = "1.3"
|
||||
preferServerCipherSuites: bool = True
|
||||
cipherSuites: Optional[str] = None
|
||||
certificates: Optional[List[Certificate]] = None
|
||||
disableSystemRoot: bool = False
|
||||
enableSessionResumption: bool = False
|
||||
fingerprint: Optional[str] = None # Client-side
|
||||
pinnedPeerCertificateChainSha256: Optional[List[str]] = None
|
||||
rejectUnknownSni: bool = False # Server-side
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format with proper field handling"""
|
||||
config = super().to_xray_json()
|
||||
|
||||
# Handle certificates properly
|
||||
if self.certificates:
|
||||
config["certificates"] = [cert.to_xray_json() for cert in self.certificates]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# REALITY Configuration
|
||||
@dataclass
|
||||
class REALITYConfig(XrayConfig):
|
||||
"""REALITY configuration"""
|
||||
__xray_type__ = "xray.transport.internet.reality.Config"
|
||||
|
||||
# Server-side
|
||||
show: bool = False
|
||||
dest: Optional[str] = None # e.g., "example.com:443"
|
||||
xver: int = 0
|
||||
serverNames: Optional[List[str]] = None
|
||||
privateKey: Optional[str] = None
|
||||
shortIds: Optional[List[str]] = None
|
||||
|
||||
# Client-side
|
||||
serverName: Optional[str] = None
|
||||
fingerprint: str = "chrome"
|
||||
publicKey: Optional[str] = None
|
||||
shortId: Optional[str] = None
|
||||
spiderX: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def generate_keys(cls) -> Dict[str, str]:
|
||||
"""Generate REALITY key pair using xray x25519"""
|
||||
import subprocess
|
||||
try:
|
||||
# Use xray x25519 to generate proper keys
|
||||
result = subprocess.run(['xray', 'x25519'], capture_output=True, text=True, check=True)
|
||||
lines = result.stdout.strip().split('\n')
|
||||
|
||||
private_key = ""
|
||||
public_key = ""
|
||||
|
||||
for line in lines:
|
||||
if line.startswith('Private key:'):
|
||||
private_key = line.split(': ')[1].strip()
|
||||
elif line.startswith('Public key:'):
|
||||
public_key = line.split(': ')[1].strip()
|
||||
|
||||
return {
|
||||
"privateKey": private_key,
|
||||
"publicKey": public_key
|
||||
}
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, IndexError):
|
||||
# Fallback to base64 encoded random bytes (32 bytes for X25519)
|
||||
import base64
|
||||
private_bytes = secrets.token_bytes(32)
|
||||
public_bytes = secrets.token_bytes(32)
|
||||
return {
|
||||
"privateKey": base64.b64encode(private_bytes).decode().rstrip('='),
|
||||
"publicKey": base64.b64encode(public_bytes).decode().rstrip('=')
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def generate_short_id(cls) -> str:
|
||||
"""Generate random short ID (1-16 hex chars)"""
|
||||
# Generate 1-8 bytes (2-16 hex chars)
|
||||
length = secrets.randbelow(8) + 1
|
||||
return secrets.token_hex(length)
|
||||
|
||||
|
||||
# XTLS Configuration (deprecated but still supported)
|
||||
@dataclass
|
||||
class XTLSConfig(XrayConfig):
|
||||
"""XTLS configuration (legacy)"""
|
||||
__xray_type__ = "xray.transport.internet.xtls.Config"
|
||||
|
||||
serverName: Optional[str] = None
|
||||
allowInsecure: bool = False
|
||||
alpn: Optional[List[str]] = None
|
||||
minVersion: str = "1.2"
|
||||
maxVersion: str = "1.3"
|
||||
preferServerCipherSuites: bool = True
|
||||
cipherSuites: Optional[str] = None
|
||||
certificates: Optional[List[Certificate]] = None
|
||||
disableSystemRoot: bool = False
|
||||
fingerprint: Optional[str] = None
|
||||
rejectUnknownSni: bool = False
|
||||
|
||||
|
||||
# Security factory
|
||||
def create_tls_config(
|
||||
server_name: Optional[str] = None,
|
||||
cert_file: Optional[str] = None,
|
||||
key_file: Optional[str] = None,
|
||||
alpn: Optional[List[str]] = None,
|
||||
fingerprint: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> TLSConfig:
|
||||
"""Create TLS configuration"""
|
||||
config = TLSConfig(
|
||||
serverName=server_name,
|
||||
alpn=alpn or ["h2", "http/1.1"],
|
||||
fingerprint=fingerprint,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if cert_file and key_file:
|
||||
config.certificates = [Certificate(
|
||||
certificateFile=cert_file,
|
||||
keyFile=key_file
|
||||
)]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def create_reality_config(
|
||||
dest: str,
|
||||
server_names: Optional[List[str]] = None,
|
||||
private_key: Optional[str] = None,
|
||||
short_ids: Optional[List[str]] = None,
|
||||
**kwargs
|
||||
) -> REALITYConfig:
|
||||
"""Create REALITY configuration for server"""
|
||||
if not private_key:
|
||||
keys = REALITYConfig.generate_keys()
|
||||
private_key = keys["privateKey"]
|
||||
|
||||
if not short_ids:
|
||||
short_ids = [REALITYConfig.generate_short_id()]
|
||||
|
||||
return REALITYConfig(
|
||||
show=False,
|
||||
dest=dest,
|
||||
serverNames=server_names or [dest.split(":")[0]],
|
||||
privateKey=private_key,
|
||||
shortIds=short_ids,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def create_reality_client_config(
|
||||
server_name: str,
|
||||
public_key: str,
|
||||
short_id: str,
|
||||
fingerprint: str = "chrome",
|
||||
**kwargs
|
||||
) -> REALITYConfig:
|
||||
"""Create REALITY configuration for client"""
|
||||
return REALITYConfig(
|
||||
serverName=server_name,
|
||||
publicKey=public_key,
|
||||
shortId=short_id,
|
||||
fingerprint=fingerprint,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
# Certificate generation utilities
|
||||
def generate_self_signed_certificate(
|
||||
common_name: str = "localhost",
|
||||
san_list: Optional[List[str]] = None,
|
||||
days: int = 365,
|
||||
key_size: int = 2048,
|
||||
save_to_files: bool = False,
|
||||
cert_path: Optional[str] = None,
|
||||
key_path: Optional[str] = None
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Generate self-signed certificate
|
||||
|
||||
Args:
|
||||
common_name: Certificate common name
|
||||
san_list: Subject Alternative Names (domains/IPs)
|
||||
days: Certificate validity in days
|
||||
key_size: RSA key size
|
||||
save_to_files: Whether to save to files
|
||||
cert_path: Path to save certificate
|
||||
key_path: Path to save private key
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID, ExtensionOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
except ImportError:
|
||||
raise ImportError("cryptography package is required for certificate generation. Install with: pip install cryptography")
|
||||
|
||||
# Generate private key
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_size,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
# Create certificate subject
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
||||
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "State"),
|
||||
x509.NameAttribute(NameOID.LOCALITY_NAME, "City"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Xray Self-Signed"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
|
||||
])
|
||||
|
||||
# Create certificate builder
|
||||
builder = x509.CertificateBuilder()
|
||||
builder = builder.subject_name(subject)
|
||||
builder = builder.issuer_name(issuer)
|
||||
builder = builder.public_key(private_key.public_key())
|
||||
builder = builder.serial_number(x509.random_serial_number())
|
||||
builder = builder.not_valid_before(datetime.utcnow())
|
||||
builder = builder.not_valid_after(datetime.utcnow() + timedelta(days=days))
|
||||
|
||||
# Add Subject Alternative Names
|
||||
san_list = san_list or [common_name]
|
||||
alt_names = []
|
||||
for san in san_list:
|
||||
if san.replace('.', '').isdigit(): # IP address
|
||||
alt_names.append(x509.IPAddress(ipaddress.ip_address(san)))
|
||||
else: # Domain name
|
||||
alt_names.append(x509.DNSName(san))
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectAlternativeName(alt_names),
|
||||
critical=False,
|
||||
)
|
||||
|
||||
# Add basic constraints
|
||||
builder = builder.add_extension(
|
||||
x509.BasicConstraints(ca=True, path_length=0),
|
||||
critical=True,
|
||||
)
|
||||
|
||||
# Add key usage
|
||||
builder = builder.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
content_commitment=False,
|
||||
key_encipherment=True,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=True,
|
||||
crl_sign=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False,
|
||||
),
|
||||
critical=True,
|
||||
)
|
||||
|
||||
# Sign certificate
|
||||
certificate = builder.sign(private_key, hashes.SHA256(), default_backend())
|
||||
|
||||
# Serialize to PEM
|
||||
cert_pem = certificate.public_bytes(serialization.Encoding.PEM).decode('utf-8')
|
||||
key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
).decode('utf-8')
|
||||
|
||||
# Save to files if requested
|
||||
if save_to_files:
|
||||
cert_file = cert_path or f"{common_name}_cert.pem"
|
||||
key_file = key_path or f"{common_name}_key.pem"
|
||||
|
||||
with open(cert_file, 'w') as f:
|
||||
f.write(cert_pem)
|
||||
|
||||
with open(key_file, 'w') as f:
|
||||
f.write(key_pem)
|
||||
|
||||
# Set appropriate permissions
|
||||
Path(key_file).chmod(0o600)
|
||||
|
||||
return cert_pem, key_pem
|
||||
|
||||
|
||||
def create_tls_config_with_self_signed(
|
||||
common_name: str = "localhost",
|
||||
san_list: Optional[List[str]] = None,
|
||||
alpn: Optional[List[str]] = None,
|
||||
**tls_kwargs
|
||||
) -> Tuple[TLSConfig, str, str]:
|
||||
"""
|
||||
Create TLS configuration with self-signed certificate
|
||||
|
||||
Returns:
|
||||
Tuple of (TLSConfig, certificate_pem, private_key_pem)
|
||||
"""
|
||||
cert_pem, key_pem = generate_self_signed_certificate(
|
||||
common_name=common_name,
|
||||
san_list=san_list
|
||||
)
|
||||
|
||||
# Convert PEM to lines for Xray format
|
||||
cert_lines = cert_pem.strip().split('\n')
|
||||
key_lines = key_pem.strip().split('\n')
|
||||
|
||||
# Create certificate config
|
||||
certificate = Certificate(
|
||||
certificate=cert_lines,
|
||||
key=key_lines,
|
||||
oneTimeLoading=True
|
||||
)
|
||||
|
||||
# Create TLS config
|
||||
tls_config = TLSConfig(
|
||||
serverName=common_name,
|
||||
alpn=alpn or ["h2", "http/1.1"],
|
||||
certificates=[certificate],
|
||||
**tls_kwargs
|
||||
)
|
||||
|
||||
return tls_config, cert_pem, key_pem
|
||||
|
||||
|
||||
# Add missing import
|
||||
try:
|
||||
import ipaddress
|
||||
except ImportError:
|
||||
ipaddress = None
|
||||
241
vpn/xray_api_v2/models/transports.py
Normal file
241
vpn/xray_api_v2/models/transports.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Transport configuration models for Xray"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, TransportProtocol
|
||||
|
||||
|
||||
# TCP Transport
|
||||
@dataclass
|
||||
class TCPSettings(XrayConfig):
|
||||
"""TCP transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.tcp.Config"
|
||||
|
||||
acceptProxyProtocol: bool = False
|
||||
header: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# KCP Transport
|
||||
@dataclass
|
||||
class KCPSettings(XrayConfig):
|
||||
"""KCP transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.kcp.Config"
|
||||
|
||||
mtu: int = 1350
|
||||
tti: int = 50
|
||||
uplinkCapacity: int = 5
|
||||
downlinkCapacity: int = 20
|
||||
congestion: bool = False
|
||||
readBufferSize: int = 2
|
||||
writeBufferSize: int = 2
|
||||
header: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# WebSocket Transport
|
||||
@dataclass
|
||||
class WebSocketSettings(XrayConfig):
|
||||
"""WebSocket transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.websocket.Config"
|
||||
|
||||
path: str = "/"
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
acceptProxyProtocol: bool = False
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format"""
|
||||
config = super().to_xray_json()
|
||||
# Ensure headers is a dict even if empty
|
||||
if self.headers:
|
||||
config["headers"] = self.headers
|
||||
return config
|
||||
|
||||
|
||||
# HTTP/2 Transport
|
||||
@dataclass
|
||||
class HTTPSettings(XrayConfig):
|
||||
"""HTTP/2 transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.http.Config"
|
||||
|
||||
path: str = "/"
|
||||
host: Optional[List[str]] = None
|
||||
method: str = "PUT"
|
||||
headers: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
|
||||
# XHTTP Transport (New)
|
||||
@dataclass
|
||||
class XHTTPSettings(XrayConfig):
|
||||
"""XHTTP transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.xhttp.Config"
|
||||
|
||||
path: str = "/"
|
||||
host: Optional[str] = None
|
||||
method: str = "GET"
|
||||
headers: Optional[Dict[str, Any]] = None
|
||||
mode: str = "auto"
|
||||
|
||||
|
||||
# Domain Socket Transport
|
||||
@dataclass
|
||||
class DomainSocketSettings(XrayConfig):
|
||||
"""Domain socket transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.domainsocket.Config"
|
||||
|
||||
path: str
|
||||
abstract: bool = False
|
||||
padding: bool = False
|
||||
|
||||
|
||||
# QUIC Transport
|
||||
@dataclass
|
||||
class QUICSettings(XrayConfig):
|
||||
"""QUIC transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.quic.Config"
|
||||
|
||||
security: str = "none"
|
||||
key: str = ""
|
||||
header: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# gRPC Transport
|
||||
@dataclass
|
||||
class GRPCSettings(XrayConfig):
|
||||
"""gRPC transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.grpc.encoding.Config"
|
||||
|
||||
serviceName: str = ""
|
||||
multiMode: bool = False
|
||||
idle_timeout: int = 60
|
||||
health_check_timeout: int = 20
|
||||
permit_without_stream: bool = False
|
||||
initial_windows_size: int = 0
|
||||
|
||||
|
||||
# Stream Settings
|
||||
@dataclass
|
||||
class StreamSettings(BaseXrayModel):
|
||||
"""Stream settings for inbound/outbound"""
|
||||
|
||||
network: TransportProtocol = TransportProtocol.TCP
|
||||
security: Optional[str] = None
|
||||
tlsSettings: Optional[Any] = None
|
||||
xtlsSettings: Optional[Any] = None
|
||||
realitySettings: Optional[Any] = None
|
||||
tcpSettings: Optional[TCPSettings] = None
|
||||
kcpSettings: Optional[KCPSettings] = None
|
||||
wsSettings: Optional[WebSocketSettings] = None
|
||||
httpSettings: Optional[HTTPSettings] = None
|
||||
xhttpSettings: Optional[XHTTPSettings] = None
|
||||
dsSettings: Optional[DomainSocketSettings] = None
|
||||
quicSettings: Optional[QUICSettings] = None
|
||||
grpcSettings: Optional[GRPCSettings] = None
|
||||
sockopt: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format with correct field names"""
|
||||
config = {
|
||||
"network": self.network.value if isinstance(self.network, TransportProtocol) else self.network
|
||||
}
|
||||
|
||||
if self.security:
|
||||
config["security"] = self.security
|
||||
|
||||
# Map transport settings
|
||||
transport_map = {
|
||||
TransportProtocol.TCP: ("tcpSettings", self.tcpSettings),
|
||||
TransportProtocol.KCP: ("kcpSettings", self.kcpSettings),
|
||||
TransportProtocol.WS: ("wsSettings", self.wsSettings),
|
||||
TransportProtocol.HTTP: ("httpSettings", self.httpSettings),
|
||||
TransportProtocol.XHTTP: ("xhttpSettings", self.xhttpSettings),
|
||||
TransportProtocol.DOMAINSOCKET: ("dsSettings", self.dsSettings),
|
||||
TransportProtocol.QUIC: ("quicSettings", self.quicSettings),
|
||||
TransportProtocol.GRPC: ("grpcSettings", self.grpcSettings),
|
||||
}
|
||||
|
||||
network = self.network if isinstance(self.network, TransportProtocol) else TransportProtocol(self.network)
|
||||
field_name, settings = transport_map.get(network, (None, None))
|
||||
|
||||
if field_name and settings:
|
||||
config[field_name] = settings.to_xray_json() if hasattr(settings, 'to_xray_json') else settings
|
||||
|
||||
# Add security settings
|
||||
if self.tlsSettings:
|
||||
config["tlsSettings"] = self.tlsSettings if isinstance(self.tlsSettings, dict) else self.tlsSettings.to_xray_json()
|
||||
if self.xtlsSettings:
|
||||
config["xtlsSettings"] = self.xtlsSettings if isinstance(self.xtlsSettings, dict) else self.xtlsSettings.to_xray_json()
|
||||
if self.realitySettings:
|
||||
config["realitySettings"] = self.realitySettings if isinstance(self.realitySettings, dict) else self.realitySettings.to_xray_json()
|
||||
|
||||
if self.sockopt:
|
||||
config["sockopt"] = self.sockopt
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Factory functions
|
||||
def create_tcp_stream(
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create TCP stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.TCP,
|
||||
security=security,
|
||||
tcpSettings=TCPSettings(**kwargs) if kwargs else None
|
||||
)
|
||||
|
||||
|
||||
def create_ws_stream(
|
||||
path: str = "/",
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create WebSocket stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.WS,
|
||||
security=security,
|
||||
wsSettings=WebSocketSettings(path=path, headers=headers, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
def create_grpc_stream(
|
||||
service_name: str = "",
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create gRPC stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.GRPC,
|
||||
security=security,
|
||||
grpcSettings=GRPCSettings(serviceName=service_name, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
def create_http_stream(
|
||||
path: str = "/",
|
||||
host: Optional[List[str]] = None,
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create HTTP/2 stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.HTTP,
|
||||
security=security,
|
||||
httpSettings=HTTPSettings(path=path, host=host, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
def create_xhttp_stream(
|
||||
path: str = "/",
|
||||
host: Optional[str] = None,
|
||||
security: Optional[str] = None,
|
||||
mode: str = "auto",
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create XHTTP stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.XHTTP,
|
||||
security=security,
|
||||
xhttpSettings=XHTTPSettings(path=path, host=host, mode=mode, **kwargs)
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user