diff --git a/interfaces/forms.py b/interfaces/forms.py
index 7ef2b2d0..c62f1816 100644
--- a/interfaces/forms.py
+++ b/interfaces/forms.py
@@ -1,3 +1,4 @@
+import ipaddress
import re
from django import forms
@@ -57,29 +58,28 @@ class AddInterface(forms.Form):
def clean_ipv6_addr(self):
ipv6_addr = self.cleaned_data["ipv6_addr"]
- have_symbol = re.match("^[0-9a-f./:]+|^$", ipv6_addr)
- if not have_symbol:
- raise forms.ValidationError(
- _("The IPv6 address must not contain any special characters")
- )
- elif len(ipv6_addr) > 100:
- raise forms.ValidationError(
- _("The IPv6 address must not exceed 100 characters")
- )
- return ipv6_addr
+ if not ipv6_addr:
+ return ipv6_addr
+ try:
+ # Always address/prefix, as the interface XML needs both
+ return str(ipaddress.IPv6Interface(ipv6_addr))
+ except ValueError:
+ raise forms.ValidationError(_("The IPv6 address is not valid (e.g. 2001:db8::10/64)"))
def clean_ipv6_gw(self):
ipv6_gw = self.cleaned_data["ipv6_gw"]
- have_symbol = re.match("^[0-9a-f./:]+|^$", ipv6_gw)
- if not have_symbol:
- raise forms.ValidationError(
- _("The IPv6 gateway must not contain any special characters")
- )
- elif len(ipv6_gw) > 100:
- raise forms.ValidationError(
- _("The IPv6 gateway must not exceed 100 characters")
- )
- return ipv6_gw
+ if not ipv6_gw:
+ return ipv6_gw
+ try:
+ return str(ipaddress.IPv6Address(ipv6_gw))
+ except ValueError:
+ raise forms.ValidationError(_("The IPv6 gateway is not valid"))
+
+ def clean(self):
+ cleaned = super().clean()
+ if cleaned.get("ipv6_type") == "static" and not cleaned.get("ipv6_addr") and "ipv6_addr" not in self.errors:
+ self.add_error("ipv6_addr", _("A static IPv6 configuration needs an address"))
+ return cleaned
def clean_name(self):
name = self.cleaned_data["name"]
diff --git a/interfaces/templates/create_iface_block.html b/interfaces/templates/create_iface_block.html
index dbd15551..aad6c07d 100644
--- a/interfaces/templates/create_iface_block.html
+++ b/interfaces/templates/create_iface_block.html
@@ -122,13 +122,13 @@
diff --git a/interfaces/test_forms.py b/interfaces/test_forms.py
new file mode 100644
index 00000000..e1bea3c3
--- /dev/null
+++ b/interfaces/test_forms.py
@@ -0,0 +1,69 @@
+"""Host interface form: IPv6 values are validated before they go into the interface XML."""
+
+from unittest.mock import MagicMock
+
+from django.test import SimpleTestCase
+from lxml import etree
+from vrtManager.interface import wvmInterfaces
+
+from interfaces.forms import AddInterface
+
+BASE = {
+ "name": "br1", "itype": "bridge", "start_mode": "onboot", "netdev": "eth1",
+ "ipv4_type": "static", "ipv4_addr": "192.0.2.10/24", "ipv4_gw": "192.0.2.1",
+ "ipv6_type": "static", "stp": "on", "delay": 0,
+}
+
+
+def form(**ipv6):
+ return AddInterface(data={**BASE, **ipv6})
+
+
+class Ipv6FormTestCase(SimpleTestCase):
+ def test_valid_addresses_are_normalized(self):
+ f = form(ipv6_addr="2001:DB8::10/64", ipv6_gw="2001:DB8::1")
+ self.assertTrue(f.is_valid(), f.errors)
+ self.assertEqual(f.cleaned_data["ipv6_addr"], "2001:db8::10/64")
+ self.assertEqual(f.cleaned_data["ipv6_gw"], "2001:db8::1")
+
+ def test_address_without_prefix_gets_one(self):
+ f = form(ipv6_addr="2001:db8::10", ipv6_gw="")
+ self.assertTrue(f.is_valid(), f.errors)
+ self.assertEqual(f.cleaned_data["ipv6_addr"], "2001:db8::10/128")
+
+ def test_values_that_only_start_like_an_address_are_rejected(self):
+ for field, value in (
+ ("ipv6_addr", "2001:db8::10/64'/>"),
+ ("ipv6_gw", "2001:db8::1'/>"),
+ ("ipv6_addr", "2001:db8::zz/64"),
+ ("ipv6_addr", "192.0.2.10/24"),
+ ):
+ with self.subTest(field=field, value=value):
+ f = form(**{"ipv6_addr": "2001:db8::10/64", "ipv6_gw": "", field: value})
+ self.assertFalse(f.is_valid())
+ self.assertIn(field, f.errors)
+
+ def test_static_needs_an_address(self):
+ f = form(ipv6_addr="", ipv6_gw="")
+ self.assertFalse(f.is_valid())
+ self.assertIn("ipv6_addr", f.errors)
+
+ def test_dhcp_needs_no_address(self):
+ self.assertTrue(form(ipv6_type="dhcp", ipv6_addr="", ipv6_gw="").is_valid())
+
+
+class Ipv6XmlTestCase(SimpleTestCase):
+ def xml(self, gw):
+ conn = wvmInterfaces.__new__(wvmInterfaces)
+ conn.define_iface = MagicMock()
+ conn.get_iface = MagicMock()
+ conn.create_iface("br1", "bridge", "onboot", "eth1", "none", "", "", "static", "2001:db8::10/64", gw, "on", 0)
+ return etree.fromstring(conn.define_iface.call_args[0][0])
+
+ def test_static_address_with_gateway(self):
+ proto = self.xml("2001:db8::1").find("protocol[@family='ipv6']")
+ self.assertEqual(proto.find("ip").attrib, {"address": "2001:db8::10", "prefix": "64"})
+ self.assertEqual(proto.find("route").get("gateway"), "2001:db8::1")
+
+ def test_no_empty_route_without_gateway(self):
+ self.assertIsNone(self.xml("").find("protocol[@family='ipv6']/route"))
diff --git a/vrtManager/interface.py b/vrtManager/interface.py
index 9dbecf64..52d40704 100644
--- a/vrtManager/interface.py
+++ b/vrtManager/interface.py
@@ -44,9 +44,10 @@ class wvmInterfaces(wvmConnect):
if ipv6_type == "static":
address, prefix = ipv6_addr.split("/")
xml += f"""
-
-
- """
+ """
+ if ipv6_gw:
+ xml += f""""""
+ xml += """"""
if itype == "bridge":
xml += f"""