From 9e6f7ed6ed2b5dd25a51db057bc268ee7ae626bc Mon Sep 17 00:00:00 2001 From: Chad Smith Date: Mon, 12 Dec 2022 16:30:31 -0700 Subject: [PATCH] netplan: define features.NETPLAN_CONFIG_ROOT_READ_ONLY flag To make retaining original behavior in stable downstreams easier, provide a feature flag NETPLAN_CONFIG_ROOT_READ_ONLY so /etc/netplan/50-cloud-init.yaml config can remain unchanged as world-readable. Set this flag False to ensure world-readable 50-cloud-init.yaml. Add tests.integration_tests.util.get_feature_flag to extract feature values from cloudinit.features on test system. Co-authored-by: James Falcon --- cloudinit/features.py | 10 ++++++++++ cloudinit/net/netplan.py | 4 +++- .../modules/test_combined.py | 14 +++++++++++--- tests/integration_tests/util.py | 10 ++++++++++ .../unittests/test_distros/test_netconfig.py | 19 ++++++++++++++++++- 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/cloudinit/features.py b/cloudinit/features.py index e1116a1..25748e2 100644 --- a/cloudinit/features.py +++ b/cloudinit/features.py @@ -49,6 +49,16 @@ mirrors via :py:mod:`apt: ` directives in cloud-config. """ +NETPLAN_CONFIG_ROOT_READ_ONLY = True +""" +If ``NETPLAN_CONFIG_ROOT_READ_ONLY`` is True, then netplan configuration will +be written as a single root readon-only file /etc/netplan/50-cloud-init.yaml. +This prevents wifi passwords in network v2 configuration from being +world-readable. Prior to 23.1, netplan configuration is world-readable. + +(This flag can be removed after Jammy is no longer supported.) +""" + try: # pylint: disable=wildcard-import from cloudinit.feature_overrides import * # noqa diff --git a/cloudinit/net/netplan.py b/cloudinit/net/netplan.py index 72309fd..6bb2cb5 100644 --- a/cloudinit/net/netplan.py +++ b/cloudinit/net/netplan.py @@ -11,6 +11,7 @@ from .network_state import ( IPV6_DYNAMIC_TYPES, ) +from cloudinit import features from cloudinit import log as logging from cloudinit import util from cloudinit import subp @@ -235,7 +236,8 @@ class Renderer(renderer.Renderer): if not header.endswith("\n"): header += "\n" - util.write_file(fpnplan, header + content, mode=0o600) + mode = 0o600 if features.NETPLAN_CONFIG_ROOT_READ_ONLY else 0o644 + util.write_file(fpnplan, header + content, mode=mode) if self.clean_default: _clean_default(target=target) diff --git a/tests/integration_tests/modules/test_combined.py b/tests/integration_tests/modules/test_combined.py index bbf59bd..e9a3e71 100644 --- a/tests/integration_tests/modules/test_combined.py +++ b/tests/integration_tests/modules/test_combined.py @@ -9,9 +9,11 @@ import json import pytest import re +from cloudinit.util import is_true from tests.integration_tests.clouds import ImageSpecification from tests.integration_tests.instances import IntegrationInstance from tests.integration_tests.util import ( + get_feature_flag_value, verify_clean_log, verify_ordered_items_in_text, ) @@ -46,11 +48,17 @@ class TestCombined: """ Test that netplan config file is generated with proper permissions """ - response = class_client.execute( + file_perms = class_client.execute( "stat -c %a /etc/netplan/50-cloud-init.yaml" ) - assert response.ok, "Unable to check perms on 50-cloud-init.yaml" - assert "600" == response.stdout.strip() + assert file_perms.ok, "Unable to check perms on 50-cloud-init.yaml" + feature_netplan_root_only = is_true( + get_feature_flag_value( + class_client, "NETPLAN_CONFIG_ROOT_READ_ONLY" + ) + ) + config_perms = "600" if feature_netplan_root_only else "644" + assert config_perms == file_perms.stdout.strip() def test_final_message(self, class_client: IntegrationInstance): """Test that final_message module works as expected. diff --git a/tests/integration_tests/util.py b/tests/integration_tests/util.py index 407096c..29ee875 100644 --- a/tests/integration_tests/util.py +++ b/tests/integration_tests/util.py @@ -128,3 +128,13 @@ def retry(*, tries: int = 30, delay: int = 1): raise last_error return wrapper return _retry + + +def get_feature_flag_value(client: IntegrationInstance, key): + value = client.execute( + 'python3 -c "from cloudinit import features; ' + f'print(features.{key})"' + ).strip() + if "NameError" in value: + raise NameError(f"name '{key}' is not defined") + return value diff --git a/tests/unittests/test_distros/test_netconfig.py b/tests/unittests/test_distros/test_netconfig.py index 37cfcdf..39de989 100644 --- a/tests/unittests/test_distros/test_netconfig.py +++ b/tests/unittests/test_distros/test_netconfig.py @@ -11,6 +11,7 @@ from cloudinit import distros from cloudinit.distros.parsers.sys_conf import SysConf from cloudinit import helpers from cloudinit import settings +from cloudinit import features from cloudinit.tests.helpers import ( FilesystemMockingTestCase, dir2dict) from cloudinit import subp @@ -424,6 +425,8 @@ class TestNetCfgDistroUbuntuNetplan(TestNetCfgDistroBase): apply_fn(config, bringup) results = dir2dict(tmpd) + + mode = 0o600 if features.NETPLAN_CONFIG_ROOT_READ_ONLY else 0o644 for cfgpath, expected in expected_cfgs.items(): print("----------") print(expected) @@ -431,7 +434,7 @@ class TestNetCfgDistroUbuntuNetplan(TestNetCfgDistroBase): print(results[cfgpath]) print("----------") self.assertEqual(expected, results[cfgpath]) - self.assertEqual(0o600, get_mode(cfgpath, tmpd)) + self.assertEqual(mode, get_mode(cfgpath, tmpd)) def netplan_path(self): return '/etc/netplan/50-cloud-init.yaml' @@ -465,6 +468,20 @@ class TestNetCfgDistroUbuntuNetplan(TestNetCfgDistroBase): V2_NET_CFG, expected_cfgs=expected_cfgs.copy()) + def test_apply_network_config_v2_passthrough_ub_old_behavior(self): + """Kinetic and earlier have 50-cloud-init.yaml world-readable""" + expected_cfgs = { + self.netplan_path(): V2_TO_V2_NET_CFG_OUTPUT, + } + # ub_distro.apply_network_config(V2_NET_CFG, False) + with mock.patch.object( + features, "NETPLAN_CONFIG_ROOT_READ_ONLY", False + ): + self._apply_and_verify_netplan( + self.distro.apply_network_config, + V2_NET_CFG, + expected_cfgs=expected_cfgs.copy(), + ) class TestNetCfgDistroRedhat(TestNetCfgDistroBase): -- 2.33.0