Răsfoiți Sursa

Closes #9816: VPN tunnel support (#14276)

- Introduces a new `vpn` app with the following models:
    - Tunnel
    - TunnelTermination
    - IKEProposal
    - IKEPolicy
    - IPSecProposal
    - IPSecPolicy
    - IPSecProfile
Jeremy Stretch 2 ani în urmă
părinte
comite
6678880db5
58 a modificat fișierele cu 5656 adăugiri și 10 ștergeri
  1. 49 0
      docs/features/vpn-tunnels.md
  2. 25 0
      docs/models/vpn/ikepolicy.md
  3. 39 0
      docs/models/vpn/ikeproposal.md
  4. 17 0
      docs/models/vpn/ipsecpolicy.md
  5. 21 0
      docs/models/vpn/ipsecprofile.md
  6. 25 0
      docs/models/vpn/ipsecproposal.md
  7. 36 0
      docs/models/vpn/tunnel.md
  8. 30 0
      docs/models/vpn/tunneltermination.md
  9. 9 0
      mkdocs.yml
  10. 1 1
      netbox/core/management/commands/nbshell.py
  11. 10 0
      netbox/dcim/models/device_components.py
  12. 10 3
      netbox/dcim/tables/devices.py
  13. 10 0
      netbox/dcim/tables/template_code.py
  14. 1 0
      netbox/netbox/api/views.py
  15. 2 0
      netbox/netbox/graphql/schema.py
  16. 21 4
      netbox/netbox/navigation/menu.py
  17. 1 0
      netbox/netbox/settings.py
  18. 2 0
      netbox/netbox/urls.py
  19. 67 0
      netbox/templates/vpn/ikepolicy.html
  20. 63 0
      netbox/templates/vpn/ikeproposal.html
  21. 55 0
      netbox/templates/vpn/ipsecpolicy.html
  22. 112 0
      netbox/templates/vpn/ipsecprofile.html
  23. 59 0
      netbox/templates/vpn/ipsecproposal.html
  24. 85 0
      netbox/templates/vpn/tunnel.html
  25. 62 0
      netbox/templates/vpn/tunneltermination.html
  26. 6 0
      netbox/virtualization/models/virtualmachines.py
  27. 3 2
      netbox/virtualization/tables/virtualmachines.py
  28. 0 0
      netbox/vpn/__init__.py
  29. 3 0
      netbox/vpn/admin.py
  30. 0 0
      netbox/vpn/api/__init__.py
  31. 84 0
      netbox/vpn/api/nested_serializers.py
  32. 193 0
      netbox/vpn/api/serializers.py
  33. 15 0
      netbox/vpn/api/urls.py
  34. 74 0
      netbox/vpn/api/views.py
  35. 9 0
      netbox/vpn/apps.py
  36. 201 0
      netbox/vpn/choices.py
  37. 241 0
      netbox/vpn/filtersets.py
  38. 4 0
      netbox/vpn/forms/__init__.py
  39. 243 0
      netbox/vpn/forms/bulk_edit.py
  40. 230 0
      netbox/vpn/forms/bulk_import.py
  41. 182 0
      netbox/vpn/forms/filtersets.py
  42. 357 0
      netbox/vpn/forms/model_forms.py
  43. 0 0
      netbox/vpn/graphql/__init__.py
  44. 51 0
      netbox/vpn/graphql/schema.py
  45. 69 0
      netbox/vpn/graphql/types.py
  46. 186 0
      netbox/vpn/migrations/0001_initial.py
  47. 0 0
      netbox/vpn/migrations/__init__.py
  48. 2 0
      netbox/vpn/models/__init__.py
  49. 254 0
      netbox/vpn/models/crypto.py
  50. 146 0
      netbox/vpn/models/tunnels.py
  51. 65 0
      netbox/vpn/search.py
  52. 254 0
      netbox/vpn/tables.py
  53. 0 0
      netbox/vpn/tests/__init__.py
  54. 473 0
      netbox/vpn/tests/test_api.py
  55. 592 0
      netbox/vpn/tests/test_filtersets.py
  56. 508 0
      netbox/vpn/tests/test_views.py
  57. 65 0
      netbox/vpn/urls.py
  58. 334 0
      netbox/vpn/views.py

+ 49 - 0
docs/features/vpn-tunnels.md

@@ -0,0 +1,49 @@
+# Tunnels
+
+NetBox can model private tunnels formed among virtual termination points across your network. Typical tunnel implementations include GRE, IP-in-IP, and IPSec. A tunnel may be terminated to two or more device or virtual machine interfaces.
+
+```mermaid
+flowchart TD
+    Termination1[TunnelTermination]
+    Termination2[TunnelTermination]
+    Interface1[Interface]
+    Interface2[Interface]
+    Tunnel --> Termination1 & Termination2
+    Termination1 --> Interface1
+    Termination2 --> Interface2
+    Interface1 --> Device
+    Interface2 --> VirtualMachine
+
+click Tunnel "../../models/vpn/tunnel/"
+click TunnelTermination1 "../../models/vpn/tunneltermination/"
+click TunnelTermination2 "../../models/vpn/tunneltermination/"
+```
+
+# IPSec & IKE
+
+NetBox includes robust support for modeling IPSec & IKE policies. These are used to define encryption and authentication parameters for IPSec tunnels.
+
+```mermaid
+flowchart TD
+    subgraph IKEProposals[Proposals]
+    IKEProposal1[IKEProposal]
+    IKEProposal2[IKEProposal]
+    end
+    subgraph IPSecProposals[Proposals]
+    IPSecProposal1[IPSecProposal]
+    IPSecProposal2[IPSecProposal]
+    end
+    IKEProposals --> IKEPolicy
+    IPSecProposals --> IPSecPolicy
+    IKEPolicy & IPSecPolicy--> IPSecProfile
+    IPSecProfile --> Tunnel
+
+click IKEProposal1 "../../models/vpn/ikeproposal/"
+click IKEProposal2 "../../models/vpn/ikeproposal/"
+click IKEPolicy "../../models/vpn/ikepolicy/"
+click IPSecProposal1 "../../models/vpn/ipsecproposal/"
+click IPSecProposal2 "../../models/vpn/ipsecproposal/"
+click IPSecPolicy "../../models/vpn/ipsecpolicy/"
+click IPSecProfile "../../models/vpn/ipsecprofile/"
+click Tunnel "../../models/vpn/tunnel/"
+```

+ 25 - 0
docs/models/vpn/ikepolicy.md

