Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft: Support for Wireguard vpn #17801

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions netbox/dcim/models/device_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,10 @@ def count_ipaddresses(self):
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):
"""
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions netbox/netbox/navigation/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@
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')),
),
),
),
Expand Down
10 changes: 6 additions & 4 deletions netbox/templates/vpn/tunnel.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ <h2 class="card-header">{% trans "Tunnel" %}</h2>
<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>
Expand Down
32 changes: 32 additions & 0 deletions netbox/templates/vpn/wireguardconfig.html
Original file line number Diff line number Diff line change
@@ -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 %}
6 changes: 6 additions & 0 deletions netbox/virtualization/models/virtualmachines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
12 changes: 11 additions & 1 deletion netbox/vpn/api/serializers_/crypto.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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',
'IKEProposalSerializer',
'IPSecPolicySerializer',
'IPSecProfileSerializer',
'IPSecProposalSerializer',
'WireguardConfigSerializer',
)


Expand Down Expand Up @@ -119,3 +121,11 @@ class Meta:
'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')
2 changes: 2 additions & 0 deletions netbox/vpn/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
]


Expand Down
114 changes: 106 additions & 8 deletions netbox/vpn/forms/model_forms.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,6 +24,7 @@
'IPSecPolicyForm',
'IPSecProfileForm',
'IPSecProposalForm',
'WireguardConfigForm',
'L2VPNForm',
'L2VPNTerminationForm',
'TunnelCreateForm',
Expand Down Expand Up @@ -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')),
)

Expand All @@ -68,6 +72,15 @@ class Meta:
'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):
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -264,12 +276,23 @@ class Meta:
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'
Expand All @@ -280,16 +303,26 @@ def __init__(self, *args, initial=None, **kwargs):
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
self.fields['termination'].initial = self.instance.termination

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):
Expand Down Expand Up @@ -387,6 +420,71 @@ class Meta:
]


#
# 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
#
Expand Down
49 changes: 49 additions & 0 deletions netbox/vpn/migrations/0006_wireguardconfig_and_more.py
Original file line number Diff line number Diff line change
@@ -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.'),
),
]
Loading
Loading