From 289abdada31b4b0b5e4f99c67288d14c8ae1fd22 Mon Sep 17 00:00:00 2001 From: Eduardo Silva Date: Thu, 12 Mar 2026 14:15:14 -0300 Subject: [PATCH] add app_gateway management views and templates --- app_gateway/forms.py | 205 ++++++++++++ .../0002_alter_application_display_name.py | 18 + .../0003_alter_application_display_name.py | 18 + .../0004_alter_application_display_name.py | 18 + app_gateway/models.py | 7 +- app_gateway/urls.py | 28 ++ app_gateway/views.py | 310 +++++++++++++++++- templates/app_gateway/app_gateway_list.html | 254 ++++++++++++++ wireguard_webadmin/urls.py | 1 + 9 files changed, 856 insertions(+), 3 deletions(-) create mode 100644 app_gateway/forms.py create mode 100644 app_gateway/migrations/0002_alter_application_display_name.py create mode 100644 app_gateway/migrations/0003_alter_application_display_name.py create mode 100644 app_gateway/migrations/0004_alter_application_display_name.py create mode 100644 app_gateway/urls.py create mode 100644 templates/app_gateway/app_gateway_list.html diff --git a/app_gateway/forms.py b/app_gateway/forms.py new file mode 100644 index 0000000..fe875a4 --- /dev/null +++ b/app_gateway/forms.py @@ -0,0 +1,205 @@ +from urllib.parse import urlparse + +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Submit, HTML, Div +from django import forms +from django.utils.translation import gettext_lazy as _ + +from app_gateway.models import ( + Application, ApplicationHost, AccessPolicy, ApplicationPolicy, ApplicationRoute +) + + +class ApplicationForm(forms.ModelForm): + class Meta: + model = Application + fields = ['name', 'display_name', 'upstream'] + labels = { + 'name': _('Name'), + 'display_name': _('Display Name'), + 'upstream': _('Upstream'), + } + + def __init__(self, *args, **kwargs): + cancel_url = kwargs.pop('cancel_url', '#') + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Div('name', css_class='col-md-6'), + Div('display_name', css_class='col-md-6'), + css_class='row' + ), + Div( + Div('upstream', css_class='col-md-12'), + css_class='row' + ), + Div( + Div( + Submit('submit', _('Save'), css_class='btn btn-primary'), + HTML(f'{_("Cancel")}'), + css_class='col-12 d-flex justify-content-end gap-2 mt-3' + ), + css_class='row' + ) + ) + + def clean(self): + cleaned_data = super().clean() + upstream = (cleaned_data.get("upstream") or "").strip() + + if upstream: + if " " in upstream: + self.add_error("upstream", _("Upstream URL cannot contain spaces.")) + + parsed = urlparse(upstream) + is_valid = (parsed.scheme in {"http", "https"} and bool(parsed.netloc)) + + if not is_valid: + self.add_error("upstream", _("Enter a valid upstream URL starting with http:// or https://")) + + return cleaned_data + + +class ApplicationHostForm(forms.ModelForm): + class Meta: + model = ApplicationHost + fields = ['application', 'hostname'] + labels = { + 'application': _('Application'), + 'hostname': _('Hostname'), + } + + def __init__(self, *args, **kwargs): + cancel_url = kwargs.pop('cancel_url', '#') + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Div('application', css_class='col-md-6'), + Div('hostname', css_class='col-md-6'), + css_class='row' + ), + Div( + Div( + Submit('submit', _('Save'), css_class='btn btn-primary'), + HTML(f'{_("Cancel")}'), + css_class='col-12 d-flex justify-content-end gap-2 mt-3' + ), + css_class='row' + ) + ) + + +class AccessPolicyForm(forms.ModelForm): + class Meta: + model = AccessPolicy + fields = ['name', 'policy_type', 'groups', 'methods'] + labels = { + 'name': _('Name'), + 'policy_type': _('Policy Type'), + 'groups': _('Allowed Groups'), + 'methods': _('Authentication Methods'), + } + + def __init__(self, *args, **kwargs): + cancel_url = kwargs.pop('cancel_url', '#') + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Div('name', css_class='col-md-6'), + Div('policy_type', css_class='col-md-6'), + css_class='row' + ), + Div( + Div('groups', css_class='col-md-6'), + Div('methods', css_class='col-md-6'), + css_class='row' + ), + Div( + Div( + Submit('submit', _('Save'), css_class='btn btn-primary'), + HTML(f'{_("Cancel")}'), + css_class='col-12 d-flex justify-content-end gap-2 mt-3' + ), + css_class='row' + ) + ) + + +class ApplicationPolicyForm(forms.ModelForm): + class Meta: + model = ApplicationPolicy + fields = ['application', 'default_policy'] + labels = { + 'application': _('Application'), + 'default_policy': _('Default Policy'), + } + + def __init__(self, *args, **kwargs): + cancel_url = kwargs.pop('cancel_url', '#') + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Div('application', css_class='col-md-6'), + Div('default_policy', css_class='col-md-6'), + css_class='row' + ), + Div( + Div( + Submit('submit', _('Save'), css_class='btn btn-primary'), + HTML(f'{_("Cancel")}'), + css_class='col-12 d-flex justify-content-end gap-2 mt-3' + ), + css_class='row' + ) + ) + + +class ApplicationRouteForm(forms.ModelForm): + class Meta: + model = ApplicationRoute + fields = ['application', 'name', 'path_prefix', 'policy', 'order'] + labels = { + 'application': _('Application'), + 'name': _('Route Name'), + 'path_prefix': _('Path Prefix'), + 'policy': _('Policy'), + 'order': _('Priority Order'), + } + + def __init__(self, *args, **kwargs): + cancel_url = kwargs.pop('cancel_url', '#') + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Div('application', css_class='col-md-6'), + Div('name', css_class='col-md-6'), + css_class='row' + ), + Div( + Div('path_prefix', css_class='col-md-8'), + Div('order', css_class='col-md-4'), + css_class='row' + ), + Div( + Div('policy', css_class='col-md-12'), + css_class='row' + ), + Div( + Div( + Submit('submit', _('Save'), css_class='btn btn-primary'), + HTML(f'{_("Cancel")}'), + css_class='col-12 d-flex justify-content-end gap-2 mt-3' + ), + css_class='row' + ) + ) diff --git a/app_gateway/migrations/0002_alter_application_display_name.py b/app_gateway/migrations/0002_alter_application_display_name.py new file mode 100644 index 0000000..da11eee --- /dev/null +++ b/app_gateway/migrations/0002_alter_application_display_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-03-12 14:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app_gateway', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='display_name', + field=models.CharField(blank=True, max_length=128), + ), + ] diff --git a/app_gateway/migrations/0003_alter_application_display_name.py b/app_gateway/migrations/0003_alter_application_display_name.py new file mode 100644 index 0000000..875fd68 --- /dev/null +++ b/app_gateway/migrations/0003_alter_application_display_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-03-12 14:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app_gateway', '0002_alter_application_display_name'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='display_name', + field=models.CharField(max_length=128), + ), + ] diff --git a/app_gateway/migrations/0004_alter_application_display_name.py b/app_gateway/migrations/0004_alter_application_display_name.py new file mode 100644 index 0000000..b055dc2 --- /dev/null +++ b/app_gateway/migrations/0004_alter_application_display_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-03-12 14:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app_gateway', '0003_alter_application_display_name'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='display_name', + field=models.CharField(blank=True, max_length=128), + ), + ] diff --git a/app_gateway/models.py b/app_gateway/models.py index 0ae00de..22bcff4 100644 --- a/app_gateway/models.py +++ b/app_gateway/models.py @@ -8,7 +8,7 @@ from gatekeeper.models import GatekeeperGroup, AuthMethod class Application(models.Model): name = models.SlugField(max_length=64, unique=True) - display_name = models.CharField(max_length=128) + display_name = models.CharField(max_length=128, blank=True) upstream = models.CharField(max_length=255, help_text=_("Upstream address, e.g.: http://10.188.18.27:3000")) created = models.DateTimeField(auto_now_add=True) @@ -16,7 +16,10 @@ class Application(models.Model): uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) def __str__(self): - return self.display_name + if self.display_name: + return f"{self.display_name} ({self.name})" + else: + return self.name class Meta: ordering = ['name'] diff --git a/app_gateway/urls.py b/app_gateway/urls.py new file mode 100644 index 0000000..14d0e56 --- /dev/null +++ b/app_gateway/urls.py @@ -0,0 +1,28 @@ +from django.urls import path + +from app_gateway import views + +urlpatterns = [ + # Main Dashboard / List + path('', views.view_app_gateway_list, name='app_gateway_list'), + + # Applications + path('application/manage/', views.view_manage_application, name='manage_application'), + path('application/delete/', views.view_delete_application, name='delete_application'), + + # Application Hosts + path('host/manage/', views.view_manage_application_host, name='manage_application_host'), + path('host/delete/', views.view_delete_application_host, name='delete_application_host'), + + # Access Policies + path('policy/manage/', views.view_manage_access_policy, name='manage_access_policy'), + path('policy/delete/', views.view_delete_access_policy, name='delete_access_policy'), + + # Application Default Policies + path('app_policy/manage/', views.view_manage_application_policy, name='manage_application_policy'), + path('app_policy/delete/', views.view_delete_application_policy, name='delete_application_policy'), + + # Application Routes + path('route/manage/', views.view_manage_application_route, name='manage_application_route'), + path('route/delete/', views.view_delete_application_route, name='delete_application_route'), +] diff --git a/app_gateway/views.py b/app_gateway/views.py index 60f00ef..ae51793 100644 --- a/app_gateway/views.py +++ b/app_gateway/views.py @@ -1 +1,309 @@ -# Create your views here. +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, get_object_or_404, redirect +from django.urls import reverse +from django.utils.translation import gettext as _ + +from app_gateway.forms import ( + ApplicationForm, ApplicationHostForm, AccessPolicyForm, + ApplicationPolicyForm, ApplicationRouteForm +) +from app_gateway.models import ( + Application, ApplicationHost, AccessPolicy, ApplicationPolicy, ApplicationRoute +) +from user_manager.models import UserAcl + + +@login_required +def view_app_gateway_list(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=20).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + applications = Application.objects.all().order_by('name') + hosts = ApplicationHost.objects.all().order_by('hostname') + access_policies = AccessPolicy.objects.all().order_by('name') + app_policies = ApplicationPolicy.objects.all().order_by('application__name') + routes = ApplicationRoute.objects.all().order_by('application__name', 'order', 'path_prefix') + + tab = request.GET.get('tab', 'applications') + + context = { + 'applications': applications, + 'hosts': hosts, + 'access_policies': access_policies, + 'app_policies': app_policies, + 'routes': routes, + 'active_tab': tab, + } + return render(request, 'app_gateway/app_gateway_list.html', context) + + +@login_required +def view_manage_application(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + application_uuid = request.GET.get('uuid') + + if application_uuid: + application = get_object_or_404(Application, uuid=application_uuid) + title = _('Edit Application') + else: + application = None + title = _('Create Application') + + cancel_url = reverse('app_gateway_list') + '?tab=applications' + + form = ApplicationForm(request.POST or None, instance=application, cancel_url=cancel_url) + if form.is_valid(): + form.save() + messages.success(request, _('Application saved successfully.')) + return redirect(cancel_url) + + context = { + 'form': form, + 'title': title, + 'page_title': title, + } + return render(request, 'generic_form.html', context) + + +@login_required +def view_delete_application(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + application = get_object_or_404(Application, uuid=request.GET.get('uuid')) + + cancel_url = reverse('app_gateway_list') + '?tab=applications' + + if request.method == 'POST': + application.delete() + messages.success(request, _('Application deleted successfully.')) + return redirect(cancel_url) + + context = { + 'application': application, + 'title': _('Delete Application'), + 'cancel_url': cancel_url, + 'text': _('Are you sure you want to delete the application "%(name)s"?') % {'name': application.display_name} + } + return render(request, 'generic_delete_confirmation.html', context) + + +@login_required +def view_manage_application_host(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + application_host_uuid = request.GET.get('uuid') + + if application_host_uuid: + application_host = get_object_or_404(ApplicationHost, uuid=application_host_uuid) + title = _('Edit Application Host') + else: + application_host = None + title = _('Add Application Host') + + cancel_url = reverse('app_gateway_list') + '?tab=hosts' + + form = ApplicationHostForm(request.POST or None, instance=application_host, cancel_url=cancel_url) + if form.is_valid(): + form.save() + messages.success(request, _('Application Host saved successfully.')) + return redirect(cancel_url) + + context = { + 'form': form, + 'title': title, + 'page_title': title, + } + return render(request, 'generic_form.html', context) + + +@login_required +def view_delete_application_host(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + application_host = get_object_or_404(ApplicationHost, uuid=request.GET.get('uuid')) + + cancel_url = reverse('app_gateway_list') + '?tab=hosts' + + if request.method == 'POST': + application_host.delete() + messages.success(request, _('Application Host deleted successfully.')) + return redirect(cancel_url) + + context = { + 'application_host': application_host, + 'title': _('Delete Application Host'), + 'cancel_url': cancel_url, + 'text': _('Are you sure you want to delete the host "%(hostname)s"?') % {'hostname': application_host.hostname} + } + return render(request, 'generic_delete_confirmation.html', context) + + +@login_required +def view_manage_access_policy(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + access_policy_uuid = request.GET.get('uuid') + + if access_policy_uuid: + access_policy = get_object_or_404(AccessPolicy, uuid=access_policy_uuid) + title = _('Edit Access Policy') + else: + access_policy = None + title = _('Create Access Policy') + + cancel_url = reverse('app_gateway_list') + '?tab=policies' + + form = AccessPolicyForm(request.POST or None, instance=access_policy, cancel_url=cancel_url) + if form.is_valid(): + form.save() + messages.success(request, _('Access Policy saved successfully.')) + return redirect(cancel_url) + + context = { + 'form': form, + 'title': title, + 'page_title': title, + } + return render(request, 'generic_form.html', context) + + +@login_required +def view_delete_access_policy(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + access_policy = get_object_or_404(AccessPolicy, uuid=request.GET.get('uuid')) + + cancel_url = reverse('app_gateway_list') + '?tab=policies' + + if request.method == 'POST': + access_policy.delete() + messages.success(request, _('Access Policy deleted successfully.')) + return redirect(cancel_url) + + context = { + 'access_policy': access_policy, + 'title': _('Delete Access Policy'), + 'cancel_url': cancel_url, + 'text': _('Are you sure you want to delete the access policy "%(name)s"?') % {'name': access_policy.name} + } + return render(request, 'generic_delete_confirmation.html', context) + + +@login_required +def view_manage_application_policy(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + application_policy_uuid = request.GET.get('uuid') + + if application_policy_uuid: + application_policy = get_object_or_404(ApplicationPolicy, uuid=application_policy_uuid) + title = _('Edit Application Default Policy') + else: + application_policy = None + title = _('Set Application Default Policy') + + cancel_url = reverse('app_gateway_list') + '?tab=applications' + + + form = ApplicationPolicyForm(request.POST or None, instance=application_policy, cancel_url=cancel_url) + if form.is_valid(): + form.save() + messages.success(request, _('Application Default Policy saved successfully.')) + return redirect(cancel_url) + + context = { + 'form': form, + 'title': title, + 'page_title': title, + } + return render(request, 'generic_form.html', context) + + +@login_required +def view_delete_application_policy(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + application_policy = get_object_or_404(ApplicationPolicy, uuid=request.GET.get('uuid')) + + cancel_url = reverse('app_gateway_list') + '?tab=applications' + + if request.method == 'POST': + application_policy.delete() + messages.success(request, _('Application Default Policy deleted successfully.')) + return redirect(cancel_url) + + context = { + 'application_policy': application_policy, + 'title': _('Delete Application Default Policy'), + 'cancel_url': cancel_url, + 'text': _('Are you sure you want to remove the default policy for "%(name)s"?') % { + 'name': application_policy.application.display_name + } + } + return render(request, 'generic_delete_confirmation.html', context) + + +@login_required +def view_manage_application_route(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + application_route_uuid = request.GET.get('uuid') + + if application_route_uuid: + application_route = get_object_or_404(ApplicationRoute, uuid=application_route_uuid) + title = _('Edit Application Route') + else: + application_route = None + title = _('Add Application Route') + + cancel_url = reverse('app_gateway_list') + '?tab=routes' + + form = ApplicationRouteForm(request.POST or None, instance=application_route, cancel_url=cancel_url) + if form.is_valid(): + form.save() + messages.success(request, _('Application Route saved successfully.')) + return redirect(cancel_url) + + context = { + 'form': form, + 'title': title, + 'page_title': title, + } + return render(request, 'generic_form.html', context) + + +@login_required +def view_delete_application_route(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + application_route = get_object_or_404(ApplicationRoute, uuid=request.GET.get('uuid')) + + cancel_url = reverse('app_gateway_list') + '?tab=routes' + + if request.method == 'POST': + application_route.delete() + messages.success(request, _('Application Route deleted successfully.')) + return redirect(cancel_url) + + context = { + 'application_route': application_route, + 'title': _('Delete Application Route'), + 'cancel_url': cancel_url, + 'text': _('Are you sure you want to delete the route "%(name)s" (%(path)s)?') % { + 'name': application_route.name, + 'path': application_route.path_prefix + } + } + return render(request, 'generic_delete_confirmation.html', context) diff --git a/templates/app_gateway/app_gateway_list.html b/templates/app_gateway/app_gateway_list.html new file mode 100644 index 0000000..d52058b --- /dev/null +++ b/templates/app_gateway/app_gateway_list.html @@ -0,0 +1,254 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
+
+ + + +
+ + {% if active_tab == 'applications' or active_tab == 'hosts' %} + + + {% if active_tab == 'applications' %} + + + {% if applications %} +
+ + + + + + + + + + + + {% for app in applications %} + + + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Display Name' %}{% trans 'Upstream' %}{% trans 'Default Policy' %}{% trans 'Actions' %}
{{ app.name }}{{ app.display_name }}{{ app.upstream }} + {% if app.default_policy_config %} + {{ app.default_policy_config.default_policy.name }} + + + + + + + {% else %} + {% trans 'Not set' %} + {% endif %} + + + + + + + +
+
+ {% else %} +
+ {% trans 'No Applications found.' %} +
+ {% endif %} + + {% elif active_tab == 'hosts' %} + + + {% if hosts %} +
+ + + + + + + + + + {% for host in hosts %} + + + + + + {% endfor %} + +
{% trans 'Hostname' %}{% trans 'Application' %}{% trans 'Actions' %}
{{ host.hostname }}{{ host.application.display_name }} + + + + + + +
+
+ {% else %} +
+ {% trans 'No Hosts found.' %} +
+ {% endif %} + {% endif %} + + {% elif active_tab == 'policies' %} + + + {% if access_policies %} +
+ + + + + + + + + + + + {% for policy in access_policies %} + + + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Policy Type' %}{% trans 'Groups' %}{% trans 'Auth Methods' %}{% trans 'Actions' %}
{{ policy.name }}{{ policy.get_policy_type_display }}{{ policy.groups.count }}{{ policy.methods.count }} + + + + + + +
+
+ {% else %} +
+ {% trans 'No Access Policies found.' %} +
+ {% endif %} + + {% elif active_tab == 'routes' %} + + + {% if routes %} +
+ + + + + + + + + + + + + {% for route in routes %} + + + + + + + + + {% endfor %} + +
{% trans 'Application' %}{% trans 'Route Name' %}{% trans 'Path Prefix' %}{% trans 'Policy' %}{% trans 'Order' %}{% trans 'Actions' %}
{{ route.application.display_name }}{{ route.name }}{{ route.path_prefix }}{{ route.policy.name }}{{ route.order }} + + + + + + +
+
+ {% else %} +
+ {% trans 'No Routes found.' %} +
+ {% endif %} + + {% endif %} +
+ +
+
+{% endblock %} diff --git a/wireguard_webadmin/urls.py b/wireguard_webadmin/urls.py index 1e2647d..d27d6a1 100644 --- a/wireguard_webadmin/urls.py +++ b/wireguard_webadmin/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('peer/', include('wireguard_peer.urls')), path('routing-templates/', include('routing_templates.urls')), path('gatekeeper/', include('gatekeeper.urls')), + path('app_gateway/', include('app_gateway.urls')), path('scheduler/', include('scheduler.urls')), path('server/', include('wireguard.urls')), path('tools/', include('wireguard_tools.urls')),