Draft code for Wireguard support

This commit is contained in:
Mattias Loverot 2024-10-17 09:32:41 +02:00
parent 9f7743e5da
commit 351ab1ecb0
14 changed files with 421 additions and 14 deletions

View File

@ -577,6 +577,10 @@ class BaseInterface(models.Model):
def count_fhrp_groups(self):
return self.fhrp_group_assignments.count()
@property
def wireguard_config(self):
return self.wireguard_configs.first()
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
"""
@ -734,6 +738,12 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
object_id_field='assigned_object_id',
related_query_name='interface',
)
wireguard_configs = GenericRelation(
to='vpn.WireguardConfig',
content_type_field='tunnel_interface_type',
object_id_field='tunnel_interface_id',
related_query_name='interface'
)
clone_fields = (
'device', 'module', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'mtu', 'mode', 'speed', 'duplex', 'rf_role',

View File

@ -234,6 +234,7 @@ VPN_MENU = Menu(
get_model_item('vpn', 'ipsecproposal', _('IPSec Proposals')),
get_model_item('vpn', 'ipsecpolicy', _('IPSec Policies')),
get_model_item('vpn', 'ipsecprofile', _('IPSec Profiles')),
get_model_item('vpn', 'wireguardconfig', _('Wireguard Configs')),
),
),
),

View File

@ -37,10 +37,12 @@
<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>
{% if not object.is_wireguard %}
<tr>
<th scope="row">{% trans "IPSec profile" %}</th>
<td>{{ object.ipsec_profile|linkify|placeholder }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Tunnel ID" %}</th>
<td>{{ object.tunnel_id|placeholder }}</td>

View File

@ -0,0 +1,32 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{# TODO: Fix this template.. #}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Tunnel" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Listen port" %}</th>
<td>{{ object.listen_port }}</td>
</tr>
<tr>
<th scope="row">{% trans "Allowed ips" %}</th>
<td>{{ object.allowed_ips }}</td>
</tr>
</table>
</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>
{% endblock %}

View File

@ -372,6 +372,12 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
object_id_field='assigned_object_id',
related_query_name='vminterface',
)
wireguard_configs = GenericRelation(
to='vpn.WireguardConfig',
content_type_field='tunnel_interface_type',
object_id_field='tunnel_interface_id',
related_query_name='vminterface',
)
class Meta(ComponentModel.Meta):
verbose_name = _('interface')

View File

@ -1,7 +1,8 @@
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer
from vpn.choices import *
from vpn.models import IKEPolicy, IKEProposal, IPSecPolicy, IPSecProfile, IPSecProposal
from vpn.models import IKEPolicy, IKEProposal, IPSecPolicy, IPSecProfile, IPSecProposal, \
WireguardConfig
__all__ = (
'IKEPolicySerializer',
@ -9,6 +10,7 @@ __all__ = (
'IPSecPolicySerializer',
'IPSecProfileSerializer',
'IPSecProposalSerializer',
'WireguardConfigSerializer',
)
@ -119,3 +121,11 @@ class IPSecProfileSerializer(NetBoxModelSerializer):
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
)
brief_fields = ('id', 'url', 'display', 'name', 'description')
# TODO: Fix missing stuff
class WireguardConfigSerializer(NetBoxModelSerializer):
class Meta:
model = WireguardConfig
fields = ('id', 'tunnel_interface_type')
brief_fields = ('id', 'tunnel_interface_type')

View File

@ -26,12 +26,14 @@ class TunnelEncapsulationChoices(ChoiceSet):
ENCAP_IP_IP = 'ip-ip'
ENCAP_IPSEC_TRANSPORT = 'ipsec-transport'
ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel'
ENCAP_WIREGUARD = 'wireguard'
CHOICES = [
(ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')),
(ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')),
(ENCAP_IP_IP, _('IP-in-IP')),
(ENCAP_GRE, _('GRE')),
(ENCAP_WIREGUARD, _('Wireguard')),
]

View File

@ -1,9 +1,13 @@
from random import choices
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from dcim.choices import InterfaceTypeChoices
from dcim.models import Device, Interface
from ipam.models import IPAddress, RouteTarget, VLAN
from netbox.api.fields import ChoiceField
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
@ -20,6 +24,7 @@ __all__ = (
'IPSecPolicyForm',
'IPSecProfileForm',
'IPSecProposalForm',
'WireguardConfigForm',
'L2VPNForm',
'L2VPNTerminationForm',
'TunnelCreateForm',
@ -57,8 +62,7 @@ class TunnelForm(TenancyForm, NetBoxModelForm):
comments = CommentField()
fieldsets = (
FieldSet('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags', name=_('Tunnel')),
FieldSet('ipsec_profile', name=_('Security')),
FieldSet('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tags', name=_('Tunnel')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
@ -68,6 +72,15 @@ class TunnelForm(TenancyForm, NetBoxModelForm):
'name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tenant_group',
'tenant', 'comments', 'tags',
]
widgets = {
'encapsulation': HTMXSelect(),
}
def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
if get_field_value(self, 'encapsulation') == TunnelEncapsulationChoices.ENCAP_WIREGUARD:
del(self.fields['ipsec_profile'])
class TunnelCreateForm(TunnelForm):
@ -142,8 +155,7 @@ class TunnelCreateForm(TunnelForm):
)
fieldsets = (
FieldSet('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags', name=_('Tunnel')),
FieldSet('ipsec_profile', name=_('Security')),
FieldSet('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tags', name=_('Tunnel')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet(
'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_termination',
@ -264,12 +276,23 @@ class TunnelTerminationForm(NetBoxModelForm):
def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
if (get_field_value(self, 'type') is None and
self.instance.pk and isinstance(self.instance.termination.parent_object, VirtualMachine)):
# Mimic HTMXSelect()
self.fields['tunnel'].widget.attrs.update({
'hx-get': '.',
'hx-include': '#form_fields',
'hx-target': '#form_fields',
})
tunnel_id = get_field_value(self, 'tunnel')
tunnel = Tunnel.objects.filter(id=tunnel_id).first() if tunnel_id else None
tt_type = get_field_value(self, 'type')
if tt_type is None and self.instance.pk and isinstance(self.instance.termination.parent_object, VirtualMachine):
self.fields['type'].initial = TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE
# If initial or self.data is set and the type is a VIRTUALMACHINE type, swap the field querysets.
if get_field_value(self, 'type') == TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE:
if tt_type == TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE:
self.fields['parent'].label = _('Virtual Machine')
self.fields['parent'].queryset = VirtualMachine.objects.all()
self.fields['parent'].widget.attrs['selector'] = 'virtualization.virtualmachine'
@ -280,6 +303,10 @@ class TunnelTerminationForm(NetBoxModelForm):
self.fields['outside_ip'].widget.add_query_params({
'virtual_machine_id': '$parent',
})
elif tunnel and tunnel.is_wireguard:
self.fields['termination'].help_text = _('As this is a Wireguard tunnel, only virtual interfaces are available for selection')
if tt_type == TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE:
self.fields['termination'].widget.add_query_params({'type': InterfaceTypeChoices.TYPE_VIRTUAL})
if self.instance.pk:
self.fields['parent'].initial = self.instance.termination.parent_object
@ -287,9 +314,15 @@ class TunnelTerminationForm(NetBoxModelForm):
def clean(self):
super().clean()
termination = self.cleaned_data['termination']
# verify that interface is virtual
is_virtual_interface = termination.type == InterfaceTypeChoices.TYPE_VIRTUAL if self.cleaned_data['type'] == TunnelTerminationTypeChoices.TYPE_DEVICE else True
if self.cleaned_data['tunnel'].encapsulation == TunnelEncapsulationChoices.ENCAP_WIREGUARD and not is_virtual_interface:
raise forms.ValidationError(_('Interface must be virtual for Wireguard tunnels'))
# Set the terminated object
self.instance.termination = self.cleaned_data.get('termination')
self.instance.termination = termination
class IKEProposalForm(NetBoxModelForm):
@ -387,6 +420,71 @@ class IPSecProfileForm(NetBoxModelForm):
]
#
# Wireguard Config
#
class WireguardConfigForm(NetBoxModelForm):
type = forms.ChoiceField(
choices=TunnelTerminationTypeChoices,
widget=HTMXSelect(),
label=_('Type')
)
parent = DynamicModelChoiceField(
queryset=Device.objects.all(),
selector=True,
label=_('Device')
)
tunnel_interface = DynamicModelChoiceField(
queryset=Interface.objects.filter(type=InterfaceTypeChoices.TYPE_VIRTUAL),
label=_('Tunnel interface'),
query_params={
'device_id': '$parent',
'type': InterfaceTypeChoices.TYPE_VIRTUAL,
},
help_text=_('Only virtual interfaces are shown'),
)
fieldsets = (
FieldSet('type', 'parent', 'tunnel_interface', 'tags', name=_('Wireguard config')),
FieldSet('private_key', 'public_key', name=_('Keys')),
FieldSet('listen_port', 'allowed_ips', 'fwmark', 'persistent_keepalive_interval', name=_('Parameters')),
)
class Meta:
model = WireguardConfig
fields = [
'type', 'parent', 'tunnel_interface', 'tags', 'private_key', 'public_key', 'listen_port', 'allowed_ips', 'fwmark', 'persistent_keepalive_interval',
]
def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
if (get_field_value(self, 'type') is None and
self.instance.pk and isinstance(self.instance.tunnel_interface.parent_object, VirtualMachine)):
self.fields['type'].initial = TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE
# If initial or self.data is set and the type is a VIRTUALMACHINE type, swap the field querysets.
if get_field_value(self, 'type') == TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE:
self.fields['parent'].label = _('Virtual Machine')
self.fields['parent'].queryset = VirtualMachine.objects.all()
self.fields['parent'].widget.attrs['selector'] = 'virtualization.virtualmachine'
self.fields['tunnel_interface'].queryset = VMInterface.objects.all()
self.fields['tunnel_interface'].widget.add_query_params({
'virtual_machine_id': '$parent',
})
if self.instance.pk:
self.fields['parent'].initial = self.instance.tunnel_interface.parent_object
self.fields['tunnel_interface'].initial = self.instance.tunnel_interface
def clean(self):
super().clean()
# Set the tunnel_interface object
self.instance.tunnel_interface = self.cleaned_data.get('tunnel_interface')
#
# L2VPN
#

View File

@ -0,0 +1,49 @@
# Generated by Django 5.0.7 on 2024-10-15 11:15
import django.contrib.postgres.fields
import django.core.validators
import django.db.models.deletion
import ipam.fields
import taggit.managers
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0121_customfield_related_object_filter'),
('vpn', '0005_rename_indexes'),
]
operations = [
migrations.CreateModel(
name='WireguardConfig',
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)),
('tunnel_interface_id', models.PositiveBigIntegerField(blank=True, null=True)),
('private_key', models.TextField(blank=True)),
('public_key', models.TextField(blank=True)),
('listen_port', models.PositiveIntegerField(default=51820, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)])),
('allowed_ips', django.contrib.postgres.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None)),
('fwmark', models.PositiveIntegerField(blank=True, null=True)),
('persistent_keepalive_interval', models.PositiveIntegerField(default=0)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
('tunnel_interface_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
],
options={
'verbose_name': 'wireguard config',
'verbose_name_plural': 'wireguard configs',
'ordering': ('pk',),
'indexes': [models.Index(fields=['tunnel_interface_type', 'tunnel_interface_id'], name='vpn_wiregua_tunnel__5cdbbe_idx')],
},
),
migrations.AddConstraint(
model_name='wireguardconfig',
constraint=models.UniqueConstraint(fields=('tunnel_interface_type', 'tunnel_interface_id'), name='vpn_wireguardconfig_tunnel_interface', violation_error_message='An tunnel_interface may only have one wireguard configration.'),
),
]

View File

@ -1,9 +1,15 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from netbox.models import PrimaryModel
from ipam.constants import SERVICE_PORT_MIN, SERVICE_PORT_MAX
from ipam.fields import IPNetworkField
from netbox.models import PrimaryModel, CustomFieldsMixin, CustomLinksMixin, TagsMixin, \
ChangeLoggedModel
from vpn.choices import *
__all__ = (
@ -12,9 +18,13 @@ __all__ = (
'IPSecPolicy',
'IPSecProfile',
'IPSecProposal',
'WireguardConfig',
)
WIREGUARD_DEFAULT_PORT = 51820
#
# IKE
#
@ -255,3 +265,94 @@ class IPSecProfile(PrimaryModel):
def get_absolute_url(self):
return reverse('vpn:ipsecprofile', args=[self.pk])
class WireguardConfig(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLoggedModel):
tunnel_interface_type = models.ForeignKey(
to='contenttypes.ContentType',
on_delete=models.PROTECT,
related_name='+'
)
tunnel_interface_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
tunnel_interface = GenericForeignKey(
ct_field='tunnel_interface_type',
fk_field='tunnel_interface_id'
)
private_key = models.TextField(
verbose_name=_('private key'),
blank=True,
)
public_key = models.TextField(
verbose_name=_('public key'),
blank=True
)
listen_port = models.PositiveIntegerField(
verbose_name=_('listen port'),
default=WIREGUARD_DEFAULT_PORT,
validators=[
MinValueValidator(SERVICE_PORT_MIN),
MaxValueValidator(SERVICE_PORT_MAX)
]
)
allowed_ips = ArrayField(
base_field=IPNetworkField(),
blank=True,
null=True,
verbose_name=_('allowed ips'),
help_text=_(
"Represents the permissible IPv4/IPv6 networks for use by other peers in their "
"'allowed_ips' configuration while creating a tunnel with this peer. "
"Ex: '10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64'"
),
)
fwmark = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name=_('fwmark'),
help_text=_('Optional. Set a 32-bit integer firewall mark (fwmark) for outgoing packets'),
)
persistent_keepalive_interval = models.PositiveIntegerField(
default=0,
verbose_name=_('persistent keepalive interval'),
help_text=_('Persistant keepalive interval in seconds, 0 disables this feature'),
)
class Meta:
ordering = ('pk',)
indexes = (
models.Index(fields=('tunnel_interface_type', 'tunnel_interface_id')),
)
constraints = (
models.UniqueConstraint(
fields=('tunnel_interface_type', 'tunnel_interface_id'),
name='%(app_label)s_%(class)s_tunnel_interface',
violation_error_message=_("An tunnel_interface may only have one wireguard configration.")
),
)
verbose_name = _('wireguard config')
verbose_name_plural = _('wireguard configs')
def __str__(self):
return f'{self.tunnel_interface.name}: Wireguard config'
def get_absolute_url(self):
return reverse('vpn:wireguardconfig', args=[self.pk])
def clean(self):
super().clean()
# Check that the selected termination object is not already
if getattr(self.tunnel_interface, 'wireguard_config', None) and self.tunnel_interface.wireguard_config.pk != self.pk:
raise ValidationError({
'tunnel_interface': _("{name} already has a Wireguard config").format(
name=self.tunnel_interface.name,
)
})
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
objectchange.related_object = self.tunnel_interface
return objectchange

View File

@ -103,6 +103,10 @@ class Tunnel(PrimaryModel):
def get_status_color(self):
return TunnelStatusChoices.colors.get(self.status)
@property
def is_wireguard(self):
return self.encapsulation == TunnelEncapsulationChoices.ENCAP_WIREGUARD
class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLoggedModel):
tunnel = models.ForeignKey(

View File

@ -10,6 +10,7 @@ __all__ = (
'IPSecPolicyTable',
'IPSecProposalTable',
'IPSecProfileTable',
'WireguardConfigTable',
)
@ -183,3 +184,36 @@ class IPSecProfileTable(NetBoxTable):
'last_updated',
)
default_columns = ('pk', 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description')
# TODO: Fix..
class WireguardConfigTable(NetBoxTable):
tunnel_interface_parent = tables.Column(
accessor='tunnel_interface__parent_object',
linkify=True,
orderable=False,
verbose_name=_('Host')
)
tunnel_interface = tables.Column(
verbose_name=_('Tunnel interface'),
linkify=True
)
ip_addresses = columns.ManyToManyColumn(
accessor=tables.A('tunnel_interface__ip_addresses'),
orderable=False,
linkify_item=True,
verbose_name=_('IP Addresses')
)
tags = columns.TagColumn(
url_name='vpn:tunneltermination_list'
)
class Meta(NetBoxTable.Meta):
model = WireguardConfig
fields = (
'pk', 'id', 'tunnel', 'role', 'tunnel_interface_parent', 'tunnel_interface', 'ip_addresses', 'tags',
'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'tunnel_interface_parent', 'tunnel_interface', 'ip_addresses',
)

View File

@ -70,6 +70,14 @@ urlpatterns = [
path('ipsec-profiles/delete/', views.IPSecProfileBulkDeleteView.as_view(), name='ipsecprofile_bulk_delete'),
path('ipsec-profiles/<int:pk>/', include(get_model_urls('vpn', 'ipsecprofile'))),
# Wireguard configs
path('wireguard-configs/', views.WireguardConfigListView.as_view(), name='wireguardconfig_list'),
path('wireguard-configs/add/', views.WireguardConfigEditView.as_view(), name='wireguardconfig_add'),
path('wireguard-configs/import/', views.WireguardConfigBulkImportView.as_view(), name='wireguardconfig_import'),
path('wireguard-configs/edit/', views.WireguardConfigBulkEditView.as_view(), name='wireguardconfig_bulk_edit'),
path('wireguard-configs/delete/', views.WireguardConfigBulkDeleteView.as_view(), name='wireguardconfig_bulk_delete'),
path('wireguard-configs/<int:pk>/', include(get_model_urls('vpn', 'wireguardconfig'))),
# L2VPN
path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'),
path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'),

View File

@ -392,6 +392,56 @@ class IPSecProfileBulkDeleteView(generic.BulkDeleteView):
table = tables.IPSecProfileTable
#
# Wireguard configs
#
# TODO: Fix..
class WireguardConfigListView(generic.ObjectListView):
queryset = WireguardConfig.objects.all()
# filterset = filtersets.WireguardConfigFilterSet
# filterset_form = forms.WireguardConfigFilterForm
table = tables.WireguardConfigTable
@register_model_view(WireguardConfig)
class WireguardConfigView(generic.ObjectView):
queryset = WireguardConfig.objects.all()
@register_model_view(WireguardConfig, 'edit')
class WireguardConfigEditView(generic.ObjectEditView):
queryset = WireguardConfig.objects.all()
form = forms.WireguardConfigForm
@register_model_view(WireguardConfig, 'delete')
class WireguardConfigDeleteView(generic.ObjectDeleteView):
queryset = WireguardConfig.objects.all()
# TODO: Fix..
class WireguardConfigBulkImportView(generic.BulkImportView):
queryset = WireguardConfig.objects.all()
# model_form = forms.WireguardConfigImportForm
# TODO: Fix..
class WireguardConfigBulkEditView(generic.BulkEditView):
queryset = WireguardConfig.objects.all()
# filterset = filtersets.WireguardConfigFilterSet
table = tables.WireguardConfigTable
# form = forms.WireguardConfigBulkEditForm
# TODO: Fix..
class WireguardConfigBulkDeleteView(generic.BulkDeleteView):
queryset = WireguardConfig.objects.all()
# filterset = filtersets.WireguardConfigFilterSet
table = tables.WireguardConfigTable
# L2VPN
class L2VPNListView(generic.ObjectListView):