@@ -0,0 +1,25 @@
+# IKE Policies
+
+An [Internet Key Exhcnage (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) policy defines an IKE version, mode, and set of [proposals](./ikeproposal.md) to be used in IKE negotiation. These policies are referenced by [IPSec profiles](./ipsecprofile.md).
+
+## Fields
+
+### Name
+
+The unique user-assigned name for the policy.
+
+### Version
+
+The IKE version employed (v1 or v2).
+
+### Mode
+
+The IKE mode employed (main or aggressive).
+
+### Proposals
+
+One or more [IKE proposals](./ikeproposal.md) supported for use by this policy.
+
+### Pre-shared Key
+
+A pre-shared secret key associated with this policy (optional).

+ 39 - 0
docs/models/vpn/ikeproposal.md

@@ -0,0 +1,39 @@
+# IKE Proposals
+
+An [Internet Key Exhcnage (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) proposal defines a set of parameters used to establish a secure bidirectional connection across an untrusted medium, such as the Internet. IKE proposals defined in NetBox can be referenced by [IKE policies](./ikepolicy.md), which are in turn employed by [IPSec profiles](./ipsecprofile.md).
+
+!!! note
+    Some platforms refer to IKE proposals as [ISAKMP](https://en.wikipedia.org/wiki/Internet_Security_Association_and_Key_Management_Protocol), which is a framework for authentication and key exchange which employs IKE.
+
+## Fields
+
+### Name
+
+The unique user-assigned name for the proposal.
+
+### Authentication Method
+
+The strategy employed for authenticating the IKE peer. Available options are listed below.
+
+| Name           |
+|----------------|
+| Pre-shared key |
+| Certificate    |
+| RSA signature  |
+| DSA signature  |
+
+### Encryption Algorithm
+
+The protocol employed for data encryption. Options include DES, 3DES, and various flavors of AES.
+
+### Authentication Algorithm
+
+The mechanism employed to ensure data integrity. Options include MD5 and SHA HMAC implementations.
+
+### Group
+
+The [Diffie-Hellman group](https://en.wikipedia.org/wiki/Diffie%E2%80%93Hellman_key_exchange) supported by the proposal. Group IDs are [managed by IANA](https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-8).
+
+### SA Lifetime
+
+The maximum lifetime for the IKE security association (SA), in seconds.

+ 17 - 0
docs/models/vpn/ipsecpolicy.md

@@ -0,0 +1,17 @@
+# IPSec Policy
+
+An [IPSec](https://en.wikipedia.org/wiki/IPsec) policy defines a set of [proposals](./ikeproposal.md) to be used in the formation of IPSec tunnels. A perfect forward secrecy (PFS) group may optionally also be defined. These policies are referenced by [IPSec profiles](./ipsecprofile.md).
+
+## Fields
+
+### Name
+
+The unique user-assigned name for the policy.
+
+### Proposals
+
+One or more [IPSec proposals](./ipsecproposal.md) supported for use by this policy.
+
+### PFS Group
+
+The [perfect forward secrecy (PFS)](https://en.wikipedia.org/wiki/Forward_secrecy) group supported by this policy (optional).

+ 21 - 0
docs/models/vpn/ipsecprofile.md

@@ -0,0 +1,21 @@
+# IPSec Profile
+
+An [IPSec](https://en.wikipedia.org/wiki/IPsec) profile defines an [IKE policy](./ikepolicy.md), [IPSec policy](./ipsecpolicy.md), and IPSec mode used for establishing an IPSec tunnel.
+
+## Fields
+
+### Name
+
+The unique user-assigned name for the profile.
+
+### Mode
+
+The IPSec mode employed by the profile: Encapsulating Security Payload (ESP) or Authentication Header (AH).
+
+### IKE Policy
+
+The [IKE policy](./ikepolicy.md) associated with the profile.
+
+### IPSec Policy
+
+The [IPSec policy](./ipsecpolicy.md) associated with the profile.

+ 25 - 0
docs/models/vpn/ipsecproposal.md

@@ -0,0 +1,25 @@
+# IPSec Proposal
+
+An [IPSec](https://en.wikipedia.org/wiki/IPsec) proposal defines a set of parameters used in negotiating security associations for IPSec tunnels. IPSec proposals defined in NetBox can be referenced by [IPSec policies](./ipsecpolicy.md), which are in turn employed by [IPSec profiles](./ipsecprofile.md).
+
+## Fields
+
+### Name
+
+The unique user-assigned name for the proposal.
+
+### Encryption Algorithm
+
+The protocol employed for data encryption. Options include DES, 3DES, and various flavors of AES.
+
+### Authentication Algorithm
+
+The mechanism employed to ensure data integrity. Options include MD5 and SHA HMAC implementations.
+
+### SA Lifetime (Seconds)
+
+The maximum amount of time for which the security association (SA) may be active, in seconds.
+
+### SA Lifetime (Data)
+
+The maximum amount of data which can be transferred within the security association (SA) before it must be rebuilt, in kilobytes.

+ 36 - 0
docs/models/vpn/tunnel.md

@@ -0,0 +1,36 @@
+# Tunnels
+
+A tunnel represents a private virtual connection established among two or more endpoints across a shared infrastructure by employing protocol encapsulation. Common encapsulation techniques include [Generic Routing Encapsulation (GRE)](https://en.wikipedia.org/wiki/Generic_Routing_Encapsulation), [IP-in-IP](https://en.wikipedia.org/wiki/IP_in_IP), and [IPSec](https://en.wikipedia.org/wiki/IPsec). NetBox supports modeling both peer-to-peer and hub-and-spoke tunnel topologies.
+
+Device and virtual machine interfaces are associated to tunnels by creating [tunnel terminations](./tunneltermination.md).
+
+## Fields
+
+### Name
+
+A unique name assigned to the tunnel for identification.
+
+### Status
+
+The operational status of the tunnel. By default, the following statuses are available:
+
+| Name           |
+|----------------|
+| Planned        |
+| Active         |
+| Disabled       |
+
+!!! tip "Custom tunnel statuses"
+    Additional tunnel statuses may be defined by setting `Tunnel.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
+### Encapsulation
+
+The encapsulation protocol or technique employed to effect the tunnel. NetBox supports GRE, IP-in-IP, and IPSec encapsulations.
+
+### Tunnel ID
+
+An optional numeric identifier for the tunnel.
+
+### IPSec Profile
+
+For IPSec tunnels, this is the [IPSec Profile](./ipsecprofile.md) employed to negotiate security associations.

+ 30 - 0
docs/models/vpn/tunneltermination.md

@@ -0,0 +1,30 @@
+# Tunnel Terminations
+
+A tunnel termination connects a device or virtual machine interface to a [tunnel](./tunnel.md). The tunnel must be created before any terminations may be added.
+
+## Fields
+
+### Tunnel
+
+The [tunnel](./tunnel.md) to which this termination is made.
+
+### Role
+
+The functional role of the attached interface. The following options are available:
+
+| Name  | Description                                      |
+|-------|--------------------------------------------------|
+| Peer  | An endpoint in a point-to-point or mesh topology |
+| Hub   | A central point in a hub-and-spoke topology      |
+| Spoke | An edge point in a hub-and-spoke topology        |
+
+!!! note
+    Multiple hub terminations may be attached to a tunnel.
+
+### Termination
+
+The device or virtual machine interface terminated to the tunnel.
+
+### Outside IP
+
+The public or underlay IP address with which this termination is associated. This is the IP to which peers will route tunneled traffic.

+ 9 - 0
mkdocs.yml

@@ -74,6 +74,7 @@ nav:
         - Circuits: 'features/circuits.md'
         - Wireless: 'features/wireless.md'
         - Virtualization: 'features/virtualization.md'
+        - VPN Tunnels: 'features/vpn-tunnels.md'
         - Tenancy: 'features/tenancy.md'
         - Contacts: 'features/contacts.md'
         - Search: 'features/search.md'
@@ -252,6 +253,14 @@ nav:
             - ClusterType: 'models/virtualization/clustertype.md'
             - VMInterface: 'models/virtualization/vminterface.md'
             - VirtualMachine: 'models/virtualization/virtualmachine.md'
+        - VPN:
+            - IKEPolicy: 'models/vpn/ikepolicy.md'
+            - IKEProposal: 'models/vpn/ikeproposal.md'
+            - IPSecPolicy: 'models/vpn/ipsecpolicy.md'
+            - IPSecProfile: 'models/vpn/ipsecprofile.md'
+            - IPSecProposal: 'models/vpn/ipsecproposal.md'
+            - Tunnel: 'models/vpn/tunnel.md'
+            - TunnelTermination: 'models/vpn/tunneltermination.md'
         - Wireless:
             - WirelessLAN: 'models/wireless/wirelesslan.md'
             - WirelessLANGroup: 'models/wireless/wirelesslangroup.md'

+ 1 - 1
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}

+ 10 - 0
netbox/dcim/models/device_components.py

@@ -566,6 +566,10 @@ class BaseInterface(models.Model):
 
         return super().save(*args, **kwargs)
 
+    @property
+    def tunnel_termination(self):
+        return self.tunnel_terminations.first()
+
     @property
     def count_ipaddresses(self):
         return self.ip_addresses.count()
@@ -719,6 +723,12 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         object_id_field='interface_id',
         related_query_name='+'
     )
+    tunnel_terminations = GenericRelation(
+        to='vpn.TunnelTermination',
+        content_type_field='termination_type',
+        object_id_field='termination_id',
+        related_query_name='interface'
+    )
     l2vpn_terminations = GenericRelation(
         to='ipam.L2VPNTermination',
         content_type_field='assigned_object_type',

+ 10 - 3
netbox/dcim/tables/devices.py

@@ -584,6 +584,12 @@ class BaseInterfaceTable(NetBoxTable):
         orderable=False,
         verbose_name=_('L2VPN')
     )
+    tunnel = tables.Column(
+        accessor=tables.A('tunnel_termination__tunnel'),
+        linkify=True,
+        orderable=False,
+        verbose_name=_('Tunnel')
+    )
     untagged_vlan = tables.Column(
         verbose_name=_('Untagged VLAN'),
         linkify=True
@@ -646,7 +652,8 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
             'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
             'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
             'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
-            'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated',
+            'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created',
+            'last_updated',
         )
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
 
@@ -682,8 +689,8 @@ class DeviceInterfaceTable(InterfaceTable):
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
             'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
             'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
-            'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
-            'untagged_vlan', 'tagged_vlans', 'actions',
+            'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
+            'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions',
         )
         default_columns = (
             'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',

+ 10 - 0
netbox/dcim/tables/template_code.py

@@ -359,6 +359,16 @@ INTERFACE_BUTTONS = """
             <i class="mdi mdi-wifi-off" aria-hidden="true"></i>
         </a>
     {% endif %}
+{% elif record.type == 'virtual' %}
+    {% if perms.vpn.add_tunnel and not record.tunnel_termination %}
+        <a href="{% url 'vpn:tunnel_add' %}?termination1_type=dcim.device&termination1_parent={{ record.device.pk }}&termination1_interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Create a tunnel" class="btn btn-success btn-sm">
+            <i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
+        </a>
+    {% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %}
+        <a href="{% url 'vpn:tunneltermination_delete' pk=record.tunnel_termination.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove tunnel" class="btn btn-danger btn-sm">
+            <i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
+        </a>
+    {% endif %}
 {% elif record.is_wired and perms.dcim.add_cable %}
     <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
     <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>

+ 1 - 0
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),
         })
 

+ 2 - 0
netbox/netbox/graphql/schema.py

@@ -9,6 +9,7 @@ from netbox.registry import registry
 from tenancy.graphql.schema import TenancyQuery
 from users.graphql.schema import UsersQuery
 from virtualization.graphql.schema import VirtualizationQuery
+from vpn.graphql.schema import VPNQuery
 from wireless.graphql.schema import WirelessQuery
 
 
@@ -21,6 +22,7 @@ class Query(
     IPAMQuery,
     TenancyQuery,
     VirtualizationQuery,
+    VPNQuery,
     WirelessQuery,
     *registry['plugins']['graphql_schemas'],  # Append plugin schemas
     graphene.ObjectType

+ 21 - 4
netbox/netbox/navigation/menu.py

@@ -195,17 +195,34 @@ IPAM_MENU = Menu(
     ),
 )
 
-OVERLAY_MENU = Menu(
-    label=_('Overlay'),
+VPN_MENU = Menu(
+    label=_('VPN'),
     icon_class='mdi mdi-graph-outline',
     groups=(
         MenuGroup(
-            label='L2VPNs',
+            label=_('Tunnels'),
+            items=(
+                get_model_item('vpn', 'tunnel', _('Tunnels')),
+                get_model_item('vpn', 'tunneltermination', _('Tunnel Terminations')),
+            ),
+        ),
+        MenuGroup(
+            label=_('L2VPNs'),
             items=(
                 get_model_item('ipam', 'l2vpn', _('L2VPNs')),
                 get_model_item('ipam', 'l2vpntermination', _('Terminations')),
             ),
         ),
+        MenuGroup(
+            label=_('Security'),
+            items=(
+                get_model_item('vpn', 'ikeproposal', _('IKE Proposals')),
+                get_model_item('vpn', 'ikepolicy', _('IKE Policies')),
+                get_model_item('vpn', 'ipsecproposal', _('IPSec Proposals')),
+                get_model_item('vpn', 'ipsecpolicy', _('IPSec Policies')),
+                get_model_item('vpn', 'ipsecprofile', _('IPSec Profiles')),
+            ),
+        ),
     ),
 )
 
@@ -444,7 +461,7 @@ MENUS = [
     CONNECTIONS_MENU,
     WIRELESS_MENU,
     IPAM_MENU,
-    OVERLAY_MENU,
+    VPN_MENU,
     VIRTUALIZATION_MENU,
     CIRCUITS_MENU,
     POWER_MENU,

+ 1 - 0
netbox/netbox/settings.py

@@ -379,6 +379,7 @@ INSTALLED_APPS = [
     'users',
     'utilities',
     'virtualization',
+    'vpn',
     'wireless',
     'django_rq',  # Must come after extras to allow overriding management commands
     'drf_spectacular',

+ 2 - 0
netbox/netbox/urls.py

@@ -33,6 +33,7 @@ _patterns = [
     path('tenancy/', include('tenancy.urls')),
     path('users/', include('users.urls')),
     path('virtualization/', include('virtualization.urls')),
+    path('vpn/', include('vpn.urls')),
     path('wireless/', include('wireless.urls')),
 
     # Current user views
@@ -51,6 +52,7 @@ _patterns = [
     path('api/tenancy/', include('tenancy.api.urls')),
     path('api/users/', include('users.api.urls')),
     path('api/virtualization/', include('virtualization.api.urls')),
+    path('api/vpn/', include('vpn.api.urls')),
     path('api/wireless/', include('wireless.api.urls')),
     path('api/status/', StatusView.as_view(), name='api-status'),
 

+ 67 - 0
netbox/templates/vpn/ikepolicy.html

@@ -0,0 +1,67 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "IKE Policy" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Name" %}</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "IKE Version" %}</th>
+              <td>{{ object.get_version_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Mode" %}</th>
+              <td>{{ object.get_mode_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Pre-Shared Key" %}</th>
+              <td>
+                <span id="secret" class="font-monospace" data-secret="{{ object.preshared_key }}">{{ object.preshared_key|placeholder }}</span>
+                {% if object.preshared_key %}
+                  <button type="button" class="btn btn-sm btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "IPSec Profiles" %}</th>
+              <td>
+                <a href="{% url 'vpn:ipsecprofile_list' %}?ike_policy_id={{ object.pk }}">{{ object.ipsec_profiles.count }}</a>
+              </td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      <div class="card">
+        <h5 class="card-header">{% trans "Proposals" %}</h5>
+        <div class="card-body htmx-container table-responsive"
+          hx-get="{% url 'vpn:ikeproposal_list' %}?ike_policy_id={{ object.pk }}"
+          hx-trigger="load"
+        ></div>
+      </div>
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 63 - 0
netbox/templates/vpn/ikeproposal.html

@@ -0,0 +1,63 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "IKE Proposal" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Name" %}</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Authentication method" %}</th>
+              <td>{{ object.get_authentication_method_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Encryption algorithm" %}</th>
+              <td>{{ object.get_encryption_algorithm_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Authentication algorithm" %}</th>
+              <td>{{ object.get_authentication_algorithm_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "DH group" %}</th>
+              <td>{{ object.get_group_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "SA lifetime (seconds)" %}</th>
+              <td>{{ object.sa_lifetime|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "IKE Policies" %}</th>
+              <td>
+                <a href="{% url 'vpn:ikepolicy_list' %}?proposal_id={{ object.pk }}">{{ object.ike_policies.count }}</a>
+              </td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 55 - 0
netbox/templates/vpn/ipsecpolicy.html

@@ -0,0 +1,55 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "IPSec Policy" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Name" %}</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "PFS group" %}</th>
+              <td>{{ object.get_pfs_group_display|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "IPSec Profiles" %}</th>
+              <td>
+                <a href="{% url 'vpn:ipsecprofile_list' %}?ipsec_policy_id={{ object.pk }}">{{ object.ipsec_profiles.count }}</a>
+              </td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+    <div class="col col-md-12">
+      <div class="card">
+        <h5 class="card-header">{% trans "Proposals" %}</h5>
+        <div class="card-body htmx-container table-responsive"
+          hx-get="{% url 'vpn:ipsecproposal_list' %}?ipsec_policy_id={{ object.pk }}"
+          hx-trigger="load"
+        ></div>
+      </div>
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 112 - 0
netbox/templates/vpn/ipsecprofile.html

@@ -0,0 +1,112 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "IPSec Profile" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Name" %}</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Mode" %}</th>
+              <td>{{ object.get_mode_display }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "IKE Policy" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Name" %}</th>
+              <td>{{ object.ike_policy|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.ike_policy.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Version" %}</th>
+              <td>{{ object.ike_policy.get_version_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Mode" %}</th>
+              <td>{{ object.ike_policy.get_mode_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Proposals" %}</th>
+              <td>
+                <ul class="list-unstyled mb-0">
+                  {% for proposal in object.ike_policy.proposals.all %}
+                    <li>
+                      <a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a>
+                    </li>
+                  {% endfor %}
+                </ul>
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Pre-Shared Key" %}</th>
+              <td>{% checkmark object.ike_policy.preshared_key %}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">{% trans "IPSec Policy" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Name" %}</th>
+              <td>{{ object.ipsec_policy|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.ipsec_policy.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Proposals" %}</th>
+              <td>
+                <ul class="list-unstyled mb-0">
+                  {% for proposal in object.ipsec_policy.proposals.all %}
+                    <li>
+                      <a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a>
+                    </li>
+                  {% endfor %}
+                </ul>
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "PFS Group" %}</th>
+              <td>{{ object.ipsec_policy.get_pfs_group_display }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 59 - 0
netbox/templates/vpn/ipsecproposal.html

@@ -0,0 +1,59 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "IPSec Proposal" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Name" %}</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Encryption algorithm" %}</th>
+              <td>{{ object.get_encryption_algorithm_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Authentication algorithm" %}</th>
+              <td>{{ object.get_authentication_algorithm_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "SA lifetime (seconds)" %}</th>
+              <td>{{ object.sa_lifetime_seconds|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "SA lifetime (KB)" %}</th>
+              <td>{{ object.sa_lifetime_data|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "IPSec Policies" %}</th>
+              <td>
+                <a href="{% url 'vpn:ipsecpolicy_list' %}?proposal_id={{ object.pk }}">{{ object.ipsec_policies.count }}</a>
+              </td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 85 - 0
netbox/templates/vpn/tunnel.html

@@ -0,0 +1,85 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block extra_controls %}
+  {% if perms.vpn.add_tunneltermination %}
+    <a href="{% url 'vpn:tunneltermination_add' %}?tunnel={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-primary">
+      <i class="mdi mdi-plus-thick"></i> {% trans "Add Termination" %}
+    </a>
+  {% endif %}
+{% endblock %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Tunnel" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Name" %}</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Status" %}</th>
+              <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Encapsulation" %}</th>
+              <td>{{ object.get_encapsulation_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "IPSec profile" %}</th>
+              <td>{{ object.ipsec_profile|linkify|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Tunnel ID" %}</th>
+              <td>{{ object.tunnel_id|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Tenant" %}</th>
+              <td>
+                {% if object.tenant.group %}
+                  {{ object.tenant.group|linkify }} /
+                {% endif %}
+                {{ object.tenant|linkify|placeholder }}
+              </td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      <div class="card">
+        <h5 class="card-header">{% trans "Terminations" %}</h5>
+        <div class="card-body htmx-container table-responsive"
+          hx-get="{% url 'vpn:tunneltermination_list' %}?tunnel_id={{ object.pk }}"
+          hx-trigger="load"
+        ></div>
+        {% if perms.vpn.add_tunneltermination %}
+          <div class="card-footer text-end noprint">
+            <a href="{% url 'vpn:tunneltermination_add' %}?tunnel={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
+              <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Termination" %}
+            </a>
+          </div>
+        {% endif %}
+      </div>
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 62 - 0
netbox/templates/vpn/tunneltermination.html

@@ -0,0 +1,62 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Tunnel Termination" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Tunnel" %}</th>
+              <td>{{ object.tunnel|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Role" %}</th>
+              <td>{% badge object.get_role_display bg_color=object.get_role_color %}</td>
+            </tr>
+            <tr>
+              <th scope="row">
+                {% if object.termination.device %}
+                  {% trans "Device" %}
+                {% elif object.termination.virtual_machine %}
+                  {% trans "Virtual Machine" %}
+                {% endif %}
+              </th>
+              <td>{{ object.termination.parent_object|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Interface" %}</th>
+              <td>{{ object.termination|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Outside IP" %}</th>
+              <td>{{ object.outside_ip|linkify|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      <div class="card">
+        <h5 class="card-header">{% trans "Peer Terminations" %}</h5>
+        <div class="card-body htmx-container table-responsive"
+          hx-get="{% url 'vpn:tunneltermination_list' %}?tunnel_id={{ object.tunnel.pk }}&id__n={{ object.pk }}"
+          hx-trigger="load"
+        ></div>
+      </div>
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 6 - 0
netbox/virtualization/models/virtualmachines.py

@@ -351,6 +351,12 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
         object_id_field='interface_id',
         related_query_name='+'
     )
+    tunnel_terminations = GenericRelation(
+        to='vpn.TunnelTermination',
+        content_type_field='termination_type',
+        object_id_field='termination_id',
+        related_query_name='vminterface',
+    )
     l2vpn_terminations = GenericRelation(
         to='ipam.L2VPNTermination',
         content_type_field='assigned_object_type',

+ 3 - 2
netbox/virtualization/tables/virtualmachines.py

@@ -131,7 +131,8 @@ class VMInterfaceTable(BaseInterfaceTable):
         model = VMInterface
         fields = (
             'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
-            'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
+            'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created',
+            'last_updated',
         )
         default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
 
@@ -154,7 +155,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
         model = VMInterface
         fields = (
             'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags',
-            'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions',
+            'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions',
         )
         default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses')
         row_attrs = {

+ 0 - 0
netbox/vpn/__init__.py


+ 3 - 0
netbox/vpn/admin.py

@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.

+ 0 - 0
netbox/vpn/api/__init__.py


+ 84 - 0
netbox/vpn/api/nested_serializers.py

@@ -0,0 +1,84 @@
+from rest_framework import serializers
+
+from netbox.api.serializers import WritableNestedSerializer
+from vpn import models
+
+__all__ = (
+    'NestedIKEPolicySerializer',
+    'NestedIKEProposalSerializer',
+    'NestedIPSecPolicySerializer',
+    'NestedIPSecProfileSerializer',
+    'NestedIPSecProposalSerializer',
+    'NestedTunnelSerializer',
+    'NestedTunnelTerminationSerializer',
+)
+
+
+class NestedTunnelSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:tunnel-detail'
+    )
+
+    class Meta:
+        model = models.Tunnel
+        fields = ('id', 'url', 'display', 'name')
+
+
+class NestedTunnelTerminationSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:tunneltermination-detail'
+    )
+
+    class Meta:
+        model = models.TunnelTermination
+        fields = ('id', 'url', 'display')
+
+
+class NestedIKEProposalSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ikeproposal-detail'
+    )
+
+    class Meta:
+        model = models.IKEProposal
+        fields = ('id', 'url', 'display', 'name')
+
+
+class NestedIKEPolicySerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ikepolicy-detail'
+    )
+
+    class Meta:
+        model = models.IKEPolicy
+        fields = ('id', 'url', 'display', 'name')
+
+
+class NestedIPSecProposalSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ipsecproposal-detail'
+    )
+
+    class Meta:
+        model = models.IPSecProposal
+        fields = ('id', 'url', 'display', 'name')
+
+
+class NestedIPSecPolicySerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ipsecpolicy-detail'
+    )
+
+    class Meta:
+        model = models.IPSecPolicy
+        fields = ('id', 'url', 'display', 'name')
+
+
+class NestedIPSecProfileSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ipsecprofile-detail'
+    )
+
+    class Meta:
+        model = models.IPSecProfile
+        fields = ('id', 'url', 'display', 'name')

+ 193 - 0
netbox/vpn/api/serializers.py

@@ -0,0 +1,193 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from ipam.api.nested_serializers import NestedIPAddressSerializer
+from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
+from netbox.api.serializers import NetBoxModelSerializer
+from netbox.constants import NESTED_SERIALIZER_PREFIX
+from tenancy.api.nested_serializers import NestedTenantSerializer
+from utilities.api import get_serializer_for_model
+from vpn.choices import *
+from vpn.models import *
+from .nested_serializers import *
+
+__all__ = (
+    'IKEPolicySerializer',
+    'IKEProposalSerializer',
+    'IPSecPolicySerializer',
+    'IPSecProfileSerializer',
+    'IPSecProposalSerializer',
+    'TunnelSerializer',
+    'TunnelTerminationSerializer',
+)
+
+
+class TunnelSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:tunnel-detail'
+    )
+    status = ChoiceField(
+        choices=TunnelStatusChoices
+    )
+    encapsulation = ChoiceField(
+        choices=TunnelEncapsulationChoices
+    )
+    ipsec_profile = NestedIPSecProfileSerializer(
+        required=False,
+        allow_null=True
+    )
+    tenant = NestedTenantSerializer(
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = Tunnel
+        fields = (
+            'id', 'url', 'display', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id',
+            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        )
+
+
+class TunnelTerminationSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:tunneltermination-detail'
+    )
+    tunnel = NestedTunnelSerializer()
+    role = ChoiceField(
+        choices=TunnelTerminationRoleChoices
+    )
+    termination_type = ContentTypeField(
+        queryset=ContentType.objects.all()
+    )
+    termination = serializers.SerializerMethodField(
+        read_only=True
+    )
+    outside_ip = NestedIPAddressSerializer(
+        required=False,
+        allow_null=True
+    )
+
+    class Meta:
+        model = TunnelTermination
+        fields = (
+            'id', 'url', 'display', 'tunnel', 'role', 'termination_type', 'termination_id', 'termination', 'outside_ip',
+            'tags', 'custom_fields', 'created', 'last_updated',
+        )
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_termination(self, obj):
+        serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX)
+        context = {'request': self.context['request']}
+        return serializer(obj.termination, context=context).data
+
+
+class IKEProposalSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ikeproposal-detail'
+    )
+    authentication_method = ChoiceField(
+        choices=AuthenticationMethodChoices
+    )
+    encryption_algorithm = ChoiceField(
+        choices=EncryptionAlgorithmChoices
+    )
+    authentication_algorithm = ChoiceField(
+        choices=AuthenticationAlgorithmChoices
+    )
+    group = ChoiceField(
+        choices=DHGroupChoices
+    )
+
+    class Meta:
+        model = IKEProposal
+        fields = (
+            'id', 'url', 'display', 'name', 'description', 'authentication_method', 'encryption_algorithm',
+            'authentication_algorithm', 'group', 'sa_lifetime', 'tags', 'custom_fields', 'created', 'last_updated',
+        )
+
+
+class IKEPolicySerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ikepolicy-detail'
+    )
+    version = ChoiceField(
+        choices=IKEVersionChoices
+    )
+    mode = ChoiceField(
+        choices=IKEModeChoices
+    )
+    proposals = SerializedPKRelatedField(
+        queryset=IKEProposal.objects.all(),
+        serializer=NestedIKEProposalSerializer,
+        required=False,
+        many=True
+    )
+
+    class Meta:
+        model = IKEPolicy
+        fields = (
+            'id', 'url', 'display', 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        )
+
+
+class IPSecProposalSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ipsecproposal-detail'
+    )
+    encryption_algorithm = ChoiceField(
+        choices=EncryptionAlgorithmChoices
+    )
+    authentication_algorithm = ChoiceField(
+        choices=AuthenticationAlgorithmChoices
+    )
+
+    class Meta:
+        model = IPSecProposal
+        fields = (
+            'id', 'url', 'display', 'name', 'description', 'encryption_algorithm', 'authentication_algorithm',
+            'sa_lifetime_seconds', 'sa_lifetime_data', 'tags', 'custom_fields', 'created', 'last_updated',
+        )
+
+
+class IPSecPolicySerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ipsecpolicy-detail'
+    )
+    proposals = SerializedPKRelatedField(
+        queryset=IPSecProposal.objects.all(),
+        serializer=NestedIPSecProposalSerializer,
+        required=False,
+        many=True
+    )
+    pfs_group = ChoiceField(
+        choices=DHGroupChoices,
+        required=False
+    )
+
+    class Meta:
+        model = IPSecPolicy
+        fields = (
+            'id', 'url', 'display', 'name', 'description', 'proposals', 'pfs_group', 'tags', 'custom_fields', 'created',
+            'last_updated',
+        )
+
+
+class IPSecProfileSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(
+        view_name='vpn-api:ipsecprofile-detail'
+    )
+    mode = ChoiceField(
+        choices=IPSecModeChoices
+    )
+    ike_policy = NestedIKEPolicySerializer()
+    ipsec_policy = NestedIPSecPolicySerializer()
+
+    class Meta:
+        model = IPSecProfile
+        fields = (
+            'id', 'url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        )

+ 15 - 0
netbox/vpn/api/urls.py

@@ -0,0 +1,15 @@
+from netbox.api.routers import NetBoxRouter
+from . import views
+
+router = NetBoxRouter()
+router.APIRootView = views.VPNRootView
+router.register('ike-policies', views.IKEPolicyViewSet)
+router.register('ike-proposals', views.IKEProposalViewSet)
+router.register('ipsec-policies', views.IPSecPolicyViewSet)
+router.register('ipsec-proposals', views.IPSecProposalViewSet)
+router.register('ipsec-profiles', views.IPSecProfileViewSet)
+router.register('tunnels', views.TunnelViewSet)
+router.register('tunnel-terminations', views.TunnelTerminationViewSet)
+
+app_name = 'vpn-api'
+urlpatterns = router.urls

+ 74 - 0
netbox/vpn/api/views.py

@@ -0,0 +1,74 @@
+from rest_framework.routers import APIRootView
+
+from netbox.api.viewsets import NetBoxModelViewSet
+from utilities.utils import count_related
+from vpn import filtersets
+from vpn.models import *
+from . import serializers
+
+__all__ = (
+    'IKEPolicyViewSet',
+    'IKEProposalViewSet',
+    'IPSecPolicyViewSet',
+    'IPSecProfileViewSet',
+    'IPSecProposalViewSet',
+    'TunnelTerminationViewSet',
+    'TunnelViewSet',
+    'VPNRootView',
+)
+
+
+class VPNRootView(APIRootView):
+    """
+    VPN API root view
+    """
+    def get_view_name(self):
+        return 'VPN'
+
+
+#
+# Viewsets
+#
+
+class TunnelViewSet(NetBoxModelViewSet):
+    queryset = Tunnel.objects.prefetch_related('ipsec_profile', 'tenant').annotate(
+        terminations_count=count_related(TunnelTermination, 'tunnel')
+    )
+    serializer_class = serializers.TunnelSerializer
+    filterset_class = filtersets.TunnelFilterSet
+
+
+class TunnelTerminationViewSet(NetBoxModelViewSet):
+    queryset = TunnelTermination.objects.prefetch_related('tunnel')
+    serializer_class = serializers.TunnelTerminationSerializer
+    filterset_class = filtersets.TunnelTerminationFilterSet
+
+
+class IKEProposalViewSet(NetBoxModelViewSet):
+    queryset = IKEProposal.objects.all()
+    serializer_class = serializers.IKEProposalSerializer
+    filterset_class = filtersets.IKEProposalFilterSet
+
+
+class IKEPolicyViewSet(NetBoxModelViewSet):
+    queryset = IKEPolicy.objects.all()
+    serializer_class = serializers.IKEPolicySerializer
+    filterset_class = filtersets.IKEPolicyFilterSet
+
+
+class IPSecProposalViewSet(NetBoxModelViewSet):
+    queryset = IPSecProposal.objects.all()
+    serializer_class = serializers.IPSecProposalSerializer
+    filterset_class = filtersets.IPSecProposalFilterSet
+
+
+class IPSecPolicyViewSet(NetBoxModelViewSet):
+    queryset = IPSecPolicy.objects.all()
+    serializer_class = serializers.IPSecPolicySerializer
+    filterset_class = filtersets.IPSecPolicyFilterSet
+
+
+class IPSecProfileViewSet(NetBoxModelViewSet):
+    queryset = IPSecProfile.objects.all()
+    serializer_class = serializers.IPSecProfileSerializer
+    filterset_class = filtersets.IPSecProfileFilterSet

+ 9 - 0
netbox/vpn/apps.py

@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+
+
+class VPNConfig(AppConfig):
+    name = 'vpn'
+    verbose_name = 'VPN'
+
+    def ready(self):
+        from . import search

+ 201 - 0
netbox/vpn/choices.py

@@ -0,0 +1,201 @@
+from django.utils.translation import gettext_lazy as _
+
+from utilities.choices import ChoiceSet
+
+
+#
+# Tunnels
+#
+
+class TunnelStatusChoices(ChoiceSet):
+    key = 'Tunnel.status'
+
+    STATUS_PLANNED = 'planned'
+    STATUS_ACTIVE = 'active'
+    STATUS_DISABLED = 'disabled'
+
+    CHOICES = [
+        (STATUS_PLANNED, _('Planned'), 'cyan'),
+        (STATUS_ACTIVE, _('Active'), 'green'),
+        (STATUS_DISABLED, _('Disabled'), 'red'),
+    ]
+
+
+class TunnelEncapsulationChoices(ChoiceSet):
+    ENCAP_GRE = 'gre'
+    ENCAP_IP_IP = 'ip-ip'
+    ENCAP_IPSEC_TRANSPORT = 'ipsec-transport'
+    ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel'
+
+    CHOICES = [
+        (ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')),
+        (ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')),
+        (ENCAP_IP_IP, _('IP-in-IP')),
+        (ENCAP_GRE, _('GRE')),
+    ]
+
+
+class TunnelTerminationTypeChoices(ChoiceSet):
+    # For TunnelCreateForm
+    TYPE_DEVICE = 'dcim.device'
+    TYPE_VIRUTALMACHINE = 'virtualization.virtualmachine'
+
+    CHOICES = (
+        (TYPE_DEVICE, _('Device')),
+        (TYPE_VIRUTALMACHINE, _('Virtual Machine')),
+    )
+
+
+class TunnelTerminationRoleChoices(ChoiceSet):
+    ROLE_PEER = 'peer'
+    ROLE_HUB = 'hub'
+    ROLE_SPOKE = 'spoke'
+
+    CHOICES = [
+        (ROLE_PEER, _('Peer'), 'green'),
+        (ROLE_HUB, _('Hub'), 'blue'),
+        (ROLE_SPOKE, _('Spoke'), 'orange'),
+    ]
+
+
+#
+# Crypto
+#
+
+class IKEVersionChoices(ChoiceSet):
+    VERSION_1 = 1
+    VERSION_2 = 2
+
+    CHOICES = (
+        (VERSION_1, 'IKEv1'),
+        (VERSION_2, 'IKEv2'),
+    )
+
+
+class IKEModeChoices(ChoiceSet):
+    AGGRESSIVE = 'aggressive'
+    MAIN = 'main'
+
+    CHOICES = (
+        (AGGRESSIVE, _('Aggressive')),
+        (MAIN, _('Main')),
+    )
+
+
+class AuthenticationMethodChoices(ChoiceSet):
+    PRESHARED_KEYS = 'preshared-keys'
+    CERTIFICATES = 'certificates'
+    RSA_SIGNATURES = 'rsa-signatures'
+    DSA_SIGNATURES = 'dsa-signatures'
+
+    CHOICES = (
+        (PRESHARED_KEYS, _('Pre-shared keys')),
+        (CERTIFICATES, _('Certificates')),
+        (RSA_SIGNATURES, _('RSA signatures')),
+        (DSA_SIGNATURES, _('DSA signatures')),
+    )
+
+
+class IPSecModeChoices(ChoiceSet):
+    ESP = 'esp'
+    AH = 'ah'
+
+    CHOICES = (
+        (ESP, 'ESP'),
+        (AH, 'AH'),
+    )
+
+
+class EncryptionAlgorithmChoices(ChoiceSet):
+    ENCRYPTION_AES128_CBC = 'aes-128-cbc'
+    ENCRYPTION_AES128_GCM = 'aes-128-gcm'
+    ENCRYPTION_AES192_CBC = 'aes-192-cbc'
+    ENCRYPTION_AES192_GCM = 'aes-192-gcm'
+    ENCRYPTION_AES256_CBC = 'aes-256-cbc'
+    ENCRYPTION_AES256_GCM = 'aes-256-gcm'
+    ENCRYPTION_3DES = '3des-cbc'
+    ENCRYPTION_DES = 'des-cbc'
+
+    CHOICES = (
+        (ENCRYPTION_AES128_CBC, '128-bit AES (CBC)'),
+        (ENCRYPTION_AES128_GCM, '128-bit AES (GCM)'),
+        (ENCRYPTION_AES192_CBC, '192-bit AES (CBC)'),
+        (ENCRYPTION_AES192_GCM, '192-bit AES (GCM)'),
+        (ENCRYPTION_AES256_CBC, '256-bit AES (CBC)'),
+        (ENCRYPTION_AES256_GCM, '256-bit AES (GCM)'),
+        (ENCRYPTION_3DES, '3DES'),
+        (ENCRYPTION_3DES, 'DES'),
+    )
+
+
+class AuthenticationAlgorithmChoices(ChoiceSet):
+    AUTH_HMAC_SHA1 = 'hmac-sha1'
+    AUTH_HMAC_SHA256 = 'hmac-sha256'
+    AUTH_HMAC_SHA384 = 'hmac-sha384'
+    AUTH_HMAC_SHA512 = 'hmac-sha512'
+    AUTH_HMAC_MD5 = 'hmac-md5'
+
+    CHOICES = (
+        (AUTH_HMAC_SHA1, 'SHA-1 HMAC'),
+        (AUTH_HMAC_SHA256, 'SHA-256 HMAC'),
+        (AUTH_HMAC_SHA384, 'SHA-384 HMAC'),
+        (AUTH_HMAC_SHA512, 'SHA-512 HMAC'),
+        (AUTH_HMAC_MD5, 'MD5 HMAC'),
+    )
+
+
+class DHGroupChoices(ChoiceSet):
+    # https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-8
+    GROUP_1 = 1    # 768-bit MODP
+    GROUP_2 = 2    # 1024-but MODP
+    # Groups 3-4 reserved
+    GROUP_5 = 5    # 1536-bit MODP
+    # Groups 6-13 unassigned
+    GROUP_14 = 14  # 2048-bit MODP
+    GROUP_15 = 15  # 3072-bit MODP
+    GROUP_16 = 16  # 4096-bit MODP
+    GROUP_17 = 17  # 6144-bit MODP
+    GROUP_18 = 18  # 8192-bit MODP
+    GROUP_19 = 19  # 256-bit random ECP
+    GROUP_20 = 20  # 384-bit random ECP
+    GROUP_21 = 21  # 521-bit random ECP (521 is not a typo)
+    GROUP_22 = 22  # 1024-bit MODP w/160-bit prime
+    GROUP_23 = 23  # 2048-bit MODP w/224-bit prime
+    GROUP_24 = 24  # 2048-bit MODP w/256-bit prime
+    GROUP_25 = 25  # 192-bit ECP
+    GROUP_26 = 26  # 224-bit ECP
+    GROUP_27 = 27  # brainpoolP224r1
+    GROUP_28 = 28  # brainpoolP256r1
+    GROUP_29 = 29  # brainpoolP384r1
+    GROUP_30 = 30  # brainpoolP512r1
+    GROUP_31 = 31  # Curve25519
+    GROUP_32 = 32  # Curve448
+    GROUP_33 = 33  # GOST3410_2012_256
+    GROUP_34 = 34  # GOST3410_2012_512
+
+    CHOICES = (
+        # Strings are formatted in this manner to optimize translations
+        (GROUP_1, _('Group {n}').format(n=1)),
+        (GROUP_2, _('Group {n}').format(n=2)),
+        (GROUP_5, _('Group {n}').format(n=5)),
+        (GROUP_14, _('Group {n}').format(n=14)),
+        (GROUP_16, _('Group {n}').format(n=16)),
+        (GROUP_17, _('Group {n}').format(n=17)),
+        (GROUP_18, _('Group {n}').format(n=18)),
+        (GROUP_19, _('Group {n}').format(n=19)),
+        (GROUP_20, _('Group {n}').format(n=20)),
+        (GROUP_21, _('Group {n}').format(n=21)),
+        (GROUP_22, _('Group {n}').format(n=22)),
+        (GROUP_23, _('Group {n}').format(n=23)),
+        (GROUP_24, _('Group {n}').format(n=24)),
+        (GROUP_25, _('Group {n}').format(n=25)),
+        (GROUP_26, _('Group {n}').format(n=26)),
+        (GROUP_27, _('Group {n}').format(n=27)),
+        (GROUP_28, _('Group {n}').format(n=28)),
+        (GROUP_29, _('Group {n}').format(n=29)),
+        (GROUP_30, _('Group {n}').format(n=30)),
+        (GROUP_31, _('Group {n}').format(n=31)),
+        (GROUP_32, _('Group {n}').format(n=32)),
+        (GROUP_33, _('Group {n}').format(n=33)),
+        (GROUP_34, _('Group {n}').format(n=34)),
+    )

+ 241 - 0
netbox/vpn/filtersets.py

@@ -0,0 +1,241 @@
+import django_filters
+from django.db.models import Q
+from django.utils.translation import gettext as _
+
+from dcim.models import Interface
+from ipam.models import IPAddress
+from netbox.filtersets import NetBoxModelFilterSet
+from tenancy.filtersets import TenancyFilterSet
+from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
+from virtualization.models import VMInterface
+from .choices import *
+from .models import *
+
+__all__ = (
+    'IKEPolicyFilterSet',
+    'IKEProposalFilterSet',
+    'IPSecPolicyFilterSet',
+    'IPSecProfileFilterSet',
+    'IPSecProposalFilterSet',
+    'TunnelFilterSet',
+    'TunnelTerminationFilterSet',
+)
+
+
+class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+    status = django_filters.MultipleChoiceFilter(
+        choices=TunnelStatusChoices
+    )
+    encapsulation = django_filters.MultipleChoiceFilter(
+        choices=TunnelEncapsulationChoices
+    )
+    ipsec_profile_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=IPSecProfile.objects.all(),
+        label=_('IPSec profile (ID)'),
+    )
+    ipsec_profile = django_filters.ModelMultipleChoiceFilter(
+        field_name='ipsec_profile__name',
+        queryset=IPSecProfile.objects.all(),
+        to_field_name='name',
+        label=_('IPSec profile (name)'),
+    )
+
+    class Meta:
+        model = Tunnel
+        fields = ['id', 'name', 'tunnel_id']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value) |
+            Q(comments__icontains=value)
+        )
+
+
+class TunnelTerminationFilterSet(NetBoxModelFilterSet):
+    tunnel_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='tunnel',
+        queryset=Tunnel.objects.all(),
+        label=_('Tunnel (ID)'),
+    )
+    tunnel = django_filters.ModelMultipleChoiceFilter(
+        field_name='tunnel__name',
+        queryset=Tunnel.objects.all(),
+        to_field_name='name',
+        label=_('Tunnel (name)'),
+    )
+    role = django_filters.MultipleChoiceFilter(
+        choices=TunnelTerminationRoleChoices
+    )
+    termination_type = ContentTypeFilter()
+    interface = django_filters.ModelMultipleChoiceFilter(
+        field_name='interface__name',
+        queryset=Interface.objects.all(),
+        to_field_name='name',
+        label=_('Interface (name)'),
+    )
+    interface_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='interface',
+        queryset=Interface.objects.all(),
+        label=_('Interface (ID)'),
+    )
+    vminterface = django_filters.ModelMultipleChoiceFilter(
+        field_name='vminterface__name',
+        queryset=VMInterface.objects.all(),
+        to_field_name='name',
+        label=_('VM interface (name)'),
+    )
+    vminterface_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='vminterface',
+        queryset=VMInterface.objects.all(),
+        label=_('VM interface (ID)'),
+    )
+    outside_ip_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='outside_ip',
+        queryset=IPAddress.objects.all(),
+        label=_('Outside IP (ID)'),
+    )
+
+    class Meta:
+        model = TunnelTermination
+        fields = ['id']
+
+
+class IKEProposalFilterSet(NetBoxModelFilterSet):
+    authentication_method = django_filters.MultipleChoiceFilter(
+        choices=AuthenticationMethodChoices
+    )
+    encryption_algorithm = django_filters.MultipleChoiceFilter(
+        choices=EncryptionAlgorithmChoices
+    )
+    authentication_algorithm = django_filters.MultipleChoiceFilter(
+        choices=AuthenticationAlgorithmChoices
+    )
+    group = django_filters.MultipleChoiceFilter(
+        choices=DHGroupChoices
+    )
+
+    class Meta:
+        model = IKEProposal
+        fields = ['id', 'name', 'sa_lifetime']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+
+
+class IKEPolicyFilterSet(NetBoxModelFilterSet):
+    version = django_filters.MultipleChoiceFilter(
+        choices=IKEVersionChoices
+    )
+    mode = django_filters.MultipleChoiceFilter(
+        choices=IKEModeChoices
+    )
+    proposal_id = MultiValueNumberFilter(
+        field_name='proposals__id'
+    )
+    proposal = MultiValueCharFilter(
+        field_name='proposals__name'
+    )
+
+    class Meta:
+        model = IKEPolicy
+        fields = ['id', 'name', 'preshared_key']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+
+
+class IPSecProposalFilterSet(NetBoxModelFilterSet):
+    encryption_algorithm = django_filters.MultipleChoiceFilter(
+        choices=EncryptionAlgorithmChoices
+    )
+    authentication_algorithm = django_filters.MultipleChoiceFilter(
+        choices=AuthenticationAlgorithmChoices
+    )
+
+    class Meta:
+        model = IPSecProposal
+        fields = ['id', 'name', 'sa_lifetime_seconds', 'sa_lifetime_data']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+
+
+class IPSecPolicyFilterSet(NetBoxModelFilterSet):
+    pfs_group = django_filters.MultipleChoiceFilter(
+        choices=DHGroupChoices
+    )
+    proposal_id = MultiValueNumberFilter(
+        field_name='proposals__id'
+    )
+    proposal = MultiValueCharFilter(
+        field_name='proposals__name'
+    )
+
+    class Meta:
+        model = IPSecPolicy
+        fields = ['id', 'name']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+
+
+class IPSecProfileFilterSet(NetBoxModelFilterSet):
+    mode = django_filters.MultipleChoiceFilter(
+        choices=IPSecModeChoices
+    )
+    ike_policy_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=IKEPolicy.objects.all(),
+        label=_('IKE policy (ID)'),
+    )
+    ike_policy = django_filters.ModelMultipleChoiceFilter(
+        field_name='ike_policy__name',
+        queryset=IKEPolicy.objects.all(),
+        to_field_name='name',
+        label=_('IKE policy (name)'),
+    )
+    ipsec_policy_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=IPSecPolicy.objects.all(),
+        label=_('IPSec policy (ID)'),
+    )
+    ipsec_policy = django_filters.ModelMultipleChoiceFilter(
+        field_name='ipsec_policy__name',
+        queryset=IPSecPolicy.objects.all(),
+        to_field_name='name',
+        label=_('IPSec policy (name)'),
+    )
+
+    class Meta:
+        model = IPSecProfile
+        fields = ['id', 'name']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value) |
+            Q(comments__icontains=value)
+        )

+ 4 - 0
netbox/vpn/forms/__init__.py

@@ -0,0 +1,4 @@
+from .bulk_edit import *
+from .bulk_import import *
+from .filtersets import *
+from .model_forms import *

+ 243 - 0
netbox/vpn/forms/bulk_edit.py

@@ -0,0 +1,243 @@
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from netbox.forms import NetBoxModelBulkEditForm
+from tenancy.models import Tenant
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from vpn.choices import *
+from vpn.models import *
+
+__all__ = (
+    'IKEPolicyBulkEditForm',
+    'IKEProposalBulkEditForm',
+    'IPSecPolicyBulkEditForm',
+    'IPSecProfileBulkEditForm',
+    'IPSecProposalBulkEditForm',
+    'TunnelBulkEditForm',
+    'TunnelTerminationBulkEditForm',
+)
+
+
+class TunnelBulkEditForm(NetBoxModelBulkEditForm):
+    status = forms.ChoiceField(
+        label=_('Status'),
+        choices=add_blank_choice(TunnelStatusChoices),
+        required=False
+    )
+    encapsulation = forms.ChoiceField(
+        label=_('Encapsulation'),
+        choices=add_blank_choice(TunnelEncapsulationChoices),
+        required=False
+    )
+    ipsec_profile = DynamicModelMultipleChoiceField(
+        queryset=IPSecProfile.objects.all(),
+        label=_('IPSec profile'),
+        required=False
+    )
+    tenant = DynamicModelChoiceField(
+        label=_('Tenant'),
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+    tunnel_id = forms.IntegerField(
+        label=_('Tunnel ID'),
+        required=False
+    )
+    comments = CommentField()
+
+    model = Tunnel
+    fieldsets = (
+        (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id', 'description')),
+        (_('Security'), ('ipsec_profile',)),
+        (_('Tenancy'), ('tenant',)),
+    )
+    nullable_fields = (
+        'ipsec_profile', 'tunnel_id', 'tenant', 'description', 'comments',
+    )
+
+
+class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm):
+    role = forms.ChoiceField(
+        label=_('Role'),
+        choices=add_blank_choice(TunnelTerminationRoleChoices),
+        required=False
+    )
+
+    model = TunnelTermination
+
+
+class IKEProposalBulkEditForm(NetBoxModelBulkEditForm):
+    authentication_method = forms.ChoiceField(
+        label=_('Authentication method'),
+        choices=add_blank_choice(AuthenticationMethodChoices),
+        required=False
+    )
+    encryption_algorithm = forms.ChoiceField(
+        label=_('Encryption algorithm'),
+        choices=add_blank_choice(EncryptionAlgorithmChoices),
+        required=False
+    )
+    authentication_algorithm = forms.ChoiceField(
+        label=_('Authentication algorithm'),
+        choices=add_blank_choice(AuthenticationAlgorithmChoices),
+        required=False
+    )
+    group = forms.ChoiceField(
+        label=_('Group'),
+        choices=add_blank_choice(DHGroupChoices),
+        required=False
+    )
+    sa_lifetime = forms.IntegerField(
+        label=_('SA lifetime'),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+    comments = CommentField()
+
+    model = IKEProposal
+    fieldsets = (
+        (None, (
+            'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime',
+            'description',
+        )),
+    )
+    nullable_fields = (
+        'sa_lifetime', 'description', 'comments',
+    )
+
+
+class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm):
+    version = forms.ChoiceField(
+        label=_('Version'),
+        choices=add_blank_choice(IKEVersionChoices),
+        required=False
+    )
+    mode = forms.ChoiceField(
+        label=_('Mode'),
+        choices=add_blank_choice(IKEModeChoices),
+        required=False
+    )
+    preshared_key = forms.CharField(
+        label=_('Pre-shared key'),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+    comments = CommentField()
+
+    model = IKEPolicy
+    fieldsets = (
+        (None, (
+            'version', 'mode', 'preshared_key', 'description',
+        )),
+    )
+    nullable_fields = (
+        'preshared_key', 'description', 'comments',
+    )
+
+
+class IPSecProposalBulkEditForm(NetBoxModelBulkEditForm):
+    encryption_algorithm = forms.ChoiceField(
+        label=_('Encryption algorithm'),
+        choices=add_blank_choice(EncryptionAlgorithmChoices),
+        required=False
+    )
+    authentication_algorithm = forms.ChoiceField(
+        label=_('Authentication algorithm'),
+        choices=add_blank_choice(AuthenticationAlgorithmChoices),
+        required=False
+    )
+    sa_lifetime_seconds = forms.IntegerField(
+        label=_('SA lifetime (seconds)'),
+        required=False
+    )
+    sa_lifetime_data = forms.IntegerField(
+        label=_('SA lifetime (KB)'),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+    comments = CommentField()
+
+    model = IPSecProposal
+    fieldsets = (
+        (None, (
+            'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data',
+            'description',
+        )),
+    )
+    nullable_fields = (
+        'sa_lifetime_seconds', 'sa_lifetime_data', 'description', 'comments',
+    )
+
+
+class IPSecPolicyBulkEditForm(NetBoxModelBulkEditForm):
+    pfs_group = forms.ChoiceField(
+        label=_('PFS group'),
+        choices=add_blank_choice(DHGroupChoices),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+    comments = CommentField()
+
+    model = IPSecPolicy
+    fieldsets = (
+        (None, ('pfs_group', 'description',)),
+    )
+    nullable_fields = (
+        'pfs_group', 'description', 'comments',
+    )
+
+
+class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm):
+    mode = forms.ChoiceField(
+        label=_('Mode'),
+        choices=add_blank_choice(IPSecModeChoices),
+        required=False
+    )
+    ike_policy = DynamicModelChoiceField(
+        label=_('IKE policy'),
+        queryset=IKEPolicy.objects.all(),
+        required=False
+    )
+    ipsec_policy = DynamicModelChoiceField(
+        label=_('IPSec policy'),
+        queryset=IPSecPolicy.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+    comments = CommentField()
+
+    model = IPSecProfile
+    fieldsets = (
+        (_('Profile'), (
+            'mode', 'ike_policy', 'ipsec_policy', 'description',
+        )),
+    )
+    nullable_fields = (
+        'description', 'comments',
+    )

+ 230 - 0
netbox/vpn/forms/bulk_import.py

@@ -0,0 +1,230 @@
+from django.utils.translation import gettext_lazy as _
+
+from dcim.models import Device, Interface
+from ipam.models import IPAddress
+from netbox.forms import NetBoxModelImportForm
+from tenancy.models import Tenant
+from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
+from virtualization.models import VirtualMachine, VMInterface
+from vpn.choices import *
+from vpn.models import *
+
+__all__ = (
+    'IKEPolicyImportForm',
+    'IKEProposalImportForm',
+    'IPSecPolicyImportForm',
+    'IPSecProfileImportForm',
+    'IPSecProposalImportForm',
+    'TunnelImportForm',
+    'TunnelTerminationImportForm',
+)
+
+
+class TunnelImportForm(NetBoxModelImportForm):
+    status = CSVChoiceField(
+        label=_('Status'),
+        choices=TunnelStatusChoices,
+        help_text=_('Operational status')
+    )
+    encapsulation = CSVChoiceField(
+        label=_('Encapsulation'),
+        choices=TunnelEncapsulationChoices,
+        help_text=_('Tunnel encapsulation')
+    )
+    ipsec_profile = CSVModelChoiceField(
+        label=_('IPSec profile'),
+        queryset=IPSecProfile.objects.all(),
+        required=False,
+        to_field_name='name'
+    )
+    tenant = CSVModelChoiceField(
+        label=_('Tenant'),
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Assigned tenant')
+    )
+
+    class Meta:
+        model = Tunnel
+        fields = (
+            'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', 'description', 'comments',
+            'tags',
+        )
+
+
+class TunnelTerminationImportForm(NetBoxModelImportForm):
+    tunnel = CSVModelChoiceField(
+        label=_('Tunnel'),
+        queryset=Tunnel.objects.all(),
+        to_field_name='name'
+    )
+    role = CSVChoiceField(
+        label=_('Role'),
+        choices=TunnelTerminationRoleChoices,
+        help_text=_('Operational role')
+    )
+    device = CSVModelChoiceField(
+        label=_('Device'),
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Parent device of assigned interface')
+    )
+    virtual_machine = CSVModelChoiceField(
+        label=_('Virtual machine'),
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Parent VM of assigned interface')
+    )
+    termination = CSVModelChoiceField(
+        label=_('Termination'),
+        queryset=Interface.objects.none(),  # Can also refer to VMInterface
+        required=False,
+        to_field_name='name',
+        help_text=_('Device or virtual machine interface')
+    )
+    outside_ip = CSVModelChoiceField(
+        label=_('Outside IP'),
+        queryset=IPAddress.objects.all(),
+        required=False,
+        to_field_name='name'
+    )
+
+    class Meta:
+        model = TunnelTermination
+        fields = (
+            'tunnel', 'role', 'outside_ip', 'tags',
+        )
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+
+            # Limit termination queryset by assigned device/VM
+            if data.get('device'):
+                self.fields['termination'].queryset = Interface.objects.filter(
+                    **{f"device__{self.fields['device'].to_field_name}": data['device']}
+                )
+            elif data.get('virtual_machine'):
+                self.fields['termination'].queryset = VMInterface.objects.filter(
+                    **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
+                )
+
+    def save(self, *args, **kwargs):
+
+        # Assign termination object
+        if self.cleaned_data.get('termination'):
+            self.instance.termination = self.cleaned_data['termination']
+
+        return super().save(*args, **kwargs)
+
+
+class IKEProposalImportForm(NetBoxModelImportForm):
+    authentication_method = CSVChoiceField(
+        label=_('Authentication method'),
+        choices=AuthenticationMethodChoices
+    )
+    encryption_algorithm = CSVChoiceField(
+        label=_('Encryption algorithm'),
+        choices=EncryptionAlgorithmChoices
+    )
+    authentication_algorithm = CSVChoiceField(
+        label=_('Authentication algorithm'),
+        choices=AuthenticationAlgorithmChoices
+    )
+    group = CSVChoiceField(
+        label=_('Group'),
+        choices=DHGroupChoices
+    )
+
+    class Meta:
+        model = IKEProposal
+        fields = (
+            'name', 'description', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm',
+            'group', 'sa_lifetime', 'tags',
+        )
+
+
+class IKEPolicyImportForm(NetBoxModelImportForm):
+    version = CSVChoiceField(
+        label=_('Version'),
+        choices=IKEVersionChoices
+    )
+    mode = CSVChoiceField(
+        label=_('Mode'),
+        choices=IKEModeChoices
+    )
+    proposals = CSVModelMultipleChoiceField(
+        queryset=IKEProposal.objects.all(),
+        to_field_name='name',
+        help_text=_('IKE proposal(s)'),
+    )
+
+    class Meta:
+        model = IKEPolicy
+        fields = (
+            'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'tags',
+        )
+
+
+class IPSecProposalImportForm(NetBoxModelImportForm):
+    encryption_algorithm = CSVChoiceField(
+        label=_('Encryption algorithm'),
+        choices=EncryptionAlgorithmChoices
+    )
+    authentication_algorithm = CSVChoiceField(
+        label=_('Authentication algorithm'),
+        choices=AuthenticationAlgorithmChoices
+    )
+
+    class Meta:
+        model = IPSecProposal
+        fields = (
+            'name', 'description', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds',
+            'sa_lifetime_data', 'tags',
+        )
+
+
+class IPSecPolicyImportForm(NetBoxModelImportForm):
+    pfs_group = CSVChoiceField(
+        label=_('Diffie-Hellman group for Perfect Forward Secrecy'),
+        choices=DHGroupChoices
+    )
+    proposals = CSVModelMultipleChoiceField(
+        queryset=IPSecProposal.objects.all(),
+        to_field_name='name',
+        help_text=_('IPSec proposal(s)'),
+    )
+
+    class Meta:
+        model = IPSecPolicy
+        fields = (
+            'name', 'description', 'proposals', 'pfs_group', 'tags',
+        )
+
+
+class IPSecProfileImportForm(NetBoxModelImportForm):
+    mode = CSVChoiceField(
+        label=_('Mode'),
+        choices=IPSecModeChoices,
+        help_text=_('IPSec protocol')
+    )
+    ike_policy = CSVModelChoiceField(
+        label=_('IKE policy'),
+        queryset=IKEPolicy.objects.all(),
+        to_field_name='name'
+    )
+    ipsec_policy = CSVModelChoiceField(
+        label=_('IPSec policy'),
+        queryset=IPSecPolicy.objects.all(),
+        to_field_name='name'
+    )
+
+    class Meta:
+        model = IPSecProfile
+        fields = (
+            'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags',
+        )

+ 182 - 0
netbox/vpn/forms/filtersets.py

@@ -0,0 +1,182 @@
+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 *
+
+__all__ = (
+    'IKEPolicyFilterForm',
+    'IKEProposalFilterForm',
+    'IPSecPolicyFilterForm',
+    'IPSecProfileFilterForm',
+    'IPSecProposalFilterForm',
+    'TunnelFilterForm',
+    'TunnelTerminationFilterForm',
+)
+
+
+class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+    model = Tunnel
+    fieldsets = (
+        (None, ('q', 'filter_id', 'tag')),
+        (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id')),
+        (_('Security'), ('ipsec_profile_id',)),
+        (_('Tenancy'), ('tenant_group_id', 'tenant_id')),
+    )
+    status = forms.MultipleChoiceField(
+        label=_('Status'),
+        choices=TunnelStatusChoices,
+        required=False
+    )
+    encapsulation = forms.MultipleChoiceField(
+        label=_('Encapsulation'),
+        choices=TunnelEncapsulationChoices,
+        required=False
+    )
+    ipsec_profile_id = DynamicModelMultipleChoiceField(
+        queryset=IPSecProfile.objects.all(),
+        required=False,
+        label=_('IPSec profile')
+    )
+    tunnel_id = forms.IntegerField(
+        required=False,
+        label=_('Tunnel ID')
+    )
+    tag = TagFilterField(model)
+
+
+class TunnelTerminationFilterForm(NetBoxModelFilterSetForm):
+    model = TunnelTermination
+    fieldsets = (
+        (None, ('q', 'filter_id', 'tag')),
+        (_('Termination'), ('tunnel_id', 'role')),
+    )
+    tunnel_id = DynamicModelMultipleChoiceField(
+        queryset=Tunnel.objects.all(),
+        required=False,
+        label=_('Tunnel')
+    )
+    role = forms.MultipleChoiceField(
+        label=_('Role'),
+        choices=TunnelTerminationRoleChoices,
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class IKEProposalFilterForm(NetBoxModelFilterSetForm):
+    model = IKEProposal
+    fieldsets = (
+        (None, ('q', 'filter_id', 'tag')),
+        (_('Parameters'), ('authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group')),
+    )
+    authentication_method = forms.MultipleChoiceField(
+        label=_('Authentication method'),
+        choices=AuthenticationMethodChoices,
+        required=False
+    )
+    encryption_algorithm = forms.MultipleChoiceField(
+        label=_('Encryption algorithm'),
+        choices=EncryptionAlgorithmChoices,
+        required=False
+    )
+    authentication_algorithm = forms.MultipleChoiceField(
+        label=_('Authentication algorithm'),
+        choices=AuthenticationAlgorithmChoices,
+        required=False
+    )
+    group = forms.MultipleChoiceField(
+        label=_('Group'),
+        choices=DHGroupChoices,
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class IKEPolicyFilterForm(NetBoxModelFilterSetForm):
+    model = IKEPolicy
+    fieldsets = (
+        (None, ('q', 'filter_id', 'tag')),
+        (_('Parameters'), ('version', 'mode', 'proposal_id')),
+    )
+    version = forms.MultipleChoiceField(
+        label=_('IKE version'),
+        choices=IKEVersionChoices,
+        required=False
+    )
+    mode = forms.MultipleChoiceField(
+        label=_('Mode'),
+        choices=IKEModeChoices,
+        required=False
+    )
+    proposal_id = DynamicModelMultipleChoiceField(
+        queryset=IKEProposal.objects.all(),
+        required=False,
+        label=_('Proposal')
+    )
+    tag = TagFilterField(model)
+
+
+class IPSecProposalFilterForm(NetBoxModelFilterSetForm):
+    model = IPSecProposal
+    fieldsets = (
+        (None, ('q', 'filter_id', 'tag')),
+        (_('Parameters'), ('encryption_algorithm', 'authentication_algorithm')),
+    )
+    encryption_algorithm = forms.MultipleChoiceField(
+        label=_('Encryption algorithm'),
+        choices=EncryptionAlgorithmChoices,
+        required=False
+    )
+    authentication_algorithm = forms.MultipleChoiceField(
+        label=_('Authentication algorithm'),
+        choices=AuthenticationAlgorithmChoices,
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class IPSecPolicyFilterForm(NetBoxModelFilterSetForm):
+    model = IPSecPolicy
+    fieldsets = (
+        (None, ('q', 'filter_id', 'tag')),
+        (_('Parameters'), ('proposal_id', 'pfs_group')),
+    )
+    proposal_id = DynamicModelMultipleChoiceField(
+        queryset=IKEProposal.objects.all(),
+        required=False,
+        label=_('Proposal')
+    )
+    pfs_group = forms.MultipleChoiceField(
+        label=_('Mode'),
+        choices=DHGroupChoices,
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class IPSecProfileFilterForm(NetBoxModelFilterSetForm):
+    model = IPSecProfile
+    fieldsets = (
+        (None, ('q', 'filter_id', 'tag')),
+        (_('Profile'), ('mode', 'ike_policy_id', 'ipsec_policy_id')),
+    )
+    mode = forms.MultipleChoiceField(
+        label=_('Mode'),
+        choices=IPSecModeChoices,
+        required=False
+    )
+    ike_policy_id = DynamicModelMultipleChoiceField(
+        queryset=IKEPolicy.objects.all(),
+        required=False,
+        label=_('IKE policy')
+    )
+    ipsec_policy_id = DynamicModelMultipleChoiceField(
+        queryset=IPSecPolicy.objects.all(),
+        required=False,
+        label=_('IPSec policy')
+    )
+    tag = TagFilterField(model)

+ 357 - 0
netbox/vpn/forms/model_forms.py

@@ -0,0 +1,357 @@
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from dcim.models import Device, Interface
+from ipam.models import IPAddress
+from netbox.forms import NetBoxModelForm
+from tenancy.forms import TenancyForm
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.utils import add_blank_choice
+from utilities.forms.widgets import HTMXSelect
+from virtualization.models import VirtualMachine, VMInterface
+from vpn.choices import *
+from vpn.models import *
+
+__all__ = (
+    'IKEPolicyForm',
+    'IKEProposalForm',
+    'IPSecPolicyForm',
+    'IPSecProfileForm',
+    'IPSecProposalForm',
+    'TunnelCreateForm',
+    'TunnelForm',
+    'TunnelTerminationForm',
+)
+
+
+class TunnelForm(TenancyForm, NetBoxModelForm):
+    ipsec_profile = DynamicModelChoiceField(
+        queryset=IPSecProfile.objects.all(),
+        label=_('IPSec Profile'),
+        required=False
+    )
+    comments = CommentField()
+
+    fieldsets = (
+        (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')),
+        (_('Security'), ('ipsec_profile',)),
+        (_('Tenancy'), ('tenant_group', 'tenant')),
+    )
+
+    class Meta:
+        model = Tunnel
+        fields = [
+            'name', 'status', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tenant_group', 'tenant',
+            'comments', 'tags',
+        ]
+
+
+class TunnelCreateForm(TunnelForm):
+    # First termination
+    termination1_role = forms.ChoiceField(
+        choices=add_blank_choice(TunnelTerminationRoleChoices),
+        required=False,
+        label=_('Role')
+    )
+    termination1_type = forms.ChoiceField(
+        choices=TunnelTerminationTypeChoices,
+        required=False,
+        widget=HTMXSelect(),
+        label=_('Type')
+    )
+    termination1_parent = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        selector=True,
+        label=_('Device')
+    )
+    termination1_termination = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        label=_('Interface'),
+        query_params={
+            'device_id': '$termination1_parent',
+        }
+    )
+    termination1_outside_ip = DynamicModelChoiceField(
+        queryset=IPAddress.objects.all(),
+        label=_('Outside IP'),
+        required=False,
+        query_params={
+            'device_id': '$termination1_parent',
+        }
+    )
+
+    # Second termination
+    termination2_role = forms.ChoiceField(
+        choices=add_blank_choice(TunnelTerminationRoleChoices),
+        required=False,
+        label=_('Role')
+    )
+    termination2_type = forms.ChoiceField(
+        choices=TunnelTerminationTypeChoices,
+        required=False,
+        widget=HTMXSelect(),
+        label=_('Type')
+    )
+    termination2_parent = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        selector=True,
+        label=_('Device')
+    )
+    termination2_termination = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        label=_('Interface'),
+        query_params={
+            'device_id': '$termination2_parent',
+        }
+    )
+    termination2_outside_ip = DynamicModelChoiceField(
+        queryset=IPAddress.objects.all(),
+        required=False,
+        label=_('Outside IP'),
+        query_params={
+            'device_id': '$termination2_parent',
+        }
+    )
+
+    fieldsets = (
+        (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')),
+        (_('Security'), ('ipsec_profile',)),
+        (_('Tenancy'), ('tenant_group', 'tenant')),
+        (_('First Termination'), (
+            'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_termination',
+            'termination1_outside_ip',
+        )),
+        (_('Second Termination'), (
+            'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_termination',
+            'termination2_outside_ip',
+        )),
+    )
+
+    def __init__(self, *args, initial=None, **kwargs):
+        super().__init__(*args, initial=initial, **kwargs)
+
+        if initial and initial.get('termination1_type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE:
+            self.fields['termination1_parent'].label = _('Virtual Machine')
+            self.fields['termination1_parent'].queryset = VirtualMachine.objects.all()
+            self.fields['termination1_termination'].queryset = VMInterface.objects.all()
+            self.fields['termination1_termination'].widget.add_query_params({
+                'virtual_machine_id': '$termination1_parent',
+            })
+            self.fields['termination1_outside_ip'].widget.add_query_params({
+                'virtual_machine_id': '$termination1_parent',
+            })
+
+        if initial and initial.get('termination2_type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE:
+            self.fields['termination2_parent'].label = _('Virtual Machine')
+            self.fields['termination2_parent'].queryset = VirtualMachine.objects.all()
+            self.fields['termination2_termination'].queryset = VMInterface.objects.all()
+            self.fields['termination2_termination'].widget.add_query_params({
+                'virtual_machine_id': '$termination2_parent',
+            })
+            self.fields['termination2_outside_ip'].widget.add_query_params({
+                'virtual_machine_id': '$termination2_parent',
+            })
+
+    def clean(self):
+        super().clean()
+
+        # Validate attributes for each termination (if any)
+        for term in ('termination1', 'termination2'):
+            required_parameters = (
+                f'{term}_role', f'{term}_parent', f'{term}_termination',
+            )
+            parameters = (
+                *required_parameters,
+                f'{term}_outside_ip',
+            )
+        if any([self.cleaned_data[param] for param in parameters]):
+            for param in required_parameters:
+                if not self.cleaned_data[param]:
+                    raise forms.ValidationError({
+                        param: _("This parameter is required when defining a termination.")
+                    })
+
+    def save(self, *args, **kwargs):
+        instance = super().save(*args, **kwargs)
+
+        # Create first termination
+        if self.cleaned_data['termination1_termination']:
+            TunnelTermination.objects.create(
+                tunnel=instance,
+                role=self.cleaned_data['termination1_role'],
+                termination=self.cleaned_data['termination1_termination'],
+                outside_ip=self.cleaned_data['termination1_outside_ip'],
+            )
+
+        # Create second termination, if defined
+        if self.cleaned_data['termination2_termination']:
+            TunnelTermination.objects.create(
+                tunnel=instance,
+                role=self.cleaned_data['termination2_role'],
+                termination=self.cleaned_data['termination2_termination'],
+                outside_ip=self.cleaned_data.get('termination1_outside_ip'),
+            )
+
+        return instance
+
+
+class TunnelTerminationForm(NetBoxModelForm):
+    tunnel = DynamicModelChoiceField(
+        queryset=Tunnel.objects.all()
+    )
+    type = forms.ChoiceField(
+        choices=TunnelTerminationTypeChoices,
+        widget=HTMXSelect(),
+        label=_('Type')
+    )
+    parent = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        selector=True,
+        label=_('Device')
+    )
+    termination = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        label=_('Interface'),
+        query_params={
+            'device_id': '$parent',
+        }
+    )
+    outside_ip = DynamicModelChoiceField(
+        queryset=IPAddress.objects.all(),
+        label=_('Outside IP'),
+        required=False,
+        query_params={
+            'device_id': '$parent',
+        }
+    )
+
+    fieldsets = (
+        (None, ('tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags')),
+    )
+
+    class Meta:
+        model = TunnelTermination
+        fields = [
+            'tunnel', 'role', 'termination', 'outside_ip', 'tags',
+        ]
+
+    def __init__(self, *args, initial=None, **kwargs):
+        super().__init__(*args, initial=initial, **kwargs)
+
+        if initial and initial.get('type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE:
+            self.fields['parent'].label = _('Virtual Machine')
+            self.fields['parent'].queryset = VirtualMachine.objects.all()
+            self.fields['termination'].queryset = VMInterface.objects.all()
+            self.fields['termination'].widget.add_query_params({
+                'virtual_machine_id': '$parent',
+            })
+            self.fields['outside_ip'].widget.add_query_params({
+                'virtual_machine_id': '$parent',
+            })
+
+        if self.instance.pk:
+            self.fields['parent'].initial = self.instance.termination.parent_object
+            self.fields['termination'].initial = self.instance.termination
+
+    def clean(self):
+        super().clean()
+
+        # Set the terminated object
+        self.instance.termination = self.cleaned_data.get('termination')
+
+
+class IKEProposalForm(NetBoxModelForm):
+
+    fieldsets = (
+        (_('Proposal'), ('name', 'description', 'tags')),
+        (_('Parameters'), (
+            'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime',
+        )),
+    )
+
+    class Meta:
+        model = IKEProposal
+        fields = [
+            'name', 'description', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group',
+            'sa_lifetime', 'tags',
+        ]
+
+
+class IKEPolicyForm(NetBoxModelForm):
+    proposals = DynamicModelMultipleChoiceField(
+        queryset=IKEProposal.objects.all(),
+        label=_('Proposals')
+    )
+
+    fieldsets = (
+        (_('Policy'), ('name', 'description', 'tags')),
+        (_('Parameters'), ('version', 'mode', 'proposals', 'preshared_key')),
+    )
+
+    class Meta:
+        model = IKEPolicy
+        fields = [
+            'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'tags',
+        ]
+
+
+class IPSecProposalForm(NetBoxModelForm):
+
+    fieldsets = (
+        (_('Proposal'), ('name', 'description', 'tags')),
+        (_('Parameters'), (
+            'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data',
+        )),
+    )
+
+    class Meta:
+        model = IPSecProposal
+        fields = [
+            'name', 'description', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds',
+            'sa_lifetime_data', 'tags',
+        ]
+
+
+class IPSecPolicyForm(NetBoxModelForm):
+    proposals = DynamicModelMultipleChoiceField(
+        queryset=IPSecProposal.objects.all(),
+        label=_('Proposals')
+    )
+
+    fieldsets = (
+        (_('Policy'), ('name', 'description', 'tags')),
+        (_('Parameters'), ('proposals', 'pfs_group')),
+    )
+
+    class Meta:
+        model = IPSecPolicy
+        fields = [
+            'name', 'description', 'proposals', 'pfs_group', 'tags',
+        ]
+
+
+class IPSecProfileForm(NetBoxModelForm):
+    ike_policy = DynamicModelChoiceField(
+        queryset=IKEPolicy.objects.all(),
+        label=_('IKE policy')
+    )
+    ipsec_policy = DynamicModelChoiceField(
+        queryset=IPSecPolicy.objects.all(),
+        label=_('IPSec policy')
+    )
+    comments = CommentField()
+
+    fieldsets = (
+        (_('Profile'), ('name', 'description', 'tags')),
+        (_('Parameters'), ('mode', 'ike_policy', 'ipsec_policy')),
+    )
+
+    class Meta:
+        model = IPSecProfile
+        fields = [
+            'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags',
+        ]

+ 0 - 0
netbox/vpn/graphql/__init__.py


+ 51 - 0
netbox/vpn/graphql/schema.py

@@ -0,0 +1,51 @@
+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):
+
+    ike_policy = ObjectField(IKEPolicyType)
+    ike_policy_list = ObjectListField(IKEPolicyType)
+
+    def resolve_ike_policy_list(root, info, **kwargs):
+        return gql_query_optimizer(models.IKEPolicy.objects.all(), info)
+
+    ike_proposal = ObjectField(IKEProposalType)
+    ike_proposal_list = ObjectListField(IKEProposalType)
+
+    def resolve_ike_proposal_list(root, info, **kwargs):
+        return gql_query_optimizer(models.IKEProposal.objects.all(), info)
+
+    ipsec_policy = ObjectField(IPSecPolicyType)
+    ipsec_policy_list = ObjectListField(IPSecPolicyType)
+
+    def resolve_ipsec_policy_list(root, info, **kwargs):
+        return gql_query_optimizer(models.IPSecPolicy.objects.all(), info)
+
+    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)
+
+    ipsec_proposal = ObjectField(IPSecProposalType)
+    ipsec_proposal_list = ObjectListField(IPSecProposalType)
+
+    def resolve_ipsec_proposal_list(root, info, **kwargs):
+        return gql_query_optimizer(models.IPSecProposal.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)

+ 69 - 0
netbox/vpn/graphql/types.py

@@ -0,0 +1,69 @@
+from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
+from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
+from vpn import filtersets, models
+
+__all__ = (
+    'IKEPolicyType',
+    'IKEProposalType',
+    'IPSecPolicyType',
+    'IPSecProfileType',
+    'IPSecProposalType',
+    '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 IKEProposalType(OrganizationalObjectType):
+
+    class Meta:
+        model = models.IKEProposal
+        fields = '__all__'
+        filterset_class = filtersets.IKEProposalFilterSet
+
+
+class IKEPolicyType(OrganizationalObjectType):
+
+    class Meta:
+        model = models.IKEPolicy
+        fields = '__all__'
+        filterset_class = filtersets.IKEPolicyFilterSet
+
+
+class IPSecProposalType(OrganizationalObjectType):
+
+    class Meta:
+        model = models.IPSecProposal
+        fields = '__all__'
+        filterset_class = filtersets.IPSecProposalFilterSet
+
+
+class IPSecPolicyType(OrganizationalObjectType):
+
+    class Meta:
+        model = models.IPSecPolicy
+        fields = '__all__'
+        filterset_class = filtersets.IPSecPolicyFilterSet
+
+
+class IPSecProfileType(OrganizationalObjectType):
+
+    class Meta:
+        model = models.IPSecProfile
+        fields = '__all__'
+        filterset_class = filtersets.IPSecProfileFilterSet

+ 186 - 0
netbox/vpn/migrations/0001_initial.py

@@ -0,0 +1,186 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0099_cachedvalue_ordering'),
+        ('ipam', '0067_ipaddress_index_host'),
+        ('tenancy', '0012_contactassignment_custom_fields'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='IKEPolicy',
+            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)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('version', models.PositiveSmallIntegerField(default=2)),
+                ('mode', models.CharField()),
+                ('preshared_key', models.TextField(blank=True)),
+            ],
+            options={
+                'verbose_name': 'IKE policy',
+                'verbose_name_plural': 'IKE policies',
+                'ordering': ('name',),
+            },
+        ),
+        migrations.CreateModel(
+            name='IPSecPolicy',
+            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)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('pfs_group', models.PositiveSmallIntegerField(blank=True, null=True)),
+            ],
+            options={
+                'verbose_name': 'IPSec policy',
+                'verbose_name_plural': 'IPSec policies',
+                'ordering': ('name',),
+            },
+        ),
+        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)),
+                ('mode', models.CharField()),
+                ('ike_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ikepolicy')),
+                ('ipsec_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ipsecpolicy')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'IPSec profile',
+                'verbose_name_plural': 'IPSec profiles',
+                '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)),
+                ('tunnel_id', models.PositiveBigIntegerField(blank=True, null=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)),
+                ('termination_id', models.PositiveBigIntegerField(blank=True, null=True)),
+                ('termination_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
+                ('outside_ip', models.OneToOneField(blank=True, null=True, 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', 'role', 'pk'),
+            },
+        ),
+        migrations.CreateModel(
+            name='IPSecProposal',
+            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)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('encryption_algorithm', models.CharField()),
+                ('authentication_algorithm', models.CharField()),
+                ('sa_lifetime_seconds', models.PositiveIntegerField(blank=True, null=True)),
+                ('sa_lifetime_data', models.PositiveIntegerField(blank=True, null=True)),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'IPSec proposal',
+                'verbose_name_plural': 'IPSec proposals',
+                'ordering': ('name',),
+            },
+        ),
+        migrations.AddField(
+            model_name='ipsecpolicy',
+            name='proposals',
+            field=models.ManyToManyField(related_name='ipsec_policies', to='vpn.ipsecproposal'),
+        ),
+        migrations.AddField(
+            model_name='ipsecpolicy',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.CreateModel(
+            name='IKEProposal',
+            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)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('authentication_method', models.CharField()),
+                ('encryption_algorithm', models.CharField()),
+                ('authentication_algorithm', models.CharField()),
+                ('group', models.PositiveSmallIntegerField()),
+                ('sa_lifetime', models.PositiveIntegerField(blank=True, null=True)),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'IKE proposal',
+                'verbose_name_plural': 'IKE proposals',
+                'ordering': ('name',),
+            },
+        ),
+        migrations.AddField(
+            model_name='ikepolicy',
+            name='proposals',
+            field=models.ManyToManyField(related_name='ike_policies', to='vpn.ikeproposal'),
+        ),
+        migrations.AddField(
+            model_name='ikepolicy',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddConstraint(
+            model_name='tunneltermination',
+            constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='vpn_tunneltermination_termination', violation_error_message='An object may be terminated to only one tunnel at a time.'),
+        ),
+    ]

+ 0 - 0
netbox/vpn/migrations/__init__.py


+ 2 - 0
netbox/vpn/models/__init__.py

@@ -0,0 +1,2 @@
+from .crypto import *
+from .tunnels import *

+ 254 - 0
netbox/vpn/models/crypto.py

@@ -0,0 +1,254 @@
+from django.db import models
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from netbox.models import NetBoxModel, PrimaryModel
+from vpn.choices import *
+
+__all__ = (
+    'IKEPolicy',
+    'IKEProposal',
+    'IPSecPolicy',
+    'IPSecProfile',
+    'IPSecProposal',
+)
+
+
+#
+# IKE
+#
+
+class IKEProposal(NetBoxModel):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True
+    )
+    description = models.CharField(
+        verbose_name=_('description'),
+        max_length=200,
+        blank=True
+    )
+    authentication_method = models.CharField(
+        verbose_name=('authentication method'),
+        choices=AuthenticationMethodChoices
+    )
+    encryption_algorithm = models.CharField(
+        verbose_name=_('encryption algorithm'),
+        choices=EncryptionAlgorithmChoices
+    )
+    authentication_algorithm = models.CharField(
+        verbose_name=_('authentication algorithm'),
+        choices=AuthenticationAlgorithmChoices
+    )
+    group = models.PositiveSmallIntegerField(
+        verbose_name=_('group'),
+        choices=DHGroupChoices,
+        help_text=_('Diffie-Hellman group ID')
+    )
+    sa_lifetime = models.PositiveIntegerField(
+        verbose_name=_('SA lifetime'),
+        blank=True,
+        null=True,
+        help_text=_('Security association lifetime (in seconds)')
+    )
+
+    clone_fields = (
+        'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime',
+    )
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('IKE proposal')
+        verbose_name_plural = _('IKE proposals')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('vpn:ikeproposal', args=[self.pk])
+
+
+class IKEPolicy(NetBoxModel):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True
+    )
+    description = models.CharField(
+        verbose_name=_('description'),
+        max_length=200,
+        blank=True
+    )
+    version = models.PositiveSmallIntegerField(
+        verbose_name=_('version'),
+        choices=IKEVersionChoices,
+        default=IKEVersionChoices.VERSION_2
+    )
+    mode = models.CharField(
+        verbose_name=_('mode'),
+        choices=IKEModeChoices
+    )
+    proposals = models.ManyToManyField(
+        to='vpn.IKEProposal',
+        related_name='ike_policies',
+        verbose_name=_('proposals')
+    )
+    preshared_key = models.TextField(
+        verbose_name=_('pre-shared key'),
+        blank=True
+    )
+
+    clone_fields = (
+        'version', 'mode', 'proposals',
+    )
+    prerequisite_models = (
+        'vpn.IKEProposal',
+    )
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('IKE policy')
+        verbose_name_plural = _('IKE policies')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('vpn:ikepolicy', args=[self.pk])
+
+
+#
+# IPSec
+#
+
+class IPSecProposal(NetBoxModel):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True
+    )
+    description = models.CharField(
+        verbose_name=_('description'),
+        max_length=200,
+        blank=True
+    )
+    encryption_algorithm = models.CharField(
+        verbose_name=_('encryption'),
+        choices=EncryptionAlgorithmChoices
+    )
+    authentication_algorithm = models.CharField(
+        verbose_name=_('authentication'),
+        choices=AuthenticationAlgorithmChoices
+    )
+    sa_lifetime_seconds = models.PositiveIntegerField(
+        verbose_name=_('SA lifetime (seconds)'),
+        blank=True,
+        null=True,
+        help_text=_('Security association lifetime (seconds)')
+    )
+    sa_lifetime_data = models.PositiveIntegerField(
+        verbose_name=_('SA lifetime (KB)'),
+        blank=True,
+        null=True,
+        help_text=_('Security association lifetime (in kilobytes)')
+    )
+
+    clone_fields = (
+        'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data',
+    )
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('IPSec proposal')
+        verbose_name_plural = _('IPSec proposals')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('vpn:ipsecproposal', args=[self.pk])
+
+
+class IPSecPolicy(NetBoxModel):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True
+    )
+    description = models.CharField(
+        verbose_name=_('description'),
+        max_length=200,
+        blank=True
+    )
+    proposals = models.ManyToManyField(
+        to='vpn.IPSecProposal',
+        related_name='ipsec_policies',
+        verbose_name=_('proposals')
+    )
+    pfs_group = models.PositiveSmallIntegerField(
+        verbose_name=_('PFS group'),
+        choices=DHGroupChoices,
+        blank=True,
+        null=True,
+        help_text=_('Diffie-Hellman group for Perfect Forward Secrecy')
+    )
+
+    clone_fields = (
+        'proposals', 'pfs_group',
+    )
+    prerequisite_models = (
+        'vpn.IPSecProposal',
+    )
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('IPSec policy')
+        verbose_name_plural = _('IPSec policies')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('vpn:ipsecpolicy', args=[self.pk])
+
+
+class IPSecProfile(PrimaryModel):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True
+    )
+    mode = models.CharField(
+        verbose_name=_('mode'),
+        choices=IPSecModeChoices
+    )
+    ike_policy = models.ForeignKey(
+        to='vpn.IKEPolicy',
+        on_delete=models.PROTECT,
+        related_name='ipsec_profiles'
+    )
+    ipsec_policy = models.ForeignKey(
+        to='vpn.IPSecPolicy',
+        on_delete=models.PROTECT,
+        related_name='ipsec_profiles'
+    )
+
+    clone_fields = (
+        'mode', 'ike_policy', 'ipsec_policy',
+    )
+    prerequisite_models = (
+        'vpn.IKEPolicy',
+        'vpn.IPSecPolicy',
+    )
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('IPSec profile')
+        verbose_name_plural = _('IPSec profiles')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('vpn:ipsecprofile', args=[self.pk])

+ 146 - 0
netbox/vpn/models/tunnels.py

@@ -0,0 +1,146 @@
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from netbox.models import ChangeLoggedModel, PrimaryModel
+from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, TagsMixin
+from vpn.choices import *
+
+__all__ = (
+    'Tunnel',
+    'TunnelTermination',
+)
+
+
+class Tunnel(PrimaryModel):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True
+    )
+    status = models.CharField(
+        verbose_name=_('status'),
+        max_length=50,
+        choices=TunnelStatusChoices,
+        default=TunnelStatusChoices.STATUS_ACTIVE
+    )
+    encapsulation = models.CharField(
+        verbose_name=_('encapsulation'),
+        max_length=50,
+        choices=TunnelEncapsulationChoices
+    )
+    ipsec_profile = models.ForeignKey(
+        to='vpn.IPSecProfile',
+        on_delete=models.PROTECT,
+        related_name='tunnels',
+        blank=True,
+        null=True
+    )
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='tunnels',
+        blank=True,
+        null=True
+    )
+    tunnel_id = models.PositiveBigIntegerField(
+        verbose_name=_('tunnel ID'),
+        blank=True,
+        null=True
+    )
+
+    clone_fields = (
+        'status', 'encapsulation', 'ipsec_profile', 'tenant',
+    )
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('tunnel')
+        verbose_name_plural = _('tunnels')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('vpn:tunnel', args=[self.pk])
+
+    def get_status_color(self):
+        return TunnelStatusChoices.colors.get(self.status)
+
+
+class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLoggedModel):
+    tunnel = models.ForeignKey(
+        to='vpn.Tunnel',
+        on_delete=models.CASCADE,
+        related_name='terminations'
+    )
+    role = models.CharField(
+        verbose_name=_('role'),
+        max_length=50,
+        choices=TunnelTerminationRoleChoices,
+        default=TunnelTerminationRoleChoices.ROLE_PEER
+    )
+    termination_type = models.ForeignKey(
+        to='contenttypes.ContentType',
+        on_delete=models.PROTECT,
+        related_name='+'
+    )
+    termination_id = models.PositiveBigIntegerField(
+        blank=True,
+        null=True
+    )
+    termination = GenericForeignKey(
+        ct_field='termination_type',
+        fk_field='termination_id'
+    )
+    outside_ip = models.OneToOneField(
+        to='ipam.IPAddress',
+        on_delete=models.PROTECT,
+        related_name='tunnel_termination',
+        blank=True,
+        null=True
+    )
+
+    prerequisite_models = (
+        'vpn.Tunnel',
+    )
+
+    class Meta:
+        ordering = ('tunnel', 'role', 'pk')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('termination_type', 'termination_id'),
+                name='%(app_label)s_%(class)s_termination',
+                violation_error_message=_("An object may be terminated to only one tunnel at a time.")
+            ),
+        )
+        verbose_name = _('tunnel termination')
+        verbose_name_plural = _('tunnel terminations')
+
+    def __str__(self):
+        return f'{self.tunnel}: Termination {self.pk}'
+
+    def get_absolute_url(self):
+        return reverse('vpn:tunneltermination', args=[self.pk])
+
+    def get_role_color(self):
+        return TunnelTerminationRoleChoices.colors.get(self.role)
+
+    def clean(self):
+        super().clean()
+
+        # Check that the selected termination object is not already attached to a Tunnel
+        if getattr(self.termination, 'tunnel_termination', None) and self.termination.tunnel_termination.pk != self.pk:
+            raise ValidationError({
+                'termination': _("{name} is already attached to a tunnel ({tunnel}).").format(
+                    name=self.termination.name,
+                    tunnel=self.termination.tunnel_termination.tunnel
+                )
+            })
+
+    def to_objectchange(self, action):
+        objectchange = super().to_objectchange(action)
+        objectchange.related_object = self.tunnel
+        return objectchange

+ 65 - 0
netbox/vpn/search.py

@@ -0,0 +1,65 @@
+from netbox.search import SearchIndex, register_search
+from . import models
+
+
+@register_search
+class TunnelIndex(SearchIndex):
+    model = models.Tunnel
+    fields = (
+        ('name', 100),
+        ('tunnel_id', 300),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('status', 'encapsulation', 'tenant', 'description')
+
+
+@register_search
+class IKEProposalIndex(SearchIndex):
+    model = models.IKEProposal
+    fields = (
+        ('name', 100),
+        ('description', 500),
+    )
+    display_attrs = ('description',)
+
+
+@register_search
+class IKEPolicyIndex(SearchIndex):
+    model = models.IKEPolicy
+    fields = (
+        ('name', 100),
+        ('description', 500),
+    )
+    display_attrs = ('description',)
+
+
+@register_search
+class IPSecProposalIndex(SearchIndex):
+    model = models.IPSecProposal
+    fields = (
+        ('name', 100),
+        ('description', 500),
+    )
+    display_attrs = ('description',)
+
+
+@register_search
+class IPSecPolicyIndex(SearchIndex):
+    model = models.IPSecPolicy
+    fields = (
+        ('name', 100),
+        ('description', 500),
+    )
+    display_attrs = ('description',)
+
+
+@register_search
+class IPSecProfileIndex(SearchIndex):
+    model = models.IPSecProfile
+    fields = (
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('description',)

+ 254 - 0
netbox/vpn/tables.py

@@ -0,0 +1,254 @@
+import django_tables2 as tables
+from django.utils.translation import gettext_lazy as _
+from django_tables2.utils import Accessor
+
+from tenancy.tables import TenancyColumnsMixin
+from netbox.tables import NetBoxTable, columns
+from vpn.models import *
+
+__all__ = (
+    'IKEPolicyTable',
+    'IKEProposalTable',
+    'IPSecPolicyTable',
+    'IPSecProposalTable',
+    'IPSecProfileTable',
+    'TunnelTable',
+    'TunnelTerminationTable',
+)
+
+
+class TunnelTable(TenancyColumnsMixin, NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    status = columns.ChoiceFieldColumn(
+        verbose_name=_('Status')
+    )
+    ipsec_profile = tables.Column(
+        verbose_name=_('IPSec profile'),
+        linkify=True
+    )
+    terminations_count = columns.LinkedCountColumn(
+        accessor=Accessor('count_terminations'),
+        viewname='vpn:tunneltermination_list',
+        url_params={'tunnel_id': 'pk'},
+        verbose_name=_('Terminations')
+    )
+    comments = columns.MarkdownColumn(
+        verbose_name=_('Comments'),
+    )
+    tags = columns.TagColumn(
+        url_name='vpn:tunnel_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = Tunnel
+        fields = (
+            'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'tunnel_id',
+            'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated',
+        )
+        default_columns = ('pk', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'terminations_count')
+
+
+class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable):
+    tunnel = tables.Column(
+        verbose_name=_('Tunnel'),
+        linkify=True
+    )
+    role = columns.ChoiceFieldColumn(
+        verbose_name=_('Role')
+    )
+    termination_parent = tables.Column(
+        accessor='termination__parent_object',
+        linkify=True,
+        orderable=False,
+        verbose_name=_('Host')
+    )
+    termination = tables.Column(
+        verbose_name=_('Termination'),
+        linkify=True
+    )
+    ip_addresses = tables.ManyToManyColumn(
+        accessor=tables.A('termination__ip_addresses'),
+        orderable=False,
+        linkify_item=True,
+        verbose_name=_('IP Addresses')
+    )
+    outside_ip = tables.Column(
+        verbose_name=_('Outside IP'),
+        linkify=True
+    )
+    tags = columns.TagColumn(
+        url_name='vpn:tunneltermination_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = TunnelTermination
+        fields = (
+            'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip', 'tags',
+            'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip',
+        )
+
+
+class IKEProposalTable(NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    authentication_method = tables.Column(
+        verbose_name=_('Authentication Method')
+    )
+    encryption_algorithm = tables.Column(
+        verbose_name=_('Encryption Algorithm')
+    )
+    authentication_algorithm = tables.Column(
+        verbose_name=_('Authentication Algorithm')
+    )
+    group = tables.Column(
+        verbose_name=_('Group')
+    )
+    sa_lifetime = tables.Column(
+        verbose_name=_('SA Lifetime')
+    )
+    tags = columns.TagColumn(
+        url_name='vpn:ikeproposal_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = IKEProposal
+        fields = (
+            'pk', 'id', 'name', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm',
+            'group', 'sa_lifetime', 'description', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group',
+            'sa_lifetime', 'description',
+        )
+
+
+class IKEPolicyTable(NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    version = tables.Column(
+        verbose_name=_('Version')
+    )
+    mode = tables.Column(
+        verbose_name=_('Mode')
+    )
+    proposals = tables.ManyToManyColumn(
+        linkify_item=True,
+        verbose_name=_('Proposals')
+    )
+    preshared_key = tables.Column(
+        verbose_name=_('Pre-shared Key')
+    )
+    tags = columns.TagColumn(
+        url_name='vpn:ikepolicy_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = IKEPolicy
+        fields = (
+            'pk', 'id', 'name', 'version', 'mode', 'proposals', 'preshared_key', 'description', 'tags', 'created',
+            'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'version', 'mode', 'proposals', 'description',
+        )
+
+
+class IPSecProposalTable(NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    encryption_algorithm = tables.Column(
+        verbose_name=_('Encryption Algorithm')
+    )
+    authentication_algorithm = tables.Column(
+        verbose_name=_('Authentication Algorithm')
+    )
+    sa_lifetime_seconds = tables.Column(
+        verbose_name=_('SA Lifetime (Seconds)')
+    )
+    sa_lifetime_data = tables.Column(
+        verbose_name=_('SA Lifetime (KB)')
+    )
+    tags = columns.TagColumn(
+        url_name='vpn:ipsecproposal_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = IPSecProposal
+        fields = (
+            'pk', 'id', 'name', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds',
+            'sa_lifetime_data', 'description', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds',
+            'sa_lifetime_data', 'description',
+        )
+
+
+class IPSecPolicyTable(NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    proposals = tables.ManyToManyColumn(
+        linkify_item=True,
+        verbose_name=_('Proposals')
+    )
+    pfs_group = tables.Column(
+        verbose_name=_('PFS Group')
+    )
+    tags = columns.TagColumn(
+        url_name='vpn:ipsecpolicy_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = IPSecPolicy
+        fields = (
+            'pk', 'id', 'name', 'proposals', 'pfs_group', 'description', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'proposals', 'pfs_group', 'description',
+        )
+
+
+class IPSecProfileTable(NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    mode = tables.Column(
+        verbose_name=_('Mode')
+    )
+    ike_policy = tables.Column(
+        linkify=True,
+        verbose_name=_('IKE Policy')
+    )
+    ipsec_policy = tables.Column(
+        linkify=True,
+        verbose_name=_('IPSec Policy')
+    )
+    comments = columns.MarkdownColumn(
+        verbose_name=_('Comments'),
+    )
+    tags = columns.TagColumn(
+        url_name='vpn:ipsecprofile_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = IPSecProfile
+        fields = (
+            'pk', 'id', 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', 'created',
+            'last_updated',
+        )
+        default_columns = ('pk', 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description')

+ 0 - 0
netbox/vpn/tests/__init__.py


+ 473 - 0
netbox/vpn/tests/test_api.py

@@ -0,0 +1,473 @@
+from django.urls import reverse
+
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Interface
+from utilities.testing import APITestCase, APIViewTestCases, create_test_device
+from vpn.choices import *
+from vpn.models import *
+
+
+class AppTest(APITestCase):
+
+    def test_root(self):
+        url = reverse('vpn-api:api-root')
+        response = self.client.get('{}?format=api'.format(url), **self.header)
+
+        self.assertEqual(response.status_code, 200)
+
+
+class TunnelTest(APIViewTestCases.APIViewTestCase):
+    model = Tunnel
+    brief_fields = ['display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'status': TunnelStatusChoices.STATUS_PLANNED,
+        'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        tunnels = (
+            Tunnel(
+                name='Tunnel 1',
+                status=TunnelStatusChoices.STATUS_ACTIVE,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+            ),
+            Tunnel(
+                name='Tunnel 2',
+                status=TunnelStatusChoices.STATUS_ACTIVE,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+            ),
+            Tunnel(
+                name='Tunnel 3',
+                status=TunnelStatusChoices.STATUS_ACTIVE,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+            ),
+        )
+        Tunnel.objects.bulk_create(tunnels)
+
+        cls.create_data = [
+            {
+                'name': 'Tunnel 4',
+                'status': TunnelStatusChoices.STATUS_DISABLED,
+                'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+            },
+            {
+                'name': 'Tunnel 5',
+                'status': TunnelStatusChoices.STATUS_DISABLED,
+                'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+            },
+            {
+                'name': 'Tunnel 6',
+                'status': TunnelStatusChoices.STATUS_DISABLED,
+                'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+            },
+        ]
+
+
+class TunnelTerminationTest(APIViewTestCases.APIViewTestCase):
+    model = TunnelTermination
+    brief_fields = ['display', 'id', 'url']
+    bulk_update_data = {
+        'role': TunnelTerminationRoleChoices.ROLE_PEER,
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+        interfaces = (
+            Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 4', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 5', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 6', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+        )
+        Interface.objects.bulk_create(interfaces)
+
+        tunnel = Tunnel.objects.create(
+            name='Tunnel 1',
+            status=TunnelStatusChoices.STATUS_ACTIVE,
+            encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+        )
+
+        tunnel_terminations = (
+            TunnelTermination(
+                tunnel=tunnel,
+                role=TunnelTerminationRoleChoices.ROLE_HUB,
+                termination=interfaces[0]
+            ),
+            TunnelTermination(
+                tunnel=tunnel,
+                role=TunnelTerminationRoleChoices.ROLE_HUB,
+                termination=interfaces[1]
+            ),
+            TunnelTermination(
+                tunnel=tunnel,
+                role=TunnelTerminationRoleChoices.ROLE_HUB,
+                termination=interfaces[2]
+            ),
+        )
+        TunnelTermination.objects.bulk_create(tunnel_terminations)
+
+        cls.create_data = [
+            {
+                'tunnel': tunnel.pk,
+                'role': TunnelTerminationRoleChoices.ROLE_PEER,
+                'termination_type': 'dcim.interface',
+                'termination_id': interfaces[3].pk,
+            },
+            {
+                'tunnel': tunnel.pk,
+                'role': TunnelTerminationRoleChoices.ROLE_PEER,
+                'termination_type': 'dcim.interface',
+                'termination_id': interfaces[4].pk,
+            },
+            {
+                'tunnel': tunnel.pk,
+                'role': TunnelTerminationRoleChoices.ROLE_PEER,
+                'termination_type': 'dcim.interface',
+                'termination_id': interfaces[5].pk,
+            },
+        ]
+
+
+class IKEProposalTest(APIViewTestCases.APIViewTestCase):
+    model = IKEProposal
+    brief_fields = ['display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+        'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+        'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_MD5,
+        'group': DHGroupChoices.GROUP_19,
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        ike_proposals = (
+            IKEProposal(
+                name='IKE Proposal 1',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+            IKEProposal(
+                name='IKE Proposal 2',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+            IKEProposal(
+                name='IKE Proposal 3',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+        )
+        IKEProposal.objects.bulk_create(ike_proposals)
+
+        cls.create_data = [
+            {
+                'name': 'IKE Proposal 4',
+                'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+                'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+                'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+                'group': DHGroupChoices.GROUP_19,
+            },
+            {
+                'name': 'IKE Proposal 5',
+                'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+                'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+                'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+                'group': DHGroupChoices.GROUP_19,
+            },
+            {
+                'name': 'IKE Proposal 6',
+                'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+                'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+                'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+                'group': DHGroupChoices.GROUP_19,
+            },
+        ]
+
+
+class IKEPolicyTest(APIViewTestCases.APIViewTestCase):
+    model = IKEPolicy
+    brief_fields = ['display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'version': IKEVersionChoices.VERSION_1,
+        'mode': IKEModeChoices.AGGRESSIVE,
+        'description': 'New description',
+        'preshared_key': 'New key',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        ike_proposals = (
+            IKEProposal(
+                name='IKE Proposal 1',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+            IKEProposal(
+                name='IKE Proposal 2',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+        )
+        IKEProposal.objects.bulk_create(ike_proposals)
+
+        ike_policies = (
+            IKEPolicy(
+                name='IKE Policy 1',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+            IKEPolicy(
+                name='IKE Policy 2',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+            IKEPolicy(
+                name='IKE Policy 3',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+        )
+        IKEPolicy.objects.bulk_create(ike_policies)
+        for ike_policy in ike_policies:
+            ike_policy.proposals.set(ike_proposals)
+
+        cls.create_data = [
+            {
+                'name': 'IKE Policy 4',
+                'version': IKEVersionChoices.VERSION_1,
+                'mode': IKEModeChoices.MAIN,
+                'proposals': [ike_proposals[0].pk, ike_proposals[1].pk],
+            },
+            {
+                'name': 'IKE Policy 5',
+                'version': IKEVersionChoices.VERSION_1,
+                'mode': IKEModeChoices.MAIN,
+                'proposals': [ike_proposals[0].pk, ike_proposals[1].pk],
+            },
+            {
+                'name': 'IKE Policy 6',
+                'version': IKEVersionChoices.VERSION_1,
+                'mode': IKEModeChoices.MAIN,
+                'proposals': [ike_proposals[0].pk, ike_proposals[1].pk],
+            },
+        ]
+
+
+class IPSecProposalTest(APIViewTestCases.APIViewTestCase):
+    model = IPSecProposal
+    brief_fields = ['display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+        'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_MD5,
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        ipsec_proposals = (
+            IPSecProposal(
+                name='IPSec Proposal 1',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+            ),
+            IPSecProposal(
+                name='IPSec Proposal 2',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+            ),
+            IPSecProposal(
+                name='IPSec Proposal 3',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+            ),
+        )
+        IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+        cls.create_data = [
+            {
+                'name': 'IPSec Proposal 4',
+                'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+                'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+            },
+            {
+                'name': 'IPSec Proposal 5',
+                'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+                'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+            },
+            {
+                'name': 'IPSec Proposal 6',
+                'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+                'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+            },
+        ]
+
+
+class IPSecPolicyTest(APIViewTestCases.APIViewTestCase):
+    model = IPSecPolicy
+    brief_fields = ['display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'pfs_group': DHGroupChoices.GROUP_5,
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        ipsec_proposals = (
+            IPSecProposal(
+                name='IPSec Policy 1',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+            ),
+            IPSecProposal(
+                name='IPSec Proposal 2',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+            ),
+        )
+        IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+        ipsec_policies = (
+            IPSecPolicy(
+                name='IPSec Policy 1',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+            IPSecPolicy(
+                name='IPSec Policy 2',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+            IPSecPolicy(
+                name='IPSec Policy 3',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+        )
+        IPSecPolicy.objects.bulk_create(ipsec_policies)
+        for ipsec_policy in ipsec_policies:
+            ipsec_policy.proposals.set(ipsec_proposals)
+
+        cls.create_data = [
+            {
+                'name': 'IPSec Policy 4',
+                'pfs_group': DHGroupChoices.GROUP_16,
+                'proposals': [ipsec_proposals[0].pk, ipsec_proposals[1].pk],
+            },
+            {
+                'name': 'IPSec Policy 5',
+                'pfs_group': DHGroupChoices.GROUP_16,
+                'proposals': [ipsec_proposals[0].pk, ipsec_proposals[1].pk],
+            },
+            {
+                'name': 'IPSec Policy 6',
+                'pfs_group': DHGroupChoices.GROUP_16,
+                'proposals': [ipsec_proposals[0].pk, ipsec_proposals[1].pk],
+            },
+        ]
+
+
+class IPSecProfileTest(APIViewTestCases.APIViewTestCase):
+    model = IPSecProfile
+    brief_fields = ['display', 'id', 'name', 'url']
+
+    @classmethod
+    def setUpTestData(cls):
+
+        ike_proposal = IKEProposal.objects.create(
+            name='IKE Proposal 1',
+            authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+            encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+            authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+            group=DHGroupChoices.GROUP_14
+        )
+
+        ipsec_proposal = IPSecProposal.objects.create(
+            name='IPSec Proposal 1',
+            encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+            authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+        )
+
+        ike_policies = (
+            IKEPolicy(
+                name='IKE Policy 1',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+            IKEPolicy(
+                name='IKE Policy 2',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+        )
+        IKEPolicy.objects.bulk_create(ike_policies)
+        for ike_policy in ike_policies:
+            ike_policy.proposals.add(ike_proposal)
+
+        ipsec_policies = (
+            IPSecPolicy(
+                name='IPSec Policy 1',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+            IPSecPolicy(
+                name='IPSec Policy 2',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+        )
+        IPSecPolicy.objects.bulk_create(ipsec_policies)
+        for ipsec_policy in ipsec_policies:
+            ipsec_policy.proposals.add(ipsec_proposal)
+
+        ipsec_profiles = (
+            IPSecProfile(
+                name='IPSec Profile 1',
+                mode=IPSecModeChoices.ESP,
+                ike_policy=ike_policies[0],
+                ipsec_policy=ipsec_policies[0]
+            ),
+            IPSecProfile(
+                name='IPSec Profile 2',
+                mode=IPSecModeChoices.ESP,
+                ike_policy=ike_policies[0],
+                ipsec_policy=ipsec_policies[0]
+            ),
+            IPSecProfile(
+                name='IPSec Profile 3',
+                mode=IPSecModeChoices.ESP,
+                ike_policy=ike_policies[0],
+                ipsec_policy=ipsec_policies[0]
+            ),
+        )
+        IPSecProfile.objects.bulk_create(ipsec_profiles)
+
+        cls.create_data = [
+            {
+                'name': 'IPSec Profile 4',
+                'mode': IPSecModeChoices.AH,
+                'ike_policy': ike_policies[1].pk,
+                'ipsec_policy': ipsec_policies[1].pk,
+            },
+        ]
+
+        cls.bulk_update_data = {
+            'mode': IPSecModeChoices.AH,
+            'ike_policy': ike_policies[1].pk,
+            'ipsec_policy': ipsec_policies[1].pk,
+            'description': 'New description',
+        }

+ 592 - 0
netbox/vpn/tests/test_filtersets.py

@@ -0,0 +1,592 @@
+from django.test import TestCase
+
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Interface
+from ipam.models import IPAddress
+from virtualization.models import VMInterface
+from vpn.choices import *
+from vpn.filtersets import *
+from vpn.models import *
+from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
+
+
+class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = Tunnel.objects.all()
+    filterset = TunnelFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        ike_proposal = IKEProposal.objects.create(
+            name='IKE Proposal 1',
+            authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+            encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+            authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+            group=DHGroupChoices.GROUP_14
+        )
+        ike_policy = IKEPolicy.objects.create(
+            name='IKE Policy 1',
+            version=IKEVersionChoices.VERSION_1,
+            mode=IKEModeChoices.MAIN,
+        )
+        ike_policy.proposals.add(ike_proposal)
+        ipsec_proposal = IPSecProposal.objects.create(
+            name='IPSec Proposal 1',
+            encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+            authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+        )
+        ipsec_policy = IPSecPolicy.objects.create(
+            name='IPSec Policy 1',
+            pfs_group=DHGroupChoices.GROUP_14
+        )
+        ipsec_policy.proposals.add(ipsec_proposal)
+        ipsec_profiles = (
+            IPSecProfile(
+                name='IPSec Profile 1',
+                mode=IPSecModeChoices.ESP,
+                ike_policy=ike_policy,
+                ipsec_policy=ipsec_policy
+            ),
+            IPSecProfile(
+                name='IPSec Profile 2',
+                mode=IPSecModeChoices.ESP,
+                ike_policy=ike_policy,
+                ipsec_policy=ipsec_policy
+            ),
+        )
+        IPSecProfile.objects.bulk_create(ipsec_profiles)
+
+        tunnels = (
+            Tunnel(
+                name='Tunnel 1',
+                status=TunnelStatusChoices.STATUS_ACTIVE,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_GRE,
+                ipsec_profile=ipsec_profiles[0],
+                tunnel_id=100
+            ),
+            Tunnel(
+                name='Tunnel 2',
+                status=TunnelStatusChoices.STATUS_PLANNED,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP,
+                ipsec_profile=ipsec_profiles[0],
+                tunnel_id=200
+            ),
+            Tunnel(
+                name='Tunnel 3',
+                status=TunnelStatusChoices.STATUS_DISABLED,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_IPSEC_TUNNEL,
+                ipsec_profile=None,
+                tunnel_id=300
+            ),
+        )
+        Tunnel.objects.bulk_create(tunnels)
+
+    def test_name(self):
+        params = {'name': ['Tunnel 1', 'Tunnel 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_status(self):
+        params = {'status': [TunnelStatusChoices.STATUS_ACTIVE, TunnelStatusChoices.STATUS_PLANNED]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_encapsulation(self):
+        params = {'encapsulation': [TunnelEncapsulationChoices.ENCAP_GRE, TunnelEncapsulationChoices.ENCAP_IP_IP]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_ipsec_profile(self):
+        ipsec_profiles = IPSecProfile.objects.all()[:2]
+        params = {'ipsec_profile_id': [ipsec_profiles[0].pk, ipsec_profiles[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'ipsec_profile': [ipsec_profiles[0].name, ipsec_profiles[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_tunnel_id(self):
+        params = {'tunnel_id': [100, 200]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class TunnelTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = TunnelTermination.objects.all()
+    filterset = TunnelTerminationFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+        interfaces = (
+            Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+        )
+        Interface.objects.bulk_create(interfaces)
+
+        virtual_machine = create_test_virtualmachine('Virtual Machine 1')
+        vm_interfaces = (
+            VMInterface(virtual_machine=virtual_machine, name='Interface 1'),
+            VMInterface(virtual_machine=virtual_machine, name='Interface 2'),
+            VMInterface(virtual_machine=virtual_machine, name='Interface 3'),
+        )
+        VMInterface.objects.bulk_create(vm_interfaces)
+
+        ip_addresses = (
+            IPAddress(address='192.168.0.1/32'),
+            IPAddress(address='192.168.0.2/32'),
+            IPAddress(address='192.168.0.3/32'),
+            IPAddress(address='192.168.0.4/32'),
+            IPAddress(address='192.168.0.5/32'),
+            IPAddress(address='192.168.0.6/32'),
+        )
+        IPAddress.objects.bulk_create(ip_addresses)
+
+        tunnels = (
+            Tunnel(
+                name='Tunnel 1',
+                status=TunnelStatusChoices.STATUS_ACTIVE,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+            ),
+            Tunnel(
+                name='Tunnel 2',
+                status=TunnelStatusChoices.STATUS_ACTIVE,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+            ),
+            Tunnel(
+                name='Tunnel 3',
+                status=TunnelStatusChoices.STATUS_ACTIVE,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+            ),
+        )
+        Tunnel.objects.bulk_create(tunnels)
+
+        tunnel_terminations = (
+            # Tunnel 1
+            TunnelTermination(
+                tunnel=tunnels[0],
+                role=TunnelTerminationRoleChoices.ROLE_HUB,
+                termination=interfaces[0],
+                outside_ip=ip_addresses[0]
+            ),
+            TunnelTermination(
+                tunnel=tunnels[0],
+                role=TunnelTerminationRoleChoices.ROLE_SPOKE,
+                termination=vm_interfaces[0],
+                outside_ip=ip_addresses[1]
+            ),
+            # Tunnel 2
+            TunnelTermination(
+                tunnel=tunnels[1],
+                role=TunnelTerminationRoleChoices.ROLE_HUB,
+                termination=interfaces[1],
+                outside_ip=ip_addresses[2]
+            ),
+            TunnelTermination(
+                tunnel=tunnels[1],
+                role=TunnelTerminationRoleChoices.ROLE_SPOKE,
+                termination=vm_interfaces[1],
+                outside_ip=ip_addresses[3]
+            ),
+            # Tunnel 3
+            TunnelTermination(
+                tunnel=tunnels[2],
+                role=TunnelTerminationRoleChoices.ROLE_PEER,
+                termination=interfaces[2],
+                outside_ip=ip_addresses[4]
+            ),
+            TunnelTermination(
+                tunnel=tunnels[2],
+                role=TunnelTerminationRoleChoices.ROLE_PEER,
+                termination=vm_interfaces[2],
+                outside_ip=ip_addresses[5]
+            ),
+        )
+        TunnelTermination.objects.bulk_create(tunnel_terminations)
+
+    def test_tunnel(self):
+        tunnels = Tunnel.objects.all()[:2]
+        params = {'tunnel_id': [tunnels[0].pk, tunnels[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'tunnel': [tunnels[0].name, tunnels[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_role(self):
+        params = {'role': [TunnelTerminationRoleChoices.ROLE_HUB, TunnelTerminationRoleChoices.ROLE_SPOKE]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_termination_type(self):
+        params = {'termination_type': 'dcim.interface'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'termination_type': 'virtualization.vminterface'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_interface(self):
+        interfaces = Interface.objects.all()[:2]
+        params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'interface': [interfaces[0].name, interfaces[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_vminterface(self):
+        vm_interfaces = VMInterface.objects.all()[:2]
+        params = {'vminterface_id': [vm_interfaces[0].pk, vm_interfaces[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'vminterface': [vm_interfaces[0].name, vm_interfaces[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_outside_ip(self):
+        ip_addresses = IPAddress.objects.all()[:2]
+        params = {'outside_ip_id': [ip_addresses[0].pk, ip_addresses[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = IKEProposal.objects.all()
+    filterset = IKEProposalFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        ike_proposals = (
+            IKEProposal(
+                name='IKE Proposal 1',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_1,
+                sa_lifetime=1000
+            ),
+            IKEProposal(
+                name='IKE Proposal 2',
+                authentication_method=AuthenticationMethodChoices.CERTIFICATES,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+                group=DHGroupChoices.GROUP_2,
+                sa_lifetime=2000
+            ),
+            IKEProposal(
+                name='IKE Proposal 3',
+                authentication_method=AuthenticationMethodChoices.RSA_SIGNATURES,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA512,
+                group=DHGroupChoices.GROUP_5,
+                sa_lifetime=3000
+            ),
+        )
+        IKEProposal.objects.bulk_create(ike_proposals)
+
+    def test_name(self):
+        params = {'name': ['IKE Proposal 1', 'IKE Proposal 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_authentication_method(self):
+        params = {'authentication_method': [
+            AuthenticationMethodChoices.PRESHARED_KEYS, AuthenticationMethodChoices.CERTIFICATES
+        ]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_encryption_algorithm(self):
+        params = {'encryption_algorithm': [
+            EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC
+        ]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_authentication_algorithm(self):
+        params = {'authentication_algorithm': [
+            AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256
+        ]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_group(self):
+        params = {'group': [DHGroupChoices.GROUP_1, DHGroupChoices.GROUP_2]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_sa_lifetime(self):
+        params = {'sa_lifetime': [1000, 2000]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class IKEPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = IKEPolicy.objects.all()
+    filterset = IKEPolicyFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        ike_proposals = (
+            IKEProposal(
+                name='IKE Proposal 1',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+            IKEProposal(
+                name='IKE Proposal 2',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+            IKEProposal(
+                name='IKE Proposal 3',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+        )
+        IKEProposal.objects.bulk_create(ike_proposals)
+
+        ike_policies = (
+            IKEPolicy(
+                name='IKE Policy 1',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+            IKEPolicy(
+                name='IKE Policy 2',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+            IKEPolicy(
+                name='IKE Policy 3',
+                version=IKEVersionChoices.VERSION_2,
+                mode=IKEModeChoices.AGGRESSIVE,
+            ),
+        )
+        IKEPolicy.objects.bulk_create(ike_policies)
+        ike_policies[0].proposals.add(ike_proposals[0])
+        ike_policies[1].proposals.add(ike_proposals[1])
+        ike_policies[2].proposals.add(ike_proposals[2])
+
+    def test_name(self):
+        params = {'name': ['IKE Policy 1', 'IKE Policy 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_version(self):
+        params = {'version': [IKEVersionChoices.VERSION_1]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_mode(self):
+        params = {'mode': [IKEModeChoices.MAIN]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_proposal(self):
+        proposals = IKEProposal.objects.all()[:2]
+        params = {'proposal_id': [proposals[0].pk, proposals[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'proposal': [proposals[0].name, proposals[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class IPSecProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = IPSecProposal.objects.all()
+    filterset = IPSecProposalFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        ipsec_proposals = (
+            IPSecProposal(
+                name='IPSec Proposal 1',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                sa_lifetime_seconds=1000,
+                sa_lifetime_data=1000
+            ),
+            IPSecProposal(
+                name='IPSec Proposal 2',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+                sa_lifetime_seconds=2000,
+                sa_lifetime_data=2000
+            ),
+            IPSecProposal(
+                name='IPSec Proposal 3',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA512,
+                sa_lifetime_seconds=3000,
+                sa_lifetime_data=3000
+            ),
+        )
+        IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+    def test_name(self):
+        params = {'name': ['IPSec Proposal 1', 'IPSec Proposal 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_encryption_algorithm(self):
+        params = {'encryption_algorithm': [
+            EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC
+        ]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_authentication_algorithm(self):
+        params = {'authentication_algorithm': [
+            AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256
+        ]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_sa_lifetime_seconds(self):
+        params = {'sa_lifetime_seconds': [1000, 2000]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_sa_lifetime_data(self):
+        params = {'sa_lifetime_data': [1000, 2000]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class IPSecPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = IPSecPolicy.objects.all()
+    filterset = IPSecPolicyFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        ipsec_proposals = (
+            IPSecProposal(
+                name='IPSec Policy 1',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+            ),
+            IPSecProposal(
+                name='IPSec Proposal 2',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+            ),
+            IPSecProposal(
+                name='IPSec Proposal 3',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+            ),
+        )
+        IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+        ipsec_policies = (
+            IPSecPolicy(
+                name='IPSec Policy 1',
+                pfs_group=DHGroupChoices.GROUP_1
+            ),
+            IPSecPolicy(
+                name='IPSec Policy 2',
+                pfs_group=DHGroupChoices.GROUP_2
+            ),
+            IPSecPolicy(
+                name='IPSec Policy 3',
+                pfs_group=DHGroupChoices.GROUP_5
+            ),
+        )
+        IPSecPolicy.objects.bulk_create(ipsec_policies)
+        ipsec_policies[0].proposals.add(ipsec_proposals[0])
+        ipsec_policies[1].proposals.add(ipsec_proposals[1])
+        ipsec_policies[2].proposals.add(ipsec_proposals[2])
+
+    def test_name(self):
+        params = {'name': ['IPSec Policy 1', 'IPSec Policy 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_pfs_group(self):
+        params = {'pfs_group': [DHGroupChoices.GROUP_1, DHGroupChoices.GROUP_2]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_proposal(self):
+        proposals = IPSecProposal.objects.all()[:2]
+        params = {'proposal_id': [proposals[0].pk, proposals[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'proposal': [proposals[0].name, proposals[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class IPSecProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = IPSecProfile.objects.all()
+    filterset = IPSecProfileFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        ike_proposal = IKEProposal.objects.create(
+            name='IKE Proposal 1',
+            authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+            encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+            authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+            group=DHGroupChoices.GROUP_14
+        )
+        ipsec_proposal = IPSecProposal.objects.create(
+            name='IPSec Proposal 1',
+            encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+            authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+        )
+
+        ike_policies = (
+            IKEPolicy(
+                name='IKE Policy 1',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+            IKEPolicy(
+                name='IKE Policy 2',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+            IKEPolicy(
+                name='IKE Policy 3',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+        )
+        IKEPolicy.objects.bulk_create(ike_policies)
+        for ike_policy in ike_policies:
+            ike_policy.proposals.add(ike_proposal)
+
+        ipsec_policies = (
+            IPSecPolicy(
+                name='IPSec Policy 1',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+            IPSecPolicy(
+                name='IPSec Policy 2',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+            IPSecPolicy(
+                name='IPSec Policy 3',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+        )
+        IPSecPolicy.objects.bulk_create(ipsec_policies)
+        for ipsec_policy in ipsec_policies:
+            ipsec_policy.proposals.add(ipsec_proposal)
+
+        ipsec_profiles = (
+            IPSecProfile(
+                name='IPSec Profile 1',
+                mode=IPSecModeChoices.ESP,
+                ike_policy=ike_policies[0],
+                ipsec_policy=ipsec_policies[0]
+            ),
+            IPSecProfile(
+                name='IPSec Profile 2',
+                mode=IPSecModeChoices.ESP,
+                ike_policy=ike_policies[1],
+                ipsec_policy=ipsec_policies[1]
+            ),
+            IPSecProfile(
+                name='IPSec Profile 3',
+                mode=IPSecModeChoices.AH,
+                ike_policy=ike_policies[2],
+                ipsec_policy=ipsec_policies[2]
+            ),
+        )
+        IPSecProfile.objects.bulk_create(ipsec_profiles)
+
+    def test_name(self):
+        params = {'name': ['IPSec Profile 1', 'IPSec Profile 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_mode(self):
+        params = {'mode': [IPSecModeChoices.ESP]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_ike_policy(self):
+        ike_policies = IKEPolicy.objects.all()[:2]
+        params = {'ike_policy_id': [ike_policies[0].pk, ike_policies[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'ike_policy': [ike_policies[0].name, ike_policies[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_ipsec_policy(self):
+        ipsec_policies = IPSecPolicy.objects.all()[:2]
+        params = {'ipsec_policy_id': [ipsec_policies[0].pk, ipsec_policies[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'ipsec_policy': [ipsec_policies[0].name, ipsec_policies[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 508 - 0
netbox/vpn/tests/test_views.py

@@ -0,0 +1,508 @@
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Interface
+from vpn.choices import *
+from vpn.models import *
+from utilities.testing import ViewTestCases, create_tags, create_test_device
+
+
+class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = Tunnel
+
+    @classmethod
+    def setUpTestData(cls):
+
+        tunnels = (
+            Tunnel(
+                name='Tunnel 1',
+                status=TunnelStatusChoices.STATUS_ACTIVE,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+            ),
+            Tunnel(
+                name='Tunnel 2',
+                status=TunnelStatusChoices.STATUS_ACTIVE,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+            ),
+            Tunnel(
+                name='Tunnel 3',
+                status=TunnelStatusChoices.STATUS_ACTIVE,
+                encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+            ),
+        )
+        Tunnel.objects.bulk_create(tunnels)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Tunnel X',
+            'description': 'New tunnel',
+            'status': TunnelStatusChoices.STATUS_PLANNED,
+            'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,status,encapsulation",
+            "Tunnel 4,planned,gre",
+            "Tunnel 5,planned,gre",
+            "Tunnel 6,planned,gre",
+        )
+
+        cls.csv_update_data = (
+            "id,status,encapsulation",
+            f"{tunnels[0].pk},active,ip-ip",
+            f"{tunnels[1].pk},active,ip-ip",
+            f"{tunnels[2].pk},active,ip-ip",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+            'status': TunnelStatusChoices.STATUS_DISABLED,
+            'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+        }
+
+
+class TunnelTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = TunnelTermination
+    # TODO: Workaround for conflict between form field and GFK
+    validation_excluded_fields = ('termination',)
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+        interfaces = (
+            Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 4', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 5', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 6', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 7', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+        )
+        Interface.objects.bulk_create(interfaces)
+
+        tunnel = Tunnel.objects.create(
+            name='Tunnel 1',
+            status=TunnelStatusChoices.STATUS_ACTIVE,
+            encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+        )
+
+        tunnel_terminations = (
+            TunnelTermination(
+                tunnel=tunnel,
+                role=TunnelTerminationRoleChoices.ROLE_HUB,
+                termination=interfaces[0]
+            ),
+            TunnelTermination(
+                tunnel=tunnel,
+                role=TunnelTerminationRoleChoices.ROLE_SPOKE,
+                termination=interfaces[1]
+            ),
+            TunnelTermination(
+                tunnel=tunnel,
+                role=TunnelTerminationRoleChoices.ROLE_SPOKE,
+                termination=interfaces[2]
+            ),
+        )
+        TunnelTermination.objects.bulk_create(tunnel_terminations)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'tunnel': tunnel.pk,
+            'role': TunnelTerminationRoleChoices.ROLE_PEER,
+            'type': TunnelTerminationTypeChoices.TYPE_DEVICE,
+            'parent': device.pk,
+            'termination': interfaces[6].pk,
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "tunnel,role,device,termination",
+            "Tunnel 1,peer,Device 1,Interface 4",
+            "Tunnel 1,peer,Device 1,Interface 5",
+            "Tunnel 1,peer,Device 1,Interface 6",
+        )
+
+        cls.csv_update_data = (
+            "id,role",
+            f"{tunnel_terminations[0].pk},peer",
+            f"{tunnel_terminations[1].pk},peer",
+            f"{tunnel_terminations[2].pk},peer",
+        )
+
+        cls.bulk_edit_data = {
+            'role': TunnelTerminationRoleChoices.ROLE_PEER,
+        }
+
+
+class IKEProposalTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = IKEProposal
+
+    @classmethod
+    def setUpTestData(cls):
+
+        ike_proposals = (
+            IKEProposal(
+                name='IKE Proposal 1',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+            IKEProposal(
+                name='IKE Proposal 2',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+            IKEProposal(
+                name='IKE Proposal 3',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+        )
+        IKEProposal.objects.bulk_create(ike_proposals)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'IKE Proposal X',
+            'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+            'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+            'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+            'group': DHGroupChoices.GROUP_19,
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,authentication_method,encryption_algorithm,authentication_algorithm,group",
+            "IKE Proposal 4,preshared-keys,aes-128-cbc,hmac-sha1,14",
+            "IKE Proposal 5,preshared-keys,aes-128-cbc,hmac-sha1,14",
+            "IKE Proposal 6,preshared-keys,aes-128-cbc,hmac-sha1,14",
+        )
+
+        cls.csv_update_data = (
+            "id,description",
+            f"{ike_proposals[0].pk},New description",
+            f"{ike_proposals[1].pk},New description",
+            f"{ike_proposals[2].pk},New description",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+            'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+            'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+            'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+            'group': DHGroupChoices.GROUP_19
+        }
+
+
+class IKEPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = IKEPolicy
+
+    @classmethod
+    def setUpTestData(cls):
+
+        ike_proposals = (
+            IKEProposal(
+                name='IKE Proposal 1',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+            IKEProposal(
+                name='IKE Proposal 2',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14
+            ),
+        )
+        IKEProposal.objects.bulk_create(ike_proposals)
+
+        ike_policies = (
+            IKEPolicy(
+                name='IKE Policy 1',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+            IKEPolicy(
+                name='IKE Policy 2',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+            IKEPolicy(
+                name='IKE Policy 3',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+        )
+        IKEPolicy.objects.bulk_create(ike_policies)
+        for ike_policy in ike_policies:
+            ike_policy.proposals.set(ike_proposals)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'IKE Policy X',
+            'version': IKEVersionChoices.VERSION_2,
+            'mode': IKEModeChoices.AGGRESSIVE,
+            'proposals': [p.pk for p in ike_proposals],
+            'tags': [t.pk for t in tags],
+        }
+
+        ike_proposal_names = ','.join([p.name for p in ike_proposals])
+        cls.csv_data = (
+            "name,version,mode,proposals",
+            f"IKE Proposal 4,2,aggressive,\"{ike_proposal_names}\"",
+            f"IKE Proposal 5,2,aggressive,\"{ike_proposal_names}\"",
+            f"IKE Proposal 6,2,aggressive,\"{ike_proposal_names}\"",
+        )
+
+        cls.csv_update_data = (
+            "id,description",
+            f"{ike_policies[0].pk},New description",
+            f"{ike_policies[1].pk},New description",
+            f"{ike_policies[2].pk},New description",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+            'version': IKEVersionChoices.VERSION_2,
+            'mode': IKEModeChoices.AGGRESSIVE,
+        }
+
+
+class IPSecProposalTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = IPSecProposal
+
+    @classmethod
+    def setUpTestData(cls):
+
+        ipsec_proposals = (
+            IPSecProposal(
+                name='IPSec Proposal 1',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+            ),
+            IPSecProposal(
+                name='IPSec Proposal 2',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+            ),
+            IPSecProposal(
+                name='IPSec Proposal 3',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+            ),
+        )
+        IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'IPSec Proposal X',
+            'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+            'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+            'sa_lifetime_seconds': 3600,
+            'sa_lifetime_data': 1000000,
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,encryption_algorithm,authentication_algorithm,sa_lifetime_seconds,sa_lifetime_data",
+            "IKE Proposal 4,aes-128-cbc,hmac-sha1,3600,1000000",
+            "IKE Proposal 5,aes-128-cbc,hmac-sha1,3600,1000000",
+            "IKE Proposal 6,aes-128-cbc,hmac-sha1,3600,1000000",
+        )
+
+        cls.csv_update_data = (
+            "id,description",
+            f"{ipsec_proposals[0].pk},New description",
+            f"{ipsec_proposals[1].pk},New description",
+            f"{ipsec_proposals[2].pk},New description",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+            'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+            'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+            'sa_lifetime_seconds': 3600,
+            'sa_lifetime_data': 1000000,
+        }
+
+
+class IPSecPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = IPSecPolicy
+
+    @classmethod
+    def setUpTestData(cls):
+
+        ipsec_proposals = (
+            IPSecProposal(
+                name='IPSec Policy 1',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+            ),
+            IPSecProposal(
+                name='IPSec Proposal 2',
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+            ),
+        )
+        IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+        ipsec_policies = (
+            IPSecPolicy(
+                name='IPSec Policy 1',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+            IPSecPolicy(
+                name='IPSec Policy 2',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+            IPSecPolicy(
+                name='IPSec Policy 3',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+        )
+        IPSecPolicy.objects.bulk_create(ipsec_policies)
+        for ipsec_policy in ipsec_policies:
+            ipsec_policy.proposals.set(ipsec_proposals)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'IPSec Policy X',
+            'pfs_group': DHGroupChoices.GROUP_5,
+            'proposals': [p.pk for p in ipsec_proposals],
+            'tags': [t.pk for t in tags],
+        }
+
+        ipsec_proposal_names = ','.join([p.name for p in ipsec_proposals])
+        cls.csv_data = (
+            "name,pfs_group,proposals",
+            f"IKE Proposal 4,19,\"{ipsec_proposal_names}\"",
+            f"IKE Proposal 5,19,\"{ipsec_proposal_names}\"",
+            f"IKE Proposal 6,19,\"{ipsec_proposal_names}\"",
+        )
+
+        cls.csv_update_data = (
+            "id,description",
+            f"{ipsec_policies[0].pk},New description",
+            f"{ipsec_policies[1].pk},New description",
+            f"{ipsec_policies[2].pk},New description",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+            'pfs_group': DHGroupChoices.GROUP_5,
+        }
+
+
+class IPSecProfileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = IPSecProfile
+
+    @classmethod
+    def setUpTestData(cls):
+
+        ike_proposal = IKEProposal.objects.create(
+            name='IKE Proposal 1',
+            authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+            encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+            authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+            group=DHGroupChoices.GROUP_14
+        )
+
+        ipsec_proposal = IPSecProposal.objects.create(
+            name='IPSec Proposal 1',
+            encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+            authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+        )
+
+        ike_policies = (
+            IKEPolicy(
+                name='IKE Policy 1',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+            IKEPolicy(
+                name='IKE Policy 2',
+                version=IKEVersionChoices.VERSION_1,
+                mode=IKEModeChoices.MAIN,
+            ),
+        )
+        IKEPolicy.objects.bulk_create(ike_policies)
+        for ike_policy in ike_policies:
+            ike_policy.proposals.add(ike_proposal)
+
+        ipsec_policies = (
+            IPSecPolicy(
+                name='IPSec Policy 1',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+            IPSecPolicy(
+                name='IPSec Policy 2',
+                pfs_group=DHGroupChoices.GROUP_14
+            ),
+        )
+        IPSecPolicy.objects.bulk_create(ipsec_policies)
+        for ipsec_policy in ipsec_policies:
+            ipsec_policy.proposals.add(ipsec_proposal)
+
+        ipsec_profiles = (
+            IPSecProfile(
+                name='IPSec Profile 1',
+                mode=IPSecModeChoices.ESP,
+                ike_policy=ike_policies[0],
+                ipsec_policy=ipsec_policies[0]
+            ),
+            IPSecProfile(
+                name='IPSec Profile 2',
+                mode=IPSecModeChoices.ESP,
+                ike_policy=ike_policies[0],
+                ipsec_policy=ipsec_policies[0]
+            ),
+            IPSecProfile(
+                name='IPSec Profile 3',
+                mode=IPSecModeChoices.ESP,
+                ike_policy=ike_policies[0],
+                ipsec_policy=ipsec_policies[0]
+            ),
+        )
+        IPSecProfile.objects.bulk_create(ipsec_profiles)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'IPSec Profile X',
+            'mode': IPSecModeChoices.AH,
+            'ike_policy': ike_policies[1].pk,
+            'ipsec_policy': ipsec_policies[1].pk,
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,mode,ike_policy,ipsec_policy",
+            f"IKE Proposal 4,ah,IKE Policy 2,IPSec Policy 2",
+            f"IKE Proposal 5,ah,IKE Policy 2,IPSec Policy 2",
+            f"IKE Proposal 6,ah,IKE Policy 2,IPSec Policy 2",
+        )
+
+        cls.csv_update_data = (
+            "id,description",
+            f"{ipsec_profiles[0].pk},New description",
+            f"{ipsec_profiles[1].pk},New description",
+            f"{ipsec_profiles[2].pk},New description",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+            'mode': IPSecModeChoices.AH,
+            'ike_policy': ike_policies[1].pk,
+            'ipsec_policy': ipsec_policies[1].pk,
+        }

+ 65 - 0
netbox/vpn/urls.py

@@ -0,0 +1,65 @@
+from django.urls import include, path
+
+from utilities.urls import get_model_urls
+from . import views
+
+app_name = 'vpn'
+urlpatterns = [
+
+    # Tunnels
+    path('tunnels/', views.TunnelListView.as_view(), name='tunnel_list'),
+    path('tunnels/add/', views.TunnelEditView.as_view(), name='tunnel_add'),
+    path('tunnels/import/', views.TunnelBulkImportView.as_view(), name='tunnel_import'),
+    path('tunnels/edit/', views.TunnelBulkEditView.as_view(), name='tunnel_bulk_edit'),
+    path('tunnels/delete/', views.TunnelBulkDeleteView.as_view(), name='tunnel_bulk_delete'),
+    path('tunnels/<int:pk>/', include(get_model_urls('vpn', 'tunnel'))),
+
+    # Tunnel terminations
+    path('tunnel-terminations/', views.TunnelTerminationListView.as_view(), name='tunneltermination_list'),
+    path('tunnel-terminations/add/', views.TunnelTerminationEditView.as_view(), name='tunneltermination_add'),
+    path('tunnel-terminations/import/', views.TunnelTerminationBulkImportView.as_view(), name='tunneltermination_import'),
+    path('tunnel-terminations/edit/', views.TunnelTerminationBulkEditView.as_view(), name='tunneltermination_bulk_edit'),
+    path('tunnel-terminations/delete/', views.TunnelTerminationBulkDeleteView.as_view(), name='tunneltermination_bulk_delete'),
+    path('tunnel-terminations/<int:pk>/', include(get_model_urls('vpn', 'tunneltermination'))),
+
+    # IKE proposals
+    path('ike-proposals/', views.IKEProposalListView.as_view(), name='ikeproposal_list'),
+    path('ike-proposals/add/', views.IKEProposalEditView.as_view(), name='ikeproposal_add'),
+    path('ike-proposals/import/', views.IKEProposalBulkImportView.as_view(), name='ikeproposal_import'),
+    path('ike-proposals/edit/', views.IKEProposalBulkEditView.as_view(), name='ikeproposal_bulk_edit'),
+    path('ike-proposals/delete/', views.IKEProposalBulkDeleteView.as_view(), name='ikeproposal_bulk_delete'),
+    path('ike-proposals/<int:pk>/', include(get_model_urls('vpn', 'ikeproposal'))),
+
+    # IKE policies
+    path('ike-policys/', views.IKEPolicyListView.as_view(), name='ikepolicy_list'),
+    path('ike-policys/add/', views.IKEPolicyEditView.as_view(), name='ikepolicy_add'),
+    path('ike-policys/import/', views.IKEPolicyBulkImportView.as_view(), name='ikepolicy_import'),
+    path('ike-policys/edit/', views.IKEPolicyBulkEditView.as_view(), name='ikepolicy_bulk_edit'),
+    path('ike-policys/delete/', views.IKEPolicyBulkDeleteView.as_view(), name='ikepolicy_bulk_delete'),
+    path('ike-policys/<int:pk>/', include(get_model_urls('vpn', 'ikepolicy'))),
+
+    # IPSec proposals
+    path('ipsec-proposals/', views.IPSecProposalListView.as_view(), name='ipsecproposal_list'),
+    path('ipsec-proposals/add/', views.IPSecProposalEditView.as_view(), name='ipsecproposal_add'),
+    path('ipsec-proposals/import/', views.IPSecProposalBulkImportView.as_view(), name='ipsecproposal_import'),
+    path('ipsec-proposals/edit/', views.IPSecProposalBulkEditView.as_view(), name='ipsecproposal_bulk_edit'),
+    path('ipsec-proposals/delete/', views.IPSecProposalBulkDeleteView.as_view(), name='ipsecproposal_bulk_delete'),
+    path('ipsec-proposals/<int:pk>/', include(get_model_urls('vpn', 'ipsecproposal'))),
+
+    # IPSec policies
+    path('ipsec-policys/', views.IPSecPolicyListView.as_view(), name='ipsecpolicy_list'),
+    path('ipsec-policys/add/', views.IPSecPolicyEditView.as_view(), name='ipsecpolicy_add'),
+    path('ipsec-policys/import/', views.IPSecPolicyBulkImportView.as_view(), name='ipsecpolicy_import'),
+    path('ipsec-policys/edit/', views.IPSecPolicyBulkEditView.as_view(), name='ipsecpolicy_bulk_edit'),
+    path('ipsec-policys/delete/', views.IPSecPolicyBulkDeleteView.as_view(), name='ipsecpolicy_bulk_delete'),
+    path('ipsec-policys/<int:pk>/', include(get_model_urls('vpn', 'ipsecpolicy'))),
+
+    # IPSec profiles
+    path('ipsec-profiles/', views.IPSecProfileListView.as_view(), name='ipsecprofile_list'),
+    path('ipsec-profiles/add/', views.IPSecProfileEditView.as_view(), name='ipsecprofile_add'),
+    path('ipsec-profiles/import/', views.IPSecProfileBulkImportView.as_view(), name='ipsecprofile_import'),
+    path('ipsec-profiles/edit/', views.IPSecProfileBulkEditView.as_view(), name='ipsecprofile_bulk_edit'),
+    path('ipsec-profiles/delete/', views.IPSecProfileBulkDeleteView.as_view(), name='ipsecprofile_bulk_delete'),
+    path('ipsec-profiles/<int:pk>/', include(get_model_urls('vpn', 'ipsecprofile'))),
+
+]

+ 334 - 0
netbox/vpn/views.py

@@ -0,0 +1,334 @@
+from netbox.views import generic
+from utilities.utils import count_related
+from utilities.views import register_model_view
+from . import filtersets, forms, tables
+from .models import *
+
+
+#
+# Tunnels
+#
+
+class TunnelListView(generic.ObjectListView):
+    queryset = Tunnel.objects.annotate(
+        count_terminations=count_related(TunnelTermination, 'tunnel')
+    )
+    filterset = filtersets.TunnelFilterSet
+    filterset_form = forms.TunnelFilterForm
+    table = tables.TunnelTable
+
+
+@register_model_view(Tunnel)
+class TunnelView(generic.ObjectView):
+    queryset = Tunnel.objects.all()
+
+
+@register_model_view(Tunnel, 'edit')
+class TunnelEditView(generic.ObjectEditView):
+    queryset = Tunnel.objects.all()
+    form = forms.TunnelForm
+
+    def dispatch(self, request, *args, **kwargs):
+
+        # If creating a new Tunnel, use the creation form
+        if 'pk' not in kwargs:
+            self.form = forms.TunnelCreateForm
+
+        return super().dispatch(request, *args, **kwargs)
+
+
+@register_model_view(Tunnel, 'delete')
+class TunnelDeleteView(generic.ObjectDeleteView):
+    queryset = Tunnel.objects.all()
+
+
+class TunnelBulkImportView(generic.BulkImportView):
+    queryset = Tunnel.objects.all()
+    model_form = forms.TunnelImportForm
+
+
+class TunnelBulkEditView(generic.BulkEditView):
+    queryset = Tunnel.objects.annotate(
+        count_terminations=count_related(TunnelTermination, 'tunnel')
+    )
+    filterset = filtersets.TunnelFilterSet
+    table = tables.TunnelTable
+    form = forms.TunnelBulkEditForm
+
+
+class TunnelBulkDeleteView(generic.BulkDeleteView):
+    queryset = Tunnel.objects.annotate(
+        count_terminations=count_related(TunnelTermination, 'tunnel')
+    )
+    filterset = filtersets.TunnelFilterSet
+    table = tables.TunnelTable
+
+
+#
+# Tunnel terminations
+#
+
+class TunnelTerminationListView(generic.ObjectListView):
+    queryset = TunnelTermination.objects.all()
+    filterset = filtersets.TunnelTerminationFilterSet
+    filterset_form = forms.TunnelTerminationFilterForm
+    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()
+    form = forms.TunnelTerminationForm
+
+
+@register_model_view(TunnelTermination, 'delete')
+class TunnelTerminationDeleteView(generic.ObjectDeleteView):
+    queryset = TunnelTermination.objects.all()
+
+
+class TunnelTerminationBulkImportView(generic.BulkImportView):
+    queryset = TunnelTermination.objects.all()
+    model_form = forms.TunnelTerminationImportForm
+
+
+class TunnelTerminationBulkEditView(generic.BulkEditView):
+    queryset = TunnelTermination.objects.all()
+    filterset = filtersets.TunnelTerminationFilterSet
+    table = tables.TunnelTerminationTable
+    form = forms.TunnelTerminationBulkEditForm
+
+
+class TunnelTerminationBulkDeleteView(generic.BulkDeleteView):
+    queryset = TunnelTermination.objects.all()
+    filterset = filtersets.TunnelTerminationFilterSet
+    table = tables.TunnelTerminationTable
+
+
+#
+# IKE proposals
+#
+
+class IKEProposalListView(generic.ObjectListView):
+    queryset = IKEProposal.objects.all()
+    filterset = filtersets.IKEProposalFilterSet
+    filterset_form = forms.IKEProposalFilterForm
+    table = tables.IKEProposalTable
+
+
+@register_model_view(IKEProposal)
+class IKEProposalView(generic.ObjectView):
+    queryset = IKEProposal.objects.all()
+
+
+@register_model_view(IKEProposal, 'edit')
+class IKEProposalEditView(generic.ObjectEditView):
+    queryset = IKEProposal.objects.all()
+    form = forms.IKEProposalForm
+
+
+@register_model_view(IKEProposal, 'delete')
+class IKEProposalDeleteView(generic.ObjectDeleteView):
+    queryset = IKEProposal.objects.all()
+
+
+class IKEProposalBulkImportView(generic.BulkImportView):
+    queryset = IKEProposal.objects.all()
+    model_form = forms.IKEProposalImportForm
+
+
+class IKEProposalBulkEditView(generic.BulkEditView):
+    queryset = IKEProposal.objects.all()
+    filterset = filtersets.IKEProposalFilterSet
+    table = tables.IKEProposalTable
+    form = forms.IKEProposalBulkEditForm
+
+
+class IKEProposalBulkDeleteView(generic.BulkDeleteView):
+    queryset = IKEProposal.objects.all()
+    filterset = filtersets.IKEProposalFilterSet
+    table = tables.IKEProposalTable
+
+
+#
+# IKE policies
+#
+
+class IKEPolicyListView(generic.ObjectListView):
+    queryset = IKEPolicy.objects.all()
+    filterset = filtersets.IKEPolicyFilterSet
+    filterset_form = forms.IKEPolicyFilterForm
+    table = tables.IKEPolicyTable
+
+
+@register_model_view(IKEPolicy)
+class IKEPolicyView(generic.ObjectView):
+    queryset = IKEPolicy.objects.all()
+
+
+@register_model_view(IKEPolicy, 'edit')
+class IKEPolicyEditView(generic.ObjectEditView):
+    queryset = IKEPolicy.objects.all()
+    form = forms.IKEPolicyForm
+
+
+@register_model_view(IKEPolicy, 'delete')
+class IKEPolicyDeleteView(generic.ObjectDeleteView):
+    queryset = IKEPolicy.objects.all()
+
+
+class IKEPolicyBulkImportView(generic.BulkImportView):
+    queryset = IKEPolicy.objects.all()
+    model_form = forms.IKEPolicyImportForm
+
+
+class IKEPolicyBulkEditView(generic.BulkEditView):
+    queryset = IKEPolicy.objects.all()
+    filterset = filtersets.IKEPolicyFilterSet
+    table = tables.IKEPolicyTable
+    form = forms.IKEPolicyBulkEditForm
+
+
+class IKEPolicyBulkDeleteView(generic.BulkDeleteView):
+    queryset = IKEPolicy.objects.all()
+    filterset = filtersets.IKEPolicyFilterSet
+    table = tables.IKEPolicyTable
+
+
+#
+# IPSec proposals
+#
+
+class IPSecProposalListView(generic.ObjectListView):
+    queryset = IPSecProposal.objects.all()
+    filterset = filtersets.IPSecProposalFilterSet
+    filterset_form = forms.IPSecProposalFilterForm
+    table = tables.IPSecProposalTable
+
+
+@register_model_view(IPSecProposal)
+class IPSecProposalView(generic.ObjectView):
+    queryset = IPSecProposal.objects.all()
+
+
+@register_model_view(IPSecProposal, 'edit')
+class IPSecProposalEditView(generic.ObjectEditView):
+    queryset = IPSecProposal.objects.all()
+    form = forms.IPSecProposalForm
+
+
+@register_model_view(IPSecProposal, 'delete')
+class IPSecProposalDeleteView(generic.ObjectDeleteView):
+    queryset = IPSecProposal.objects.all()
+
+
+class IPSecProposalBulkImportView(generic.BulkImportView):
+    queryset = IPSecProposal.objects.all()
+    model_form = forms.IPSecProposalImportForm
+
+
+class IPSecProposalBulkEditView(generic.BulkEditView):
+    queryset = IPSecProposal.objects.all()
+    filterset = filtersets.IPSecProposalFilterSet
+    table = tables.IPSecProposalTable
+    form = forms.IPSecProposalBulkEditForm
+
+
+class IPSecProposalBulkDeleteView(generic.BulkDeleteView):
+    queryset = IPSecProposal.objects.all()
+    filterset = filtersets.IPSecProposalFilterSet
+    table = tables.IPSecProposalTable
+
+
+#
+# IPSec policies
+#
+
+class IPSecPolicyListView(generic.ObjectListView):
+    queryset = IPSecPolicy.objects.all()
+    filterset = filtersets.IPSecPolicyFilterSet
+    filterset_form = forms.IPSecPolicyFilterForm
+    table = tables.IPSecPolicyTable
+
+
+@register_model_view(IPSecPolicy)
+class IPSecPolicyView(generic.ObjectView):
+    queryset = IPSecPolicy.objects.all()
+
+
+@register_model_view(IPSecPolicy, 'edit')
+class IPSecPolicyEditView(generic.ObjectEditView):
+    queryset = IPSecPolicy.objects.all()
+    form = forms.IPSecPolicyForm
+
+
+@register_model_view(IPSecPolicy, 'delete')
+class IPSecPolicyDeleteView(generic.ObjectDeleteView):
+    queryset = IPSecPolicy.objects.all()
+
+
+class IPSecPolicyBulkImportView(generic.BulkImportView):
+    queryset = IPSecPolicy.objects.all()
+    model_form = forms.IPSecPolicyImportForm
+
+
+class IPSecPolicyBulkEditView(generic.BulkEditView):
+    queryset = IPSecPolicy.objects.all()
+    filterset = filtersets.IPSecPolicyFilterSet
+    table = tables.IPSecPolicyTable
+    form = forms.IPSecPolicyBulkEditForm
+
+
+class IPSecPolicyBulkDeleteView(generic.BulkDeleteView):
+    queryset = IPSecPolicy.objects.all()
+    filterset = filtersets.IPSecPolicyFilterSet
+    table = tables.IPSecPolicyTable
+
+
+#
+# IPSec profiles
+#
+
+class IPSecProfileListView(generic.ObjectListView):
+    queryset = IPSecProfile.objects.all()
+    filterset = filtersets.IPSecProfileFilterSet
+    filterset_form = forms.IPSecProfileFilterForm
+    table = tables.IPSecProfileTable
+
+
+@register_model_view(IPSecProfile)
+class IPSecProfileView(generic.ObjectView):
+    queryset = IPSecProfile.objects.all()
+
+
+@register_model_view(IPSecProfile, 'edit')
+class IPSecProfileEditView(generic.ObjectEditView):
+    queryset = IPSecProfile.objects.all()
+    form = forms.IPSecProfileForm
+
+
+@register_model_view(IPSecProfile, 'delete')
+class IPSecProfileDeleteView(generic.ObjectDeleteView):
+    queryset = IPSecProfile.objects.all()
+
+
+class IPSecProfileBulkImportView(generic.BulkImportView):
+    queryset = IPSecProfile.objects.all()
+    model_form = forms.IPSecProfileImportForm
+
+
+class IPSecProfileBulkEditView(generic.BulkEditView):
+    queryset = IPSecProfile.objects.all()
+    filterset = filtersets.IPSecProfileFilterSet
+    table = tables.IPSecProfileTable
+    form = forms.IPSecProfileBulkEditForm
+
+
+class IPSecProfileBulkDeleteView(generic.BulkDeleteView):
+    queryset = IPSecProfile.objects.all()
+    filterset = filtersets.IPSecProfileFilterSet
+    table = tables.IPSecProfileTable