diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index 674a878c7..fd86627d2 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -9,7 +9,7 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand -APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless') +APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless') BANNER_TEXT = """### NetBox interactive shell ({node}) ### Python {python} | Django {django} | NetBox {netbox} diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 4e71ca193..cfbe82f14 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -39,6 +39,7 @@ class APIRootView(APIView): 'tenancy': reverse('tenancy-api:api-root', request=request, format=format), 'users': reverse('users-api:api-root', request=request, format=format), 'virtualization': reverse('virtualization-api:api-root', request=request, format=format), + 'vpn': reverse('vpn-api:api-root', request=request, format=format), 'wireless': reverse('wireless-api:api-root', request=request, format=format), }) diff --git a/netbox/templates/vpn/ipsecprofile.html b/netbox/templates/vpn/ipsecprofile.html new file mode 100644 index 000000000..3f50c39ab --- /dev/null +++ b/netbox/templates/vpn/ipsecprofile.html @@ -0,0 +1,92 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "IPSec Profile" %}
+
+ + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Protocol" %}{{ object.get_protocol_display }}
{% trans "IKE Version" %}{{ object.get_ike_version_display }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+
+
{% trans "Phase 1 Parameters" %}
+
+ + + + + + + + + + + + + + + + + +
{% trans "Encryption" %}{{ object.get_phase1_encryption_display }}
{% trans "Authentication" %}{{ object.get_phase1_authentication_display }}
{% trans "DH Group" %}{{ object.get_phase1_group_display }}
{% trans "SA Lifetime" %}{{ object.phase1_sa_lifetime|placeholder }}
+
+
+
+
{% trans "Phase 2 Parameters" %}
+
+ + + + + + + + + + + + + + + + + +
{% trans "Encryption" %}{{ object.get_phase2_encryption_display }}
{% trans "Authentication" %}{{ object.get_phase2_authentication_display }}
{% trans "DH Group" %}{{ object.get_phase2_group_display }}
{% trans "SA Lifetime" %}{{ object.phase2_sa_lifetime|placeholder }}
+
+
+ {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/vpn/tunnel.html b/netbox/templates/vpn/tunnel.html new file mode 100644 index 000000000..2420a1230 --- /dev/null +++ b/netbox/templates/vpn/tunnel.html @@ -0,0 +1,81 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "Tunnel" %}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Encapsulation" %}{{ object.get_encapsulation_display }}
{% trans "IPSec profile" %}{{ object.ipsec_profile|linkify|placeholder }}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Pre-shared key" %}{{ object.preshared_key|placeholder }}
{% trans "Tunnel ID" %}{{ object.tunnel_id|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
{% trans "Terminations" %}
+
+ {% if perms.vpn.add_tunneltermination %} + + {% endif %} +
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index 803db2acd..c342110a3 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -69,14 +69,14 @@ class TunnelTerminationSerializer(NetBoxModelSerializer): model = TunnelTermination fields = ( 'id', 'url', 'display', 'tunnel', 'role', 'interface_type', 'interface_id', 'interface', 'outside_ip', - 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'tags', 'custom_fields', 'created', 'last_updated', ) @extend_schema_field(serializers.JSONField(allow_null=True)) def get_interface(self, obj): serializer = get_serializer_for_model(obj.interface, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} - return serializer(obj.assigned_object, context=context).data + return serializer(obj.interface, context=context).data class IPSecProfileSerializer(NetBoxModelSerializer): diff --git a/netbox/vpn/api/views.py b/netbox/vpn/api/views.py index 6cb99f2a7..7d01c48cf 100644 --- a/netbox/vpn/api/views.py +++ b/netbox/vpn/api/views.py @@ -35,7 +35,7 @@ class TunnelViewSet(NetBoxModelViewSet): class TunnelTerminationViewSet(NetBoxModelViewSet): - queryset = Tunnel.objects.prefetch_related('tunnel') + queryset = TunnelTermination.objects.prefetch_related('tunnel') serializer_class = serializers.TunnelTerminationSerializer filterset_class = filtersets.TunnelTerminationFilterSet diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index 24a7b8c8d..96d73633f 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -24,12 +24,14 @@ class TunnelStatusChoices(ChoiceSet): class TunnelEncapsulationChoices(ChoiceSet): ENCAP_GRE = 'gre' ENCAP_IP_IP = 'ip-ip' - ENCAP_IPSEC = 'ipsec' + ENCAP_IPSEC_TRANSPORT = 'ipsec-transport' + ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel' CHOICES = [ - (ENCAP_IPSEC, _('IPsec')), - (ENCAP_IP_IP, _('Active')), - (ENCAP_GRE, _('Disabled')), + (ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')), + (ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')), + (ENCAP_IP_IP, _('IP-in-IP')), + (ENCAP_GRE, _('GRE')), ] @@ -39,9 +41,9 @@ class TunnelTerminationRoleChoices(ChoiceSet): ROLE_SPOKE = 'spoke' CHOICES = [ - (ROLE_PEER, _('Peer')), - (ROLE_HUB, _('Hub')), - (ROLE_SPOKE, _('Spoke')), + (ROLE_PEER, _('Peer'), 'green'), + (ROLE_HUB, _('Hub'), 'blue'), + (ROLE_SPOKE, _('Spoke'), 'orange'), ] diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index 9f11de5a3..53a3bd634 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -2,6 +2,7 @@ from django import forms from django.utils.translation import gettext as _ from netbox.forms import NetBoxModelFilterSetForm +from tenancy.forms import TenancyFilterForm from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField from vpn.choices import * from vpn.models import * @@ -13,13 +14,13 @@ __all__ = ( ) -class TunnelFilterForm(NetBoxModelFilterSetForm): +class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Tunnel fieldsets = ( (None, ('q', 'filter_id', 'tag')), (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id')), (_('Security'), ('ipsec_profile_id', 'preshared_key')), - (_('Tenancy'), ('tenant',)), + (_('Tenancy'), ('tenant_group_id', 'tenant_id')), ) status = forms.MultipleChoiceField( label=_('Status'), @@ -36,6 +37,14 @@ class TunnelFilterForm(NetBoxModelFilterSetForm): required=False, label=_('IPSec profile') ) + preshared_key = forms.CharField( + required=False, + label=_('Pre-shared key') + ) + tunnel_id = forms.IntegerField( + required=False, + label=_('Tunnel ID') + ) tag = TagFilterField(model) diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 2621cdd46..9755bd538 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -2,6 +2,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ from dcim.models import Interface +from ipam.models import IPAddress from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField @@ -17,7 +18,8 @@ __all__ = ( class TunnelForm(TenancyForm, NetBoxModelForm): ipsec_profile = DynamicModelChoiceField( - queryset=IPSecProfile.objects.all() + queryset=IPSecProfile.objects.all(), + label=_('IPSec Profile') ) comments = CommentField() @@ -51,6 +53,11 @@ class TunnelTerminationForm(NetBoxModelForm): selector=True, label=_('Interface'), ) + outside_ip = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + selector=True, + label=_('Outside IP'), + ) class Meta: model = TunnelTermination diff --git a/netbox/vpn/graphql/__init__.py b/netbox/vpn/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/vpn/graphql/schema.py b/netbox/vpn/graphql/schema.py new file mode 100644 index 000000000..0ec4cd207 --- /dev/null +++ b/netbox/vpn/graphql/schema.py @@ -0,0 +1,26 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from utilities.graphql_optimizer import gql_query_optimizer +from vpn import models +from .types import * + + +class VPNQuery(graphene.ObjectType): + ipsec_profile = ObjectField(IPSecProfileType) + ipsec_profile_list = ObjectListField(IPSecProfileType) + + def resolve_ipsec_profile_list(root, info, **kwargs): + return gql_query_optimizer(models.IPSecProfile.objects.all(), info) + + tunnel = ObjectField(TunnelType) + tunnel_list = ObjectListField(TunnelType) + + def resolve_tunnel_list(root, info, **kwargs): + return gql_query_optimizer(models.Tunnel.objects.all(), info) + + tunnel_termination = ObjectField(TunnelTerminationType) + tunnel_termination_list = ObjectListField(TunnelTerminationType) + + def resolve_tunnel_termination_list(root, info, **kwargs): + return gql_query_optimizer(models.TunnelTermination.objects.all(), info) diff --git a/netbox/vpn/graphql/types.py b/netbox/vpn/graphql/types.py new file mode 100644 index 000000000..d6b04ad2f --- /dev/null +++ b/netbox/vpn/graphql/types.py @@ -0,0 +1,33 @@ +from extras.graphql.mixins import CustomFieldsMixin, TagsMixin +from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType +from vpn import filtersets, models + +__all__ = ( + 'IPSecProfileType', + 'TunnelTerminationType', + 'TunnelType', +) + + +class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): + + class Meta: + model = models.TunnelTermination + fields = '__all__' + filterset_class = filtersets.TunnelTerminationFilterSet + + +class TunnelType(NetBoxObjectType): + + class Meta: + model = models.Tunnel + fields = '__all__' + filterset_class = filtersets.TunnelFilterSet + + +class IPSecProfileType(OrganizationalObjectType): + + class Meta: + model = models.IPSecProfile + fields = '__all__' + filterset_class = filtersets.IPSecProfileFilterSet diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py new file mode 100644 index 000000000..0bb111859 --- /dev/null +++ b/netbox/vpn/migrations/0001_initial.py @@ -0,0 +1,93 @@ +# Generated by Django 4.2.6 on 2023-11-07 21:49 + +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.json + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('tenancy', '0011_contactassignment_tags'), + ('extras', '0099_cachedvalue_ordering'), + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0067_ipaddress_index_host'), + ] + + operations = [ + migrations.CreateModel( + name='IPSecProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('protocol', models.CharField()), + ('ike_version', models.PositiveSmallIntegerField(default=2)), + ('phase1_encryption', models.CharField()), + ('phase1_authentication', models.CharField()), + ('phase1_group', models.PositiveSmallIntegerField()), + ('phase1_sa_lifetime', models.PositiveSmallIntegerField(blank=True, null=True)), + ('phase2_encryption', models.CharField()), + ('phase2_authentication', models.CharField()), + ('phase2_group', models.PositiveSmallIntegerField()), + ('phase2_sa_lifetime', models.PositiveSmallIntegerField(blank=True, null=True)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'tunnel', + 'verbose_name_plural': 'tunnels', + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='Tunnel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('status', models.CharField(default='active', max_length=50)), + ('encapsulation', models.CharField(max_length=50)), + ('preshared_key', models.TextField(blank=True)), + ('tunnel_id', models.PositiveBigIntegerField(blank=True)), + ('ipsec_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.ipsecprofile')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'tunnel', + 'verbose_name_plural': 'tunnels', + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='TunnelTermination', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('role', models.CharField(default='peer', max_length=50)), + ('interface_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('interface_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('outside_ip', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='tunnel_termination', to='ipam.ipaddress')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tunnel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.tunnel')), + ], + options={ + 'verbose_name': 'tunnel termination', + 'verbose_name_plural': 'tunnel terminations', + 'ordering': ('tunnel', 'pk'), + }, + ), + ] diff --git a/netbox/vpn/migrations/0002_alter_ipsecprofile_phase1_sa_lifetime_and_more.py b/netbox/vpn/migrations/0002_alter_ipsecprofile_phase1_sa_lifetime_and_more.py new file mode 100644 index 000000000..f07076c50 --- /dev/null +++ b/netbox/vpn/migrations/0002_alter_ipsecprofile_phase1_sa_lifetime_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.6 on 2023-11-08 16:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='ipsecprofile', + name='phase1_sa_lifetime', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='ipsecprofile', + name='phase2_sa_lifetime', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/vpn/migrations/0003_alter_tunnel_tunnel_id.py b/netbox/vpn/migrations/0003_alter_tunnel_tunnel_id.py new file mode 100644 index 000000000..b02b4aa86 --- /dev/null +++ b/netbox/vpn/migrations/0003_alter_tunnel_tunnel_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.6 on 2023-11-08 16:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0002_alter_ipsecprofile_phase1_sa_lifetime_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='tunnel', + name='tunnel_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/vpn/models/crypto.py b/netbox/vpn/models/crypto.py index 8e1ee2870..8c817b296 100644 --- a/netbox/vpn/models/crypto.py +++ b/netbox/vpn/models/crypto.py @@ -40,7 +40,7 @@ class IPSecProfile(PrimaryModel): choices=DHGroupChoices, help_text=_('Diffie-Hellman group') ) - phase1_sa_lifetime = models.PositiveSmallIntegerField( + phase1_sa_lifetime = models.PositiveIntegerField( verbose_name=_('phase 1 SA lifetime'), blank=True, null=True, @@ -61,7 +61,7 @@ class IPSecProfile(PrimaryModel): choices=DHGroupChoices, help_text=_('Diffie-Hellman group') ) - phase2_sa_lifetime = models.PositiveSmallIntegerField( + phase2_sa_lifetime = models.PositiveIntegerField( verbose_name=_('phase 2 SA lifetime'), blank=True, null=True, @@ -70,8 +70,8 @@ class IPSecProfile(PrimaryModel): # TODO: Add PFS group? clone_fields = ( - 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_as_lifetime', - 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_as_lifetime', + 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', + 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', ) class Meta: diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index 4912ac3cd..b48a30a4f 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -50,7 +50,8 @@ class Tunnel(PrimaryModel): ) tunnel_id = models.PositiveBigIntegerField( verbose_name=_('tunnel ID'), - blank=True + blank=True, + null=True ) clone_fields = ( @@ -113,3 +114,6 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo def get_absolute_url(self): return self.tunnel.get_absolute_url() + + def get_role_color(self): + return TunnelTerminationRoleChoices.colors.get(self.role) diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py index 4f8b08066..3d589abca 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables.py @@ -21,9 +21,6 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable): status = columns.ChoiceFieldColumn( verbose_name=_('Status') ) - encapsulation = columns.ChoiceFieldColumn( - verbose_name=_('Encapsulation') - ) ipsec_profile = tables.Column( verbose_name=_('IPSec profile'), linkify=True @@ -47,7 +44,7 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable): 'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'preshared_key', 'tunnel_id', 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'status', 'encapsulation', 'tenant', 'termination_count') + default_columns = ('pk', 'name', 'status', 'encapsulation', 'tenant', 'terminations_count') class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index 58034391a..9ea4fd215 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -67,11 +67,6 @@ class TunnelTerminationListView(generic.ObjectListView): table = tables.TunnelTerminationTable -@register_model_view(TunnelTermination) -class TunnelTerminationView(generic.ObjectView): - queryset = TunnelTermination.objects.all() - - @register_model_view(TunnelTermination, 'edit') class TunnelTerminationEditView(generic.ObjectEditView): queryset = TunnelTermination.objects.all()