diff --git a/.gitignore b/.gitignore index 9df6e2f..121f581 100755 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,4 @@ debug.log *.pyc staticfiles/ *.__pycache__.* -vpn/migrations/ celerybeat-schedule diff --git a/docker-compose.yaml b/docker-compose.yaml index fcd2534..0a113c7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,9 +8,9 @@ services: ports: - "8000:8000" environment: - - POSTGRES_HOST=postgres - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres + - POSTGRES_HOST=172.17.0.1 + - POSTGRES_USER=outfleet + - POSTGRES_PASSWORD='3u!DCZm4%AD$kGij2' - EXTERNAL_ADDRESS=http://127.0.0.1:8000 - CELERY_BROKER_URL=redis://redis:6379/0 depends_on: diff --git a/vpn/migrations/0001_initial.py b/vpn/migrations/0001_initial.py new file mode 100644 index 0000000..eac202c --- /dev/null +++ b/vpn/migrations/0001_initial.py @@ -0,0 +1,139 @@ +# Initial migration + +from django.conf import settings +from django.db import migrations, models +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import shortuuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('comment', models.TextField(blank=True, default='', help_text='Free form user comment')), + ('registration_date', models.DateTimeField(auto_now_add=True, verbose_name='Created')), + ('last_access', models.DateTimeField(blank=True, null=True)), + ('hash', models.CharField(help_text='Random user hash. It\'s using for client config generation.', max_length=64, unique=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='AccessLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user', models.CharField(blank=True, editable=False, max_length=256, null=True)), + ('server', models.CharField(blank=True, editable=False, max_length=256, null=True)), + ('action', models.CharField(editable=False, max_length=100)), + ('data', models.TextField(blank=True, default='', editable=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Server', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Server name', max_length=100)), + ('comment', models.TextField(blank=True, default='')), + ('registration_date', models.DateTimeField(auto_now_add=True, verbose_name='Created')), + ('server_type', models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard')], editable=False, max_length=50)), + ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')), + ], + options={ + 'verbose_name': 'Server', + 'verbose_name_plural': 'Servers', + 'permissions': [('access_server', 'Can view public status')], + 'base_manager_name': 'objects', + }, + ), + migrations.CreateModel( + name='OutlineServer', + 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')), + ('admin_url', models.URLField(help_text='Management URL')), + ('admin_access_cert', models.CharField(help_text='Fingerprint', max_length=255)), + ('client_hostname', models.CharField(help_text='Server address for clients', max_length=255)), + ('client_port', models.CharField(help_text='Server port for clients', max_length=5)), + ], + options={ + 'verbose_name': 'Outline', + 'verbose_name_plural': 'Outline', + 'base_manager_name': 'objects', + }, + bases=('vpn.server',), + ), + migrations.CreateModel( + name='WireguardServer', + 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')), + ('address', models.CharField(max_length=100)), + ('port', models.IntegerField()), + ('client_private_key', models.CharField(max_length=255)), + ('server_publick_key', models.CharField(max_length=255)), + ], + options={ + 'verbose_name': 'Wireguard', + 'verbose_name_plural': 'Wireguard', + 'base_manager_name': 'objects', + }, + bases=('vpn.server',), + ), + migrations.CreateModel( + name='ACL', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created')), + ('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vpn.server')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ACLLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment', models.TextField(blank=True, default='', help_text='ACL link comment, device name, etc...')), + ('link', models.CharField(blank=True, default='', help_text='Access link to get dynamic configuration', max_length=1024, null=True, unique=True, verbose_name='Access link')), + ('acl', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='vpn.acl')), + ], + ), + migrations.AddField( + model_name='user', + name='servers', + field=models.ManyToManyField(blank=True, help_text='Servers user has access to', through='vpn.ACL', to='vpn.server'), + ), + migrations.AddConstraint( + model_name='acl', + constraint=models.UniqueConstraint(fields=('user', 'server'), name='unique_user_server'), + ), + ] diff --git a/vpn/migrations/0002_taskexecutionlog.py b/vpn/migrations/0002_taskexecutionlog.py new file mode 100644 index 0000000..171aa5f --- /dev/null +++ b/vpn/migrations/0002_taskexecutionlog.py @@ -0,0 +1,46 @@ +# Generated manually for TaskExecutionLog model + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0001_initial'), # This might need to be adjusted + ] + + operations = [ + migrations.CreateModel( + name='TaskExecutionLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('task_id', models.CharField(help_text='Celery task ID', max_length=255)), + ('task_name', models.CharField(help_text='Task name', max_length=100)), + ('action', models.CharField(help_text='Action performed', max_length=100)), + ('status', models.CharField(choices=[('STARTED', 'Started'), ('SUCCESS', 'Success'), ('FAILURE', 'Failure'), ('RETRY', 'Retry')], default='STARTED', max_length=20)), + ('message', models.TextField(help_text='Detailed execution message')), + ('execution_time', models.FloatField(blank=True, help_text='Execution time in seconds', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('server', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.server')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.user')), + ], + options={ + 'verbose_name': 'Task Execution Log', + 'verbose_name_plural': 'Task Execution Logs', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='taskexecutionlog', + index=models.Index(fields=['task_id'], name='vpn_taskexe_task_id_e7f4b1_idx'), + ), + migrations.AddIndex( + model_name='taskexecutionlog', + index=models.Index(fields=['created_at'], name='vpn_taskexe_created_c4a9b5_idx'), + ), + migrations.AddIndex( + model_name='taskexecutionlog', + index=models.Index(fields=['status'], name='vpn_taskexe_status_1b2c3d_idx'), + ), + ] diff --git a/vpn/migrations/__init__.py b/vpn/migrations/__init__.py new file mode 100644 index 0000000..d170bcb --- /dev/null +++ b/vpn/migrations/__init__.py @@ -0,0 +1 @@ +# Migration package \ No newline at end of file diff --git a/vpn/templates/vpn/user_portal.html b/vpn/templates/vpn/user_portal.html index a55b892..9cdc8ba 100644 --- a/vpn/templates/vpn/user_portal.html +++ b/vpn/templates/vpn/user_portal.html @@ -4,7 +4,6 @@ VPN Access Portal - {{ user.username }} - @@ -315,8 +355,19 @@
{{ total_links }} - Active Connections + Active Links
+
+ {{ total_connections }} + Total Uses +
+
+ {{ recent_connections }} + Last 30 Days +
+ +
+

