From b97a30f0a05c1dea918c46ca9c05c869d15fe2d5 Mon Sep 17 00:00:00 2001
From: Harald <hjensas@redhat.com>
Date: Tue, 8 Feb 2022 15:49:00 +0100
Subject: [PATCH] Fix IPv6 netmask format for sysconfig (#1215)

Reference:https://github.com/canonical/cloud-init/commit/b97a30f0a05c1dea918c46ca9c05c869d15fe2d5
Conflict:format diffs.

This change converts the IPv6 netmask from the network_data.json[1]
format to the CIDR style, <IPv6_addr>/<prefix>.

Using an IPv6 address like ffff:ffff:ffff:ffff:: does not work with
NetworkManager, nor networkscripts.

NetworkManager will ignore the route, logging:
  ifcfg-rh: ignoring invalid route at \
    "::/:: via fd00:fd00:fd00:2::fffe dev " \
    (/etc/sysconfig/network-scripts/route6-:3): \
    Argument for "::/::" is not ADDR/PREFIX format

Similarly if using networkscripts, ip route fail with error:
  Error: inet6 prefix is expected rather than  \
   "fd00:fd00:fd00::/ffff:ffff:ffff:ffff::".

Also a bit of refactoring ...

cloudinit.net.sysconfig.Route.to_string:
* Move a couple of lines around to reduce repeated code.
* if "ADDRESS" not in key -> continute, so that the
  code block following it can be de-indented.
cloudinit.net.network_state:
* Refactors the ipv4_mask_to_net_prefix, ipv6_mask_to_net_prefix
  removes mask_to_net_prefix methods. Utilize ipaddress library to
  do some of the heavy lifting.

LP: #1959148
---
 cloudinit/net/__init__.py                     |   7 +-
 cloudinit/net/network_state.py                | 101 +++++++-----------
 cloudinit/net/sysconfig.py                    |  91 +++++++++-------
 cloudinit/sources/DataSourceOpenNebula.py     |   2 +-
 .../sources/helpers/vmware/imc/config_nic.py  |   4 +-
 cloudinit/net/tests/test_init.py              |   4 +-
 cloudinit/net/tests/test_network_state.py     |  58 +++++++++-
 tests/unittests/test_net.py                   |  78 +++++++++++++-
 8 files changed, 233 insertions(+), 112 deletions(-)

diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
index a503210..d3ac4c8 100644
--- a/cloudinit/net/__init__.py
+++ b/cloudinit/net/__init__.py
@@ -15,7 +15,7 @@ from typing import Any, Dict
 
 from cloudinit import subp
 from cloudinit import util
-from cloudinit.net.network_state import mask_to_net_prefix
+from cloudinit.net.network_state import ipv4_mask_to_net_prefix
 from cloudinit.url_helper import UrlError, readurl
 
 LOG = logging.getLogger(__name__)
@@ -1140,10 +1140,11 @@ class EphemeralIPv4Network(object):
                 'Cannot init network on {0} with {1}/{2} and bcast {3}'.format(
                     interface, ip, prefix_or_mask, broadcast))
         try:
-            self.prefix = mask_to_net_prefix(prefix_or_mask)
+            self.prefix = ipv4_mask_to_net_prefix(prefix_or_mask)
         except ValueError as e:
             raise ValueError(
-                'Cannot setup network: {0}'.format(e)
+                "Cannot setup network, invalid prefix or "
+                "netmask: {0}".format(e)
             ) from e
 
         self.connectivity_url_data = connectivity_url_data
diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py
index ac44304..0a4e0c6 100644
--- a/cloudinit/net/network_state.py
+++ b/cloudinit/net/network_state.py
@@ -6,6 +6,7 @@
 
 import copy
 import functools
+import ipaddress
 import logging
 import socket
 import struct
@@ -878,10 +879,16 @@ def _normalize_net_keys(network, address_keys=()):
         try:
             prefix = int(maybe_prefix)
         except ValueError:
-            # this supports input of <address>/255.255.255.0
-            prefix = mask_to_net_prefix(maybe_prefix)
-    elif netmask:
-        prefix = mask_to_net_prefix(netmask)
+            if ipv6:
+                # this supports input of ffff:ffff:ffff::
+                prefix = ipv6_mask_to_net_prefix(maybe_prefix)
+            else:
+                # this supports input of 255.255.255.0
+                prefix = ipv4_mask_to_net_prefix(maybe_prefix)
+    elif netmask and not ipv6:
+        prefix = ipv4_mask_to_net_prefix(netmask)
+    elif netmask and ipv6:
+        prefix = ipv6_mask_to_net_prefix(netmask)
     elif 'prefix' in net:
         prefix = int(net['prefix'])
     else:
