25 Commits
Xray ... django

Author SHA1 Message Date
Ultradesu
777af49ebf Fixed TG messages quotes. Fixed sync tasks loop.
All checks were successful
Docker hub build / docker (push) Successful in 9m33s
2025-09-17 16:34:55 +03:00
Ultradesu
d4042435fe Fixed TG messages quotes. Fixed sync tasks loop. 2025-09-17 13:54:48 +03:00
Ultradesu
f304825836 Fixed TG messages quotes. Fixed sync tasks loop. 2025-09-17 13:34:46 +03:00
Ultradesu
c4057180b9 Fixed TG messages quotes. Fixed sync tasks loop. 2025-09-17 13:20:20 +03:00
Ultradesu
7584e80477 Added tg bot autoconfirm
All checks were successful
Docker hub build / docker (push) Successful in 5m39s
2025-08-15 17:09:31 +03:00
Ultradesu
95e0d08b51 Added tg bot autoconfirm 2025-08-15 16:33:23 +03:00
AB
57cef79748 Approve reworked 2025-08-15 15:37:58 +03:00
Ultradesu
9158e330e5 Added TG bot
All checks were successful
Docker hub build / docker (push) Successful in 7m48s
2025-08-15 05:28:21 +03:00
Ultradesu
14590aaddc Added TG bot 2025-08-15 05:15:13 +03:00
Ultradesu
afd7ad2b28 Added TG bot 2025-08-15 04:53:30 +03:00
Ultradesu
36f9e495b5 Added TG bot 2025-08-15 04:02:22 +03:00
AB from home.homenet
402e4d84fc Fixed sync user tasks .
All checks were successful
Docker hub build / docker (push) Successful in 5m36s
2025-08-08 14:35:19 +03:00
AB from home.homenet
c148bb99dc Fixed multiuser outline and xray . 2025-08-08 14:24:05 +03:00
Alexandr Bogomyakov
dcad41711e Update README.md 2025-08-08 12:46:31 +03:00
AB from home.homenet
05465f9595 Fixed multiuser outline and xray . 2025-08-08 12:41:33 +03:00
AB from home.homenet
4c32679d86 Fixed cert generation .
All checks were successful
Docker hub build / docker (push) Successful in 5m43s
2025-08-08 10:32:14 +03:00
AB from home.homenet
397e05b3cc Fixed cert generation . 2025-08-08 09:08:18 +03:00
AB from home.homenet
99b79c38a0 Fixed xray grps user update . 2025-08-08 08:48:56 +03:00
AB from home.homenet
042ce6bd3f Xray works. 2025-08-08 08:35:47 +03:00
AB from home.homenet
9363bd4db8 Xray works. 2025-08-08 07:47:23 +03:00
AB from home.homenet
2fe59062c9 Xray works. 2025-08-08 07:39:01 +03:00
AB from home.homenet
fe56811b33 Xray works. fixed certs. 2025-08-08 06:50:04 +03:00
AB from home.homenet
787432cbcf Xray works 2025-08-08 05:46:36 +03:00
AB from home.homenet
56b0b160e3 Fixed sub links generation
All checks were successful
Docker hub build / docker (push) Successful in 6m12s
2025-08-05 01:50:11 +03:00
AB from home.homenet
1f7953a74c Fixed sub links generation 2025-08-05 01:40:10 +03:00
103 changed files with 14518 additions and 5202 deletions

View File