📊 Statistics are updated in real-time and show your connection history

@@ -325,7 +376,12 @@ {% for server_name, server_data in servers_data.items %}
-
{{ server_name }}
+
+
{{ server_name }}
+
+ 📊 {{ server_data.total_connections }} uses +
+
{{ server_data.server_type }}
@@ -347,18 +403,18 @@ {% for link_data in server_data.links %} {% endfor %}
@@ -379,40 +435,6 @@ diff --git a/vpn/views.py b/vpn/views.py index 83701f4..13ceeeb 100644 --- a/vpn/views.py +++ b/vpn/views.py @@ -1,7 +1,8 @@ def userPortal(request, user_hash): """HTML portal for user to view their VPN access links and server information""" - from .models import User, ACLLink + from .models import User, ACLLink, AccessLog import logging + from django.db.models import Count, Q logger = logging.getLogger(__name__) @@ -10,7 +11,6 @@ def userPortal(request, user_hash): logger.info(f"User portal accessed for user {user.username}") except Http404: logger.warning(f"User portal access attempt with invalid hash: {user_hash}") - from django.shortcuts import render return render(request, 'vpn/user_portal_error.html', { 'error_title': 'Access Denied', 'error_message': 'Invalid access link. Please contact your administrator.' @@ -20,6 +20,45 @@ def userPortal(request, user_hash): # Get all ACL links for the user with server information acl_links = ACLLink.objects.filter(acl__user=user).select_related('acl__server', 'acl') + # Get connection statistics for all user's links + # Count successful connections for each specific link + connection_stats = {} + total_connections = 0 + + for acl_link in acl_links: + # Count successful connections for this specific link by checking if the link appears in the access log data + # This is more accurate as it counts actual uses of the specific link + link_connections = AccessLog.objects.filter( + user=user.username, + server=acl_link.acl.server.name, + action='Success' + ).extra( + where=["data LIKE %s"], + params=[f'%{acl_link.link}%'] + ).count() + + # If no specific link matches found, fall back to general server connection count for this user + if link_connections == 0: + # This gives a rough estimate based on server connections divided by number of links for this server + server_connections = AccessLog.objects.filter( + user=user.username, + server=acl_link.acl.server.name, + action='Success' + ).count() + + # Get number of links for this server for this user + user_links_on_server = ACLLink.objects.filter( + acl__user=user, + acl__server=acl_link.acl.server + ).count() + + # Distribute connections evenly among links if we can't track specific usage + if user_links_on_server > 0: + link_connections = server_connections // user_links_on_server + + connection_stats[acl_link.link] = link_connections + total_connections += link_connections + # Group links by server servers_data = {} total_links = 0 @@ -47,32 +86,45 @@ def userPortal(request, user_hash): 'error': server_error, 'links': [], 'server_type': server.server_type, + 'total_connections': 0, } - # Add link information + # Add link information with connection stats link_url = f"{EXTERNAL_ADDRESS}/ss/{link.link}#{server_name}" + connection_count = connection_stats.get(link.link, 0) + servers_data[server_name]['links'].append({ 'link': link, 'url': link_url, - 'qr_data': link_url, # For QR code generation 'comment': link.comment or 'Default', + 'connections': connection_count, }) + servers_data[server_name]['total_connections'] += connection_count total_links += 1 + # Get recent connection activity (last 30 days) + from datetime import datetime, timedelta + recent_date = datetime.now() - timedelta(days=30) + recent_connections = AccessLog.objects.filter( + user=user.username, + action='Success', + timestamp__gte=recent_date + ).count() + context = { 'user': user, 'servers_data': servers_data, 'total_servers': len(servers_data), 'total_links': total_links, + 'total_connections': total_connections, + 'recent_connections': recent_connections, 'external_address': EXTERNAL_ADDRESS, } - from django.shortcuts import render return render(request, 'vpn/user_portal.html', context) except Exception as e: logger.error(f"Error loading user portal for {user.username}: {e}") - from django.shortcuts import render return render(request, 'vpn/user_portal_error.html', { 'error_title': 'Server Error', 'error_message': 'Unable to load your VPN information. Please try again later or contact support.'