@@ -978,73 +985,41 @@ def ipv4_mask_to_net_prefix(mask):
        str(24)         => 24
        "24"            => 24
     """
-    if isinstance(mask, int):
-        return mask
-    if isinstance(mask, str):
-        try:
-            return int(mask)
-        except ValueError:
-            pass
-    else:
-        raise TypeError("mask '%s' is not a string or int")
-
-    if '.' not in mask:
-        raise ValueError("netmask '%s' does not contain a '.'" % mask)
-
-    toks = mask.split(".")
-    if len(toks) != 4:
-        raise ValueError("netmask '%s' had only %d parts" % (mask, len(toks)))
-
-    return sum([bin(int(x)).count('1') for x in toks])
-
+    return ipaddress.ip_network(f"0.0.0.0/{mask}").prefixlen
 
 def ipv6_mask_to_net_prefix(mask):
     """Convert an ipv6 netmask (very uncommon) or prefix (64) to prefix.
 
-    If 'mask' is an integer or string representation of one then
-    int(mask) will be returned.
+    If the input is already an integer or a string representation of
+    an integer, then int(mask) will be returned.
+       "ffff:ffff:ffff::"  => 48
+       "48"                => 48
     """
-
-    if isinstance(mask, int):
-        return mask
-    if isinstance(mask, str):
-        try:
-            return int(mask)
-        except ValueError:
-            pass
-    else:
-        raise TypeError("mask '%s' is not a string or int")
-
-    if ':' not in mask:
-        raise ValueError("mask '%s' does not have a ':'")
-
-    bitCount = [0, 0x8000, 0xc000, 0xe000, 0xf000, 0xf800, 0xfc00, 0xfe00,
-                0xff00, 0xff80, 0xffc0, 0xffe0, 0xfff0, 0xfff8, 0xfffc,
-                0xfffe, 0xffff]
-    prefix = 0
-    for word in mask.split(':'):
-        if not word or int(word, 16) == 0:
-            break
-        prefix += bitCount.index(int(word, 16))
-
-    return prefix
-
-
-def mask_to_net_prefix(mask):
-    """Return the network prefix for the netmask provided.
-
-    Supports ipv4 or ipv6 netmasks."""
     try:
-        # if 'mask' is a prefix that is an integer.
-        # then just return it.
-        return int(mask)
+        # In the case the mask is already a prefix
+        prefixlen = ipaddress.ip_network(f"::/{mask}").prefixlen
+        return prefixlen
     except ValueError:
+        # ValueError means mask is an IPv6 address representation and need
+        # conversion.
         pass
-    if is_ipv6_addr(mask):
-        return ipv6_mask_to_net_prefix(mask)
-    else:
-        return ipv4_mask_to_net_prefix(mask)
-
+    
+    netmask = ipaddress.ip_address(mask)
+    mask_int = int(netmask)
+    # If the mask is all zeroes, just return it
+    if mask_int == 0:
+        return mask_int
+
+    trailing_zeroes = min(
+        ipaddress.IPV6LENGTH, (~mask_int & (mask_int - 1)).bit_length()
+    )
+    leading_ones = mask_int >> trailing_zeroes
+    prefixlen = ipaddress.IPV6LENGTH - trailing_zeroes
+    all_ones = (1 << prefixlen) - 1
+    if leading_ones != all_ones:
+        raise ValueError("Invalid network mask '%s'" % mask)
+
+    return prefixlen
 
 def mask_and_ipv4_to_bcast_addr(mask, ip):
     """Calculate the broadcast address from the subnet mask and ip addr.
diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
index 32a2d5a..4c6caef 100644
--- a/cloudinit/net/sysconfig.py
+++ b/cloudinit/net/sysconfig.py
@@ -12,6 +12,7 @@ from cloudinit import util
 from cloudinit import subp
 from cloudinit.distros.parsers import networkmanager_conf
 from cloudinit.distros.parsers import resolv_conf
