diff --git a/static/admin/css/vpn_admin.css b/static/admin/css/vpn_admin.css new file mode 100644 index 0000000..05d175c --- /dev/null +++ b/static/admin/css/vpn_admin.css @@ -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; +} diff --git a/vpn/admin.py b/vpn/admin.py index 7d3ab25..2091b92 100644 --- a/vpn/admin.py +++ b/vpn/admin.py @@ -337,15 +337,23 @@ class UserAdmin(admin.ModelAdmin): return qs def save_model(self, request, obj, form, change): + import logging + logger = logging.getLogger(__name__) + super().save_model(request, obj, form, change) selected_servers = form.cleaned_data.get('servers', []) # 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) for server in selected_servers: 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 @admin.register(AccessLog) @@ -405,7 +413,10 @@ class ACLAdmin(admin.ModelAdmin): data = server.get_user(user) return format_object(data) except Exception as e: - return mark_safe(f"Error: {e}") + 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"Server connection error: {e}") @admin.display(description='Dynamic Config Links') def display_links(self, obj): diff --git a/vpn/models.py b/vpn/models.py index 9ca6165..ae6584e 100644 --- a/vpn/models.py +++ b/vpn/models.py @@ -1,4 +1,5 @@ import uuid +import logging from django.db import models from vpn.tasks import sync_user from django.db.models.signals import post_save, pre_delete @@ -8,6 +9,8 @@ import shortuuid from django.contrib.auth.models import AbstractUser +logger = logging.getLogger(__name__) + class AccessLog(models.Model): user = 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) 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) 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): diff --git a/vpn/server_plugins/outline.py b/vpn/server_plugins/outline.py index 0e7851c..8ed2c0f 100644 --- a/vpn/server_plugins/outline.py +++ b/vpn/server_plugins/outline.py @@ -1,5 +1,4 @@ import logging -from venv import logger import requests from django.db import models from .generic import Server @@ -36,12 +35,15 @@ class _FingerprintAdapter(requests.adapters.HTTPAdapter): class OutlineServer(Server): - logger = logging.getLogger(__name__) admin_url = models.URLField(help_text="Management URL") 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_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: verbose_name = '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) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - def __str__(self): return f"{self.name} ({self.client_hostname}:{self.client_port})" @@ -85,6 +84,7 @@ class OutlineServer(Server): def sync_users(self): from vpn.models import User, ACL + logger = logging.getLogger(__name__) logger.debug(f"[{self.name}] Sync all users") keys = self.client.get_keys() acls = ACL.objects.filter(server=self) @@ -119,10 +119,28 @@ class OutlineServer(Server): raise OutlineConnectionError("Client error. Can't connect.", original_exception=e) def _get_key(self, user): - logger.error(f"Asking for key for user {user.username}") - result = self.client.get_key(str(user.username)) - logger.error(f"Got key for user {user.username} - {result}") - return result + logger = logging.getLogger(__name__) + logger.debug(f"[{self.name}] Looking for key for user {user.username}") + try: + # 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): user_info = self._get_key(user) @@ -140,33 +158,53 @@ class OutlineServer(Server): def add_user(self, user): + logger = logging.getLogger(__name__) try: server_user = self._get_key(user) except OutlineServerErrorException as e: server_user = None + logger.debug(f"[{self.name}] User {str(server_user)}") result = {} key = None if server_user: - if server_user.method != "chacha20-ietf-poly1305" or \ - server_user.port != int(self.client_port) or \ - server_user.name != user.username or \ - server_user.password != user.hash or \ - self.client.delete_key(user.hash): - - self.delete_user(user) - key = self.client.create_key( - key_id=user.username, - name=user.username, - method=server_user.method, - password=user.hash, - data_limit=None, - port=server_user.port - ) - logger.debug(f"[{self.name}] User {user.username} updated") + # Check if user needs update - but don't delete immediately + needs_update = ( + server_user.method != "chacha20-ietf-poly1305" or + server_user.port != int(self.client_port) or + server_user.name != user.username or + server_user.password != user.hash + ) + + if needs_update: + # Delete old key before creating new one + try: + self.client.delete_key(server_user.key_id) + logger.debug(f"[{self.name}] Deleted outdated key for user {user.username}") + except Exception as e: + logger.warning(f"[{self.name}] Failed to delete old key for user {user.username}: {e}") + + # Create new key with correct parameters + 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: + # User doesn't exist, create new key try: key = self.client.create_key( key_id=user.username, @@ -180,23 +218,39 @@ class OutlineServer(Server): except OutlineServerErrorException as e: error_message = str(e) if "code\":\"Conflict" in error_message: - logger.warning(f"[{self.name}] Conflict for User {user.username}, trying to force sync. {error_message}") - for key in self.client.get_keys(): - logger.warning(f"[{self.name}] checking user: {key.name} passowrd: {key.password}") - if key.password == user.hash: - self.delete_user(user) - return self.add_user(user) + logger.warning(f"[{self.name}] Conflict for User {user.username}, trying to resolve. {error_message}") + # Find conflicting key by password and remove it + try: + for existing_key in self.client.get_keys(): + if existing_key.password == user.hash: + 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: raise OutlineConnectionError("API Error", original_exception=e) + + # Build result from key object try: - result['key_id'] = key.key_id - result['name'] = key.name - result['method'] = key.method - result['password'] = key.password - result['data_limit'] = key.data_limit - result['port'] = key.port + if key: + result = { + 'key_id': key.key_id, + 'name': key.name, + 'method': key.method, + 'password': key.password, + 'data_limit': key.data_limit, + 'port': key.port + } + else: + result = {"error": "No key object returned"} except Exception as e: + logger.error(f"[{self.name}] Error building result for user {user.username}: {e}") result = {"error": str(e)} + return result def delete_user(self, user): diff --git a/vpn/tasks.py b/vpn/tasks.py index 9f9d523..58d59b8 100644 --- a/vpn/tasks.py +++ b/vpn/tasks.py @@ -1,9 +1,7 @@ import logging from celery import group, shared_task -#from django_celery_results.models import TaskResult -from outline_vpn.outline_vpn import OutlineServerErrorException - +from celery.exceptions import Retry logger = logging.getLogger(__name__) @@ -13,61 +11,113 @@ class TaskFailedException(Exception): super().__init__(f"{self.message}") -@shared_task(name="sync_all_servers") -def sync_all_users(): - from .models import User, ACL +@shared_task(name="sync_all_servers", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 60}) +def sync_all_users(self): from vpn.server_plugins import Server servers = Server.objects.all() + 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() - return result + return f"Initiated sync for {servers.count()} servers" -@shared_task(name="sync_all_users_on_server") -def sync_users(server_id): - from .models import Server - status = {} +@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 + try: server = Server.objects.get(id=server_id) - sync = server.sync_users() - if sync: + logger.info(f"Starting user sync for server {server.name}") + + sync_result = server.sync_users() + + if sync_result: logger.info(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: - logger.error(f"Error syncing users for server {server.name}: {e}") - raise TaskFailedException(message=f"Error syncing users for server {server.name}") + logger.error(f"Error syncing users for server id {server_id}: {e}") + 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") -def sync_server(id): +@shared_task(name="sync_server_info", bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 30}) +def sync_server(self, id): 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()} + + try: + 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") -def sync_user(user_id, server_id): +@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 vpn.server_plugins import Server errors = {} result = {} - user = User.objects.get(id=user_id) - acls = ACL.objects.filter(user=user) - - server = Server.objects.get(id=server_id) - + 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) + logger.info(f"Added/updated user {user.username} on server {server.name}") else: + # User should be removed from server 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: - errors[server.name] = {"error": e} - finally: - if errors: - raise TaskFailedException(message=f"Errors during taks: {errors}") - return result \ No newline at end of file + error_msg = f"Error syncing user {user_id} on server {server_id}: {e}" + logger.error(error_msg) + errors[f"server_{server_id}"] = error_msg + + # 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 \ No newline at end of file diff --git a/vpn/templates/admin/simple_move_clients.html b/vpn/templates/admin/simple_move_clients.html new file mode 100644 index 0000000..d4c7860 --- /dev/null +++ b/vpn/templates/admin/simple_move_clients.html @@ -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 %} + +{% endblock %} + +{% block breadcrumbs %} +
+{% endblock %} + +{% block content %} +