mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Fixed last release
This commit is contained in:
137
static/admin/css/vpn_admin.css
Normal file
137
static/admin/css/vpn_admin.css
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/* Custom styles for VPN admin interface */
|
||||||
|
|
||||||
|
/* Quick action buttons in server list */
|
||||||
|
.quick-actions .button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 0 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(to bottom, #f8f8f8, #e8e8e8);
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions .button:hover {
|
||||||
|
background: linear-gradient(to bottom, #e8e8e8, #d8d8d8);
|
||||||
|
border-color: #bbb;
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions .button:active {
|
||||||
|
background: linear-gradient(to bottom, #d8d8d8, #e8e8e8);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sync button - blue theme */
|
||||||
|
.quick-actions .button[href*="/sync/"] {
|
||||||
|
background: linear-gradient(to bottom, #4a90e2, #357abd);
|
||||||
|
border-color: #2968a3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions .button[href*="/sync/"]:hover {
|
||||||
|
background: linear-gradient(to bottom, #357abd, #2968a3);
|
||||||
|
border-color: #1f5582;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Move clients button - orange theme */
|
||||||
|
.quick-actions .button[href*="/move-clients/"] {
|
||||||
|
background: linear-gradient(to bottom, #f39c12, #e67e22);
|
||||||
|
border-color: #d35400;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions .button[href*="/move-clients/"]:hover {
|
||||||
|
background: linear-gradient(to bottom, #e67e22, #d35400);
|
||||||
|
border-color: #bf4f36;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicators improvements */
|
||||||
|
.server-status-ok {
|
||||||
|
color: #27ae60;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-status-error {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-status-warning {
|
||||||
|
color: #f39c12;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better spacing for list display */
|
||||||
|
.admin-object-tools {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve readability of pre-formatted status */
|
||||||
|
.changelist-results pre {
|
||||||
|
font-size: 11px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background: #f8f8f8;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form improvements for move clients page */
|
||||||
|
.form-row.field-box {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row.field-box label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row.field-box .readonly {
|
||||||
|
padding: 5px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
background: #e8f4fd;
|
||||||
|
border: 1px solid #b8daff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #0066cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help ul {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help li {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
15
vpn/admin.py
15
vpn/admin.py
@@ -337,15 +337,23 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
return qs
|
return qs
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
super().save_model(request, obj, form, change)
|
super().save_model(request, obj, form, change)
|
||||||
selected_servers = form.cleaned_data.get('servers', [])
|
selected_servers = form.cleaned_data.get('servers', [])
|
||||||
|
|
||||||
# Remove ACLs that are no longer selected
|
# Remove ACLs that are no longer selected
|
||||||
ACL.objects.filter(user=obj).exclude(server__in=selected_servers).delete()
|
removed_acls = ACL.objects.filter(user=obj).exclude(server__in=selected_servers)
|
||||||
|
for acl in removed_acls:
|
||||||
|
logger.info(f"Removing ACL for user {obj.username} from server {acl.server.name}")
|
||||||
|
removed_acls.delete()
|
||||||
|
|
||||||
# Create new ACLs for newly selected servers (with default links)
|
# Create new ACLs for newly selected servers (with default links)
|
||||||
for server in selected_servers:
|
for server in selected_servers:
|
||||||
acl, created = ACL.objects.get_or_create(user=obj, server=server)
|
acl, created = ACL.objects.get_or_create(user=obj, server=server)
|
||||||
|
if created:
|
||||||
|
logger.info(f"Created new ACL for user {obj.username} on server {server.name}")
|
||||||
# Note: get_or_create will use the default save() method which creates default links
|
# Note: get_or_create will use the default save() method which creates default links
|
||||||
|
|
||||||
@admin.register(AccessLog)
|
@admin.register(AccessLog)
|
||||||
@@ -405,7 +413,10 @@ class ACLAdmin(admin.ModelAdmin):
|
|||||||
data = server.get_user(user)
|
data = server.get_user(user)
|
||||||
return format_object(data)
|
return format_object(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return mark_safe(f"<span style='color: red;'>Error: {e}</span>")
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Failed to get user info for {user.username} on {server.name}: {e}")
|
||||||
|
return mark_safe(f"<span style='color: red;'>Server connection error: {e}</span>")
|
||||||
|
|
||||||
@admin.display(description='Dynamic Config Links')
|
@admin.display(description='Dynamic Config Links')
|
||||||
def display_links(self, obj):
|
def display_links(self, obj):
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from vpn.tasks import sync_user
|
from vpn.tasks import sync_user
|
||||||
from django.db.models.signals import post_save, pre_delete
|
from django.db.models.signals import post_save, pre_delete
|
||||||
@@ -8,6 +9,8 @@ import shortuuid
|
|||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class AccessLog(models.Model):
|
class AccessLog(models.Model):
|
||||||
user = models.CharField(max_length=256, blank=True, null=True, editable=False)
|
user = models.CharField(max_length=256, blank=True, null=True, editable=False)
|
||||||
server = models.CharField(max_length=256, blank=True, null=True, editable=False)
|
server = models.CharField(max_length=256, blank=True, null=True, editable=False)
|
||||||
@@ -65,11 +68,24 @@ class ACL(models.Model):
|
|||||||
|
|
||||||
@receiver(post_save, sender=ACL)
|
@receiver(post_save, sender=ACL)
|
||||||
def acl_created_or_updated(sender, instance, created, **kwargs):
|
def acl_created_or_updated(sender, instance, created, **kwargs):
|
||||||
sync_user.delay_on_commit(instance.user.id, instance.server.id)
|
try:
|
||||||
|
sync_user.delay_on_commit(instance.user.id, instance.server.id)
|
||||||
|
if created:
|
||||||
|
logger.info(f"Scheduled sync for new ACL: user {instance.user.username} on server {instance.server.name}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Scheduled sync for updated ACL: user {instance.user.username} on server {instance.server.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to schedule sync task for ACL {instance.id}: {e}")
|
||||||
|
# Don't raise exception to avoid blocking ACL creation/update
|
||||||
|
|
||||||
@receiver(pre_delete, sender=ACL)
|
@receiver(pre_delete, sender=ACL)
|
||||||
def acl_deleted(sender, instance, **kwargs):
|
def acl_deleted(sender, instance, **kwargs):
|
||||||
sync_user.delay_on_commit(instance.user.id, instance.server.id)
|
try:
|
||||||
|
sync_user.delay_on_commit(instance.user.id, instance.server.id)
|
||||||
|
logger.info(f"Scheduled sync for deleted ACL: user {instance.user.username} on server {instance.server.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to schedule sync task for ACL deletion {instance.id}: {e}")
|
||||||
|
# Don't raise exception to avoid blocking ACL deletion
|
||||||
|
|
||||||
|
|
||||||
class ACLLink(models.Model):
|
class ACLLink(models.Model):
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
from venv import logger
|
|
||||||
import requests
|
import requests
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from .generic import Server
|
from .generic import Server
|
||||||
@@ -36,12 +35,15 @@ class _FingerprintAdapter(requests.adapters.HTTPAdapter):
|
|||||||
|
|
||||||
|
|
||||||
class OutlineServer(Server):
|
class OutlineServer(Server):
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
admin_url = models.URLField(help_text="Management URL")
|
admin_url = models.URLField(help_text="Management URL")
|
||||||
admin_access_cert = models.CharField(max_length=255, help_text="Fingerprint")
|
admin_access_cert = models.CharField(max_length=255, help_text="Fingerprint")
|
||||||
client_hostname = models.CharField(max_length=255, help_text="Server address for clients")
|
client_hostname = models.CharField(max_length=255, help_text="Server address for clients")
|
||||||
client_port = models.CharField(max_length=5, help_text="Server port for clients")
|
client_port = models.CharField(max_length=5, help_text="Server port for clients")
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Outline'
|
verbose_name = 'Outline'
|
||||||
verbose_name_plural = 'Outline'
|
verbose_name_plural = 'Outline'
|
||||||
@@ -59,9 +61,6 @@ class OutlineServer(Server):
|
|||||||
return OutlineVPN(api_url=self.admin_url, cert_sha256=self.admin_access_cert)
|
return OutlineVPN(api_url=self.admin_url, cert_sha256=self.admin_access_cert)
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.client_hostname}:{self.client_port})"
|
return f"{self.name} ({self.client_hostname}:{self.client_port})"
|
||||||
|
|
||||||
@@ -85,6 +84,7 @@ class OutlineServer(Server):
|
|||||||
|
|
||||||
def sync_users(self):
|
def sync_users(self):
|
||||||
from vpn.models import User, ACL
|
from vpn.models import User, ACL
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
logger.debug(f"[{self.name}] Sync all users")
|
logger.debug(f"[{self.name}] Sync all users")
|
||||||
keys = self.client.get_keys()
|
keys = self.client.get_keys()
|
||||||
acls = ACL.objects.filter(server=self)
|
acls = ACL.objects.filter(server=self)
|
||||||
@@ -119,10 +119,28 @@ class OutlineServer(Server):
|
|||||||
raise OutlineConnectionError("Client error. Can't connect.", original_exception=e)
|
raise OutlineConnectionError("Client error. Can't connect.", original_exception=e)
|
||||||
|
|
||||||
def _get_key(self, user):
|
def _get_key(self, user):
|
||||||
logger.error(f"Asking for key for user {user.username}")
|
logger = logging.getLogger(__name__)
|
||||||
result = self.client.get_key(str(user.username))
|
logger.debug(f"[{self.name}] Looking for key for user {user.username}")
|
||||||
logger.error(f"Got key for user {user.username} - {result}")
|
try:
|
||||||
return result
|
# Try to get key by username first
|
||||||
|
result = self.client.get_key(str(user.username))
|
||||||
|
logger.debug(f"[{self.name}] Found key for user {user.username} by username")
|
||||||
|
return result
|
||||||
|
except OutlineServerErrorException:
|
||||||
|
# If not found by username, search by password (hash)
|
||||||
|
logger.debug(f"[{self.name}] Key not found by username, searching by password")
|
||||||
|
try:
|
||||||
|
keys = self.client.get_keys()
|
||||||
|
for key in keys:
|
||||||
|
if key.password == user.hash:
|
||||||
|
logger.debug(f"[{self.name}] Found key for user {user.username} by password match")
|
||||||
|
return key
|
||||||
|
# No key found
|
||||||
|
logger.debug(f"[{self.name}] No key found for user {user.username}")
|
||||||
|
raise OutlineServerErrorException(f"Key not found for user {user.username}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[{self.name}] Error searching for key for user {user.username}: {e}")
|
||||||
|
raise OutlineServerErrorException(f"Error searching for key: {e}")
|
||||||
|
|
||||||
def get_user(self, user, raw=False):
|
def get_user(self, user, raw=False):
|
||||||
user_info = self._get_key(user)
|
user_info = self._get_key(user)
|
||||||
@@ -140,33 +158,53 @@ class OutlineServer(Server):
|
|||||||
|
|
||||||
|
|
||||||
def add_user(self, user):
|
def add_user(self, user):
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
try:
|
try:
|
||||||
server_user = self._get_key(user)
|
server_user = self._get_key(user)
|
||||||
except OutlineServerErrorException as e:
|
except OutlineServerErrorException as e:
|
||||||
server_user = None
|
server_user = None
|
||||||
|
|
||||||
logger.debug(f"[{self.name}] User {str(server_user)}")
|
logger.debug(f"[{self.name}] User {str(server_user)}")
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
key = None
|
key = None
|
||||||
|
|
||||||
if server_user:
|
if server_user:
|
||||||
if server_user.method != "chacha20-ietf-poly1305" or \
|
# Check if user needs update - but don't delete immediately
|
||||||
server_user.port != int(self.client_port) or \
|
needs_update = (
|
||||||
server_user.name != user.username or \
|
server_user.method != "chacha20-ietf-poly1305" or
|
||||||
server_user.password != user.hash or \
|
server_user.port != int(self.client_port) or
|
||||||
self.client.delete_key(user.hash):
|
server_user.name != user.username or
|
||||||
|
server_user.password != user.hash
|
||||||
|
)
|
||||||
|
|
||||||
self.delete_user(user)
|
if needs_update:
|
||||||
key = self.client.create_key(
|
# Delete old key before creating new one
|
||||||
key_id=user.username,
|
try:
|
||||||
name=user.username,
|
self.client.delete_key(server_user.key_id)
|
||||||
method=server_user.method,
|
logger.debug(f"[{self.name}] Deleted outdated key for user {user.username}")
|
||||||
password=user.hash,
|
except Exception as e:
|
||||||
data_limit=None,
|
logger.warning(f"[{self.name}] Failed to delete old key for user {user.username}: {e}")
|
||||||
port=server_user.port
|
|
||||||
)
|
# Create new key with correct parameters
|
||||||
logger.debug(f"[{self.name}] User {user.username} updated")
|
try:
|
||||||
|
key = self.client.create_key(
|
||||||
|
key_id=user.username,
|
||||||
|
name=user.username,
|
||||||
|
method="chacha20-ietf-poly1305",
|
||||||
|
password=user.hash,
|
||||||
|
data_limit=None,
|
||||||
|
port=int(self.client_port)
|
||||||
|
)
|
||||||
|
logger.info(f"[{self.name}] User {user.username} updated")
|
||||||
|
except OutlineServerErrorException as e:
|
||||||
|
raise OutlineConnectionError(f"Failed to create updated key for user {user.username}", original_exception=e)
|
||||||
|
else:
|
||||||
|
# User exists and is up to date
|
||||||
|
key = server_user
|
||||||
|
logger.debug(f"[{self.name}] User {user.username} already up to date")
|
||||||
else:
|
else:
|
||||||
|
# User doesn't exist, create new key
|
||||||
try:
|
try:
|
||||||
key = self.client.create_key(
|
key = self.client.create_key(
|
||||||
key_id=user.username,
|
key_id=user.username,
|
||||||
@@ -180,23 +218,39 @@ class OutlineServer(Server):
|
|||||||
except OutlineServerErrorException as e:
|
except OutlineServerErrorException as e:
|
||||||
error_message = str(e)
|
error_message = str(e)
|
||||||
if "code\":\"Conflict" in error_message:
|
if "code\":\"Conflict" in error_message:
|
||||||
logger.warning(f"[{self.name}] Conflict for User {user.username}, trying to force sync. {error_message}")
|
logger.warning(f"[{self.name}] Conflict for User {user.username}, trying to resolve. {error_message}")
|
||||||
for key in self.client.get_keys():
|
# Find conflicting key by password and remove it
|
||||||
logger.warning(f"[{self.name}] checking user: {key.name} passowrd: {key.password}")
|
try:
|
||||||
if key.password == user.hash:
|
for existing_key in self.client.get_keys():
|
||||||
self.delete_user(user)
|
if existing_key.password == user.hash:
|
||||||
return self.add_user(user)
|
logger.warning(f"[{self.name}] Found conflicting key {existing_key.key_id} with same password")
|
||||||
|
self.client.delete_key(existing_key.key_id)
|
||||||
|
break
|
||||||
|
# Try to create again after cleanup
|
||||||
|
return self.add_user(user)
|
||||||
|
except Exception as cleanup_error:
|
||||||
|
logger.error(f"[{self.name}] Failed to resolve conflict for user {user.username}: {cleanup_error}")
|
||||||
|
raise OutlineConnectionError(f"Conflict resolution failed for user {user.username}", original_exception=e)
|
||||||
else:
|
else:
|
||||||
raise OutlineConnectionError("API Error", original_exception=e)
|
raise OutlineConnectionError("API Error", original_exception=e)
|
||||||
|
|
||||||
|
# Build result from key object
|
||||||
try:
|
try:
|
||||||
result['key_id'] = key.key_id
|
if key:
|
||||||
result['name'] = key.name
|
result = {
|
||||||
result['method'] = key.method
|
'key_id': key.key_id,
|
||||||
result['password'] = key.password
|
'name': key.name,
|
||||||
result['data_limit'] = key.data_limit
|
'method': key.method,
|
||||||
result['port'] = key.port
|
'password': key.password,
|
||||||
|
'data_limit': key.data_limit,
|
||||||
|
'port': key.port
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result = {"error": "No key object returned"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"[{self.name}] Error building result for user {user.username}: {e}")
|
||||||
result = {"error": str(e)}
|
result = {"error": str(e)}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def delete_user(self, user):
|
def delete_user(self, user):
|
||||||
|
118
vpn/tasks.py
118
vpn/tasks.py
@@ -1,9 +1,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from celery import group, shared_task
|
from celery import group, shared_task
|
||||||
#from django_celery_results.models import TaskResult
|
from celery.exceptions import Retry
|
||||||
from outline_vpn.outline_vpn import OutlineServerErrorException
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -13,61 +11,113 @@ class TaskFailedException(Exception):
|
|||||||
super().__init__(f"{self.message}")
|
super().__init__(f"{self.message}")
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="sync_all_servers")
|
@shared_task(name="sync_all_servers", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 60})
|
||||||
def sync_all_users():
|
def sync_all_users(self):
|
||||||
from .models import User, ACL
|
|
||||||
from vpn.server_plugins import Server
|
from vpn.server_plugins import Server
|
||||||
|
|
||||||
servers = Server.objects.all()
|
servers = Server.objects.all()
|
||||||
|
|
||||||
tasks = group(sync_users.s(server.id) for server in servers)
|
if not servers.exists():
|
||||||
|
logger.warning("No servers found for synchronization")
|
||||||
|
return "No servers to sync"
|
||||||
|
|
||||||
|
tasks = group(sync_users.s(server.id) for server in servers)
|
||||||
result = tasks.apply_async()
|
result = tasks.apply_async()
|
||||||
|
|
||||||
return result
|
return f"Initiated sync for {servers.count()} servers"
|
||||||
|
|
||||||
|
@shared_task(name="sync_all_users_on_server", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 60})
|
||||||
|
def sync_users(self, server_id):
|
||||||
|
from vpn.server_plugins import Server
|
||||||
|
|
||||||
@shared_task(name="sync_all_users_on_server")
|
|
||||||
def sync_users(server_id):
|
|
||||||
from .models import Server
|
|
||||||
status = {}
|
|
||||||
try:
|
try:
|
||||||
server = Server.objects.get(id=server_id)
|
server = Server.objects.get(id=server_id)
|
||||||
sync = server.sync_users()
|
logger.info(f"Starting user sync for server {server.name}")
|
||||||
if sync:
|
|
||||||
|
sync_result = server.sync_users()
|
||||||
|
|
||||||
|
if sync_result:
|
||||||
logger.info(f"Successfully synced users for server {server.name}")
|
logger.info(f"Successfully synced users for server {server.name}")
|
||||||
return f"Successfully synced users for server {server.name}"
|
return f"Successfully synced users for server {server.name}"
|
||||||
|
else:
|
||||||
|
raise TaskFailedException(f"Sync failed for server {server.name}")
|
||||||
|
|
||||||
|
except Server.DoesNotExist:
|
||||||
|
logger.error(f"Server with id {server_id} not found")
|
||||||
|
raise TaskFailedException(f"Server with id {server_id} not found")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error syncing users for server {server.name}: {e}")
|
logger.error(f"Error syncing users for server id {server_id}: {e}")
|
||||||
raise TaskFailedException(message=f"Error syncing users for server {server.name}")
|
if self.request.retries < 3:
|
||||||
|
logger.info(f"Retrying sync for server id {server_id} (attempt {self.request.retries + 1})")
|
||||||
|
raise self.retry(countdown=60)
|
||||||
|
raise TaskFailedException(f"Error syncing users for server id {server_id}: {e}")
|
||||||
|
|
||||||
@shared_task(name="sync_server_info")
|
@shared_task(name="sync_server_info", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 30})
|
||||||
def sync_server(id):
|
def sync_server(self, id):
|
||||||
from vpn.server_plugins import Server
|
from vpn.server_plugins import Server
|
||||||
# task_result = TaskResult.objects.get_task(self.request.id)
|
|
||||||
# task_result.status='RUNNING'
|
|
||||||
# task_result.save()
|
|
||||||
return {"status": Server.objects.get(id=id).sync()}
|
|
||||||
|
|
||||||
@shared_task(name="sync_user_on_server")
|
try:
|
||||||
def sync_user(user_id, server_id):
|
server = Server.objects.get(id=id)
|
||||||
|
logger.info(f"Starting server info sync for {server.name}")
|
||||||
|
|
||||||
|
sync_result = server.sync()
|
||||||
|
return {"status": sync_result, "server": server.name}
|
||||||
|
|
||||||
|
except Server.DoesNotExist:
|
||||||
|
logger.error(f"Server with id {id} not found")
|
||||||
|
return {"error": f"Server with id {id} not found"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error syncing server info for id {id}: {e}")
|
||||||
|
if self.request.retries < 3:
|
||||||
|
logger.info(f"Retrying server sync for id {id} (attempt {self.request.retries + 1})")
|
||||||
|
raise self.retry(countdown=30)
|
||||||
|
return {"error": f"Error syncing server info: {e}"}
|
||||||
|
|
||||||
|
@shared_task(name="sync_user_on_server", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 5, 'countdown': 30})
|
||||||
|
def sync_user(self, user_id, server_id):
|
||||||
from .models import User, ACL
|
from .models import User, ACL
|
||||||
from vpn.server_plugins import Server
|
from vpn.server_plugins import Server
|
||||||
|
|
||||||
errors = {}
|
errors = {}
|
||||||
result = {}
|
result = {}
|
||||||
user = User.objects.get(id=user_id)
|
|
||||||
acls = ACL.objects.filter(user=user)
|
|
||||||
|
|
||||||
server = Server.objects.get(id=server_id)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if acls.filter(server=server).exists():
|
user = User.objects.get(id=user_id)
|
||||||
|
server = Server.objects.get(id=server_id)
|
||||||
|
|
||||||
|
logger.info(f"Syncing user {user.username} on server {server.name}")
|
||||||
|
|
||||||
|
# Check if ACL exists
|
||||||
|
acl_exists = ACL.objects.filter(user=user, server=server).exists()
|
||||||
|
|
||||||
|
if acl_exists:
|
||||||
|
# User should exist on server
|
||||||
result[server.name] = server.add_user(user)
|
result[server.name] = server.add_user(user)
|
||||||
|
logger.info(f"Added/updated user {user.username} on server {server.name}")
|
||||||
else:
|
else:
|
||||||
|
# User should be removed from server
|
||||||
result[server.name] = server.delete_user(user)
|
result[server.name] = server.delete_user(user)
|
||||||
|
logger.info(f"Removed user {user.username} from server {server.name}")
|
||||||
|
|
||||||
|
except User.DoesNotExist:
|
||||||
|
error_msg = f"User with id {user_id} not found"
|
||||||
|
logger.error(error_msg)
|
||||||
|
errors["user"] = error_msg
|
||||||
|
except Server.DoesNotExist:
|
||||||
|
error_msg = f"Server with id {server_id} not found"
|
||||||
|
logger.error(error_msg)
|
||||||
|
errors["server"] = error_msg
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors[server.name] = {"error": e}
|
error_msg = f"Error syncing user {user_id} on server {server_id}: {e}"
|
||||||
finally:
|
logger.error(error_msg)
|
||||||
if errors:
|
errors[f"server_{server_id}"] = error_msg
|
||||||
raise TaskFailedException(message=f"Errors during taks: {errors}")
|
|
||||||
return result
|
# Retry on failure unless it's a permanent error
|
||||||
|
if self.request.retries < 5:
|
||||||
|
logger.info(f"Retrying user sync for user {user_id} on server {server_id} (attempt {self.request.retries + 1})")
|
||||||
|
raise self.retry(countdown=30)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise TaskFailedException(message=f"Errors during task: {errors}")
|
||||||
|
|
||||||
|
return result
|
93
vpn/templates/admin/simple_move_clients.html
Normal file
93
vpn/templates/admin/simple_move_clients.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n admin_urls static admin_list %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/vpn_admin.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||||
|
› <a href="{% url 'admin:vpn_server_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||||
|
› {{ title }}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
|
||||||
|
<div class="module aligned">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-row field-box">
|
||||||
|
<label>Source Server:</label>
|
||||||
|
<div class="readonly"><strong>{{ source_server.name }}</strong> ({{ source_server.server_type }})</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row field-box">
|
||||||
|
<label>Statistics:</label>
|
||||||
|
<div class="readonly">
|
||||||
|
<strong>{{ links_count }}</strong> client link(s) for <strong>{{ users_count }}</strong> user(s)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if links_count == 0 %}
|
||||||
|
<div class="messagelist">
|
||||||
|
<div class="warning">No client links found on this server.</div>
|
||||||
|
</div>
|
||||||
|
<div class="submit-row">
|
||||||
|
<a href="{% url 'admin:vpn_server_changelist' %}" class="default">« Back to server list</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" id="move-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<fieldset class="module aligned">
|
||||||
|
<h2>Move Options</h2>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div>
|
||||||
|
<label for="target_server" class="required">Target Server:</label>
|
||||||
|
<select id="target_server" name="target_server" class="vLargeTextField" required>
|
||||||
|
<option value="">-- Select target server --</option>
|
||||||
|
{% for server in all_servers %}
|
||||||
|
<option value="{{ server.id }}">{{ server.name }} ({{ server.server_type }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div>
|
||||||
|
<label for="add_prefix">Add prefix to comments (optional):</label>
|
||||||
|
<input type="text" id="add_prefix" name="add_prefix" class="vTextField"
|
||||||
|
placeholder="e.g. [FROM {{ source_server.name }}]">
|
||||||
|
<p class="help">This prefix will be added to all client link comments</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="submit-row">
|
||||||
|
<input type="submit" value="Move All Client Links" class="default"
|
||||||
|
onclick="return confirm('Are you sure you want to move ALL {{ links_count }} client link(s) from {{ source_server.name }} to the selected target server?\\n\\nThis action cannot be undone.');">
|
||||||
|
<a href="{% url 'admin:vpn_server_changelist' %}" class="button cancel">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="help">
|
||||||
|
<h3>What will happen:</h3>
|
||||||
|
<ul>
|
||||||
|
<li>All {{ links_count }} client links will be moved from <strong>{{ source_server.name }}</strong> to the target server</li>
|
||||||
|
<li>Users who don't have access to the target server will get new ACL entries created automatically</li>
|
||||||
|
<li>Empty ACL entries on the source server will be cleaned up</li>
|
||||||
|
<li>All link settings and comments will be preserved (with optional prefix)</li>
|
||||||
|
<li>This operation is database-only and doesn't require server connectivity</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
11
vpn/templates/admin/vpn/server/change_list.html.backup
Normal file
11
vpn/templates/admin/vpn/server/change_list.html.backup
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "admin/change_list.html" %}
|
||||||
|
{% load admin_list admin_urls %}
|
||||||
|
|
||||||
|
{% block content_title %}
|
||||||
|
<h1>{{ cl.opts.verbose_name_plural|capfirst }}</h1>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
This template overrides the default changelist to provide a cleaner interface
|
||||||
|
without any bulk operations blocks that might be added by external packages
|
||||||
|
{% endcomment %}
|
12
vpn/views.py
12
vpn/views.py
@@ -22,20 +22,28 @@ def userFrontend(request, user_hash):
|
|||||||
|
|
||||||
def shadowsocks(request, link):
|
def shadowsocks(request, link):
|
||||||
from .models import ACLLink, AccessLog
|
from .models import ACLLink, AccessLog
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
acl_link = get_object_or_404(ACLLink, link=link)
|
acl_link = get_object_or_404(ACLLink, link=link)
|
||||||
acl = acl_link.acl
|
acl = acl_link.acl
|
||||||
|
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}")
|
||||||
AccessLog.objects.create(user=None, server="Unknown", action="Failed",
|
AccessLog.objects.create(user=None, server="Unknown", action="Failed",
|
||||||
data=f"ACL not found for link: {link}")
|
data=f"ACL not found for link: {link}")
|
||||||
return JsonResponse({"error": "Not allowed"}, status=403)
|
return JsonResponse({"error": "Not allowed"}, status=403)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
server_user = acl.server.get_user(acl.user, raw=True)
|
server_user = acl.server.get_user(acl.user, raw=True)
|
||||||
|
logger.info(f"Successfully retrieved user credentials for {acl.user.username} from {acl.server.name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get user credentials for {acl.user.username} from {acl.server.name}: {e}")
|
||||||
AccessLog.objects.create(user=acl.user, server=acl.server.name, action="Failed",
|
AccessLog.objects.create(user=acl.user, server=acl.server.name, action="Failed",
|
||||||
data=f"{e}")
|
data=f"Failed to get credentials: {e}")
|
||||||
return JsonResponse({"error": f"Couldn't get credentials from server. {e}"})
|
return JsonResponse({"error": f"Couldn't get credentials from server. {e}"}, status=500)
|
||||||
|
|
||||||
if request.GET.get('mode') == 'json':
|
if request.GET.get('mode') == 'json':
|
||||||
config = {
|
config = {
|
||||||
|
Reference in New Issue
Block a user