@@ -11,7 +11,7 @@
![Forks](https://img.shields.io/github/forks/house-of-vanity/outfleet?style=social) ![Stargazers](https://img.shields.io/github/stars/house-of-vanity/outfleet?style=social) ![License](https://img.shields.io/github/license/house-of-vanity/outfleet) ![Forks](https://img.shields.io/github/forks/house-of-vanity/outfleet?style=social) ![Stargazers](https://img.shields.io/github/stars/house-of-vanity/outfleet?style=social) ![License](https://img.shields.io/github/license/house-of-vanity/outfleet)
<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 ## About The Project

View File

@@ -89,6 +89,11 @@ LOGGING = {
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False, 'propagate': False,
}, },
'telegram_bot': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': False,
},
'requests': { 'requests': {
'handlers': ['console'], 'handlers': ['console'],
'level': 'INFO', 'level': 'INFO',
@@ -115,6 +120,7 @@ INSTALLED_APPS = [
'django_celery_results', 'django_celery_results',
'django_celery_beat', 'django_celery_beat',
'vpn', 'vpn',
'telegram_bot',
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@@ -23,7 +23,7 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('ss/<path:link>', shadowsocks, name='shadowsocks'), path('ss/<path:link>', shadowsocks, name='shadowsocks'),
path('dynamic/<path:link>', shadowsocks, name='shadowsocks'), path('dynamic/<path:link>', shadowsocks, name='shadowsocks'),
path('xray/<path:link>', xray_subscription, name='xray_subscription'), path('xray/<str:user_hash>', xray_subscription, name='xray_subscription'),
path('stat/<path:user_hash>', userFrontend, name='userFrontend'), path('stat/<path:user_hash>', userFrontend, name='userFrontend'),
path('u/<path:user_hash>', userPortal, name='userPortal'), path('u/<path:user_hash>', userPortal, name='userPortal'),
path('', RedirectView.as_view(url='/admin/', permanent=False)), path('', RedirectView.as_view(url='/admin/', permanent=False)),

View File

@@ -16,3 +16,7 @@ psycopg2-binary==2.9.10
setuptools==75.2.0 setuptools==75.2.0
shortuuid==1.0.13 shortuuid==1.0.13
cryptography==45.0.5 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
View 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;
}
}

View File

@@ -333,3 +333,10 @@ table.changelist-results td:nth-child(6) {
margin: 0 2px; margin: 0 2px;
display: inline-block; 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
View File

854
telegram_bot/admin.py Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

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

View File

View 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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

318
telegram_bot/models.py Normal file
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
telegram_bot/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

File diff suppressed because it is too large Load Diff

45
vpn/admin/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -4,3 +4,106 @@ from django.contrib.auth import get_user_model
class VPN(AppConfig): class VPN(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'vpn' 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")

View File

@@ -1,14 +1,7 @@
from django import forms from django import forms
from .models import User from .models import User
from .server_plugins import Server
class UserForm(forms.ModelForm): class UserForm(forms.ModelForm):
servers = forms.ModelMultipleChoiceField(
queryset=Server.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False
)
class Meta: class Meta:
model = User model = User
fields = ['username', 'comment', 'servers'] fields = ['username', 'first_name', 'last_name', 'email', 'comment', 'is_active']

View File

@@ -0,0 +1,13 @@
"""Let's Encrypt DNS Challenge Library for OutFleet"""
from .letsencrypt_dns import (
AcmeDnsChallenge,
get_certificate,
get_certificate_for_domain
)
__all__ = [
'AcmeDnsChallenge',
'get_certificate',
'get_certificate_for_domain'
]

View File

@@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""
Let's Encrypt/ZeroSSL Certificate Library with Cloudflare DNS Challenge
Generate publicly trusted SSL certificates using ACME DNS-01 challenge
"""
import time
import logging
from typing import List, Tuple
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from acme import client, messages, challenges, errors
from acme.client import ClientV2
import josepy as jose
from cloudflare import Cloudflare
logger = logging.getLogger(__name__)
class AcmeDnsChallenge:
"""ACME DNS-01 Challenge handler with Cloudflare API"""
def __init__(self, cloudflare_token: str, acme_directory: str = None):
"""
Initialize ACME DNS challenge handler
Args:
cloudflare_token: Cloudflare API token with DNS edit permissions
acme_directory: ACME directory URL (defaults to Let's Encrypt production)
"""
self.cf_token = cloudflare_token
self.cf = Cloudflare(api_token=cloudflare_token)
# ACME directory URLs
self.acme_directories = {
'letsencrypt': 'https://acme-v02.api.letsencrypt.org/directory',
'letsencrypt_staging': 'https://acme-staging-v02.api.letsencrypt.org/directory',
'zerossl': 'https://acme.zerossl.com/v2/DV90'
}
self.acme_directory = acme_directory or self.acme_directories['letsencrypt']
self.acme_client = None
self.account_key = None
def _generate_account_key(self) -> jose.JWKRSA:
"""Generate RSA private key for ACME account"""
# Generate cryptography key first
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048
)
# Convert to josepy format for ACME
return jose.JWKRSA(key=private_key)
def _get_zone_id(self, domain: str) -> str:
"""Get Cloudflare zone ID for domain"""
try:
# Get base domain (remove subdomains)
parts = domain.split('.')
if len(parts) >= 2:
base_domain = '.'.join(parts[-2:])
else:
base_domain = domain
zones = self.cf.zones.list(name=base_domain)
if not zones.result:
raise ValueError(f"Domain {base_domain} not found in Cloudflare")
return zones.result[0].id
except Exception as e:
logger.error(f"Failed to get zone ID for {domain}: {e}")
raise
def _create_dns_record(self, domain: str, name: str, content: str) -> str:
"""Create DNS TXT record for ACME challenge"""
try:
zone_id = self._get_zone_id(domain)
result = self.cf.dns.records.create(
zone_id=zone_id,
name=name,
type='TXT',
content=content,
ttl=60 # 1 minute TTL for faster propagation
)
logger.info(f"Created DNS record: {name} = {content}")
return result.id
except Exception as e:
logger.error(f"Failed to create DNS record {name}: {e}")
raise
def _delete_dns_record(self, domain: str, record_id: str):
"""Delete DNS TXT record"""
try:
zone_id = self._get_zone_id(domain)
self.cf.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
logger.info(f"Deleted DNS record: {record_id}")
except Exception as e:
logger.warning(f"Failed to delete DNS record {record_id}: {e}")
def _wait_for_dns_propagation(self, record_name: str, expected_value: str, wait_time: int = 20):
"""Wait for DNS record to propagate - no local checks, just wait"""
logger.info(f"Waiting {wait_time} seconds for DNS propagation of {record_name}...")
logger.info(f"Record value: {expected_value}")
logger.info("(No local DNS checks - Let's Encrypt servers will verify)")
time.sleep(wait_time)
logger.info("DNS propagation wait completed - proceeding with challenge")
return True
def create_acme_client(self, email: str, accept_tos: bool = True) -> ClientV2:
"""Create and register ACME client"""
if self.acme_client:
return self.acme_client
try:
logger.info("Generating ACME account key...")
# Generate account key
self.account_key = self._generate_account_key()
logger.info("Account key generated successfully")
logger.info(f"Connecting to ACME directory: {self.acme_directory}")
# Create ACME client
net = client.ClientNetwork(self.account_key, user_agent='letsencrypt-dns-lib/1.0')
logger.info("Getting ACME directory...")
directory_response = net.get(self.acme_directory)
logger.info(f"Directory response status: {directory_response.status_code}")
directory = messages.Directory.from_json(directory_response.json())
logger.info("ACME directory loaded successfully")
self.acme_client = ClientV2(directory, net=net)
logger.info("ACME client created successfully")
# Register account
logger.info(f"Registering ACME account for email: {email}")
try:
registration = messages.NewRegistration.from_data(
email=email,
terms_of_service_agreed=accept_tos
)
logger.info("Sending account registration...")
account = self.acme_client.new_account(registration)
logger.info(f"ACME account registered: {account.uri}")
except errors.ConflictError as e:
logger.info(f"Account already exists (ConflictError): {e}")
# Account already exists
account = self.acme_client.query_registration(messages.NewRegistration())
logger.info("Using existing ACME account")
except Exception as reg_e:
logger.error(f"Account registration failed: {reg_e}")
logger.error(f"Registration error type: {type(reg_e).__name__}")
raise
return self.acme_client
except Exception as e:
logger.error(f"Failed to create ACME client: {e}")
logger.error(f"Error type: {type(e).__name__}")
import traceback
logger.error(f"Full traceback: {traceback.format_exc()}")
raise
def request_certificate(self, domains: List[str], email: str,
key_size: int = 2048) -> Tuple[str, str]:
"""
Request certificate using DNS-01 challenge
Args:
domains: List of domain names for certificate
email: Email for ACME account registration
key_size: RSA key size for certificate
Returns:
Tuple of (certificate_pem, private_key_pem)
"""
logger.info(f"Requesting certificate for domains: {domains}")
try:
# Create ACME client
logger.info("Creating ACME client...")
acme_client = self.create_acme_client(email)
logger.info("ACME client created successfully")
except Exception as e:
logger.error(f"Failed to create ACME client: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
raise
try:
# Generate private key for certificate
logger.info(f"Generating {key_size}-bit RSA private key...")
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=key_size
)
logger.info("Private key generated successfully")
# Create CSR
logger.info(f"Creating CSR for domains: {domains}")
csr_obj = x509.CertificateSigningRequestBuilder().subject_name(
x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, domains[0])
])
).add_extension(
x509.SubjectAlternativeName([
x509.DNSName(domain) for domain in domains
]),
critical=False
).sign(private_key, hashes.SHA256())
# Convert CSR to PEM format for ACME
csr_pem = csr_obj.public_bytes(serialization.Encoding.PEM)
logger.info("CSR created successfully")
# Request certificate
logger.info("Requesting certificate order from ACME...")
order = acme_client.new_order(csr_pem)
logger.info(f"Created ACME order: {order.uri}")
except Exception as e:
logger.error(f"Failed during CSR/order creation: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
raise
# Process challenges - collect all challenges first, then create DNS records
dns_records = []
challenges_to_answer = []
try:
# First pass: collect all challenges and create DNS records
for authorization in order.authorizations:
domain = authorization.body.identifier.value
logger.info(f"Processing authorization for: {domain}")
# Find DNS-01 challenge
dns_challenge = None
for challenge in authorization.body.challenges:
if isinstance(challenge.chall, challenges.DNS01):
dns_challenge = challenge
break
if not dns_challenge:
raise ValueError(f"No DNS-01 challenge found for {domain}")
# Calculate challenge response
response, validation = dns_challenge.response_and_validation(acme_client.net.key)
# For wildcard domains, use base domain for DNS record
if domain.startswith('*.'):
dns_domain = domain[2:] # Remove *. prefix
else:
dns_domain = domain
# Create DNS record
record_name = f"_acme-challenge.{dns_domain}"
# Check if we already created this DNS record
existing_record = None
for existing_domain, existing_id, existing_validation in dns_records:
if existing_domain == dns_domain:
existing_record = (existing_domain, existing_id, existing_validation)
break
if existing_record:
logger.info(f"DNS record already exists for {dns_domain}, reusing...")
record_id = existing_record[1]
# Verify the validation value matches
if existing_record[2] != validation:
logger.warning(f"Validation values differ for {dns_domain}! This may cause issues.")
else:
logger.info(f"Creating DNS record for {dns_domain}...")
record_id = self._create_dns_record(dns_domain, record_name, validation)
dns_records.append((dns_domain, record_id, validation))
# Store challenge to answer later
challenges_to_answer.append((dns_challenge, response, domain, dns_domain))
# Wait for DNS propagation once for all records
if dns_records:
logger.info(f"Waiting for DNS propagation for {len(dns_records)} DNS records...")
for dns_domain, record_id, validation in dns_records:
record_name = f"_acme-challenge.{dns_domain}"
self._wait_for_dns_propagation(record_name, validation)
# Second pass: answer all challenges
for dns_challenge, response, domain, dns_domain in challenges_to_answer:
logger.info(f"Responding to DNS challenge for {domain}...")
challenge_response = acme_client.answer_challenge(dns_challenge, response)
logger.info(f"Challenge response sent for {domain}")
# Finalize order
logger.info("Finalizing certificate order...")
order = acme_client.poll_and_finalize(order)
# Get certificate
certificate_pem = order.fullchain_pem
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
logger.info("Certificate obtained successfully!")
return certificate_pem, private_key_pem
finally:
# Clean up DNS records
for dns_domain, record_id, validation in dns_records:
try:
self._delete_dns_record(dns_domain, record_id)
except Exception as e:
logger.warning(f"Failed to cleanup DNS record for {dns_domain}: {e}")
def get_certificate(domains: List[str], email: str, cloudflare_token: str,
provider: str = 'letsencrypt', staging: bool = False) -> Tuple[str, str]:
"""
Simple function to get Let's Encrypt/ZeroSSL certificate
Args:
domains: List of domains for certificate
email: Email for ACME registration
cloudflare_token: Cloudflare API token
provider: 'letsencrypt' or 'zerossl'
staging: Use staging environment (for testing)
Returns:
Tuple of (certificate_pem, private_key_pem)
"""
# Select ACME directory
acme_dns = AcmeDnsChallenge(cloudflare_token)
if provider == 'letsencrypt':
if staging:
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt_staging']
else:
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt']
elif provider == 'zerossl':
acme_dns.acme_directory = acme_dns.acme_directories['zerossl']
else:
raise ValueError("Provider must be 'letsencrypt' or 'zerossl'")
return acme_dns.request_certificate(domains, email)
def get_certificate_for_domain(domain: str, email: str, cloudflare_token: str,
include_wildcard: bool = False, **kwargs) -> Tuple[str, str]:
"""
Helper function to get certificate for single domain (compatible with Cloudflare cert lib)
Args:
domain: Primary domain
email: Email for ACME registration
cloudflare_token: Cloudflare API token
include_wildcard: Include wildcard subdomain
**kwargs: Additional arguments (provider, staging)
Returns:
Tuple of (certificate_pem, private_key_pem)
"""
domains = [domain]
if include_wildcard:
domains.append(f"*.{domain}")
return get_certificate(domains, email, cloudflare_token, **kwargs)
if __name__ == "__main__":
# Example usage
import sys
if len(sys.argv) != 4:
print("Usage: python letsencrypt_dns.py <domain> <email> <cloudflare_token>")
sys.exit(1)
domain, email, token = sys.argv[1:4]
try:
cert_pem, key_pem = get_certificate_for_domain(
domain=domain,
email=email,
cloudflare_token=token,
include_wildcard=True,
staging=True # Use staging for testing
)
print(f"Certificate obtained for {domain}")
print(f"Certificate length: {len(cert_pem)} bytes")
print(f"Private key length: {len(key_pem)} bytes")
# Save to files
with open(f"{domain}.crt", 'w') as f:
f.write(cert_pem)
with open(f"{domain}.key", 'w') as f:
f.write(key_pem)
print(f"Saved: {domain}.crt, {domain}.key")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)

View File

@@ -0,0 +1,32 @@
# Generated manually to properly remove old Xray models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('vpn', '0014_alter_xraycoreserver_client_hostname_and_more'),
]
operations = [
# Remove unique_together first to avoid field reference issues
migrations.AlterUniqueTogether(
name='xrayinbound',
unique_together=None,
),
# Remove old models completely
migrations.DeleteModel(
name='XrayClient',
),
migrations.DeleteModel(
name='XrayInbound',
),
migrations.DeleteModel(
name='XrayInboundServer',
),
migrations.DeleteModel(
name='XrayCoreServer',
),
]

View File

@@ -0,0 +1,127 @@
# Generated manually to add new Xray models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0015_remove_old_xray_models'),
]
operations = [
migrations.CreateModel(
name='XrayConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('grpc_address', models.CharField(default='127.0.0.1:10085', help_text='Xray gRPC API address (host:port)', max_length=255)),
('default_client_hostname', models.CharField(help_text='Default hostname for client connections', max_length=255)),
('stats_enabled', models.BooleanField(default=True, help_text='Enable traffic statistics')),
('cert_renewal_days', models.IntegerField(default=60, help_text='Renew certificates X days before expiration')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Xray Configuration',
'verbose_name_plural': 'Xray Configuration',
},
),
migrations.CreateModel(
name='Credentials',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Descriptive name for these credentials', max_length=100, unique=True)),
('cred_type', models.CharField(choices=[('cloudflare', 'Cloudflare API'), ('dns_provider', 'DNS Provider'), ('email', 'Email SMTP'), ('other', 'Other')], help_text='Type of credentials', max_length=20)),
('credentials', models.JSONField(help_text="Credentials data (e.g., {'api_token': '...', 'email': '...'})")),
('description', models.TextField(blank=True, help_text='Description of what these credentials are used for')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Credentials',
'verbose_name_plural': 'Credentials',
'ordering': ['cred_type', 'name'],
},
),
migrations.CreateModel(
name='Certificate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('domain', models.CharField(help_text='Domain name for this certificate', max_length=255, unique=True)),
('certificate_pem', models.TextField(help_text='Certificate in PEM format')),
('private_key_pem', models.TextField(help_text='Private key in PEM format')),
('cert_type', models.CharField(choices=[('self_signed', 'Self-Signed'), ('letsencrypt', "Let's Encrypt"), ('custom', 'Custom')], help_text='Type of certificate', max_length=20)),
('expires_at', models.DateTimeField(help_text='Certificate expiration date')),
('auto_renew', models.BooleanField(default=True, help_text='Automatically renew certificate before expiration')),
('last_renewed', models.DateTimeField(blank=True, help_text='Last renewal timestamp', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('credentials', models.ForeignKey(blank=True, help_text="Credentials for Let's Encrypt (Cloudflare API)", null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.credentials')),
],
options={
'verbose_name': 'Certificate',
'verbose_name_plural': 'Certificates',
'ordering': ['domain'],
},
),
migrations.CreateModel(
name='Inbound',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Unique identifier for this inbound', max_length=100, unique=True)),
('protocol', models.CharField(choices=[('vless', 'VLESS'), ('vmess', 'VMess'), ('trojan', 'Trojan'), ('shadowsocks', 'Shadowsocks')], help_text='Protocol type', max_length=20)),
('port', models.IntegerField(help_text='Port to listen on')),
('network', models.CharField(choices=[('tcp', 'TCP'), ('ws', 'WebSocket'), ('grpc', 'gRPC'), ('http', 'HTTP/2'), ('quic', 'QUIC')], default='tcp', help_text='Transport protocol', max_length=20)),
('security', models.CharField(choices=[('none', 'None'), ('tls', 'TLS'), ('reality', 'REALITY')], default='none', help_text='Security type', max_length=20)),
('domain', models.CharField(blank=True, help_text='Client connection domain', max_length=255)),
('full_config', models.JSONField(default=dict, help_text='Complete configuration for creating inbound on server')),
('listen_address', models.CharField(default='0.0.0.0', help_text='IP address to listen on', max_length=45)),
('enable_sniffing', models.BooleanField(default=True, help_text='Enable protocol sniffing')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('certificate', models.ForeignKey(blank=True, help_text='Certificate for TLS', null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.certificate')),
],
options={
'verbose_name': 'Inbound',
'verbose_name_plural': 'Inbounds',
'ordering': ['protocol', 'port'],
'unique_together': {('port', 'listen_address')},
},
),
migrations.CreateModel(
name='SubscriptionGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="Group name (e.g., 'VLESS Premium', 'VMess Basic')", max_length=100, unique=True)),
('description', models.TextField(blank=True, help_text='Description of this subscription group')),
('is_active', models.BooleanField(default=True, help_text='Whether this group is active')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('inbounds', models.ManyToManyField(blank=True, help_text='Inbounds included in this group', to='vpn.inbound')),
],
options={
'verbose_name': 'Subscription Group',
'verbose_name_plural': 'Subscription Groups',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='UserSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('active', models.BooleanField(default=True, help_text='Whether this subscription is active')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('subscription_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vpn.subscriptiongroup')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='xray_subscriptions', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'User Subscription',
'verbose_name_plural': 'User Subscriptions',
'ordering': ['user__username', 'subscription_group__name'],
'unique_together': {('user', 'subscription_group')},
},
),
]

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.1.7 on 2025-08-07 13:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0016_add_new_xray_models'),
]
operations = [
migrations.CreateModel(
name='XrayServerV2',
fields=[
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
('client_hostname', models.CharField(help_text='Client connection hostname (what users see in their configs)', max_length=255)),
('api_address', models.CharField(default='127.0.0.1:10085', help_text='Xray gRPC API address for management', max_length=255)),
('api_enabled', models.BooleanField(default=True, help_text='Enable gRPC API for user management')),
('stats_enabled', models.BooleanField(default=True, help_text='Enable traffic statistics collection')),
],
options={
'verbose_name': 'Xray Server v2',
'verbose_name_plural': 'Xray Servers v2',
},
bases=('vpn.server',),
),
migrations.AlterField(
model_name='server',
name='server_type',
field=models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard'), ('xray_core', 'Xray Core'), ('xray_v2', 'Xray Server v2')], editable=False, max_length=50),
),
migrations.CreateModel(
name='ServerInbound',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('active', models.BooleanField(default=True, help_text='Whether this inbound is active on the server')),
('deployed_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deployment_config', models.JSONField(blank=True, default=dict, help_text='Server-specific deployment configuration')),
('inbound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployed_servers', to='vpn.inbound')),
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deployed_inbounds', to='vpn.server')),
],
options={
'verbose_name': 'Server Inbound Deployment',
'verbose_name_plural': 'Server Inbound Deployments',
'ordering': ['server__name', 'inbound__name'],
'unique_together': {('server', 'inbound')},
},
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.7 on 2025-08-07 14:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0017_xrayserverv2_alter_server_server_type_serverinbound'),
]
operations = [
migrations.AlterField(
model_name='certificate',
name='certificate_pem',
field=models.TextField(blank=True, help_text="Certificate in PEM format (auto-generated for Let's Encrypt)"),
),
migrations.AlterField(
model_name='certificate',
name='expires_at',
field=models.DateTimeField(blank=True, help_text='Certificate expiration date (auto-filled after generation)', null=True),
),
migrations.AlterField(
model_name='certificate',
name='private_key_pem',
field=models.TextField(blank=True, help_text="Private key in PEM format (auto-generated for Let's Encrypt)"),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-08-07 14:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0018_alter_certificate_certificate_pem_and_more'),
]
operations = [
migrations.AddField(
model_name='certificate',
name='acme_email',
field=models.EmailField(blank=True, help_text="Email address for ACME account registration (required for Let's Encrypt)", max_length=254),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-08-07 15:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vpn', '0019_certificate_acme_email'),
]
operations = [
migrations.AlterField(
model_name='inbound',
name='full_config',
field=models.JSONField(blank=True, default=dict, help_text='Complete configuration for creating inbound on server (auto-generated if empty)'),
),
]

View File

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

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

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

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

View File

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

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

View File

@@ -94,6 +94,38 @@ class User(AbstractUser):
servers = models.ManyToManyField('Server', through='ACL', blank=True, help_text="Servers user has access to") servers = models.ManyToManyField('Server', through='ACL', blank=True, help_text="Servers user has access to")
last_access = models.DateTimeField(null=True, blank=True) 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.") 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): def get_servers(self):
return Server.objects.filter(acl__user=self) return Server.objects.filter(acl__user=self)
@@ -167,3 +199,10 @@ class ACLLink(models.Model):
def __str__(self): def __str__(self):
return self.link return self.link
# Import new Xray models
from .models_xray import (
Credentials, Certificate,
Inbound, SubscriptionGroup, UserSubscription
)

464
vpn/models_xray.py Normal file
View 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'

View File

@@ -1,5 +1,5 @@
from .generic import Server from .generic import Server
from .outline import OutlineServer, OutlineServerAdmin from .outline import OutlineServer, OutlineServerAdmin
from .wireguard import WireguardServer, WireguardServerAdmin from .wireguard import WireguardServer, WireguardServerAdmin
from .xray_core import XrayCoreServer, XrayCoreServerAdmin, XrayInbound, XrayClient, XrayInboundServer, XrayInboundServerAdmin from .xray_v2 import XrayServerV2, XrayServerV2Admin
from .urls import urlpatterns from .urls import urlpatterns

View File

@@ -7,6 +7,7 @@ class Server(PolymorphicModel):
('Outline', 'Outline'), ('Outline', 'Outline'),
('Wireguard', 'Wireguard'), ('Wireguard', 'Wireguard'),
('xray_core', 'Xray Core'), ('xray_core', 'Xray Core'),
('xray_v2', 'Xray Server v2'),
) )
name = models.CharField(max_length=100, help_text="Server name") name = models.CharField(max_length=100, help_text="Server name")

View File

@@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from vpn.views import shadowsocks from vpn.views import shadowsocks, xray_subscription
urlpatterns = [ urlpatterns = [
path('ss/<str:hash_value>/', shadowsocks, name='shadowsocks'), path('ss/<str:hash_value>/', shadowsocks, name='shadowsocks'),
path('xray/<str:user_hash>/', xray_subscription, name='xray_subscription'),
] ]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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
View 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())

View File

@@ -54,7 +54,7 @@ def cleanup_task_logs():
def sync_xray_inbounds(self, server_id): def sync_xray_inbounds(self, server_id):
"""Stage 1: Sync inbounds for Xray server.""" """Stage 1: Sync inbounds for Xray server."""
from vpn.server_plugins import 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() start_time = time.time()
task_id = self.request.id task_id = self.request.id
@@ -63,7 +63,7 @@ def sync_xray_inbounds(self, server_id):
try: try:
server = Server.objects.get(id=server_id) 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" error_message = f"Server {server.name} is not an Xray server"
logger.error(error_message) 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) 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): def sync_xray_users(self, server_id):
"""Stage 2: Sync users for Xray server.""" """Stage 2: Sync users for Xray server."""
from vpn.server_plugins import 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() start_time = time.time()
task_id = self.request.id task_id = self.request.id
@@ -114,7 +114,7 @@ def sync_xray_users(self, server_id):
try: try:
server = Server.objects.get(id=server_id) 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" error_message = f"Server {server.name} is not an Xray server"
logger.error(error_message) 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) 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) 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}") 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() 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}" success_message = f"Successfully synced {user_result.get('users_added', 0)} users for {server.name}"
logger.info(f"{success_message}. Result: {user_result}") 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}") 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 # For Xray servers, use the sync_server_users task to avoid recursion
from vpn.server_plugins.xray_core import XrayCoreServer from vpn.server_plugins.xray_v2 import XrayServerV2
if isinstance(server.get_real_instance(), XrayCoreServer): if isinstance(server.get_real_instance(), XrayServerV2):
logger.info(f"Performing staged sync for Xray server {server.name}") logger.info(f"Using XrayServerV2 sync for server {server.name}")
try: # Call sync_server_users directly to perform the actual sync
# Stage 1: Sync inbounds first # Avoid calling server.sync_users() which would create another task
logger.info(f"Stage 1: Syncing inbounds for {server.name}") from vpn.models import User
inbound_task = sync_xray_inbounds.apply_async(args=[server.id]) from vpn.models_xray import UserSubscription
inbound_result = inbound_task.get() # Wait for completion
logger.info(f"Inbound sync result for {server.name}: {inbound_result}") # Get all users who should have access to this server
users_to_sync = User.objects.filter(
if "error" in inbound_result: xray_subscriptions__active=True,
logger.error(f"Inbound sync failed, skipping user sync: {inbound_result['error']}") xray_subscriptions__subscription_group__is_active=True
sync_result = inbound_result ).distinct()
else:
# Stage 2: Sync users after inbounds are ready added_count = 0
logger.info(f"Stage 2: Syncing users for {server.name}") failed_count = 0
user_task = sync_xray_users.apply_async(args=[server.id]) for user in users_to_sync:
user_result = user_task.get() # Wait for completion try:
logger.info(f"User sync result for {server.name}: {user_result}") if server.add_user(user):
added_count += 1
# Combine results except Exception as e:
if "error" in user_result: failed_count += 1
sync_result = { logger.error(f"Failed to sync user {user.username} on server {server.name}: {e}")
"status": "Staged sync partially failed",
"inbounds": inbound_result.get("inbounds", []), sync_result = {"users_added": added_count, "total_users": users_to_sync.count(), "failed": failed_count}
"users": f"User sync failed: {user_result['error']}" logger.info(f"Directly synced {added_count} users for Xray server {server.name}")
}
else:
sync_result = {
"status": "Staged sync completed successfully",
"inbounds": inbound_result.get("inbounds", []),
"users": f"Added {user_result.get('users_added', 0)} users across all inbounds"
}
except Exception as e:
logger.error(f"Staged sync failed for Xray server {server.name}: {e}")
# Fallback to regular user sync only
sync_result = server.sync_users()
else: else:
# For non-Xray servers, just sync users # For non-Xray servers, sync users directly (non-Xray servers should not create tasks)
sync_result = server.sync_users() real_server = server.get_real_instance()
sync_result = real_server.sync_users()
# Check if sync was successful (can be boolean or dict/string) # Check if sync was successful (can be boolean or dict/string)
sync_successful = bool(sync_result) and ( sync_successful = bool(sync_result) and (
@@ -566,4 +576,617 @@ def sync_user(self, user_id, server_id):
if errors: if errors:
raise TaskFailedException(message=f"Errors during task: {errors}") raise TaskFailedException(message=f"Errors during task: {errors}")
return result return result
@shared_task(name="sync_user_xray_access", bind=True)
def sync_user_xray_access(self, user_id, server_id):
"""
Sync user's Xray access based on subscription groups.
Creates inbounds on server if needed and adds user to them.
"""
from .models import User, Server
from .models_xray import SubscriptionGroup, Inbound
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

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -450,12 +450,12 @@
<div class="subtitle">Welcome back, <strong>{{ user.username }}</strong></div> <div class="subtitle">Welcome back, <strong>{{ user.username }}</strong></div>
<div class="stats"> <div class="stats">
<div class="stat"> <div class="stat">
<span class="stat-number">{{ total_servers }}</span> <span class="stat-number">{{ total_groups }}</span>
<span class="stat-label">Available Servers</span> <span class="stat-label">Subscription Groups</span>
</div> </div>
<div class="stat"> <div class="stat">
<span class="stat-number">{{ total_links }}</span> <span class="stat-number">{{ total_inbounds }}</span>
<span class="stat-label">Active Links</span> <span class="stat-label">Available Inbounds</span>
</div> </div>
<div class="stat"> <div class="stat">
<span class="stat-number">{{ total_connections }}</span> <span class="stat-number">{{ total_connections }}</span>
@@ -475,87 +475,125 @@
</div> </div>
<!-- Xray Subscription Link --> <!-- 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;"> <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> <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;"> <p style="color: #64748b; font-size: 0.9rem; margin-bottom: 10px;">
One link for all your Xray protocols (VLESS, VMess, Trojan) One link for all your Xray protocols (VLESS, VMess, Trojan)
</p> </p>
<div class="link-url" style="margin-bottom: 0;"> <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 }} {{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}
<button class="copy-btn" onclick="copyToClipboard('{{ request.scheme }}://{{ request.get_host }}{{ xray_url }}')">Copy</button> <button class="copy-btn" onclick="copyToClipboard('{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}')">Copy</button>
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if servers_data %} {% if groups_data %}
<div class="servers-grid"> <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-card">
<div class="server-header"> <div class="server-header">
<div class="server-info"> <div class="server-info">
<div class="server-name">{{ server_name }}</div> <div class="server-name">{{ group_name }}</div>
<div class="server-stats"> <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> </div>
<div class="server-type">{{ server_data.server_type }}</div> <div class="server-type">Xray Group</div>
</div> </div>
<div class="server-status"> <div class="server-status">
{% if server_data.accessible %} <div class="status-indicator status-online">
<div class="status-indicator status-online"> <div class="status-dot"></div>
<div class="status-dot"></div> Active Subscription
Online & Ready </div>
</div>
{% else %}
<div class="status-indicator status-offline">
<div class="status-dot"></div>
Connection Issues
</div>
{% endif %}
</div> </div>
<!-- Individual Subscription Link for this Group -->
<div class="links-container"> <div class="links-container">
{% for link_data in server_data.links %}
<div class="link-item"> <div class="link-item">
<div class="link-header"> <div class="link-header">
<div class="link-info"> <div class="link-info">
<div class="link-comment">📱 {{ link_data.comment }}</div> <div class="link-comment">🚀 {{ group_name }} Subscription</div>
<div class="link-stats"> <div class="link-stats">
<span class="usage-count"> {{ link_data.connections }} uses</span> <span class="last-used">🔗 {{ group_data.deployed_count }} inbound(s)</span>
<span class="recent-count">📅 {{ link_data.recent_connections }} last 30 days</span>
<span class="last-used">🕒 {{ link_data.last_access_display }}</span>
</div> </div>
</div> </div>
<div class="usage-chart" data-usage="{{ link_data.daily_usage|join:',' }}" data-max="{{ link_data.max_daily }}"> <div class="usage-chart">
<div class="chart-title">30-day activity</div> <div class="chart-title">Protocols</div>
<div class="chart-bars"> <div style="display: flex; flex-direction: column; gap: 5px; align-items: center;">
{% for day_usage in link_data.daily_usage %} {% for inbound_data in group_data.inbounds %}
<div class="chart-bar" data-height="{{ day_usage }}" data-max="{{ link_data.max_daily }}"></div> <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 %} {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<div class="link-url"> <div class="link-url">
{{ link_data.url }} {{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}?group={{ group_name }}
<button class="copy-btn" onclick="copyToClipboard('{{ link_data.url }}')">Copy</button> <button class="copy-btn" onclick="copyToClipboard('{{ force_scheme|default:request.scheme }}://{{ request.get_host }}/xray/{{ user.hash }}?group={{ group_name }}')">Copy</button>
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="no-servers"> <div class="no-servers">
<h3>No VPN Access Available</h3> <h3>No Xray Subscriptions Available</h3>
<p>You don't have access to any VPN servers yet. Please contact your administrator.</p> <p>You don't have access to any subscription groups yet. Please contact your administrator.</p>
</div> </div>
{% endif %} {% endif %}
<!-- Show old ACL links for backwards compatibility -->
{% if has_old_links %}
<h2 style="color: #9ca3af; margin: 40px 0 20px 0; text-align: center;">Legacy Shadowsocks Access</h2>
<div class="servers-grid">
{% for acl_link in acl_links %}
<div class="server-card" style="opacity: 0.7;">
<div class="server-header">
<div class="server-info">
<div class="server-name">{{ acl_link.acl.server.name }}</div>
<div class="server-stats">
<span class="connection-count">📊 Legacy</span>
</div>
</div>
<div class="server-type">Shadowsocks</div>
</div>
<div class="server-status">
<div class="status-indicator status-online">
<div class="status-dot"></div>
Legacy Access
</div>
</div>
<div class="links-container">
<div class="link-item">
<div class="link-header">
<div class="link-info">
<div class="link-comment">📱 {{ acl_link.comment }}</div>
<div class="link-stats">
<span class="last-used">🕒 {{ acl_link.last_access_time|default:"Never used" }}</span>
</div>
</div>
</div>
<div class="link-url">
{{ external_address }}/ss/{{ acl_link.link }}#{{ acl_link.acl.server.name }}
<button class="copy-btn" onclick="copyToClipboard('{{ external_address }}/ss/{{ acl_link.link }}#{{ acl_link.acl.server.name }}')">Copy</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="footer"> <div class="footer">
<p>Powered by <a href="https://github.com/house-of-vanity/OutFleet" target="_blank">OutFleet VPN Manager</a></p> <p>Powered by <a href="https://github.com/house-of-vanity/OutFleet" target="_blank">OutFleet VPN Manager</a></p>
<p>Keep this link secure and don't share it with others</p> <p>Keep this link secure and don't share it with others</p>

21
vpn/utils.py Normal file
View 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>")

View File

@@ -1,6 +1,7 @@
def userPortal(request, user_hash): def userPortal(request, user_hash):
"""HTML portal for user to view their VPN access links and server information""" """HTML portal for user to view their VPN access links and subscription groups"""
from .models import User, ACLLink, UserStatistics, AccessLog from .models import User
from .models_xray import UserSubscription, SubscriptionGroup, Inbound
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
import logging import logging
@@ -18,155 +19,166 @@ def userPortal(request, user_hash):
}, status=403) }, status=403)
try: try:
# Get all ACL links for the user with server information # Get all active subscription groups for the user
acl_links = ACLLink.objects.filter(acl__user=user).select_related('acl__server', 'acl') user_subscriptions = UserSubscription.objects.filter(
logger.info(f"Found {acl_links.count()} ACL links for user {user.username}") user=user,
active=True,
subscription_group__is_active=True
).select_related('subscription_group').prefetch_related('subscription_group__inbounds')
# Calculate overall statistics from cached data (only where cache exists) logger.info(f"Found {user_subscriptions.count()} active subscription groups for user {user.username}")
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 # Calculate overall Xray subscription statistics
servers_data = {} from .models import AccessLog
total_links = 0 total_connections = AccessLog.objects.filter(
user=user.username,
action='Success',
server='Xray-Subscription'
).count()
for link in acl_links: recent_connections = AccessLog.objects.filter(
server = link.acl.server user=user.username,
server_name = server.name action='Success',
logger.debug(f"Processing link {link.link} for server {server_name}") server='Xray-Subscription',
timestamp__gte=timezone.now() - timedelta(days=30)
).count()
logger.info(f"Xray statistics for user {user.username}: total={total_connections}, recent={recent_connections}")
# Determine protocol scheme
scheme = 'https' # Always use HTTPS as SSL is handled by ingress
# Group inbounds by subscription group
groups_data = {}
total_inbounds = 0
for subscription in user_subscriptions:
group = subscription.subscription_group
group_name = group.name
logger.debug(f"Processing subscription group {group_name}")
if server_name not in servers_data: # Get all deployed inbounds for this group (count actual server deployments)
# Get server status and info from .models_xray import ServerInbound
try: deployed_inbounds = ServerInbound.objects.filter(
server_status = server.get_server_status() inbound__in=group.inbounds.all(),
server_accessible = True active=True
server_error = None ).select_related('inbound', 'server')
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 # Calculate connections for this specific group
last_access_display = "Never used" group_connections = AccessLog.objects.filter(
if link.last_access_time: user=user.username,
time_diff = timezone.now() - link.last_access_time action='Success',
if time_diff.days > 0: server='Xray-Subscription',
last_access_display = f"{time_diff.days} days ago" data__icontains=f'"group": "{group_name}"'
elif time_diff.seconds > 3600: ).count()
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 groups_data[group_name] = {
try: 'group': group,
link_stats = UserStatistics.objects.get( 'subscription': subscription,
user=user, 'inbounds': [],
server_name=server_name, 'total_connections': group_connections,
acl_link_id=link.link 'deployed_count': deployed_inbounds.count(), # Actual deployed inbounds count
)
logger.debug(f"Found cached stats for link {link.link}: {link_stats.total_connections} connections, max_daily={link_stats.max_daily}")
link_connections = link_stats.total_connections
link_recent_connections = link_stats.recent_connections
daily_usage = link_stats.daily_usage or []
max_daily = link_stats.max_daily
except UserStatistics.DoesNotExist:
logger.warning(f"No cached stats found for link {link.link} on server {server_name}, using fallback")
# Fallback: Since AccessLog doesn't track specific links, show zero for link-specific stats
# but keep server-level stats for context
link_connections = 0
link_recent_connections = 0
daily_usage = [0] * 30 # Empty 30-day chart
max_daily = 0
logger.warning(f"Using zero stats for uncached link {link.link} - AccessLog doesn't track individual links")
logger.debug(f"Link {link.link} stats: connections={link_connections}, recent={link_recent_connections}, max_daily={max_daily}")
# Add link information with statistics
link_url = f"{EXTERNAL_ADDRESS}/ss/{link.link}#{server_name}"
link_data = {
'link': link,
'url': link_url,
'comment': link.comment or 'Default',
'last_access': link.last_access_time,
'last_access_display': last_access_display,
'connections': link_connections,
'recent_connections': link_recent_connections,
'daily_usage': daily_usage,
'max_daily': max_daily,
} }
servers_data[server_name]['links'].append(link_data) # Process each deployed inbound (each server-inbound combination)
total_links += 1 for server_inbound in deployed_inbounds:
inbound = server_inbound.inbound
logger.debug(f"Added comprehensive link data for {link.link}") logger.debug(f"Processing inbound {inbound.name} in group {group_name}")
# Generate connection URLs based on protocol
connection_urls = []
if inbound.protocol == 'vless':
# Generate VLESS URL - this is a placeholder implementation
# In the real implementation, you'd generate proper VLESS URLs with user UUID
connection_url = f"vless://user-uuid@{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(servers_data)} servers and {total_links} total links") logger.info(f"Prepared data for {len(groups_data)} subscription groups and {total_inbounds} total inbounds")
logger.info(f"Portal statistics: total_connections={total_connections}, recent_connections={recent_connections}") logger.info(f"Portal statistics: total_connections={total_connections}, recent_connections={recent_connections}")
# Check if user has access to any Xray servers # Check if user has any Xray subscription groups
from vpn.server_plugins import XrayCoreServer, XrayInboundServer has_xray_access = user_subscriptions.exists()
has_xray_servers = any(
isinstance(acl_link.acl.server.get_real_instance(), (XrayCoreServer, XrayInboundServer))
for acl_link in acl_links
)
# 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 = { context = {
'user': user, 'user': user,
'user_links': acl_links, # For accessing user's links in template 'user_subscriptions': user_subscriptions, # For accessing user's subscriptions in template
'servers_data': servers_data, 'groups_data': groups_data,
'total_servers': len(servers_data), 'total_groups': len(groups_data),
'total_links': total_links, 'total_inbounds': total_inbounds,
'total_connections': total_connections, 'total_connections': total_connections,
'recent_connections': recent_connections, 'recent_connections': recent_connections,
'external_address': EXTERNAL_ADDRESS, 'external_address': get_external_host(),
'has_xray_servers': has_xray_servers, 'has_xray_access': has_xray_access,
'force_scheme': scheme, # Override request.scheme in template
'acl_links': acl_links, # For backwards compatibility
'has_old_links': len(acl_links) > 0,
'xray_subscription_url': f"https://{request.get_host()}/xray/{user.hash}",
} }
logger.debug(f"Context prepared with keys: {list(context.keys())}") 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 # Log sample group data for debugging
for server_name, server_data in servers_data.items(): for group_name, group_data in groups_data.items():
logger.debug(f"Server {server_name}: total_connections={server_data['total_connections']}, links_count={len(server_data['links'])}") logger.debug(f"Group {group_name}: total_connections={group_data['total_connections']}, inbounds_count={len(group_data['inbounds'])}")
for i, link_data in enumerate(server_data['links']): for i, inbound_data in enumerate(group_data['inbounds']):
logger.debug(f" Link {i}: connections={link_data['connections']}, recent={link_data['recent_connections']}, last_access='{link_data['last_access_display']}'") 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) 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.shortcuts import get_object_or_404, render
from django.http import JsonResponse, HttpResponse, Http404 from django.http import JsonResponse, HttpResponse, Http404
from mysite.settings import EXTERNAL_ADDRESS 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): def userFrontend(request, user_hash):
from .models import User, ACLLink from .models import User, ACLLink
@@ -195,7 +216,7 @@ def userFrontend(request, user_hash):
server_name = link.acl.server.name server_name = link.acl.server.name
if server_name not in acl_links: if server_name not in acl_links:
acl_links[server_name] = [] 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) 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) 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': if request.GET.get('mode') == 'json':
config = { config = {
"info": "Managed by OutFleet_2 [github.com/house-of-vanity/OutFleet/]", "info": "Managed by OutFleet_2 [github.com/house-of-vanity/OutFleet/]",
"password": server_user.password, "password": password,
"method": server_user.method, "method": method,
"prefix": "\u0005\u00dc_\u00e0\u0001", "prefix": "\u0005\u00dc_\u00e0\u0001",
"server": acl.server.client_hostname, "server": acl.server.client_hostname,
"server_port": server_user.port, "server_port": port,
"access_url": server_user.access_url, "access_url": access_url,
"outfleet": { "outfleet": {
"acl_link": link, "acl_link": link,
"server_name": acl.server.name, "server_name": acl.server.name,
@@ -257,16 +290,16 @@ def shadowsocks(request, link):
"$type": "tcpudp", "$type": "tcpudp",
"tcp": { "tcp": {
"$type": "shadowsocks", "$type": "shadowsocks",
"endpoint": f"{acl.server.client_hostname}:{server_user.port}", "endpoint": f"{acl.server.client_hostname}:{port}",
"cipher": f"{server_user.method}", "cipher": f"{method}",
"secret": f"{server_user.password}", "secret": f"{password}",
"prefix": "\u0005\u00dc_\u00e0\u0001" "prefix": "\u0005\u00dc_\u00e0\u0001"
}, },
"udp": { "udp": {
"$type": "shadowsocks", "$type": "shadowsocks",
"endpoint": f"{acl.server.client_hostname}:{server_user.port}", "endpoint": f"{acl.server.client_hostname}:{port}",
"cipher": f"{server_user.method}", "cipher": f"{method}",
"secret": f"{server_user.password}", "secret": f"{password}",
"prefix": "\u0005\u00dc_\u00e0\u0001" "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' }") 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. 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 .models import User, AccessLog
from vpn.server_plugins import XrayCoreServer, XrayInboundServer from .models_xray import UserSubscription
import logging import logging
from django.utils import timezone from django.utils import timezone
import base64 import base64
import uuid
import json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Clean user_hash from any trailing slashes
user_hash = user_hash.rstrip('/')
try: try:
acl_link = get_object_or_404(ACLLink, link=link) user = get_object_or_404(User, hash=user_hash)
acl = acl_link.acl logger.info(f"Found user {user.username} for Xray subscription generation")
logger.info(f"Found ACL link for user {acl.user.username} on server {acl.server.name}")
except Http404: except Http404:
logger.warning(f"ACL link not found: {link}") logger.warning(f"User not found for hash: {user_hash}")
AccessLog.objects.create( AccessLog.objects.create(
user=None, user=None,
server="Unknown", server="Unknown",
acl_link_id=link, acl_link_id=user_hash,
action="Failed", 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) 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: try:
# Get all servers this user has access to # Check if specific group is requested
user_acls = acl.user.acl_set.all() 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 = [] subscription_configs = []
for user_acl in user_acls: for subscription in user_subscriptions:
server = user_acl.server.get_real_instance() group = subscription.subscription_group
logger.info(f"Processing subscription group {group.name} for user {user.username}")
# Handle XrayInboundServer (individual inbounds) # Get all inbounds from this group
if isinstance(server, XrayInboundServer): for inbound in group.inbounds.all():
if server.xray_inbound:
config = server.get_user(acl.user, raw=True)
if config and 'connection_string' in config:
subscription_configs.append(config['connection_string'])
logger.info(f"Added XrayInboundServer config for {server.name}")
# Handle XrayCoreServer (parent server with multiple inbounds)
elif isinstance(server, XrayCoreServer):
try: try:
# Get all inbounds for this server that have this user # Find all servers where this inbound is deployed
for inbound in server.inbounds.filter(enabled=True): from .models_xray import ServerInbound
# Check if user has a client in this inbound deployed_servers = ServerInbound.objects.filter(
client = inbound.clients.filter(user=acl.user).first() inbound=inbound,
if client: active=True
connection_string = server._generate_connection_string(client) ).select_related('server')
if connection_string:
subscription_configs.append(connection_string) # Generate connection string for each server where inbound is deployed
logger.info(f"Added inbound {inbound.tag} config for user {acl.user.username}") 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.protocol} config for inbound {inbound.name} on server {server.name}")
except Exception as e: 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: 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( AccessLog.objects.create(
user=acl.user.username, user=user.username,
server="Multiple", server="Xray-Subscription",
acl_link_id=acl_link.link, acl_link_id=user_hash,
action="Failed", 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 # Join all configs with newlines and encode in base64 for subscription format
subscription_content = '\n'.join(subscription_configs) 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') subscription_b64 = base64.b64encode(subscription_content.encode('utf-8')).decode('utf-8')
logger.info(f"Base64 subscription length: {len(subscription_b64)}") 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 # Create access log
group_msg = f" for group '{group_filter}'" if group_filter else ""
AccessLog.objects.create( AccessLog.objects.create(
user=acl.user.username, user=user.username,
server="Xray-Subscription", server="Xray-Subscription",
acl_link_id=acl_link.link, acl_link_id=user_hash,
action="Success", 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 # Return with proper headers for subscription
response = HttpResponse(subscription_b64, content_type="text/plain; charset=utf-8") 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' 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 return response
except Exception as e: 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( AccessLog.objects.create(
user=acl.user.username, user=user.username,
server="Xray-Subscription", server="Xray-Subscription",
acl_link_id=acl_link.link, acl_link_id=user_hash,
action="Failed", action="Failed",
data=f"Failed to generate subscription: {e}" data=f"Failed to generate subscription: {e}"
) )
return HttpResponse(f"Error generating subscription: {e}", status=500) 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

View File

@@ -1,23 +0,0 @@
"""
Xray Manager - Python library for managing Xray proxy server via gRPC API.
Supports VLESS, VMess, and Trojan protocols.
"""
from .client import XrayClient
from .models import User, VlessUser, VmessUser, TrojanUser, Stats
from .exceptions import XrayError, APIError, InboundNotFoundError, UserNotFoundError
__version__ = "1.0.0"
__all__ = [
"XrayClient",
"User",
"VlessUser",
"VmessUser",
"TrojanUser",
"Stats",
"XrayError",
"APIError",
"InboundNotFoundError",
"UserNotFoundError"
]

View File

@@ -1,577 +0,0 @@
"""
Main Xray client for managing proxy server via gRPC API.
"""
import json
import logging
import subprocess
from typing import Any, Dict, List, Optional
from .exceptions import APIError, InboundNotFoundError, UserNotFoundError
from .models import Stats, TrojanUser, User, VlessUser, VmessUser
from .protocols import TrojanProtocol, VlessProtocol, VmessProtocol
logger = logging.getLogger(__name__)
class XrayClient:
"""Main client for Xray server management."""
def __init__(self, server: str):
"""
Initialize Xray client.
Args:
server: Xray gRPC API server address (host:port)
"""
self.server = server
self.hostname = server.split(':')[0] # Extract hostname for client links
# Protocol handlers
self._protocols = {}
# Inbound management
def add_vless_inbound(self, port: int, users: List[VlessUser], tag: Optional[str] = None,
listen: str = "0.0.0.0", network: str = "tcp") -> None:
"""Add VLESS inbound with users."""
protocol = VlessProtocol(port, tag, listen, network)
self._protocols[protocol.tag] = protocol
config = protocol.create_inbound_config(users)
self._add_inbound(config)
def add_vmess_inbound(self, port: int, users: List[VmessUser], tag: Optional[str] = None,
listen: str = "0.0.0.0", network: str = "tcp") -> None:
"""Add VMess inbound with users."""
protocol = VmessProtocol(port, tag, listen, network)
self._protocols[protocol.tag] = protocol
config = protocol.create_inbound_config(users)
self._add_inbound(config)
def add_trojan_inbound(self, port: int, users: List[TrojanUser], tag: Optional[str] = None,
listen: str = "0.0.0.0", network: str = "tcp",
cert_pem: Optional[str] = None, key_pem: Optional[str] = None,
hostname: Optional[str] = None) -> None:
"""Add Trojan inbound with users and optional custom certificates."""
hostname = hostname or self.hostname
protocol = TrojanProtocol(port, tag, listen, network, cert_pem, key_pem, hostname)
self._protocols[protocol.tag] = protocol
config = protocol.create_inbound_config(users)
self._add_inbound(config)
def remove_inbound(self, protocol_type_or_tag: str) -> None:
"""
Remove inbound by protocol type or tag.
Args:
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
"""
# Try to find by protocol type first
tag_map = {
'vless': 'vless-inbound',
'vmess': 'vmess-inbound',
'trojan': 'trojan-inbound'
}
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
config = {"tag": tag}
self._remove_inbound(config)
if tag in self._protocols:
del self._protocols[tag]
def list_inbounds(self) -> List[Dict[str, Any]]:
"""List all inbounds."""
return self._list_inbounds()
# User management
def add_user(self, protocol_type_or_tag: str, user: User) -> None:
"""
Add user to existing inbound by recreating it with updated users.
Args:
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
user: User object matching the protocol type
"""
tag_map = {
'vless': 'vless-inbound',
'vmess': 'vmess-inbound',
'trojan': 'trojan-inbound'
}
# Check if it's a protocol type or direct tag
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
# If protocol not registered, we need to get inbound info first
if tag not in self._protocols:
logger.debug(f"Protocol for tag '{tag}' not registered, attempting to retrieve inbound info")
# Try to get inbound info to determine protocol
try:
inbounds = self._list_inbounds()
if isinstance(inbounds, dict) and 'inbounds' in inbounds:
inbound_list = inbounds['inbounds']
else:
inbound_list = inbounds if isinstance(inbounds, list) else []
# Find the inbound by tag
for inbound in inbound_list:
if inbound.get('tag') == tag:
# Determine protocol from proxySettings
proxy_settings = inbound.get('proxySettings', {})
typed_message = proxy_settings.get('_TypedMessage_', '')
if 'vless' in typed_message.lower():
from .protocols import VlessProtocol
port = inbound.get('receiverSettings', {}).get('portList', 443)
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
protocol = VlessProtocol(port, tag, listen, network)
self._protocols[tag] = protocol
elif 'vmess' in typed_message.lower():
from .protocols import VmessProtocol
port = inbound.get('receiverSettings', {}).get('portList', 443)
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
protocol = VmessProtocol(port, tag, listen, network)
self._protocols[tag] = protocol
elif 'trojan' in typed_message.lower():
from .protocols import TrojanProtocol
port = inbound.get('receiverSettings', {}).get('portList', 443)
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
protocol = TrojanProtocol(port, tag, listen, network, hostname=self.hostname)
self._protocols[tag] = protocol
break
except Exception as e:
logger.error(f"Failed to retrieve inbound info for tag '{tag}': {e}")
if tag not in self._protocols:
raise InboundNotFoundError(f"Inbound {protocol_type_or_tag} not found or could not determine protocol")
protocol = self._protocols[tag]
# Use the recreate method since direct API doesn't work reliably
self._recreate_inbound_with_user(protocol, user)
def remove_user(self, protocol_type_or_tag: str, email: str) -> None:
"""
Remove user from inbound by recreating it without the user.
Args:
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
email: User email to remove
"""
tag_map = {
'vless': 'vless-inbound',
'vmess': 'vmess-inbound',
'trojan': 'trojan-inbound'
}
tag = tag_map.get(protocol_type_or_tag, protocol_type_or_tag)
# Use same logic as add_user to find/register protocol
if tag not in self._protocols:
logger.debug(f"Protocol for tag '{tag}' not registered, attempting to retrieve inbound info")
# Try to get inbound info to determine protocol
try:
inbounds = self._list_inbounds()
if isinstance(inbounds, dict) and 'inbounds' in inbounds:
inbound_list = inbounds['inbounds']
else:
inbound_list = inbounds if isinstance(inbounds, list) else []
# Find the inbound by tag
for inbound in inbound_list:
if inbound.get('tag') == tag:
# Determine protocol from proxySettings
proxy_settings = inbound.get('proxySettings', {})
typed_message = proxy_settings.get('_TypedMessage_', '')
if 'vless' in typed_message.lower():
from .protocols import VlessProtocol
port = inbound.get('receiverSettings', {}).get('portList', 443)
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
protocol = VlessProtocol(port, tag, listen, network)
self._protocols[tag] = protocol
elif 'vmess' in typed_message.lower():
from .protocols import VmessProtocol
port = inbound.get('receiverSettings', {}).get('portList', 443)
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
protocol = VmessProtocol(port, tag, listen, network)
self._protocols[tag] = protocol
elif 'trojan' in typed_message.lower():
from .protocols import TrojanProtocol
port = inbound.get('receiverSettings', {}).get('portList', 443)
listen = inbound.get('receiverSettings', {}).get('listen', '0.0.0.0')
network = inbound.get('receiverSettings', {}).get('streamSettings', {}).get('protocolName', 'tcp')
protocol = TrojanProtocol(port, tag, listen, network, hostname=self.hostname)
self._protocols[tag] = protocol
break
except Exception as e:
logger.error(f"Failed to retrieve inbound info for tag '{tag}': {e}")
if tag not in self._protocols:
raise InboundNotFoundError(f"Inbound {protocol_type_or_tag} not found or could not determine protocol")
protocol = self._protocols[tag]
# Use the recreate method
self._recreate_inbound_without_user(protocol, email)
# Client link generation
def generate_client_link(self, protocol_type_or_tag: str, user: User) -> str:
"""
Generate client connection link.
Args:
protocol_type_or_tag: Protocol type ('vless', 'vmess', 'trojan') or specific tag
user: User object
Returns:
Client connection link (vless://, vmess://, trojan://)
"""
# First try to find by protocol type
tag_map = {
'vless': 'vless-inbound',
'vmess': 'vmess-inbound',
'trojan': 'trojan-inbound'
}
# Check if it's a protocol type or direct tag
tag = tag_map.get(protocol_type_or_tag)
if tag and tag in self._protocols:
protocol = self._protocols[tag]
elif protocol_type_or_tag in self._protocols:
protocol = self._protocols[protocol_type_or_tag]
else:
# Try to find any protocol matching the type
for stored_tag, stored_protocol in self._protocols.items():
if protocol_type_or_tag in ['vless', 'vmess', 'trojan']:
protocol_class_name = f"{protocol_type_or_tag.title()}Protocol"
if stored_protocol.__class__.__name__ == protocol_class_name:
protocol = stored_protocol
break
else:
raise InboundNotFoundError(f"Protocol {protocol_type_or_tag} not configured")
return protocol.generate_client_link(user, self.hostname)
# Statistics
def get_server_stats(self) -> Dict[str, Any]:
"""Get server system statistics."""
return self._get_stats_sys()
def get_user_stats(self, protocol_type: str, email: str) -> Stats:
"""
Get user traffic statistics.
Args:
protocol_type: Protocol type
email: User email
Returns:
Stats object with uplink/downlink data
"""
# Implementation would require stats queries
# This is a placeholder for the interface
return Stats(uplink=0, downlink=0)
# Private API methods
def _add_inbound(self, config: Dict[str, Any]) -> None:
"""Add inbound via API."""
result = self._run_api_command("adi", stdin_data=json.dumps(config))
if "error" in result.get("stderr", "").lower():
raise APIError(f"Failed to add inbound: {result['stderr']}")
def _remove_inbound(self, config: Dict[str, Any]) -> None:
"""Remove inbound via API."""
tag = config.get("tag")
if tag:
# Use tag directly as argument instead of JSON
result = self._run_api_command("rmi", args=[tag])
else:
# Fallback to JSON if no tag
result = self._run_api_command("rmi", stdin_data=json.dumps(config))
if result["returncode"] != 0 and "no inbound to remove" not in result.get("stderr", ""):
raise APIError(f"Failed to remove inbound: {result['stderr']}")
def _list_inbounds(self) -> List[Dict[str, Any]]:
"""List inbounds via API."""
result = self._run_api_command("lsi")
if result["returncode"] != 0:
raise APIError(f"Failed to list inbounds: {result['stderr']}")
try:
return json.loads(result["stdout"])
except json.JSONDecodeError:
raise APIError("Invalid JSON response from API")
def _add_user(self, config: Dict[str, Any]) -> None:
"""Add user via API."""
logger.debug(f"Adding user with config: {json.dumps(config, indent=2)}")
result = self._run_api_command("adu", stdin_data=json.dumps(config))
if result["returncode"] != 0 or "error" in result.get("stderr", "").lower():
logger.error(f"Failed to add user. stdout: {result['stdout']}, stderr: {result['stderr']}")
raise APIError(f"Failed to add user: {result['stderr'] or result['stdout']}")
logger.info(f"User {config.get('email')} added successfully to inbound {config.get('tag')}")
def _remove_user(self, inbound_tag: str, email: str) -> None:
"""Remove user via API."""
result = self._run_api_command("rmu", args=[f"--email={email}", inbound_tag])
if "error" in result.get("stderr", "").lower():
raise APIError(f"Failed to remove user: {result['stderr']}")
def _get_stats_sys(self) -> Dict[str, Any]:
"""Get system stats via API."""
result = self._run_api_command("statssys")
if result["returncode"] != 0:
raise APIError(f"Failed to get stats: {result['stderr']}")
try:
return json.loads(result["stdout"])
except json.JSONDecodeError:
raise APIError("Invalid JSON response from API")
def _build_user_config(self, tag: str, user: User, protocol) -> Dict[str, Any]:
"""
Build user configuration for Xray API.
Args:
tag: Inbound tag
user: User object (VlessUser, VmessUser, or TrojanUser)
protocol: Protocol handler
Returns:
User configuration dict for Xray API
"""
from .models import VlessUser, VmessUser, TrojanUser
base_config = {
"tag": tag,
"email": user.email
}
if isinstance(user, VlessUser):
base_config["account"] = {
"_TypedMessage_": "xray.proxy.vless.Account",
"id": user.uuid
}
elif isinstance(user, VmessUser):
base_config["account"] = {
"_TypedMessage_": "xray.proxy.vmess.Account",
"id": user.uuid,
"alterId": getattr(user, 'alter_id', 0)
}
elif isinstance(user, TrojanUser):
base_config["account"] = {
"_TypedMessage_": "xray.proxy.trojan.Account",
"password": user.password
}
else:
raise ValueError(f"Unsupported user type: {type(user)}")
return base_config
def _recreate_inbound_without_user(self, protocol, email: str) -> None:
"""
Recreate inbound without specified user.
"""
# Get existing users from the inbound
existing_users = self._get_existing_users(protocol.tag)
# Filter out the user to remove
all_users = [user for user in existing_users if user.email != email]
if len(all_users) == len(existing_users):
logger.warning(f"User {email} not found in inbound {protocol.tag}")
return
# Remove existing inbound
try:
self.remove_inbound(protocol.tag)
except Exception as e:
logger.warning(f"Failed to remove inbound {protocol.tag}: {e}")
# Recreate inbound with remaining users
if hasattr(protocol, '__class__') and 'Vless' in protocol.__class__.__name__:
self.add_vless_inbound(
port=protocol.port,
users=all_users,
tag=protocol.tag,
listen=protocol.listen,
network=protocol.network
)
elif hasattr(protocol, '__class__') and 'Vmess' in protocol.__class__.__name__:
self.add_vmess_inbound(
port=protocol.port,
users=all_users,
tag=protocol.tag,
listen=protocol.listen,
network=protocol.network
)
elif hasattr(protocol, '__class__') and 'Trojan' in protocol.__class__.__name__:
self.add_trojan_inbound(
port=protocol.port,
users=all_users,
tag=protocol.tag,
listen=protocol.listen,
network=protocol.network,
hostname=getattr(protocol, 'hostname', 'localhost')
)
def _recreate_inbound_with_user(self, protocol, new_user: User) -> None:
"""
Recreate inbound with existing users plus new user.
This is a workaround since Xray API doesn't support reliable dynamic user addition.
"""
# Get existing users from the inbound
existing_users = self._get_existing_users(protocol.tag)
# Check if user already exists
for existing_user in existing_users:
if existing_user.email == new_user.email:
return # User already exists, no need to recreate
# Add new user to existing users list
all_users = existing_users + [new_user]
# Remove existing inbound
try:
self.remove_inbound(protocol.tag)
except Exception as e:
# If removal fails, log but continue - inbound might not exist
pass
# Recreate inbound with all users
if hasattr(protocol, '__class__') and 'Vless' in protocol.__class__.__name__:
self.add_vless_inbound(
port=protocol.port,
users=all_users,
tag=protocol.tag,
listen=protocol.listen,
network=protocol.network
)
elif hasattr(protocol, '__class__') and 'Vmess' in protocol.__class__.__name__:
self.add_vmess_inbound(
port=protocol.port,
users=all_users,
tag=protocol.tag,
listen=protocol.listen,
network=protocol.network
)
elif hasattr(protocol, '__class__') and 'Trojan' in protocol.__class__.__name__:
self.add_trojan_inbound(
port=protocol.port,
users=all_users,
tag=protocol.tag,
listen=protocol.listen,
network=protocol.network,
hostname=getattr(protocol, 'hostname', 'localhost')
)
def _get_existing_users(self, tag: str) -> List[User]:
"""
Get existing users from an inbound.
"""
from .models import VlessUser, VmessUser, TrojanUser
try:
# Use inbounduser API command to get existing users
result = self._run_api_command("inbounduser", args=[f"-tag={tag}"])
if result["returncode"] != 0:
return [] # No users or inbound doesn't exist
import json
user_data = json.loads(result["stdout"])
users = []
if "users" in user_data:
for user_info in user_data["users"]:
email = user_info.get("email", "")
account = user_info.get("account", {})
# Determine protocol based on account type
account_type = account.get("_TypedMessage_", "")
if "vless" in account_type.lower():
users.append(VlessUser(
email=email,
uuid=account.get("id", "")
))
elif "vmess" in account_type.lower():
users.append(VmessUser(
email=email,
uuid=account.get("id", ""),
alter_id=account.get("alterId", 0)
))
elif "trojan" in account_type.lower():
users.append(TrojanUser(
email=email,
password=account.get("password", "")
))
return users
except Exception as e:
# If we can't get existing users, return empty list
return []
def _run_api_command(self, command: str, args: List[str] = None, stdin_data: str = None) -> Dict[str, Any]:
"""
Run xray api command.
Args:
command: API command (adi, rmi, lsi, etc.)
args: Additional command arguments
stdin_data: Data to pass via stdin
Returns:
Dict with stdout, stderr, returncode
"""
cmd = ["xray", "api", command, f"--server={self.server}"]
if args:
cmd.extend(args)
logger.debug(f"Running command: {' '.join(cmd)}")
if stdin_data:
logger.debug(f"With stdin data: {stdin_data}")
try:
result = subprocess.run(
cmd,
input=stdin_data,
text=True,
capture_output=True,
timeout=30
)
logger.debug(f"Command result - returncode: {result.returncode}, stdout: {result.stdout}, stderr: {result.stderr}")
return {
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode
}
except subprocess.TimeoutExpired:
logger.error(f"API command timeout for: {' '.join(cmd)}")
raise APIError("API command timeout")
except FileNotFoundError:
logger.error("xray command not found in PATH")
raise APIError("xray command not found")
except Exception as e:
logger.error(f"Unexpected error running command: {e}")
raise APIError(f"Failed to run command: {e}")

View File

@@ -1,33 +0,0 @@
"""
Custom exceptions for Xray Manager.
"""
class XrayError(Exception):
"""Base exception for all Xray-related errors."""
pass
class APIError(XrayError):
"""Error occurred during API communication."""
pass
class InboundNotFoundError(XrayError):
"""Inbound with specified tag not found."""
pass
class UserNotFoundError(XrayError):
"""User with specified email not found."""
pass
class ConfigurationError(XrayError):
"""Error in Xray configuration."""
pass
class CertificateError(XrayError):
"""Error related to TLS certificates."""
pass

View File

@@ -1,93 +0,0 @@
"""
Data models for Xray Manager.
"""
from dataclasses import dataclass, field
from typing import Optional, Dict, Any
from .utils import generate_uuid
@dataclass
class User:
"""Base user model."""
email: str
level: int = 0
def to_dict(self) -> Dict[str, Any]:
"""Convert user to dictionary representation."""
return {
"email": self.email,
"level": self.level
}
@dataclass
class VlessUser(User):
"""VLESS protocol user."""
uuid: str = field(default_factory=generate_uuid)
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"id": self.uuid
})
return base
@dataclass
class VmessUser(User):
"""VMess protocol user."""
uuid: str = field(default_factory=generate_uuid)
alter_id: int = 0
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"id": self.uuid,
"alterId": self.alter_id
})
return base
@dataclass
class TrojanUser(User):
"""Trojan protocol user."""
password: str = ""
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"password": self.password
})
return base
@dataclass
class Inbound:
"""Inbound configuration."""
tag: str
protocol: str
port: int
listen: str = "0.0.0.0"
def to_dict(self) -> Dict[str, Any]:
return {
"tag": self.tag,
"protocol": self.protocol,
"port": self.port,
"listen": self.listen
}
@dataclass
class Stats:
"""Statistics data."""
uplink: int = 0
downlink: int = 0
@property
def total(self) -> int:
"""Total traffic (uplink + downlink)."""
return self.uplink + self.downlink

View File

@@ -1,15 +0,0 @@
"""
Protocol-specific implementations for Xray Manager.
"""
from .base import BaseProtocol
from .vless import VlessProtocol
from .vmess import VmessProtocol
from .trojan import TrojanProtocol
__all__ = [
"BaseProtocol",
"VlessProtocol",
"VmessProtocol",
"TrojanProtocol"
]

View File

@@ -1,45 +0,0 @@
"""
Base protocol implementation.
"""
from abc import ABC, abstractmethod
from typing import List, Dict, Any, Optional
from ..models import User
class BaseProtocol(ABC):
"""Base class for all protocol implementations."""
def __init__(self, port: int, tag: Optional[str] = None, listen: str = "0.0.0.0", network: str = "tcp"):
self.port = port
self.tag = tag or self._default_tag()
self.listen = listen
self.network = network
@abstractmethod
def _default_tag(self) -> str:
"""Return default tag for this protocol."""
pass
@abstractmethod
def create_inbound_config(self, users: List[User]) -> Dict[str, Any]:
"""Create inbound configuration for this protocol."""
pass
@abstractmethod
def create_user_config(self, user: User) -> Dict[str, Any]:
"""Create user configuration for adding to existing inbound."""
pass
@abstractmethod
def generate_client_link(self, user: User, hostname: str) -> str:
"""Generate client connection link."""
pass
def _base_inbound_config(self) -> Dict[str, Any]:
"""Common inbound configuration."""
return {
"listen": self.listen,
"port": self.port,
"tag": self.tag
}

View File

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

View File

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

View File

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

View File

@@ -1,77 +0,0 @@
"""
Utility functions for Xray Manager.
"""
import uuid
import base64
import secrets
from typing import List
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
import datetime
def generate_uuid() -> str:
"""Generate a random UUID for VLESS/VMess users."""
return str(uuid.uuid4())
def generate_self_signed_cert(hostname: str = "localhost") -> tuple[str, str]:
"""
Generate self-signed certificate for Trojan.
Args:
hostname: Common name for certificate
Returns:
Tuple of (certificate_pem, private_key_pem)
"""
# Generate private key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048
)
# Create certificate
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
])
cert = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
issuer
).public_key(
private_key.public_key()
).serial_number(
x509.random_serial_number()
).not_valid_before(
datetime.datetime.utcnow()
).not_valid_after(
datetime.datetime.utcnow() + datetime.timedelta(days=365)
).add_extension(
x509.SubjectAlternativeName([
x509.DNSName(hostname),
]),
critical=False,
).sign(private_key, hashes.SHA256())
# Convert to PEM format
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
return cert_pem.decode(), key_pem.decode()
def pem_to_lines(pem_data: str) -> List[str]:
"""Convert PEM data to list of lines for Xray JSON format."""
return pem_data.strip().split('\n')

View File

@@ -0,0 +1,62 @@
"""Xray API Python Library"""
from .client import XrayClient
from .stats import StatsManager, StatItem, SystemStats
from .subscription import SubscriptionLinkGenerator
from .exceptions import (
XrayAPIError, XrayConnectionError, XrayCommandError,
XrayConfigError, XrayNotFoundError, XrayValidationError
)
from .models import (
# Base
XrayProtocol, TransportProtocol, SecurityType,
# Protocols
VLESSClient, VMeSSUser, TrojanUser, TrojanFallback,
VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig,
generate_uuid, validate_uuid,
# Transports
StreamSettings, create_tcp_stream, create_ws_stream,
create_grpc_stream, create_http_stream, create_xhttp_stream,
# Security
TLSConfig, REALITYConfig,
create_tls_config, create_reality_config,
generate_self_signed_certificate, create_tls_config_with_self_signed,
# Inbound
InboundConfig, InboundBuilder, SniffingConfig
)
__version__ = "0.1.0"
__all__ = [
# Client
'XrayClient',
# Stats
'StatsManager', 'StatItem', 'SystemStats',
# Subscription
'SubscriptionLinkGenerator',
# Exceptions
'XrayAPIError', 'XrayConnectionError', 'XrayCommandError',
'XrayConfigError', 'XrayNotFoundError', 'XrayValidationError',
# Enums
'XrayProtocol', 'TransportProtocol', 'SecurityType',
# Models
'VLESSClient', 'VMeSSUser', 'TrojanUser', 'TrojanFallback',
'VLESSInboundConfig', 'VMeSSInboundConfig', 'TrojanServerConfig',
'StreamSettings', 'TLSConfig', 'REALITYConfig',
'InboundConfig', 'InboundBuilder', 'SniffingConfig',
# Utils
'generate_uuid', 'validate_uuid',
'create_tcp_stream', 'create_ws_stream',
'create_grpc_stream', 'create_http_stream', 'create_xhttp_stream',
'create_tls_config', 'create_reality_config',
'generate_self_signed_certificate', 'create_tls_config_with_self_signed',
]

235
vpn/xray_api_v2/client.py Normal file
View File

@@ -0,0 +1,235 @@
"""Xray API client implementation"""
import json
import subprocess
from typing import Dict, Any, List, Optional, Union
from pathlib import Path
import tempfile
import os
from .exceptions import (
XrayConnectionError,
XrayCommandError,
XrayConfigError
)
from .models.base import BaseXrayModel
class XrayClient:
"""Client for interacting with Xray API via CLI commands"""
def __init__(self, server: str = "127.0.0.1:8080", timeout: int = 3):
"""
Initialize Xray client
Args:
server: API server address (host:port)
timeout: Command timeout in seconds
"""
self.server = server
self.timeout = timeout
self._xray_binary = "xray"
def execute_command(self,
command: str,
args: List[str] = None,
json_files: List[Union[str, Dict, BaseXrayModel]] = None) -> Dict[str, Any]:
"""
Execute xray API command
Args:
command: API command (e.g., 'adi', 'adu', 'lsi')
args: Additional command arguments
json_files: JSON configurations (paths, dicts, or models)
Returns:
Command output as dictionary
"""
cmd = [self._xray_binary, "api", command]
cmd.extend([f"--server={self.server}", f"--timeout={self.timeout}"])
if args:
cmd.extend(args)
temp_files = []
try:
# Handle JSON configurations
if json_files:
for config in json_files:
if isinstance(config, str):
# File path provided
cmd.append(config)
else:
# Create temporary file for dict or model
temp_file = self._create_temp_json(config)
temp_files.append(temp_file)
cmd.append(temp_file.name)
# Execute command
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.timeout + 5 # Add buffer to subprocess timeout
)
if result.returncode != 0:
raise XrayCommandError(f"Command failed: {result.stderr}")
# Parse output
output = result.stdout.strip()
if not output:
return {}
try:
return json.loads(output)
except json.JSONDecodeError:
# Some commands return plain text
return {"output": output}
except subprocess.TimeoutExpired:
raise XrayConnectionError(f"Command timed out after {self.timeout} seconds")
except Exception as e:
raise XrayCommandError(f"Command execution failed: {str(e)}")
finally:
# Cleanup temporary files
for temp_file in temp_files:
try:
os.unlink(temp_file.name)
except:
pass
def _create_temp_json(self, config: Union[Dict, BaseXrayModel]) -> tempfile.NamedTemporaryFile:
"""Create temporary JSON file from config"""
if isinstance(config, BaseXrayModel):
data = config.to_xray_json()
else:
data = config
temp_file = tempfile.NamedTemporaryFile(
mode='w',
suffix='.json',
delete=False
)
json.dump(data, temp_file, indent=2)
temp_file.close()
return temp_file
# Inbound management
def add_inbound(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
"""Add one or more inbounds"""
# Wrap inbound configs in the required format
wrapped_configs = []
for config in configs:
if isinstance(config, BaseXrayModel):
config_dict = config.to_xray_json()
else:
config_dict = config
# Wrap in inbounds array if not already wrapped
if "inbounds" not in config_dict:
wrapped_config = {"inbounds": [config_dict]}
else:
wrapped_config = config_dict
wrapped_configs.append(wrapped_config)
return self.execute_command("adi", json_files=wrapped_configs)
def remove_inbound(self, tag: str) -> Dict[str, Any]:
"""Remove inbound by tag"""
return self.execute_command("rmi", args=[tag])
def list_inbounds(self) -> List[Dict[str, Any]]:
"""List all inbounds"""
result = self.execute_command("lsi")
return result.get("inbounds", [])
# Outbound management
def add_outbound(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
"""Add one or more outbounds"""
return self.execute_command("ado", json_files=list(configs))
def remove_outbound(self, tag: str) -> Dict[str, Any]:
"""Remove outbound by tag"""
return self.execute_command("rmo", args=[tag])
def list_outbounds(self) -> List[Dict[str, Any]]:
"""List all outbounds"""
result = self.execute_command("lso")
return result.get("outbounds", [])
# User management
def add_users(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
"""Add users to inbounds using JSON files"""
return self.execute_command("adu", json_files=list(configs))
def remove_users(self, tag: str, *emails: str) -> Dict[str, Any]:
"""Remove users from inbounds using tag and email list"""
args = [f"-tag={tag}"] + list(emails)
return self.execute_command("rmu", args=args)
def get_inbound_user(self, tag: str, email: Optional[str] = None) -> Dict[str, Any]:
"""Get inbound user(s) information using -tag flag"""
args = [f"-tag={tag}"]
if email:
args.append(f"-email={email}")
return self.execute_command("inbounduser", args=args)
def get_inbound_user_count(self, tag: str) -> int:
"""Get inbound user count using -tag flag"""
args = [f"-tag={tag}"]
result = self.execute_command("inboundusercount", args=args)
# Parse the result - might be in output field or direct number
if isinstance(result, dict):
return result.get("count", 0)
return 0
# Statistics
def get_stats(self, pattern: str = "", reset: bool = False) -> List[Dict[str, Any]]:
"""Get statistics"""
args = [pattern]
if reset:
args.append("-reset")
if pattern:
args.extend(["-json"])
result = self.execute_command("statsquery", args=args)
return result.get("stat", [])
def get_system_stats(self) -> Dict[str, Any]:
"""Get system statistics"""
return self.execute_command("statssys")
def get_online_stats(self, email: str) -> Dict[str, Any]:
"""Get online session count for user"""
return self.execute_command("statsonline", args=[email])
def get_online_ips(self, email: str) -> List[Dict[str, Any]]:
"""Get user's online IP addresses"""
result = self.execute_command("statsonlineiplist", args=[email])
return result.get("ips", [])
# Routing rules
def add_routing_rules(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
"""Add routing rules"""
return self.execute_command("adrules", json_files=list(configs))
def remove_routing_rules(self, *tags: str) -> Dict[str, Any]:
"""Remove routing rules by tags"""
return self.execute_command("rmrules", args=list(tags))
# Other operations
def restart_logger(self) -> Dict[str, Any]:
"""Restart the logger"""
return self.execute_command("restartlogger")
def get_balancer_info(self, tag: str) -> Dict[str, Any]:
"""Get balancer information"""
return self.execute_command("bi", args=[tag])
def override_balancer(self, tag: str, selectors: List[str]) -> Dict[str, Any]:
"""Override balancer selection"""
return self.execute_command("bo", args=[tag] + selectors)
def block_connection(self, source_ip: str, seconds: int) -> Dict[str, Any]:
"""Block connections from source IP"""
return self.execute_command("sib", args=[source_ip, str(seconds)])

View File

View File

@@ -0,0 +1,33 @@
"""User management command wrappers"""
from dataclasses import dataclass
from typing import List, Optional, Dict, Any
from ..models.base import BaseXrayModel
@dataclass
class AddUserRequest(BaseXrayModel):
"""Request to add user to inbound"""
inboundTag: str
user: Dict[str, Any] # Protocol-specific user config
@dataclass
class RemoveUserRequest(BaseXrayModel):
"""Request to remove user from inbound"""
inboundTag: str
email: str
@dataclass
class UserStats(BaseXrayModel):
"""User statistics data"""
email: str
uplink: int = 0
downlink: int = 0
online: bool = False
ips: List[str] = None
def __post_init__(self):
if self.ips is None:
self.ips = []

View File

@@ -0,0 +1,31 @@
"""Exceptions for xray-api library"""
class XrayAPIError(Exception):
"""Base exception for all xray-api errors"""
pass
class XrayConnectionError(XrayAPIError):
"""Connection to Xray API server failed"""
pass
class XrayCommandError(XrayAPIError):
"""Xray command execution failed"""
pass
class XrayConfigError(XrayAPIError):
"""Invalid configuration"""
pass
class XrayNotFoundError(XrayAPIError):
"""Resource not found"""
pass
class XrayValidationError(XrayAPIError):
"""Validation error"""
pass

View File

@@ -0,0 +1,55 @@
"""Xray API models"""
from .base import (
BaseXrayModel, XrayConfig, XrayProtocol,
TransportProtocol, SecurityType
)
from .protocols import (
# VLESS
VLESSAccount, VLESSClient, VLESSInboundConfig,
# VMess
VMeSSAccount, VMeSSUser, VMeSSInboundConfig, VMeSSSecurityConfig,
# Trojan
TrojanAccount, TrojanUser, TrojanServerConfig, TrojanFallback,
# Shadowsocks
ShadowsocksAccount, ShadowsocksUser, ShadowsocksServerConfig,
# Utils
generate_uuid, validate_uuid, create_protocol_config
)
from .transports import (
StreamSettings, TCPSettings, WebSocketSettings, GRPCSettings,
HTTPSettings, XHTTPSettings, KCPSettings, QUICSettings, DomainSocketSettings,
create_tcp_stream, create_ws_stream, create_grpc_stream, create_http_stream, create_xhttp_stream
)
from .security import (
TLSConfig, REALITYConfig, XTLSConfig, Certificate,
create_tls_config, create_reality_config, create_reality_client_config,
generate_self_signed_certificate, create_tls_config_with_self_signed
)
from .inbound import (
InboundConfig, ReceiverConfig, SniffingConfig, InboundBuilder
)
__all__ = [
# Base
'BaseXrayModel', 'XrayConfig', 'XrayProtocol', 'TransportProtocol', 'SecurityType',
# Protocols
'VLESSAccount', 'VLESSClient', 'VLESSInboundConfig',
'VMeSSAccount', 'VMeSSUser', 'VMeSSInboundConfig', 'VMeSSSecurityConfig',
'TrojanAccount', 'TrojanUser', 'TrojanServerConfig', 'TrojanFallback',
'ShadowsocksAccount', 'ShadowsocksUser', 'ShadowsocksServerConfig',
'generate_uuid', 'validate_uuid', 'create_protocol_config',
# Transports
'StreamSettings', 'TCPSettings', 'WebSocketSettings', 'GRPCSettings',
'HTTPSettings', 'XHTTPSettings', 'KCPSettings', 'QUICSettings', 'DomainSocketSettings',
'create_tcp_stream', 'create_ws_stream', 'create_grpc_stream', 'create_http_stream', 'create_xhttp_stream',
# Security
'TLSConfig', 'REALITYConfig', 'XTLSConfig', 'Certificate',
'create_tls_config', 'create_reality_config', 'create_reality_client_config',
'generate_self_signed_certificate', 'create_tls_config_with_self_signed',
# Inbound
'InboundConfig', 'ReceiverConfig', 'SniffingConfig', 'InboundBuilder',
]

View File

@@ -0,0 +1,97 @@
"""Base models for xray-api library"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, asdict, field
from typing import Dict, Any, Optional, Type, TypeVar
import json
from enum import Enum
T = TypeVar('T', bound='BaseXrayModel')
class BaseXrayModel(ABC):
"""Base class for all Xray configuration models"""
def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary for storage"""
if hasattr(self, '__dataclass_fields__'):
return self._clean_dict(asdict(self))
return self._clean_dict(self.__dict__.copy())
def to_xray_json(self) -> Dict[str, Any]:
"""Convert model to Xray API format"""
return self.to_dict()
@classmethod
def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
"""Create model instance from dictionary"""
return cls(**data)
def _clean_dict(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Remove None values and empty collections"""
cleaned = {}
for key, value in data.items():
if value is None:
continue
if isinstance(value, (list, dict)) and not value:
continue
if isinstance(value, BaseXrayModel):
cleaned[key] = value.to_dict()
elif isinstance(value, list):
cleaned[key] = [
item.to_dict() if isinstance(item, BaseXrayModel) else item
for item in value
]
elif isinstance(value, Enum):
cleaned[key] = value.value
else:
cleaned[key] = value
return cleaned
def to_json(self) -> str:
"""Convert to JSON string"""
return json.dumps(self.to_dict(), indent=2)
@dataclass
class XrayConfig(BaseXrayModel):
"""Base configuration class"""
_TypedMessage_: Optional[str] = field(default=None, init=False)
def __post_init__(self):
"""Set TypedMessage after initialization"""
if self._TypedMessage_ is None and hasattr(self, '__xray_type__'):
self._TypedMessage_ = self.__xray_type__
class XrayProtocol(str, Enum):
"""Supported Xray protocols"""
VLESS = "vless"
VMESS = "vmess"
TROJAN = "trojan"
SHADOWSOCKS = "shadowsocks"
DOKODEMO = "dokodemo-door"
FREEDOM = "freedom"
BLACKHOLE = "blackhole"
DNS = "dns"
HTTP = "http"
SOCKS = "socks"
class TransportProtocol(str, Enum):
"""Transport protocols"""
TCP = "tcp"
KCP = "kcp"
WS = "ws"
HTTP = "http"
XHTTP = "xhttp"
DOMAINSOCKET = "domainsocket"
QUIC = "quic"
GRPC = "grpc"
class SecurityType(str, Enum):
"""Security types"""
NONE = "none"
TLS = "tls"
REALITY = "reality"
XTLS = "xtls"

View File

@@ -0,0 +1,176 @@
"""Inbound configuration models"""
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List, Union
from .base import BaseXrayModel, XrayConfig, XrayProtocol
from .protocols import (
VLESSInboundConfig, VMeSSInboundConfig,
TrojanServerConfig, ShadowsocksServerConfig
)
from .transports import StreamSettings
@dataclass
class SniffingConfig(BaseXrayModel):
"""Traffic sniffing configuration"""
enabled: bool = True
destOverride: Optional[List[str]] = None
metadataOnly: bool = False
def __post_init__(self):
if self.destOverride is None:
self.destOverride = ["http", "tls"]
@dataclass
class ReceiverConfig(XrayConfig):
"""Receiver configuration for inbound"""
__xray_type__ = "xray.app.proxyman.ReceiverConfig"
listen: str = "0.0.0.0"
port: Optional[int] = None
portList: Optional[Union[int, str]] = None # Can be int or range like "10000-20000"
streamSettings: Optional[StreamSettings] = None
def to_xray_json(self) -> Dict[str, Any]:
"""Convert to Xray format"""
config = {"listen": self.listen}
# Either port or portList must be set
if self.port is not None:
config["port"] = self.port
elif self.portList is not None:
config["portList"] = self.portList
else:
raise ValueError("Either port or portList must be specified")
if self.streamSettings:
config["streamSettings"] = self.streamSettings.to_xray_json()
return config
@dataclass
class InboundConfig(BaseXrayModel):
"""Complete inbound configuration"""
tag: str
protocol: XrayProtocol
settings: Union[VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig, ShadowsocksServerConfig]
listen: str = "0.0.0.0"
port: Optional[int] = None
portList: Optional[Union[int, str]] = None
streamSettings: Optional[StreamSettings] = None
sniffing: Optional[SniffingConfig] = None
def to_xray_json(self) -> Dict[str, Any]:
"""Convert to Xray API format with proper field order and structure"""
config = {
"listen": self.listen,
"tag": self.tag,
"protocol": self.protocol.value if hasattr(self.protocol, 'value') else str(self.protocol),
}
# Add port or portList (port comes before protocol in working format)
if self.port is not None:
config["port"] = self.port
elif self.portList is not None:
config["portList"] = self.portList
else:
raise ValueError("Either port or portList must be specified")
# Add protocol settings with _TypedMessage_
settings = self.settings.to_xray_json()
if "_TypedMessage_" not in settings:
# Add _TypedMessage_ based on protocol
protocol_type_map = {
"vless": "xray.proxy.vless.inbound.Config",
"vmess": "xray.proxy.vmess.inbound.Config",
"trojan": "xray.proxy.trojan.inbound.Config",
"shadowsocks": "xray.proxy.shadowsocks.inbound.Config"
}
protocol_name = self.protocol.value if hasattr(self.protocol, 'value') else str(self.protocol)
if protocol_name in protocol_type_map:
settings["_TypedMessage_"] = protocol_type_map[protocol_name]
config["settings"] = settings
# Add stream settings
if self.streamSettings:
config["streamSettings"] = self.streamSettings.to_xray_json()
else:
config["streamSettings"] = {"network": "tcp"} # Default TCP
# Add sniffing
if self.sniffing:
config["sniffing"] = self.sniffing.to_dict()
return config
# Builder for easier configuration
class InboundBuilder:
"""Builder for creating inbound configurations"""
def __init__(self, tag: str, protocol: XrayProtocol):
self.tag = tag
self.protocol = protocol
self._settings = None
self._listen = "0.0.0.0"
self._port = None
self._port_list = None
self._stream_settings = None
self._sniffing = None
def listen(self, address: str) -> 'InboundBuilder':
"""Set listen address"""
self._listen = address
return self
def port(self, port: int) -> 'InboundBuilder':
"""Set single port"""
self._port = port
self._port_list = None
return self
def port_range(self, start: int, end: int) -> 'InboundBuilder':
"""Set port range"""
self._port_list = f"{start}-{end}"
self._port = None
return self
def stream_settings(self, settings: StreamSettings) -> 'InboundBuilder':
"""Set stream settings"""
self._stream_settings = settings
return self
def sniffing(self, enabled: bool = True, dest_override: Optional[List[str]] = None) -> 'InboundBuilder':
"""Configure sniffing"""
self._sniffing = SniffingConfig(
enabled=enabled,
destOverride=dest_override
)
return self
def protocol_settings(self, settings: Union[VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig, ShadowsocksServerConfig]) -> 'InboundBuilder':
"""Set protocol-specific settings"""
self._settings = settings
return self
def build(self) -> InboundConfig:
"""Build the inbound configuration"""
if not self._settings:
raise ValueError("Protocol settings must be specified")
if not self._port and not self._port_list:
raise ValueError("Either port or port range must be specified")
return InboundConfig(
tag=self.tag,
protocol=self.protocol,
settings=self._settings,
listen=self._listen,
port=self._port,
portList=self._port_list,
streamSettings=self._stream_settings,
sniffing=self._sniffing
)

View File

@@ -0,0 +1,266 @@
"""Protocol models for Xray"""
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any
from uuid import uuid4
import re
from .base import BaseXrayModel, XrayConfig, XrayProtocol
def validate_uuid(uuid_str: str) -> bool:
"""Validate UUID format"""
pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I)
return bool(pattern.match(uuid_str))
def generate_uuid() -> str:
"""Generate new UUID"""
return str(uuid4())
# VLESS Protocol
@dataclass
class VLESSAccount(XrayConfig):
"""VLESS account configuration"""
__xray_type__ = "xray.proxy.vless.Account"
id: str
flow: Optional[str] = None
encryption: str = "none"
def __post_init__(self):
super().__post_init__()
if not validate_uuid(self.id):
raise ValueError(f"Invalid UUID: {self.id}")
@dataclass
class VLESSClient(BaseXrayModel):
"""VLESS client configuration"""
email: str
account: VLESSAccount
level: int = 0
@classmethod
def create(cls, email: str, uuid: Optional[str] = None, flow: Optional[str] = None) -> 'VLESSClient':
"""Create VLESS client with optional UUID generation"""
if uuid is None:
uuid = generate_uuid()
account = VLESSAccount(id=uuid, flow=flow)
return cls(email=email, account=account)
@dataclass
class VLESSInboundConfig(XrayConfig):
"""VLESS inbound configuration"""
__xray_type__ = "xray.proxy.vless.inbound.Config"
clients: List[VLESSClient] = field(default_factory=list)
decryption: str = "none"
fallbacks: Optional[List[Dict[str, Any]]] = None
def to_xray_json(self) -> Dict[str, Any]:
"""Convert to Xray API format with proper structure"""
config = {
"_TypedMessage_": self.__xray_type__,
"clients": [],
"decryption": self.decryption
}
# Convert clients to proper format
for client in self.clients:
client_data = {
"id": client.account.id,
"level": client.level,
"email": client.email
}
if client.account.flow:
client_data["flow"] = client.account.flow
config["clients"].append(client_data)
if self.fallbacks:
config["fallbacks"] = self.fallbacks
return config
# VMess Protocol
@dataclass
class VMeSSSecurityConfig(BaseXrayModel):
"""VMess security configuration"""
type: str = "AUTO" # AUTO, AES-128-GCM, CHACHA20-POLY1305, NONE
@dataclass
class VMeSSAccount(XrayConfig):
"""VMess account configuration"""
__xray_type__ = "xray.proxy.vmess.Account"
id: str
securitySettings: Optional[VMeSSSecurityConfig] = None
def __post_init__(self):
super().__post_init__()
if not validate_uuid(self.id):
raise ValueError(f"Invalid UUID: {self.id}")
if self.securitySettings is None:
self.securitySettings = VMeSSSecurityConfig()
@dataclass
class VMeSSUser(BaseXrayModel):
"""VMess user configuration"""
email: str
account: VMeSSAccount
level: int = 0
@classmethod
def create(cls, email: str, uuid: Optional[str] = None, security: str = "AUTO") -> 'VMeSSUser':
"""Create VMess user with optional UUID generation"""
if uuid is None:
uuid = generate_uuid()
account = VMeSSAccount(
id=uuid,
securitySettings=VMeSSSecurityConfig(type=security)
)
return cls(email=email, account=account)
@dataclass
class VMeSSInboundConfig(XrayConfig):
"""VMess inbound configuration"""
__xray_type__ = "xray.proxy.vmess.inbound.Config"
user: List[VMeSSUser] = field(default_factory=list)
disableInsecureEncryption: bool = False
def to_xray_json(self) -> Dict[str, Any]:
"""Convert to Xray API format with proper structure"""
config = {
"_TypedMessage_": self.__xray_type__,
"clients": []
}
# Convert users to proper format
for user in self.user:
client_data = {
"id": user.account.id,
"level": user.level,
"email": user.email,
"alterId": 0 # VMess specific
}
config["clients"].append(client_data)
return config
# Trojan Protocol
@dataclass
class TrojanAccount(XrayConfig):
"""Trojan account configuration"""
__xray_type__ = "xray.proxy.trojan.Account"
password: str
@classmethod
def generate_password(cls) -> str:
"""Generate secure password"""
return generate_uuid()
@dataclass
class TrojanUser(BaseXrayModel):
"""Trojan user configuration"""
email: str
account: TrojanAccount
level: int = 0
@classmethod
def create(cls, email: str, password: Optional[str] = None) -> 'TrojanUser':
"""Create Trojan user with optional password generation"""
if password is None:
password = TrojanAccount.generate_password()
account = TrojanAccount(password=password)
return cls(email=email, account=account)
@dataclass
class TrojanFallback(BaseXrayModel):
"""Trojan fallback configuration"""
dest: str
type: str = "tcp"
xver: int = 0
@dataclass
class TrojanServerConfig(XrayConfig):
"""Trojan server configuration"""
__xray_type__ = "xray.proxy.trojan.ServerConfig"
users: List[TrojanUser] = field(default_factory=list)
fallbacks: Optional[List[TrojanFallback]] = None
def to_xray_json(self) -> Dict[str, Any]:
"""Convert to Xray API format with proper structure"""
config = {
"_TypedMessage_": self.__xray_type__,
"clients": []
}
# Convert users to proper format
for user in self.users:
client_data = {
"password": user.account.password,
"level": user.level,
"email": user.email
}
config["clients"].append(client_data)
if self.fallbacks:
config["fallbacks"] = [fb.to_dict() for fb in self.fallbacks]
return config
# Shadowsocks Protocol
@dataclass
class ShadowsocksAccount(XrayConfig):
"""Shadowsocks account configuration"""
__xray_type__ = "xray.proxy.shadowsocks.Account"
method: str # aes-256-gcm, aes-128-gcm, chacha20-poly1305, etc.
password: str
@dataclass
class ShadowsocksUser(BaseXrayModel):
"""Shadowsocks user configuration"""
email: str
account: ShadowsocksAccount
level: int = 0
@dataclass
class ShadowsocksServerConfig(XrayConfig):
"""Shadowsocks server configuration"""
__xray_type__ = "xray.proxy.shadowsocks.ServerConfig"
users: List[ShadowsocksUser] = field(default_factory=list)
network: str = "tcp,udp"
# Protocol config factory
def create_protocol_config(protocol: XrayProtocol, **kwargs) -> XrayConfig:
"""Factory to create protocol configurations"""
protocol_map = {
XrayProtocol.VLESS: VLESSInboundConfig,
XrayProtocol.VMESS: VMeSSInboundConfig,
XrayProtocol.TROJAN: TrojanServerConfig,
XrayProtocol.SHADOWSOCKS: ShadowsocksServerConfig,
}
config_class = protocol_map.get(protocol)
if not config_class:
raise ValueError(f"Unsupported protocol: {protocol}")
return config_class(**kwargs)

View File

@@ -0,0 +1,389 @@
"""Security configuration models for Xray"""
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any, Tuple
from pathlib import Path
import secrets
from datetime import datetime, timedelta
from .base import BaseXrayModel, XrayConfig, SecurityType
# TLS Configuration
@dataclass
class Certificate(BaseXrayModel):
"""TLS certificate configuration"""
certificateFile: Optional[str] = None
keyFile: Optional[str] = None
certificate: Optional[List[str]] = None # PEM format lines
key: Optional[List[str]] = None # PEM format lines
usage: str = "encipherment"
ocspStapling: int = 3600
oneTimeLoading: bool = False
def to_xray_json(self) -> Dict[str, Any]:
"""Convert to Xray format"""
config = {}
if self.certificateFile and self.keyFile:
config["certificateFile"] = self.certificateFile
config["keyFile"] = self.keyFile
elif self.certificate and self.key:
config["certificate"] = self.certificate
config["key"] = self.key
config["usage"] = self.usage
if self.ocspStapling:
config["ocspStapling"] = self.ocspStapling
if self.oneTimeLoading:
config["OneTimeLoading"] = self.oneTimeLoading
return config
@dataclass
class TLSConfig(XrayConfig):
"""TLS configuration"""
__xray_type__ = "xray.transport.internet.tls.Config"
serverName: Optional[str] = None
allowInsecure: bool = False
alpn: Optional[List[str]] = None
minVersion: str = "1.2"
maxVersion: str = "1.3"
preferServerCipherSuites: bool = True
cipherSuites: Optional[str] = None
certificates: Optional[List[Certificate]] = None
disableSystemRoot: bool = False
enableSessionResumption: bool = False
fingerprint: Optional[str] = None # Client-side
pinnedPeerCertificateChainSha256: Optional[List[str]] = None
rejectUnknownSni: bool = False # Server-side
def to_xray_json(self) -> Dict[str, Any]:
"""Convert to Xray format with proper field handling"""
config = super().to_xray_json()
# Handle certificates properly
if self.certificates:
config["certificates"] = [cert.to_xray_json() for cert in self.certificates]
return config
# REALITY Configuration
@dataclass
class REALITYConfig(XrayConfig):
"""REALITY configuration"""
__xray_type__ = "xray.transport.internet.reality.Config"
# Server-side
show: bool = False
dest: Optional[str] = None # e.g., "example.com:443"
xver: int = 0
serverNames: Optional[List[str]] = None
privateKey: Optional[str] = None
shortIds: Optional[List[str]] = None
# Client-side
serverName: Optional[str] = None
fingerprint: str = "chrome"
publicKey: Optional[str] = None
shortId: Optional[str] = None
spiderX: Optional[str] = None
@classmethod
def generate_keys(cls) -> Dict[str, str]:
"""Generate REALITY key pair using xray x25519"""
import subprocess
try:
# Use xray x25519 to generate proper keys
result = subprocess.run(['xray', 'x25519'], capture_output=True, text=True, check=True)
lines = result.stdout.strip().split('\n')
private_key = ""
public_key = ""
for line in lines:
if line.startswith('Private key:'):
private_key = line.split(': ')[1].strip()
elif line.startswith('Public key:'):
public_key = line.split(': ')[1].strip()
return {
"privateKey": private_key,
"publicKey": public_key
}
except (subprocess.CalledProcessError, FileNotFoundError, IndexError):
# Fallback to base64 encoded random bytes (32 bytes for X25519)
import base64
private_bytes = secrets.token_bytes(32)
public_bytes = secrets.token_bytes(32)
return {
"privateKey": base64.b64encode(private_bytes).decode().rstrip('='),
"publicKey": base64.b64encode(public_bytes).decode().rstrip('=')
}
@classmethod
def generate_short_id(cls) -> str:
"""Generate random short ID (1-16 hex chars)"""
# Generate 1-8 bytes (2-16 hex chars)
length = secrets.randbelow(8) + 1
return secrets.token_hex(length)
# XTLS Configuration (deprecated but still supported)
@dataclass
class XTLSConfig(XrayConfig):
"""XTLS configuration (legacy)"""
__xray_type__ = "xray.transport.internet.xtls.Config"
serverName: Optional[str] = None
allowInsecure: bool = False
alpn: Optional[List[str]] = None
minVersion: str = "1.2"
maxVersion: str = "1.3"
preferServerCipherSuites: bool = True
cipherSuites: Optional[str] = None
certificates: Optional[List[Certificate]] = None
disableSystemRoot: bool = False
fingerprint: Optional[str] = None
rejectUnknownSni: bool = False
# Security factory
def create_tls_config(
server_name: Optional[str] = None,
cert_file: Optional[str] = None,
key_file: Optional[str] = None,
alpn: Optional[List[str]] = None,
fingerprint: Optional[str] = None,
**kwargs
) -> TLSConfig:
"""Create TLS configuration"""
config = TLSConfig(
serverName=server_name,
alpn=alpn or ["h2", "http/1.1"],
fingerprint=fingerprint,
**kwargs
)
if cert_file and key_file:
config.certificates = [Certificate(
certificateFile=cert_file,
keyFile=key_file
)]
return config
def create_reality_config(
dest: str,
server_names: Optional[List[str]] = None,
private_key: Optional[str] = None,
short_ids: Optional[List[str]] = None,
**kwargs
) -> REALITYConfig:
"""Create REALITY configuration for server"""
if not private_key:
keys = REALITYConfig.generate_keys()
private_key = keys["privateKey"]
if not short_ids:
short_ids = [REALITYConfig.generate_short_id()]
return REALITYConfig(
show=False,
dest=dest,
serverNames=server_names or [dest.split(":")[0]],
privateKey=private_key,
shortIds=short_ids,
**kwargs
)
def create_reality_client_config(
server_name: str,
public_key: str,
short_id: str,
fingerprint: str = "chrome",
**kwargs
) -> REALITYConfig:
"""Create REALITY configuration for client"""
return REALITYConfig(
serverName=server_name,
publicKey=public_key,
shortId=short_id,
fingerprint=fingerprint,
**kwargs
)
# Certificate generation utilities
def generate_self_signed_certificate(
common_name: str = "localhost",
san_list: Optional[List[str]] = None,
days: int = 365,
key_size: int = 2048,
save_to_files: bool = False,
cert_path: Optional[str] = None,
key_path: Optional[str] = None
) -> Tuple[str, str]:
"""
Generate self-signed certificate
Args:
common_name: Certificate common name
san_list: Subject Alternative Names (domains/IPs)
days: Certificate validity in days
key_size: RSA key size
save_to_files: Whether to save to files
cert_path: Path to save certificate
key_path: Path to save private key
Returns:
Tuple of (certificate_pem, private_key_pem)
"""
try:
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtensionOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
except ImportError:
raise ImportError("cryptography package is required for certificate generation. Install with: pip install cryptography")
# Generate private key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=key_size,
backend=default_backend()
)
# Create certificate subject
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "State"),
x509.NameAttribute(NameOID.LOCALITY_NAME, "City"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Xray Self-Signed"),
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
])
# Create certificate builder
builder = x509.CertificateBuilder()
builder = builder.subject_name(subject)
builder = builder.issuer_name(issuer)
builder = builder.public_key(private_key.public_key())
builder = builder.serial_number(x509.random_serial_number())
builder = builder.not_valid_before(datetime.utcnow())
builder = builder.not_valid_after(datetime.utcnow() + timedelta(days=days))
# Add Subject Alternative Names
san_list = san_list or [common_name]
alt_names = []
for san in san_list:
if san.replace('.', '').isdigit(): # IP address
alt_names.append(x509.IPAddress(ipaddress.ip_address(san)))
else: # Domain name
alt_names.append(x509.DNSName(san))
builder = builder.add_extension(
x509.SubjectAlternativeName(alt_names),
critical=False,
)
# Add basic constraints
builder = builder.add_extension(
x509.BasicConstraints(ca=True, path_length=0),
critical=True,
)
# Add key usage
builder = builder.add_extension(
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=True,
data_encipherment=False,
key_agreement=False,
key_cert_sign=True,
crl_sign=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
# Sign certificate
certificate = builder.sign(private_key, hashes.SHA256(), default_backend())
# Serialize to PEM
cert_pem = certificate.public_bytes(serialization.Encoding.PEM).decode('utf-8')
key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
# Save to files if requested
if save_to_files:
cert_file = cert_path or f"{common_name}_cert.pem"
key_file = key_path or f"{common_name}_key.pem"
with open(cert_file, 'w') as f:
f.write(cert_pem)
with open(key_file, 'w') as f:
f.write(key_pem)
# Set appropriate permissions
Path(key_file).chmod(0o600)
return cert_pem, key_pem
def create_tls_config_with_self_signed(
common_name: str = "localhost",
san_list: Optional[List[str]] = None,
alpn: Optional[List[str]] = None,
**tls_kwargs
) -> Tuple[TLSConfig, str, str]:
"""
Create TLS configuration with self-signed certificate
Returns:
Tuple of (TLSConfig, certificate_pem, private_key_pem)
"""
cert_pem, key_pem = generate_self_signed_certificate(
common_name=common_name,
san_list=san_list
)
# Convert PEM to lines for Xray format
cert_lines = cert_pem.strip().split('\n')
key_lines = key_pem.strip().split('\n')
# Create certificate config
certificate = Certificate(
certificate=cert_lines,
key=key_lines,
oneTimeLoading=True
)
# Create TLS config
tls_config = TLSConfig(
serverName=common_name,
alpn=alpn or ["h2", "http/1.1"],
certificates=[certificate],
**tls_kwargs
)
return tls_config, cert_pem, key_pem
# Add missing import
try:
import ipaddress
except ImportError:
ipaddress = None

View File

@@ -0,0 +1,241 @@
"""Transport configuration models for Xray"""
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from .base import BaseXrayModel, XrayConfig, TransportProtocol
# TCP Transport
@dataclass
class TCPSettings(XrayConfig):
"""TCP transport settings"""
__xray_type__ = "xray.transport.internet.tcp.Config"
acceptProxyProtocol: bool = False
header: Optional[Dict[str, Any]] = None
# KCP Transport
@dataclass
class KCPSettings(XrayConfig):
"""KCP transport settings"""
__xray_type__ = "xray.transport.internet.kcp.Config"
mtu: int = 1350
tti: int = 50
uplinkCapacity: int = 5
downlinkCapacity: int = 20
congestion: bool = False
readBufferSize: int = 2
writeBufferSize: int = 2
header: Optional[Dict[str, Any]] = None
# WebSocket Transport
@dataclass
class WebSocketSettings(XrayConfig):
"""WebSocket transport settings"""
__xray_type__ = "xray.transport.internet.websocket.Config"
path: str = "/"
headers: Optional[Dict[str, str]] = None
acceptProxyProtocol: bool = False
def to_xray_json(self) -> Dict[str, Any]:
"""Convert to Xray format"""
config = super().to_xray_json()
# Ensure headers is a dict even if empty
if self.headers:
config["headers"] = self.headers
return config
# HTTP/2 Transport
@dataclass
class HTTPSettings(XrayConfig):
"""HTTP/2 transport settings"""
__xray_type__ = "xray.transport.internet.http.Config"
path: str = "/"
host: Optional[List[str]] = None
method: str = "PUT"
headers: Optional[Dict[str, List[str]]] = None
# XHTTP Transport (New)
@dataclass
class XHTTPSettings(XrayConfig):
"""XHTTP transport settings"""
__xray_type__ = "xray.transport.internet.xhttp.Config"
path: str = "/"
host: Optional[str] = None
method: str = "GET"
headers: Optional[Dict[str, Any]] = None
mode: str = "auto"
# Domain Socket Transport
@dataclass
class DomainSocketSettings(XrayConfig):
"""Domain socket transport settings"""
__xray_type__ = "xray.transport.internet.domainsocket.Config"
path: str
abstract: bool = False
padding: bool = False
# QUIC Transport
@dataclass
class QUICSettings(XrayConfig):
"""QUIC transport settings"""
__xray_type__ = "xray.transport.internet.quic.Config"
security: str = "none"
key: str = ""
header: Optional[Dict[str, Any]] = None
# gRPC Transport
@dataclass
class GRPCSettings(XrayConfig):
"""gRPC transport settings"""
__xray_type__ = "xray.transport.internet.grpc.encoding.Config"
serviceName: str = ""
multiMode: bool = False
idle_timeout: int = 60
health_check_timeout: int = 20
permit_without_stream: bool = False
initial_windows_size: int = 0
# Stream Settings
@dataclass
class StreamSettings(BaseXrayModel):
"""Stream settings for inbound/outbound"""
network: TransportProtocol = TransportProtocol.TCP
security: Optional[str] = None
tlsSettings: Optional[Any] = None
xtlsSettings: Optional[Any] = None
realitySettings: Optional[Any] = None
tcpSettings: Optional[TCPSettings] = None
kcpSettings: Optional[KCPSettings] = None
wsSettings: Optional[WebSocketSettings] = None
httpSettings: Optional[HTTPSettings] = None
xhttpSettings: Optional[XHTTPSettings] = None
dsSettings: Optional[DomainSocketSettings] = None
quicSettings: Optional[QUICSettings] = None
grpcSettings: Optional[GRPCSettings] = None
sockopt: Optional[Dict[str, Any]] = None
def to_xray_json(self) -> Dict[str, Any]:
"""Convert to Xray format with correct field names"""
config = {
"network": self.network.value if isinstance(self.network, TransportProtocol) else self.network
}
if self.security:
config["security"] = self.security
# Map transport settings
transport_map = {
TransportProtocol.TCP: ("tcpSettings", self.tcpSettings),
TransportProtocol.KCP: ("kcpSettings", self.kcpSettings),
TransportProtocol.WS: ("wsSettings", self.wsSettings),
TransportProtocol.HTTP: ("httpSettings", self.httpSettings),
TransportProtocol.XHTTP: ("xhttpSettings", self.xhttpSettings),
TransportProtocol.DOMAINSOCKET: ("dsSettings", self.dsSettings),
TransportProtocol.QUIC: ("quicSettings", self.quicSettings),
TransportProtocol.GRPC: ("grpcSettings", self.grpcSettings),
}
network = self.network if isinstance(self.network, TransportProtocol) else TransportProtocol(self.network)
field_name, settings = transport_map.get(network, (None, None))
if field_name and settings:
config[field_name] = settings.to_xray_json() if hasattr(settings, 'to_xray_json') else settings
# Add security settings
if self.tlsSettings:
config["tlsSettings"] = self.tlsSettings if isinstance(self.tlsSettings, dict) else self.tlsSettings.to_xray_json()
if self.xtlsSettings:
config["xtlsSettings"] = self.xtlsSettings if isinstance(self.xtlsSettings, dict) else self.xtlsSettings.to_xray_json()
if self.realitySettings:
config["realitySettings"] = self.realitySettings if isinstance(self.realitySettings, dict) else self.realitySettings.to_xray_json()
if self.sockopt:
config["sockopt"] = self.sockopt
return config
# Factory functions
def create_tcp_stream(
security: Optional[str] = None,
**kwargs
) -> StreamSettings:
"""Create TCP stream settings"""
return StreamSettings(
network=TransportProtocol.TCP,
security=security,
tcpSettings=TCPSettings(**kwargs) if kwargs else None
)
def create_ws_stream(
path: str = "/",
headers: Optional[Dict[str, str]] = None,
security: Optional[str] = None,
**kwargs
) -> StreamSettings:
"""Create WebSocket stream settings"""
return StreamSettings(
network=TransportProtocol.WS,
security=security,
wsSettings=WebSocketSettings(path=path, headers=headers, **kwargs)
)
def create_grpc_stream(
service_name: str = "",
security: Optional[str] = None,
**kwargs
) -> StreamSettings:
"""Create gRPC stream settings"""
return StreamSettings(
network=TransportProtocol.GRPC,
security=security,
grpcSettings=GRPCSettings(serviceName=service_name, **kwargs)
)
def create_http_stream(
path: str = "/",
host: Optional[List[str]] = None,
security: Optional[str] = None,
**kwargs
) -> StreamSettings:
"""Create HTTP/2 stream settings"""
return StreamSettings(
network=TransportProtocol.HTTP,
security=security,
httpSettings=HTTPSettings(path=path, host=host, **kwargs)
)
def create_xhttp_stream(
path: str = "/",
host: Optional[str] = None,
security: Optional[str] = None,
mode: str = "auto",
**kwargs
) -> StreamSettings:
"""Create XHTTP stream settings"""
return StreamSettings(
network=TransportProtocol.XHTTP,
security=security,
xhttpSettings=XHTTPSettings(path=path, host=host, mode=mode, **kwargs)
)

Some files were not shown because too many files have changed in this diff Show More