+from cloudinit.net import network_state
 
 from . import renderer
 from .network_state import (
@@ -172,44 +173,60 @@ class Route(ConfigMap):
         # (because Route can contain a mix of IPv4 and IPv6)
         reindex = -1
         for key in sorted(self._conf.keys()):
-            if 'ADDRESS' in key:
-                index = key.replace('ADDRESS', '')
-                address_value = str(self._conf[key])
-                # only accept combinations:
-                # if proto ipv6 only display ipv6 routes
-                # if proto ipv4 only display ipv4 routes
-                # do not add ipv6 routes if proto is ipv4
-                # do not add ipv4 routes if proto is ipv6
-                # (this array will contain a mix of ipv4 and ipv6)
-                if proto == "ipv4" and not self.is_ipv6_route(address_value):
-                    netmask_value = str(self._conf['NETMASK' + index])
-                    gateway_value = str(self._conf['GATEWAY' + index])
-                    # increase IPv4 index
-                    reindex = reindex + 1
-                    buf.write("%s=%s\n" % ('ADDRESS' + str(reindex),
-                                           _quote_value(address_value)))
-                    buf.write("%s=%s\n" % ('GATEWAY' + str(reindex),
-                                           _quote_value(gateway_value)))
-                    buf.write("%s=%s\n" % ('NETMASK' + str(reindex),
-                                           _quote_value(netmask_value)))
-                    metric_key = 'METRIC' + index
-                    if metric_key in self._conf:
-                        metric_value = str(self._conf['METRIC' + index])
-                        buf.write("%s=%s\n" % ('METRIC' + str(reindex),
-                                               _quote_value(metric_value)))
-                elif proto == "ipv6" and self.is_ipv6_route(address_value):
-                    netmask_value = str(self._conf['NETMASK' + index])
-                    gateway_value = str(self._conf['GATEWAY' + index])
-                    metric_value = (
-                        'metric ' + str(self._conf['METRIC' + index])
-                        if 'METRIC' + index in self._conf else '')
-                    buf.write(
-                        "%s/%s via %s %s dev %s\n" % (address_value,
-                                                      netmask_value,
-                                                      gateway_value,
-                                                      metric_value,
-                                                      self._route_name))
+            if "ADDRESS" not in key:
+                continue
 
+            index = key.replace("ADDRESS", "")
+            address_value = str(self._conf[key])
+            netmask_value = str(self._conf["NETMASK" + index])
+            gateway_value = str(self._conf["GATEWAY" + index])
+
+            # only accept combinations:
+            # if proto ipv6 only display ipv6 routes
+            # if proto ipv4 only display ipv4 routes
+            # do not add ipv6 routes if proto is ipv4
+            # do not add ipv4 routes if proto is ipv6
+            # (this array will contain a mix of ipv4 and ipv6)
+            if proto == "ipv4" and not self.is_ipv6_route(address_value):
+                # increase IPv4 index
+                reindex = reindex + 1
+                buf.write(
+                    "%s=%s\n"
+                    % ("ADDRESS" + str(reindex), _quote_value(address_value))
+                )
+                buf.write(
+                    "%s=%s\n"
+                    % ("GATEWAY" + str(reindex), _quote_value(gateway_value))
+                )
+                buf.write(
+                    "%s=%s\n"
+                    % ("NETMASK" + str(reindex), _quote_value(netmask_value))
+                )
+                metric_key = "METRIC" + index
+                if metric_key in self._conf:
+                    metric_value = str(self._conf["METRIC" + index])
+                    buf.write("%s=%s\n"
+                            % ("METRIC" + str(reindex), _quote_value(metric_value))
+                    )
+            elif proto == "ipv6" and self.is_ipv6_route(address_value):
+                prefix_value = network_state.ipv6_mask_to_net_prefix(
+                    netmask_value
+                )
+                metric_value = (
+                    "metric " + str(self._conf["METRIC" + index])
+                    if "METRIC" + index in self._conf
+                    else ""
+                )
+                buf.write(
+                    "%s/%s via %s %s dev %s\n"
+                    % (
+                        address_value,
+                        prefix_value,
+                        gateway_value,
+                        metric_value,
+                        self._route_name,
+                    )
+                )
         return buf.getvalue()
 
 
diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py
index 21603fb..8b37f39 100644
--- a/cloudinit/sources/DataSourceOpenNebula.py
+++ b/cloudinit/sources/DataSourceOpenNebula.py
@@ -237,7 +237,7 @@ class OpenNebulaNetwork(object):
             # Set IPv4 address
             devconf['addresses'] = []
             mask = self.get_mask(c_dev)
-            prefix = str(net.mask_to_net_prefix(mask))
+            prefix = str(net.ipv4_mask_to_net_prefix(mask))
             devconf['addresses'].append(
                 self.get_ip(c_dev, mac) + '/' + prefix)

diff --git a/cloudinit/sources/helpers/vmware/imc/config_nic.py b/cloudinit/sources/helpers/vmware/imc/config_nic.py
index 9cd2c0c..3a45c67 100644
--- a/cloudinit/sources/helpers/vmware/imc/config_nic.py
+++ b/cloudinit/sources/helpers/vmware/imc/config_nic.py
@@ -9,7 +9,7 @@ import logging
 import os
 import re

-from cloudinit.net.network_state import mask_to_net_prefix
+from cloudinit.net.network_state import ipv4_mask_to_net_prefix
 from cloudinit import subp
 from cloudinit import util

@@ -180,7 +180,7 @@ class NicConfigurator(object):
         """
         route_list = []

-        cidr = mask_to_net_prefix(netmask)
+        cidr = ipv4_mask_to_net_prefix(netmask)

         for gateway in gateways:
             destination = "%s/%d" % (gen_subnet(gateway, netmask), cidr)
diff --git a/cloudinit/net/tests/test_init.py b/cloudinit/net/tests/test_init.py
index 2ef5ab7..215cabf 100644
--- a/cloudinit/net/tests/test_init.py
+++ b/cloudinit/net/tests/test_init.py
@@ -588,7 +588,9 @@ class TestEphemeralIPV4Network(CiTestCase):
                 with net.EphemeralIPv4Network(**params):
                     pass
             error = context_manager.exception
-            self.assertIn('Cannot setup network: netmask', str(error))
+            self.assertIn(
+                "Cannot setup network, invalid prefix or netmask: ", str(error)
+            )
             self.assertEqual(0, m_subp.call_count)
 
     def test_ephemeral_ipv4_network_performs_teardown(self, m_subp):
diff --git a/cloudinit/net/tests/test_network_state.py b/cloudinit/net/tests/test_network_state.py
index 45e9917..f03db50 100644
--- a/cloudinit/net/tests/test_network_state.py
+++ b/cloudinit/net/tests/test_network_state.py
@@ -1,5 +1,5 @@
 # This file is part of cloud-init. See LICENSE file for license information.
-
+import ipaddress
 from unittest import mock
 
 import pytest
@@ -161,4 +161,60 @@ class TestNetworkStateParseNameservers:
             'spam.local',
         ] == sorted(config.dns_searchdomains)
 
+class TestNetworkStateHelperFunctions(CiTestCase):
+    def test_mask_to_net_prefix_ipv4(self):
+        netmask_value = "255.255.255.0"
+        expected = 24
+        prefix_value = network_state.ipv4_mask_to_net_prefix(netmask_value)
+        assert prefix_value == expected
+
+    def test_mask_to_net_prefix_all_bits_ipv4(self):
+        netmask_value = "255.255.255.255"
+        expected = 32
+        prefix_value = network_state.ipv4_mask_to_net_prefix(netmask_value)
+        assert prefix_value == expected
+
+    def test_mask_to_net_prefix_to_many_bits_ipv4(self):
+        netmask_value = "33"
+        self.assertRaises(
+            ValueError, network_state.ipv4_mask_to_net_prefix, netmask_value
+        )
+
+    def test_mask_to_net_prefix_all_bits_ipv6(self):
+        netmask_value = "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
+        expected = 128
+        prefix_value = network_state.ipv6_mask_to_net_prefix(netmask_value)
+        assert prefix_value == expected
+
+    def test_mask_to_net_prefix_ipv6(self):
+        netmask_value = "ffff:ffff:ffff:ffff::"
+        expected = 64
+        prefix_value = network_state.ipv6_mask_to_net_prefix(netmask_value)
+        assert prefix_value == expected
+
+    def test_mask_to_net_prefix_raises_value_error(self):
+        netmask_value = "ff:ff:ff:ff::"
+        self.assertRaises(
+            ValueError, network_state.ipv6_mask_to_net_prefix, netmask_value
+        )
+
+    def test_mask_to_net_prefix_to_many_bits_ipv6(self):
+        netmask_value = "129"
+        self.assertRaises(
+            ValueError, network_state.ipv6_mask_to_net_prefix, netmask_value
+        )
+
+    def test_mask_to_net_prefix_ipv4_object(self):
+        netmask_value = ipaddress.IPv4Address("255.255.255.255")
+        expected = 32
+        prefix_value = network_state.ipv4_mask_to_net_prefix(netmask_value)
+        assert prefix_value == expected
+
+    def test_mask_to_net_prefix_ipv6_object(self):
+        netmask_value = ipaddress.IPv6Address("ffff:ffff:ffff::")
+        expected = 48
+        prefix_value = network_state.ipv6_mask_to_net_prefix(netmask_value)
+        assert prefix_value == expected
+
+
 # vi: ts=4 expandtab
diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
index 764e1c7..5013277 100644
--- a/tests/unittests/test_net.py
+++ b/tests/unittests/test_net.py
@@ -2237,10 +2237,10 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true
                     routes:
                         - gateway: 2001:67c:1562:1
                           network: 2001:67c:1
-                          netmask: ffff:ffff:0
+                          netmask: "ffff:ffff::"
                         - gateway: 3001:67c:1562:1
                           network: 3001:67c:1
-                          netmask: ffff:ffff:0
+                          netmask: "ffff:ffff::"
                           metric: 10000
             """),
         'expected_netplan': textwrap.dedent("""
@@ -2507,8 +2507,8 @@ iface bond0 inet6 static
             'route6-bond0': textwrap.dedent("""\
         # Created by cloud-init on instance boot automatically, do not edit.
         #
-        2001:67c:1/ffff:ffff:0 via 2001:67c:1562:1  dev bond0
-        3001:67c:1/ffff:ffff:0 via 3001:67c:1562:1 metric 10000 dev bond0
+        2001:67c:1/32 via 2001:67c:1562:1  dev bond0
+        3001:67c:1/32 via 3001:67c:1562:1 metric 10000 dev bond0
             """),
             'route-bond0': textwrap.dedent("""\
         ADDRESS0=10.1.3.0
@@ -3297,6 +3297,76 @@ USERCTL=no
             renderer.render_network_state(ns, target=render_dir)
         self.assertEqual([], os.listdir(render_dir))
 
+    def test_invalid_network_mask_ipv6(self):
+        net_json = {
+            "services": [{"type": "dns", "address": "172.19.0.12"}],
+            "networks": [
+                {
+                    "network_id": "public-ipv6",
+                    "type": "ipv6",
+                    "netmask": "",
+                    "link": "tap1a81968a-79",
+                    "routes": [
+                        {
+                            "gateway": "2001:DB8::1",
+                            "netmask": "ff:ff:ff:ff::",
+                            "network": "2001:DB8:1::1",
+                        },
+                    ],
+                    "ip_address": "2001:DB8::10",
+                    "id": "network1",
+                }
+            ],
+            "links": [
+                {
+                    "ethernet_mac_address": "fa:16:3e:ed:9a:59",
+                    "mtu": None,
+                    "type": "bridge",
+                    "id": "tap1a81968a-79",
+                    "vif_id": "1a81968a-797a-400f-8a80-567f997eb93f",
+                },
+            ],
+        }
+        macs = {"fa:16:3e:ed:9a:59": "eth0"}
+        network_cfg = openstack.convert_net_json(net_json, known_macs=macs)
+        with self.assertRaises(ValueError):
+            network_state.parse_net_config_data(network_cfg, skip_broken=False)
+
+    def test_invalid_network_mask_ipv4(self):
+        net_json = {
+            "services": [{"type": "dns", "address": "172.19.0.12"}],
+            "networks": [
+                {
+                    "network_id": "public-ipv4",
+                    "type": "ipv4",
+                    "netmask": "",
+                    "link": "tap1a81968a-79",
+                    "routes": [
+                        {
+                            "gateway": "172.20.0.1",
+                            "netmask": "255.234.255.0",
+                            "network": "172.19.0.0",
+                        },
+                    ],
+                    "ip_address": "172.20.0.10",
+                    "id": "network1",
+                }
+            ],
+            "links": [
+                {
+                    "ethernet_mac_address": "fa:16:3e:ed:9a:59",
+                    "mtu": None,
+                    "type": "bridge",
+                    "id": "tap1a81968a-79",
+                    "vif_id": "1a81968a-797a-400f-8a80-567f997eb93f",
+                },
+            ],
+        }
+        macs = {"fa:16:3e:ed:9a:59": "eth0"}
+        network_cfg = openstack.convert_net_json(net_json, known_macs=macs)
+        with self.assertRaises(ValueError):
+            network_state.parse_net_config_data(network_cfg, skip_broken=False)
+
     def test_openstack_rendering_samples(self):
         for os_sample in OS_SAMPLES:
             render_dir = self.tmp_dir()
-- 
2.33.0


