From 471cc202a774af0484844dbd4d25dfd30b36b8a4 Mon Sep 17 00:00:00 2001 From: CentOS Sources Date: Tue, 2 Nov 2021 05:32:57 -0400 Subject: [PATCH] import cloud-init-21.1-7.el9 --- .cloud-init.metadata | 1 + .gitignore | 1 + SOURCES/0001-Add-initial-redhat-setup.patch | 604 +++++++ ...CONTROLLED-no-in-generated-interface.patch | 283 ++++ ...03-limit-permissions-on-def_log_file.patch | 69 + ...device-number-on-EC2-derivatives-836.patch | 103 ++ ...-system-keys-and-check-folder-permis.patch | 1383 +++++++++++++++++ ...emove-ssh_genkeytypes-in-settings.py.patch | 65 + ...loudinit-to-merge-all-ssh-keys-into-.patch | 651 ++++++++ ...only-to-serial-console-lock-down-clo.patch | 371 +++++ SOURCES/cloud-init-tmpfiles.conf | 1 + SPECS/cloud-init.spec | 438 ++++++ 12 files changed, 3970 insertions(+) create mode 100644 .cloud-init.metadata create mode 100644 .gitignore create mode 100644 SOURCES/0001-Add-initial-redhat-setup.patch create mode 100644 SOURCES/0002-Do-not-write-NM_CONTROLLED-no-in-generated-interface.patch create mode 100644 SOURCES/0003-limit-permissions-on-def_log_file.patch create mode 100644 SOURCES/ci-Fix-requiring-device-number-on-EC2-derivatives-836.patch create mode 100644 SOURCES/ci-Stop-copying-ssh-system-keys-and-check-folder-permis.patch create mode 100644 SOURCES/ci-rhel-cloud.cfg-remove-ssh_genkeytypes-in-settings.py.patch create mode 100644 SOURCES/ci-ssh-util-allow-cloudinit-to-merge-all-ssh-keys-into-.patch create mode 100644 SOURCES/ci-write-passwords-only-to-serial-console-lock-down-clo.patch create mode 100644 SOURCES/cloud-init-tmpfiles.conf create mode 100644 SPECS/cloud-init.spec diff --git a/.cloud-init.metadata b/.cloud-init.metadata new file mode 100644 index 0000000..6803c51 --- /dev/null +++ b/.cloud-init.metadata @@ -0,0 +1 @@ +2ae378aa2ae23b34b0ff123623ba5e2fbdc4928d SOURCES/cloud-init-21.1.tar.gz diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..103bcf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +SOURCES/cloud-init-21.1.tar.gz diff --git a/SOURCES/0001-Add-initial-redhat-setup.patch b/SOURCES/0001-Add-initial-redhat-setup.patch new file mode 100644 index 0000000..1fffab7 --- /dev/null +++ b/SOURCES/0001-Add-initial-redhat-setup.patch @@ -0,0 +1,604 @@ +From 4b84d29211b7b2121afe9045c71ded5381536d8b Mon Sep 17 00:00:00 2001 +From: Eduardo Otubo +Date: Fri, 7 May 2021 13:36:03 +0200 +Subject: Add initial redhat setup + +Merged patches (RHEL-9/21.1): +- 5688a1d0 Removing python-nose and python-tox as dependency +- 237d57f9 Removing mock dependency +- d1c2f496 Removing python-jsonschema dependency +- 0d1cd14c Don't override default network configuration + +Merged patches (21.1): +- 915d30ad Change gating file to correct rhel version +- 311f318d Removing net-tools dependency +- 74731806 Adding man pages to Red Hat spec file +- 758d333d Removing blocking test from yaml configuration file +- c7e7c59c Changing permission of cloud-init-generator to 755 +- 8b85abbb Installing man pages in the correct place with correct permissions +- c6808d8d Fix unit failure of cloud-final.service if NetworkManager was not present. +- 11866ef6 Report full specific version with "cloud-init --version" + +Rebase notes (18.5): +- added bash_completition file +- added cloud-id file + +Merged patches (20.3): +- 01900d0 changing ds-identify patch from /usr/lib to /usr/libexec +- 7f47ca3 Render the generator from template instead of cp + +Merged patches (19.4): +- 4ab5a61 Fix for network configuration not persisting after reboot +- 84cf125 Removing cloud-user from wheel +- 31290ab Adding gating tests for Azure, ESXi and AWS + +Merged patches (18.5): +- 2d6b469 add power-state-change module to cloud_final_modules +- 764159f Adding systemd mount options to wait for cloud-init +- da4d99e Adding disk_setup to rhel/cloud.cfg +- f5c6832 Enable cloud-init by default on vmware + +Conflicts: +cloudinit/config/cc_chef.py: + - Updated header documentation text + - Replacing double quotes by simple quotes + +setup.py: + - Adding missing cmdclass info + +Signed-off-by: Eduardo Otubo + +Changes: +- move redhat to .distro to use new build script structure +- Fixing changelog for RHEL 9 + +Merged patches (21.1): +- 69bd7f71 DataSourceAzure.py: use hostnamectl to set hostname +- 0407867e Remove race condition between cloud-init and NetworkManager + +Signed-off-by: Miroslav Rezanina +--- + .distro/.gitignore | 1 + + .distro/Makefile | 74 +++++ + .distro/Makefile.common | 30 ++ + .distro/cloud-init-tmpfiles.conf | 1 + + .distro/cloud-init.spec.template | 383 ++++++++++++++++++++++++++ + .distro/gating.yaml | 8 + + .distro/rpmbuild/BUILD/.gitignore | 3 + + .distro/rpmbuild/RPMS/.gitignore | 3 + + .distro/rpmbuild/SOURCES/.gitignore | 3 + + .distro/rpmbuild/SPECS/.gitignore | 3 + + .distro/rpmbuild/SRPMS/.gitignore | 3 + + .distro/scripts/frh.py | 27 ++ + .distro/scripts/git-backport-diff | 327 ++++++++++++++++++++++ + .distro/scripts/git-compile-check | 215 +++++++++++++++ + .distro/scripts/process-patches.sh | 88 ++++++ + .distro/scripts/tarball_checksum.sh | 3 + + .gitignore | 1 + + cloudinit/config/cc_chef.py | 67 ++++- + cloudinit/settings.py | 7 +- + cloudinit/sources/DataSourceAzure.py | 2 +- + requirements.txt | 3 - + rhel/README.rhel | 5 + + rhel/cloud-init-tmpfiles.conf | 1 + + rhel/cloud.cfg | 69 +++++ + rhel/systemd/cloud-config.service | 18 ++ + rhel/systemd/cloud-config.target | 11 + + rhel/systemd/cloud-final.service | 24 ++ + rhel/systemd/cloud-init-local.service | 31 +++ + rhel/systemd/cloud-init.service | 26 ++ + rhel/systemd/cloud-init.target | 7 + + setup.py | 23 +- + tools/read-version | 28 +- + 32 files changed, 1441 insertions(+), 54 deletions(-) + create mode 100644 .distro/.gitignore + create mode 100644 .distro/Makefile + create mode 100644 .distro/Makefile.common + create mode 100644 .distro/cloud-init-tmpfiles.conf + create mode 100644 .distro/cloud-init.spec.template + create mode 100644 .distro/gating.yaml + create mode 100644 .distro/rpmbuild/BUILD/.gitignore + create mode 100644 .distro/rpmbuild/RPMS/.gitignore + create mode 100644 .distro/rpmbuild/SOURCES/.gitignore + create mode 100644 .distro/rpmbuild/SPECS/.gitignore + create mode 100644 .distro/rpmbuild/SRPMS/.gitignore + create mode 100755 .distro/scripts/frh.py + create mode 100755 .distro/scripts/git-backport-diff + create mode 100755 .distro/scripts/git-compile-check + create mode 100755 .distro/scripts/process-patches.sh + create mode 100755 .distro/scripts/tarball_checksum.sh + create mode 100644 rhel/README.rhel + create mode 100644 rhel/cloud-init-tmpfiles.conf + create mode 100644 rhel/cloud.cfg + create mode 100644 rhel/systemd/cloud-config.service + create mode 100644 rhel/systemd/cloud-config.target + create mode 100644 rhel/systemd/cloud-final.service + create mode 100644 rhel/systemd/cloud-init-local.service + create mode 100644 rhel/systemd/cloud-init.service + create mode 100644 rhel/systemd/cloud-init.target + +diff --git a/cloudinit/config/cc_chef.py b/cloudinit/config/cc_chef.py +index aaf71366..97ef649a 100644 +--- a/cloudinit/config/cc_chef.py ++++ b/cloudinit/config/cc_chef.py +@@ -6,7 +6,70 @@ + # + # This file is part of cloud-init. See LICENSE file for license information. + +-"""Chef: module that configures, starts and installs chef.""" ++""" ++Chef ++---- ++**Summary:** module that configures, starts and installs chef. ++ ++This module enables chef to be installed (from packages or ++from gems, or from omnibus). Before this occurs chef configurations are ++written to disk (validation.pem, client.pem, firstboot.json, client.rb), ++and needed chef folders/directories are created (/etc/chef and /var/log/chef ++and so-on). Then once installing proceeds correctly if configured chef will ++be started (in daemon mode or in non-daemon mode) and then once that has ++finished (if ran in non-daemon mode this will be when chef finishes ++converging, if ran in daemon mode then no further actions are possible since ++chef will have forked into its own process) then a post run function can ++run that can do finishing activities (such as removing the validation pem ++file). ++ ++**Internal name:** ``cc_chef`` ++ ++**Module frequency:** per always ++ ++**Supported distros:** all ++ ++**Config keys**:: ++ ++ chef: ++ directories: (defaulting to /etc/chef, /var/log/chef, /var/lib/chef, ++ /var/cache/chef, /var/backups/chef, /run/chef) ++ validation_cert: (optional string to be written to file validation_key) ++ special value 'system' means set use existing file ++ validation_key: (optional the path for validation_cert. default ++ /etc/chef/validation.pem) ++ firstboot_path: (path to write run_list and initial_attributes keys that ++ should also be present in this configuration, defaults ++ to /etc/chef/firstboot.json) ++ exec: boolean to run or not run chef (defaults to false, unless ++ a gem installed is requested ++ where this will then default ++ to true) ++ ++ chef.rb template keys (if falsey, then will be skipped and not ++ written to /etc/chef/client.rb) ++ ++ chef: ++ client_key: ++ encrypted_data_bag_secret: ++ environment: ++ file_backup_path: ++ file_cache_path: ++ json_attribs: ++ log_level: ++ log_location: ++ node_name: ++ omnibus_url: ++ omnibus_url_retries: ++ omnibus_version: ++ pid_file: ++ server_url: ++ show_time: ++ ssl_verify_mode: ++ validation_cert: ++ validation_key: ++ validation_name: ++""" + + import itertools + import json +@@ -31,7 +94,7 @@ CHEF_DIRS = tuple([ + '/var/lib/chef', + '/var/cache/chef', + '/var/backups/chef', +- '/var/run/chef', ++ '/run/chef', + ]) + REQUIRED_CHEF_DIRS = tuple([ + '/etc/chef', +diff --git a/cloudinit/settings.py b/cloudinit/settings.py +index 91e1bfe7..e690c0fd 100644 +--- a/cloudinit/settings.py ++++ b/cloudinit/settings.py +@@ -47,13 +47,16 @@ CFG_BUILTIN = { + ], + 'def_log_file': '/var/log/cloud-init.log', + 'log_cfgs': [], +- 'syslog_fix_perms': ['syslog:adm', 'root:adm', 'root:wheel', 'root:root'], ++ 'mount_default_fields': [None, None, 'auto', 'defaults,nofail', '0', '2'], ++ 'ssh_deletekeys': False, ++ 'ssh_genkeytypes': [], ++ 'syslog_fix_perms': [], + 'system_info': { + 'paths': { + 'cloud_dir': '/var/lib/cloud', + 'templates_dir': '/etc/cloud/templates/', + }, +- 'distro': 'ubuntu', ++ 'distro': 'rhel', + 'network': {'renderers': None}, + }, + 'vendor_data': {'enabled': True, 'prefix': []}, +diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py +index cee630f7..553b5a7e 100755 +--- a/cloudinit/sources/DataSourceAzure.py ++++ b/cloudinit/sources/DataSourceAzure.py +@@ -296,7 +296,7 @@ def get_hostname(hostname_command='hostname'): + + + def set_hostname(hostname, hostname_command='hostname'): +- subp.subp([hostname_command, hostname]) ++ util.subp(['hostnamectl', 'set-hostname', str(hostname)]) + + + @azure_ds_telemetry_reporter +diff --git a/requirements.txt b/requirements.txt +index 5817da3b..5b8becd7 100644 +--- a/requirements.txt ++++ b/requirements.txt +@@ -29,6 +29,3 @@ requests + + # For patching pieces of cloud-config together + jsonpatch +- +-# For validating cloud-config sections per schema definitions +-jsonschema +diff --git a/rhel/README.rhel b/rhel/README.rhel +new file mode 100644 +index 00000000..aa29630d +--- /dev/null ++++ b/rhel/README.rhel +@@ -0,0 +1,5 @@ ++The following cloud-init modules are currently unsupported on this OS: ++ - apt_update_upgrade ('apt_update', 'apt_upgrade', 'apt_mirror', 'apt_preserve_sources_list', 'apt_old_mirror', 'apt_sources', 'debconf_selections', 'packages' options) ++ - byobu ('byobu_by_default' option) ++ - chef ++ - grub_dpkg +diff --git a/rhel/cloud-init-tmpfiles.conf b/rhel/cloud-init-tmpfiles.conf +new file mode 100644 +index 00000000..0c6d2a3b +--- /dev/null ++++ b/rhel/cloud-init-tmpfiles.conf +@@ -0,0 +1 @@ ++d /run/cloud-init 0700 root root - - +diff --git a/rhel/cloud.cfg b/rhel/cloud.cfg +new file mode 100644 +index 00000000..9ecba215 +--- /dev/null ++++ b/rhel/cloud.cfg +@@ -0,0 +1,69 @@ ++users: ++ - default ++ ++disable_root: 1 ++ssh_pwauth: 0 ++ ++mount_default_fields: [~, ~, 'auto', 'defaults,nofail,x-systemd.requires=cloud-init.service', '0', '2'] ++resize_rootfs_tmp: /dev ++ssh_deletekeys: 1 ++ssh_genkeytypes: ~ ++syslog_fix_perms: ~ ++disable_vmware_customization: false ++ ++cloud_init_modules: ++ - disk_setup ++ - migrator ++ - bootcmd ++ - write-files ++ - growpart ++ - resizefs ++ - set_hostname ++ - update_hostname ++ - update_etc_hosts ++ - rsyslog ++ - users-groups ++ - ssh ++ ++cloud_config_modules: ++ - mounts ++ - locale ++ - set-passwords ++ - rh_subscription ++ - yum-add-repo ++ - package-update-upgrade-install ++ - timezone ++ - puppet ++ - chef ++ - salt-minion ++ - mcollective ++ - disable-ec2-metadata ++ - runcmd ++ ++cloud_final_modules: ++ - rightscale_userdata ++ - scripts-per-once ++ - scripts-per-boot ++ - scripts-per-instance ++ - scripts-user ++ - ssh-authkey-fingerprints ++ - keys-to-console ++ - phone-home ++ - final-message ++ - power-state-change ++ ++system_info: ++ default_user: ++ name: cloud-user ++ lock_passwd: true ++ gecos: Cloud User ++ groups: [adm, systemd-journal] ++ sudo: ["ALL=(ALL) NOPASSWD:ALL"] ++ shell: /bin/bash ++ distro: rhel ++ paths: ++ cloud_dir: /var/lib/cloud ++ templates_dir: /etc/cloud/templates ++ ssh_svcname: sshd ++ ++# vim:syntax=yaml +diff --git a/rhel/systemd/cloud-config.service b/rhel/systemd/cloud-config.service +new file mode 100644 +index 00000000..f3dcd4be +--- /dev/null ++++ b/rhel/systemd/cloud-config.service +@@ -0,0 +1,18 @@ ++[Unit] ++Description=Apply the settings specified in cloud-config ++After=network-online.target cloud-config.target ++Wants=network-online.target cloud-config.target ++ConditionPathExists=!/etc/cloud/cloud-init.disabled ++ConditionKernelCommandLine=!cloud-init=disabled ++ ++[Service] ++Type=oneshot ++ExecStart=/usr/bin/cloud-init modules --mode=config ++RemainAfterExit=yes ++TimeoutSec=0 ++ ++# Output needs to appear in instance console output ++StandardOutput=journal+console ++ ++[Install] ++WantedBy=cloud-init.target +diff --git a/rhel/systemd/cloud-config.target b/rhel/systemd/cloud-config.target +new file mode 100644 +index 00000000..ae9b7d02 +--- /dev/null ++++ b/rhel/systemd/cloud-config.target +@@ -0,0 +1,11 @@ ++# cloud-init normally emits a "cloud-config" upstart event to inform third ++# parties that cloud-config is available, which does us no good when we're ++# using systemd. cloud-config.target serves as this synchronization point ++# instead. Services that would "start on cloud-config" with upstart can ++# instead use "After=cloud-config.target" and "Wants=cloud-config.target" ++# as appropriate. ++ ++[Unit] ++Description=Cloud-config availability ++Wants=cloud-init-local.service cloud-init.service ++After=cloud-init-local.service cloud-init.service +diff --git a/rhel/systemd/cloud-final.service b/rhel/systemd/cloud-final.service +new file mode 100644 +index 00000000..e281c0cf +--- /dev/null ++++ b/rhel/systemd/cloud-final.service +@@ -0,0 +1,24 @@ ++[Unit] ++Description=Execute cloud user/final scripts ++After=network-online.target cloud-config.service rc-local.service ++Wants=network-online.target cloud-config.service ++ConditionPathExists=!/etc/cloud/cloud-init.disabled ++ConditionKernelCommandLine=!cloud-init=disabled ++ ++[Service] ++Type=oneshot ++ExecStart=/usr/bin/cloud-init modules --mode=final ++RemainAfterExit=yes ++TimeoutSec=0 ++KillMode=process ++# Restart NetworkManager if it is present and running. ++ExecStartPost=/bin/sh -c 'u=NetworkManager.service; \ ++ out=$(systemctl show --property=SubState $u) || exit; \ ++ [ "$out" = "SubState=running" ] || exit 0; \ ++ systemctl reload-or-try-restart $u' ++ ++# Output needs to appear in instance console output ++StandardOutput=journal+console ++ ++[Install] ++WantedBy=cloud-init.target +diff --git a/rhel/systemd/cloud-init-local.service b/rhel/systemd/cloud-init-local.service +new file mode 100644 +index 00000000..8f9f6c9f +--- /dev/null ++++ b/rhel/systemd/cloud-init-local.service +@@ -0,0 +1,31 @@ ++[Unit] ++Description=Initial cloud-init job (pre-networking) ++DefaultDependencies=no ++Wants=network-pre.target ++After=systemd-remount-fs.service ++Requires=dbus.socket ++After=dbus.socket ++Before=NetworkManager.service network.service ++Before=network-pre.target ++Before=shutdown.target ++Before=firewalld.target ++Conflicts=shutdown.target ++RequiresMountsFor=/var/lib/cloud ++ConditionPathExists=!/etc/cloud/cloud-init.disabled ++ConditionKernelCommandLine=!cloud-init=disabled ++ ++[Service] ++Type=oneshot ++ExecStartPre=/bin/mkdir -p /run/cloud-init ++ExecStartPre=/sbin/restorecon /run/cloud-init ++ExecStartPre=/usr/bin/touch /run/cloud-init/enabled ++ExecStart=/usr/bin/cloud-init init --local ++ExecStart=/bin/touch /run/cloud-init/network-config-ready ++RemainAfterExit=yes ++TimeoutSec=0 ++ ++# Output needs to appear in instance console output ++StandardOutput=journal+console ++ ++[Install] ++WantedBy=cloud-init.target +diff --git a/rhel/systemd/cloud-init.service b/rhel/systemd/cloud-init.service +new file mode 100644 +index 00000000..0b3d796d +--- /dev/null ++++ b/rhel/systemd/cloud-init.service +@@ -0,0 +1,26 @@ ++[Unit] ++Description=Initial cloud-init job (metadata service crawler) ++Wants=cloud-init-local.service ++Wants=sshd-keygen.service ++Wants=sshd.service ++After=cloud-init-local.service ++After=NetworkManager.service network.service ++After=NetworkManager-wait-online.service ++Before=network-online.target ++Before=sshd-keygen.service ++Before=sshd.service ++Before=systemd-user-sessions.service ++ConditionPathExists=!/etc/cloud/cloud-init.disabled ++ConditionKernelCommandLine=!cloud-init=disabled ++ ++[Service] ++Type=oneshot ++ExecStart=/usr/bin/cloud-init init ++RemainAfterExit=yes ++TimeoutSec=0 ++ ++# Output needs to appear in instance console output ++StandardOutput=journal+console ++ ++[Install] ++WantedBy=cloud-init.target +diff --git a/rhel/systemd/cloud-init.target b/rhel/systemd/cloud-init.target +new file mode 100644 +index 00000000..083c3b6f +--- /dev/null ++++ b/rhel/systemd/cloud-init.target +@@ -0,0 +1,7 @@ ++# cloud-init target is enabled by cloud-init-generator ++# To disable it you can either: ++# a.) boot with kernel cmdline of 'cloud-init=disabled' ++# b.) touch a file /etc/cloud/cloud-init.disabled ++[Unit] ++Description=Cloud-init target ++After=multi-user.target +diff --git a/setup.py b/setup.py +index cbacf48e..d5cd01a4 100755 +--- a/setup.py ++++ b/setup.py +@@ -125,14 +125,6 @@ INITSYS_FILES = { + 'sysvinit_deb': [f for f in glob('sysvinit/debian/*') if is_f(f)], + 'sysvinit_openrc': [f for f in glob('sysvinit/gentoo/*') if is_f(f)], + 'sysvinit_suse': [f for f in glob('sysvinit/suse/*') if is_f(f)], +- 'systemd': [render_tmpl(f) +- for f in (glob('systemd/*.tmpl') + +- glob('systemd/*.service') + +- glob('systemd/*.target')) +- if (is_f(f) and not is_generator(f))], +- 'systemd.generators': [ +- render_tmpl(f, mode=0o755) +- for f in glob('systemd/*') if is_f(f) and is_generator(f)], + 'upstart': [f for f in glob('upstart/*') if is_f(f)], + } + INITSYS_ROOTS = { +@@ -142,9 +134,6 @@ INITSYS_ROOTS = { + 'sysvinit_deb': 'etc/init.d', + 'sysvinit_openrc': 'etc/init.d', + 'sysvinit_suse': 'etc/init.d', +- 'systemd': pkg_config_read('systemd', 'systemdsystemunitdir'), +- 'systemd.generators': pkg_config_read('systemd', +- 'systemdsystemgeneratordir'), + 'upstart': 'etc/init/', + } + INITSYS_TYPES = sorted([f.partition(".")[0] for f in INITSYS_ROOTS.keys()]) +@@ -245,14 +234,11 @@ if not in_virtualenv(): + INITSYS_ROOTS[k] = "/" + INITSYS_ROOTS[k] + + data_files = [ +- (ETC + '/cloud', [render_tmpl("config/cloud.cfg.tmpl")]), ++ (ETC + '/bash_completion.d', ['bash_completion/cloud-init']), + (ETC + '/cloud/cloud.cfg.d', glob('config/cloud.cfg.d/*')), + (ETC + '/cloud/templates', glob('templates/*')), +- (USR_LIB_EXEC + '/cloud-init', ['tools/ds-identify', +- 'tools/uncloud-init', ++ (USR_LIB_EXEC + '/cloud-init', ['tools/uncloud-init', + 'tools/write-ssh-key-fingerprints']), +- (USR + '/share/bash-completion/completions', +- ['bash_completion/cloud-init']), + (USR + '/share/doc/cloud-init', [f for f in glob('doc/*') if is_f(f)]), + (USR + '/share/doc/cloud-init/examples', + [f for f in glob('doc/examples/*') if is_f(f)]), +@@ -263,8 +249,7 @@ if not platform.system().endswith('BSD'): + data_files.extend([ + (ETC + '/NetworkManager/dispatcher.d/', + ['tools/hook-network-manager']), +- (ETC + '/dhcp/dhclient-exit-hooks.d/', ['tools/hook-dhclient']), +- (LIB + '/udev/rules.d', [f for f in glob('udev/*.rules')]) ++ ('/usr/lib/udev/rules.d', [f for f in glob('udev/*.rules')]) + ]) + # Use a subclass for install that handles + # adding on the right init system configuration files +@@ -286,8 +271,6 @@ setuptools.setup( + scripts=['tools/cloud-init-per'], + license='Dual-licensed under GPLv3 or Apache 2.0', + data_files=data_files, +- install_requires=requirements, +- cmdclass=cmdclass, + entry_points={ + 'console_scripts': [ + 'cloud-init = cloudinit.cmd.main:main', +diff --git a/tools/read-version b/tools/read-version +index 02c90643..79755f78 100755 +--- a/tools/read-version ++++ b/tools/read-version +@@ -71,32 +71,8 @@ version_long = None + is_release_branch_ci = ( + os.environ.get("TRAVIS_PULL_REQUEST_BRANCH", "").startswith("upstream/") + ) +-if is_gitdir(_tdir) and which("git") and not is_release_branch_ci: +- flags = [] +- if use_tags: +- flags = ['--tags'] +- cmd = ['git', 'describe', '--abbrev=8', '--match=[0-9]*'] + flags +- +- try: +- version = tiny_p(cmd).strip() +- except RuntimeError: +- version = None +- +- if version is None or not version.startswith(src_version): +- sys.stderr.write("git describe version (%s) differs from " +- "cloudinit.version (%s)\n" % (version, src_version)) +- sys.stderr.write( +- "Please get the latest upstream tags.\n" +- "As an example, this can be done with the following:\n" +- "$ git remote add upstream https://git.launchpad.net/cloud-init\n" +- "$ git fetch upstream --tags\n" +- ) +- sys.exit(1) +- +- version_long = tiny_p(cmd + ["--long"]).strip() +-else: +- version = src_version +- version_long = None ++version = src_version ++version_long = None + + # version is X.Y.Z[+xxx.gHASH] + # version_long is None or X.Y.Z-xxx-gHASH +-- +2.27.0 + diff --git a/SOURCES/0002-Do-not-write-NM_CONTROLLED-no-in-generated-interface.patch b/SOURCES/0002-Do-not-write-NM_CONTROLLED-no-in-generated-interface.patch new file mode 100644 index 0000000..4939291 --- /dev/null +++ b/SOURCES/0002-Do-not-write-NM_CONTROLLED-no-in-generated-interface.patch @@ -0,0 +1,283 @@ +From 3f895d7236fab4f12482435829b530022a2205ec Mon Sep 17 00:00:00 2001 +From: Eduardo Otubo +Date: Fri, 7 May 2021 13:36:06 +0200 +Subject: Do not write NM_CONTROLLED=no in generated interface config files + +Conflicts 20.3: + - Not appplying patch on cloudinit/net/sysconfig.py since it now has a +mechanism to identify if cloud-init is running on RHEL, having the +correct settings for NM_CONTROLLED. + +Merged patches (21.1): +- ecbace48 sysconfig: Don't write BOOTPROTO=dhcp for ipv6 dhcp +- a1a00383 include 'NOZEROCONF=yes' in /etc/sysconfig/network +X-downstream-only: true +Signed-off-by: Eduardo Otubo +Signed-off-by: Ryan McCabe +--- + cloudinit/net/sysconfig.py | 13 +++++++++++-- + tests/unittests/test_net.py | 28 ---------------------------- + 2 files changed, 11 insertions(+), 30 deletions(-) + +diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py +index 99a4bae4..d5440998 100644 +--- a/cloudinit/net/sysconfig.py ++++ b/cloudinit/net/sysconfig.py +@@ -289,7 +289,7 @@ class Renderer(renderer.Renderer): + # details about this) + + iface_defaults = { +- 'rhel': {'ONBOOT': True, 'USERCTL': False, 'NM_CONTROLLED': False, ++ 'rhel': {'ONBOOT': True, 'USERCTL': False, + 'BOOTPROTO': 'none'}, + 'suse': {'BOOTPROTO': 'static', 'STARTMODE': 'auto'}, + } +@@ -925,7 +925,16 @@ class Renderer(renderer.Renderer): + # Distros configuring /etc/sysconfig/network as a file e.g. Centos + if sysconfig_path.endswith('network'): + util.ensure_dir(os.path.dirname(sysconfig_path)) +- netcfg = [_make_header(), 'NETWORKING=yes'] ++ netcfg = [] ++ for line in util.load_file(sysconfig_path, quiet=True).split('\n'): ++ if 'cloud-init' in line: ++ break ++ if not line.startswith(('NETWORKING=', ++ 'IPV6_AUTOCONF=', ++ 'NETWORKING_IPV6=')): ++ netcfg.append(line) ++ # Now generate the cloud-init portion of sysconfig/network ++ netcfg.extend([_make_header(), 'NETWORKING=yes']) + if network_state.use_ipv6: + netcfg.append('NETWORKING_IPV6=yes') + netcfg.append('IPV6_AUTOCONF=no') +diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py +index 38d934d4..c67b5fcc 100644 +--- a/tests/unittests/test_net.py ++++ b/tests/unittests/test_net.py +@@ -535,7 +535,6 @@ GATEWAY=172.19.3.254 + HWADDR=fa:16:3e:ed:9a:59 + IPADDR=172.19.1.34 + NETMASK=255.255.252.0 +-NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no +@@ -633,7 +632,6 @@ IPADDR=172.19.1.34 + IPADDR1=10.0.0.10 + NETMASK=255.255.252.0 + NETMASK1=255.255.255.0 +-NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no +@@ -756,7 +754,6 @@ IPV6_AUTOCONF=no + IPV6_DEFAULTGW=2001:DB8::1 + IPV6_FORCE_ACCEPT_RA=no + NETMASK=255.255.252.0 +-NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no +@@ -884,7 +881,6 @@ NETWORK_CONFIGS = { + BOOTPROTO=none + DEVICE=eth1 + HWADDR=cf:d6:af:48:e8:80 +- NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no"""), +@@ -901,7 +897,6 @@ NETWORK_CONFIGS = { + IPADDR=192.168.21.3 + NETMASK=255.255.255.0 + METRIC=10000 +- NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no"""), +@@ -1032,7 +1027,6 @@ NETWORK_CONFIGS = { + IPV6_AUTOCONF=no + IPV6_FORCE_ACCEPT_RA=no + NETMASK=255.255.255.0 +- NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no +@@ -1737,7 +1731,6 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true + DHCPV6C=yes + IPV6INIT=yes + MACADDR=aa:bb:cc:dd:ee:ff +- NM_CONTROLLED=no + ONBOOT=yes + TYPE=Bond + USERCTL=no"""), +@@ -1745,7 +1738,6 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true + BOOTPROTO=dhcp + DEVICE=bond0.200 + DHCLIENT_SET_DEFAULT_ROUTE=no +- NM_CONTROLLED=no + ONBOOT=yes + PHYSDEV=bond0 + USERCTL=no +@@ -1763,7 +1755,6 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true + IPV6_DEFAULTGW=2001:4800:78ff:1b::1 + MACADDR=bb:bb:bb:bb:bb:aa + NETMASK=255.255.255.0 +- NM_CONTROLLED=no + ONBOOT=yes + PRIO=22 + STP=no +@@ -1773,7 +1764,6 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true + BOOTPROTO=none + DEVICE=eth0 + HWADDR=c0:d6:9f:2c:e8:80 +- NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no"""), +@@ -1790,7 +1780,6 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true + MTU=1500 + NETMASK=255.255.255.0 + NETMASK1=255.255.255.0 +- NM_CONTROLLED=no + ONBOOT=yes + PHYSDEV=eth0 + USERCTL=no +@@ -1800,7 +1789,6 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true + DEVICE=eth1 + HWADDR=aa:d6:9f:2c:e8:80 + MASTER=bond0 +- NM_CONTROLLED=no + ONBOOT=yes + SLAVE=yes + TYPE=Ethernet +@@ -1810,7 +1798,6 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true + DEVICE=eth2 + HWADDR=c0:bb:9f:2c:e8:80 + MASTER=bond0 +- NM_CONTROLLED=no + ONBOOT=yes + SLAVE=yes + TYPE=Ethernet +@@ -1820,7 +1807,6 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true + BRIDGE=br0 + DEVICE=eth3 + HWADDR=66:bb:9f:2c:e8:80 +- NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no"""), +@@ -1829,7 +1815,6 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true + BRIDGE=br0 + DEVICE=eth4 + HWADDR=98:bb:9f:2c:e8:80 +- NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no"""), +@@ -1838,7 +1823,6 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true + DEVICE=eth5 + DHCLIENT_SET_DEFAULT_ROUTE=no + HWADDR=98:bb:9f:2c:e8:8a +- NM_CONTROLLED=no + ONBOOT=no + TYPE=Ethernet + USERCTL=no"""), +@@ -2294,7 +2278,6 @@ iface bond0 inet6 static + MTU=9000 + NETMASK=255.255.255.0 + NETMASK1=255.255.255.0 +- NM_CONTROLLED=no + ONBOOT=yes + TYPE=Bond + USERCTL=no +@@ -2304,7 +2287,6 @@ iface bond0 inet6 static + DEVICE=bond0s0 + HWADDR=aa:bb:cc:dd:e8:00 + MASTER=bond0 +- NM_CONTROLLED=no + ONBOOT=yes + SLAVE=yes + TYPE=Ethernet +@@ -2326,7 +2308,6 @@ iface bond0 inet6 static + DEVICE=bond0s1 + HWADDR=aa:bb:cc:dd:e8:01 + MASTER=bond0 +- NM_CONTROLLED=no + ONBOOT=yes + SLAVE=yes + TYPE=Ethernet +@@ -2383,7 +2364,6 @@ iface bond0 inet6 static + BOOTPROTO=none + DEVICE=en0 + HWADDR=aa:bb:cc:dd:e8:00 +- NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no"""), +@@ -2402,7 +2382,6 @@ iface bond0 inet6 static + MTU=2222 + NETMASK=255.255.255.0 + NETMASK1=255.255.255.0 +- NM_CONTROLLED=no + ONBOOT=yes + PHYSDEV=en0 + USERCTL=no +@@ -2467,7 +2446,6 @@ iface bond0 inet6 static + DEVICE=br0 + IPADDR=192.168.2.2 + NETMASK=255.255.255.0 +- NM_CONTROLLED=no + ONBOOT=yes + PRIO=22 + STP=no +@@ -2591,7 +2569,6 @@ iface bond0 inet6 static + HWADDR=52:54:00:12:34:00 + IPADDR=192.168.1.2 + NETMASK=255.255.255.0 +- NM_CONTROLLED=no + ONBOOT=no + TYPE=Ethernet + USERCTL=no +@@ -2601,7 +2578,6 @@ iface bond0 inet6 static + DEVICE=eth1 + HWADDR=52:54:00:12:34:aa + MTU=1480 +- NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no +@@ -2610,7 +2586,6 @@ iface bond0 inet6 static + BOOTPROTO=none + DEVICE=eth2 + HWADDR=52:54:00:12:34:ff +- NM_CONTROLLED=no + ONBOOT=no + TYPE=Ethernet + USERCTL=no +@@ -3027,7 +3002,6 @@ class TestRhelSysConfigRendering(CiTestCase): + BOOTPROTO=dhcp + DEVICE=eth1000 + HWADDR=07-1c-c6-75-a4-be +-NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no +@@ -3148,7 +3122,6 @@ GATEWAY=10.0.2.2 + HWADDR=52:54:00:12:34:00 + IPADDR=10.0.2.15 + NETMASK=255.255.255.0 +-NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no +@@ -3218,7 +3191,6 @@ USERCTL=no + # + BOOTPROTO=dhcp + DEVICE=eth0 +-NM_CONTROLLED=no + ONBOOT=yes + TYPE=Ethernet + USERCTL=no +-- +2.27.0 + diff --git a/SOURCES/0003-limit-permissions-on-def_log_file.patch b/SOURCES/0003-limit-permissions-on-def_log_file.patch new file mode 100644 index 0000000..c9da2fd --- /dev/null +++ b/SOURCES/0003-limit-permissions-on-def_log_file.patch @@ -0,0 +1,69 @@ +From 680ebcb46d1db6f02f2b21c158b4a9af2d789ba3 Mon Sep 17 00:00:00 2001 +From: Eduardo Otubo +Date: Fri, 7 May 2021 13:36:08 +0200 +Subject: limit permissions on def_log_file + +This sets a default mode of 0600 on def_log_file, and makes this +configurable via the def_log_file_mode option in cloud.cfg. + +LP: #1541196 +Resolves: rhbz#1424612 +X-approved-upstream: true + +Conflicts 21.1: + cloudinit/stages.py: adjusting call of ensure_file() to use more +recent version + +Signed-off-by: Eduardo Otubo +--- + cloudinit/settings.py | 1 + + cloudinit/stages.py | 1 + + doc/examples/cloud-config.txt | 4 ++++ + 3 files changed, 6 insertions(+) + +diff --git a/cloudinit/settings.py b/cloudinit/settings.py +index e690c0fd..43a1490c 100644 +--- a/cloudinit/settings.py ++++ b/cloudinit/settings.py +@@ -46,6 +46,7 @@ CFG_BUILTIN = { + 'None', + ], + 'def_log_file': '/var/log/cloud-init.log', ++ 'def_log_file_mode': 0o600, + 'log_cfgs': [], + 'mount_default_fields': [None, None, 'auto', 'defaults,nofail', '0', '2'], + 'ssh_deletekeys': False, +diff --git a/cloudinit/stages.py b/cloudinit/stages.py +index 3ef4491c..83e25dd1 100644 +--- a/cloudinit/stages.py ++++ b/cloudinit/stages.py +@@ -147,6 +147,7 @@ class Init(object): + def _initialize_filesystem(self): + util.ensure_dirs(self._initial_subdirs()) + log_file = util.get_cfg_option_str(self.cfg, 'def_log_file') ++ log_file_mode = util.get_cfg_option_int(self.cfg, 'def_log_file_mode') + if log_file: + util.ensure_file(log_file, preserve_mode=True) + perms = self.cfg.get('syslog_fix_perms') +diff --git a/doc/examples/cloud-config.txt b/doc/examples/cloud-config.txt +index de9a0f87..bb33ad45 100644 +--- a/doc/examples/cloud-config.txt ++++ b/doc/examples/cloud-config.txt +@@ -414,10 +414,14 @@ timezone: US/Eastern + # if syslog_fix_perms is a list, it will iterate through and use the + # first pair that does not raise error. + # ++# 'def_log_file' will be created with mode 'def_log_file_mode', which ++# is specified as a numeric value and defaults to 0600. ++# + # the default values are '/var/log/cloud-init.log' and 'syslog:adm' + # the value of 'def_log_file' should match what is configured in logging + # if either is empty, then no change of ownership will be done + def_log_file: /var/log/my-logging-file.log ++def_log_file_mode: 0600 + syslog_fix_perms: syslog:root + + # you can set passwords for a user or multiple users +-- +2.27.0 + diff --git a/SOURCES/ci-Fix-requiring-device-number-on-EC2-derivatives-836.patch b/SOURCES/ci-Fix-requiring-device-number-on-EC2-derivatives-836.patch new file mode 100644 index 0000000..0ba9401 --- /dev/null +++ b/SOURCES/ci-Fix-requiring-device-number-on-EC2-derivatives-836.patch @@ -0,0 +1,103 @@ +From 83394f05a01b5e1f8e520213537558c1cb5d9051 Mon Sep 17 00:00:00 2001 +From: Eduardo Otubo +Date: Thu, 1 Jul 2021 12:01:34 +0200 +Subject: [PATCH] Fix requiring device-number on EC2 derivatives (#836) + +RH-Author: Eduardo Otubo +RH-MergeRequest: 3: Fix requiring device-number on EC2 derivatives (#836) +RH-Commit: [1/1] a0b7af14a2bc6480bb844a496007737b8807f666 (otubo/cloud-init-src) +RH-Bugzilla: 1943511 +RH-Acked-by: Emanuele Giuseppe Esposito +RH-Acked-by: Mohamed Gamal Morsy + +commit 9bd19645a61586b82e86db6f518dd05c3363b17f +Author: James Falcon +Date: Mon Mar 8 14:09:47 2021 -0600 + + Fix requiring device-number on EC2 derivatives (#836) + + #342 (70dbccbb) introduced the ability to determine route-metrics based on + the `device-number` provided by the EC2 IMDS. Not all datasources that + subclass EC2 will have this attribute, so allow the old behavior if + `device-number` is not present. + + LP: #1917875 + +Signed-off-by: Eduardo Otubo +Signed-off-by: Miroslav Rezanina +--- + cloudinit/sources/DataSourceEc2.py | 3 +- + .../unittests/test_datasource/test_aliyun.py | 30 +++++++++++++++++++ + 2 files changed, 32 insertions(+), 1 deletion(-) + +diff --git a/cloudinit/sources/DataSourceEc2.py b/cloudinit/sources/DataSourceEc2.py +index 1930a509..a2105dc7 100644 +--- a/cloudinit/sources/DataSourceEc2.py ++++ b/cloudinit/sources/DataSourceEc2.py +@@ -765,13 +765,14 @@ def convert_ec2_metadata_network_config( + netcfg['ethernets'][nic_name] = dev_config + return netcfg + # Apply network config for all nics and any secondary IPv4/v6 addresses ++ nic_idx = 0 + for mac, nic_name in sorted(macs_to_nics.items()): + nic_metadata = macs_metadata.get(mac) + if not nic_metadata: + continue # Not a physical nic represented in metadata + # device-number is zero-indexed, we want it 1-indexed for the + # multiplication on the following line +- nic_idx = int(nic_metadata['device-number']) + 1 ++ nic_idx = int(nic_metadata.get('device-number', nic_idx)) + 1 + dhcp_override = {'route-metric': nic_idx * 100} + dev_config = {'dhcp4': True, 'dhcp4-overrides': dhcp_override, + 'dhcp6': False, +diff --git a/tests/unittests/test_datasource/test_aliyun.py b/tests/unittests/test_datasource/test_aliyun.py +index eb2828d5..cab1ac2b 100644 +--- a/tests/unittests/test_datasource/test_aliyun.py ++++ b/tests/unittests/test_datasource/test_aliyun.py +@@ -7,6 +7,7 @@ from unittest import mock + + from cloudinit import helpers + from cloudinit.sources import DataSourceAliYun as ay ++from cloudinit.sources.DataSourceEc2 import convert_ec2_metadata_network_config + from cloudinit.tests import helpers as test_helpers + + DEFAULT_METADATA = { +@@ -183,6 +184,35 @@ class TestAliYunDatasource(test_helpers.HttprettyTestCase): + self.assertEqual(ay.parse_public_keys(public_keys), + public_keys['key-pair-0']['openssh-key']) + ++ def test_route_metric_calculated_without_device_number(self): ++ """Test that route-metric code works without `device-number` ++ ++ `device-number` is part of EC2 metadata, but not supported on aliyun. ++ Attempting to access it will raise a KeyError. ++ ++ LP: #1917875 ++ """ ++ netcfg = convert_ec2_metadata_network_config( ++ {"interfaces": {"macs": { ++ "06:17:04:d7:26:09": { ++ "interface-id": "eni-e44ef49e", ++ }, ++ "06:17:04:d7:26:08": { ++ "interface-id": "eni-e44ef49f", ++ } ++ }}}, ++ macs_to_nics={ ++ '06:17:04:d7:26:09': 'eth0', ++ '06:17:04:d7:26:08': 'eth1', ++ } ++ ) ++ ++ met0 = netcfg['ethernets']['eth0']['dhcp4-overrides']['route-metric'] ++ met1 = netcfg['ethernets']['eth1']['dhcp4-overrides']['route-metric'] ++ ++ # route-metric numbers should be 100 apart ++ assert 100 == abs(met0 - met1) ++ + + class TestIsAliYun(test_helpers.CiTestCase): + ALIYUN_PRODUCT = 'Alibaba Cloud ECS' +-- +2.27.0 + diff --git a/SOURCES/ci-Stop-copying-ssh-system-keys-and-check-folder-permis.patch b/SOURCES/ci-Stop-copying-ssh-system-keys-and-check-folder-permis.patch new file mode 100644 index 0000000..f29354c --- /dev/null +++ b/SOURCES/ci-Stop-copying-ssh-system-keys-and-check-folder-permis.patch @@ -0,0 +1,1383 @@ +From 56a18921655fa829b7b76d6d515a45dd7c733620 Mon Sep 17 00:00:00 2001 +From: Emanuele Giuseppe Esposito +Date: Tue, 10 Aug 2021 00:05:25 +0200 +Subject: [PATCH 1/2] Stop copying ssh system keys and check folder permissions + (#956) + +RH-Author: Emanuele Giuseppe Esposito +RH-MergeRequest: 7: Stop copying ssh system keys and check folder permissions (#956) +RH-Commit: [1/1] e475216a77e3ae6ba9b699fb3ea50bb9c0a88dae (eesposit/cloud-init-centos-) +RH-Bugzilla: 1979099 +RH-Acked-by: Mohamed Gamal Morsy +RH-Acked-by: Eduardo Otubo + +This is a continuation of previous MR 25 and upstream PR #937. +There were still issues when using non-standard file paths like +/etc/ssh/userkeys/%u or /etc/ssh/authorized_keys, and the choice +of storing the keys of all authorized_keys files into a single +one was not ideal. This fix modifies cloudinit to support +all different cases of authorized_keys file locations, and +picks a user-specific file where to copy the new keys that +complies with ssh permissions. + +commit 00dbaf1e9ab0e59d81662f0f3561897bef499a3f +Author: Emanuele Giuseppe Esposito +Date: Mon Aug 9 16:49:56 2021 +0200 + + Stop copying ssh system keys and check folder permissions (#956) + + In /etc/ssh/sshd_config, it is possible to define a custom + authorized_keys file that will contain the keys allowed to access the + machine via the AuthorizedKeysFile option. Cloudinit is able to add + user-specific keys to the existing ones, but we need to be careful on + which of the authorized_keys files listed to pick. + Chosing a file that is shared by all user will cause security + issues, because the owner of that key can then access also other users. + + We therefore pick an authorized_keys file only if it satisfies the + following conditions: + 1. it is not a "global" file, ie it must be defined in + AuthorizedKeysFile with %u, %h or be in /home/. This avoids + security issues. + 2. it must comply with ssh permission requirements, otherwise the ssh + agent won't use that file. + + If it doesn't meet either of those conditions, write to + ~/.ssh/authorized_keys + + We also need to consider the case when the chosen authorized_keys file + does not exist. In this case, the existing behavior of cloud-init is + to create the new file. We therefore need to be sure that the file + complies with ssh permissions too, by setting: + - the actual file to permission 600, and owned by the user + - the directories in the path that do not exist must be root owned and + with permission 755. + +Signed-off-by: Emanuele Giuseppe Esposito +Signed-off-by: Miroslav Rezanina +--- + cloudinit/ssh_util.py | 133 ++++- + cloudinit/util.py | 51 +- + tests/unittests/test_sshutil.py | 952 +++++++++++++++++++++++++------- + 3 files changed, 920 insertions(+), 216 deletions(-) + +diff --git a/cloudinit/ssh_util.py b/cloudinit/ssh_util.py +index 89057262..b8a3c8f7 100644 +--- a/cloudinit/ssh_util.py ++++ b/cloudinit/ssh_util.py +@@ -249,6 +249,113 @@ def render_authorizedkeysfile_paths(value, homedir, username): + return rendered + + ++# Inspired from safe_path() in openssh source code (misc.c). ++def check_permissions(username, current_path, full_path, is_file, strictmodes): ++ """Check if the file/folder in @current_path has the right permissions. ++ ++ We need to check that: ++ 1. If StrictMode is enabled, the owner is either root or the user ++ 2. the user can access the file/folder, otherwise ssh won't use it ++ 3. If StrictMode is enabled, no write permission is given to group ++ and world users (022) ++ """ ++ ++ # group/world can only execute the folder (access) ++ minimal_permissions = 0o711 ++ if is_file: ++ # group/world can only read the file ++ minimal_permissions = 0o644 ++ ++ # 1. owner must be either root or the user itself ++ owner = util.get_owner(current_path) ++ if strictmodes and owner != username and owner != "root": ++ LOG.debug("Path %s in %s must be own by user %s or" ++ " by root, but instead is own by %s. Ignoring key.", ++ current_path, full_path, username, owner) ++ return False ++ ++ parent_permission = util.get_permissions(current_path) ++ # 2. the user can access the file/folder, otherwise ssh won't use it ++ if owner == username: ++ # need only the owner permissions ++ minimal_permissions &= 0o700 ++ else: ++ group_owner = util.get_group(current_path) ++ user_groups = util.get_user_groups(username) ++ ++ if group_owner in user_groups: ++ # need only the group permissions ++ minimal_permissions &= 0o070 ++ else: ++ # need only the world permissions ++ minimal_permissions &= 0o007 ++ ++ if parent_permission & minimal_permissions == 0: ++ LOG.debug("Path %s in %s must be accessible by user %s," ++ " check its permissions", ++ current_path, full_path, username) ++ return False ++ ++ # 3. no write permission (w) is given to group and world users (022) ++ # Group and world user can still have +rx. ++ if strictmodes and parent_permission & 0o022 != 0: ++ LOG.debug("Path %s in %s must not give write" ++ "permission to group or world users. Ignoring key.", ++ current_path, full_path) ++ return False ++ ++ return True ++ ++ ++def check_create_path(username, filename, strictmodes): ++ user_pwent = users_ssh_info(username)[1] ++ root_pwent = users_ssh_info("root")[1] ++ try: ++ # check the directories first ++ directories = filename.split("/")[1:-1] ++ ++ # scan in order, from root to file name ++ parent_folder = "" ++ # this is to comply also with unit tests, and ++ # strange home directories ++ home_folder = os.path.dirname(user_pwent.pw_dir) ++ for directory in directories: ++ parent_folder += "/" + directory ++ if home_folder.startswith(parent_folder): ++ continue ++ ++ if not os.path.isdir(parent_folder): ++ # directory does not exist, and permission so far are good: ++ # create the directory, and make it accessible by everyone ++ # but owned by root, as it might be used by many users. ++ with util.SeLinuxGuard(parent_folder): ++ os.makedirs(parent_folder, mode=0o755, exist_ok=True) ++ util.chownbyid(parent_folder, root_pwent.pw_uid, ++ root_pwent.pw_gid) ++ ++ permissions = check_permissions(username, parent_folder, ++ filename, False, strictmodes) ++ if not permissions: ++ return False ++ ++ # check the file ++ if not os.path.exists(filename): ++ # if file does not exist: we need to create it, since the ++ # folders at this point exist and have right permissions ++ util.write_file(filename, '', mode=0o600, ensure_dir_exists=True) ++ util.chownbyid(filename, user_pwent.pw_uid, user_pwent.pw_gid) ++ ++ permissions = check_permissions(username, filename, ++ filename, True, strictmodes) ++ if not permissions: ++ return False ++ except (IOError, OSError) as e: ++ util.logexc(LOG, str(e)) ++ return False ++ ++ return True ++ ++ + def extract_authorized_keys(username, sshd_cfg_file=DEF_SSHD_CFG): + (ssh_dir, pw_ent) = users_ssh_info(username) + default_authorizedkeys_file = os.path.join(ssh_dir, 'authorized_keys') +@@ -259,6 +366,7 @@ def extract_authorized_keys(username, sshd_cfg_file=DEF_SSHD_CFG): + ssh_cfg = parse_ssh_config_map(sshd_cfg_file) + key_paths = ssh_cfg.get("authorizedkeysfile", + "%h/.ssh/authorized_keys") ++ strictmodes = ssh_cfg.get("strictmodes", "yes") + auth_key_fns = render_authorizedkeysfile_paths( + key_paths, pw_ent.pw_dir, username) + +@@ -269,31 +377,31 @@ def extract_authorized_keys(username, sshd_cfg_file=DEF_SSHD_CFG): + "config from %r, using 'AuthorizedKeysFile' file " + "%r instead", DEF_SSHD_CFG, auth_key_fns[0]) + +- # check if one of the keys is the user's one ++ # check if one of the keys is the user's one and has the right permissions + for key_path, auth_key_fn in zip(key_paths.split(), auth_key_fns): + if any([ + '%u' in key_path, + '%h' in key_path, + auth_key_fn.startswith('{}/'.format(pw_ent.pw_dir)) + ]): +- user_authorizedkeys_file = auth_key_fn ++ permissions_ok = check_create_path(username, auth_key_fn, ++ strictmodes == "yes") ++ if permissions_ok: ++ user_authorizedkeys_file = auth_key_fn ++ break + + if user_authorizedkeys_file != default_authorizedkeys_file: + LOG.debug( + "AuthorizedKeysFile has an user-specific authorized_keys, " + "using %s", user_authorizedkeys_file) + +- # always store all the keys in the user's private file +- return (user_authorizedkeys_file, parse_authorized_keys(auth_key_fns)) ++ return ( ++ user_authorizedkeys_file, ++ parse_authorized_keys([user_authorizedkeys_file]) ++ ) + + + def setup_user_keys(keys, username, options=None): +- # Make sure the users .ssh dir is setup accordingly +- (ssh_dir, pwent) = users_ssh_info(username) +- if not os.path.isdir(ssh_dir): +- util.ensure_dir(ssh_dir, mode=0o700) +- util.chownbyid(ssh_dir, pwent.pw_uid, pwent.pw_gid) +- + # Turn the 'update' keys given into actual entries + parser = AuthKeyLineParser() + key_entries = [] +@@ -302,11 +410,10 @@ def setup_user_keys(keys, username, options=None): + + # Extract the old and make the new + (auth_key_fn, auth_key_entries) = extract_authorized_keys(username) ++ ssh_dir = os.path.dirname(auth_key_fn) + with util.SeLinuxGuard(ssh_dir, recursive=True): + content = update_authorized_keys(auth_key_entries, key_entries) +- util.ensure_dir(os.path.dirname(auth_key_fn), mode=0o700) +- util.write_file(auth_key_fn, content, mode=0o600) +- util.chownbyid(auth_key_fn, pwent.pw_uid, pwent.pw_gid) ++ util.write_file(auth_key_fn, content, preserve_mode=True) + + + class SshdConfigLine(object): +diff --git a/cloudinit/util.py b/cloudinit/util.py +index 4e0a72db..343976ad 100644 +--- a/cloudinit/util.py ++++ b/cloudinit/util.py +@@ -35,6 +35,7 @@ from base64 import b64decode, b64encode + from errno import ENOENT + from functools import lru_cache + from urllib import parse ++from typing import List + + from cloudinit import importer + from cloudinit import log as logging +@@ -1830,6 +1831,53 @@ def chmod(path, mode): + os.chmod(path, real_mode) + + ++def get_permissions(path: str) -> int: ++ """ ++ Returns the octal permissions of the file/folder pointed by the path, ++ encoded as an int. ++ ++ @param path: The full path of the file/folder. ++ """ ++ ++ return stat.S_IMODE(os.stat(path).st_mode) ++ ++ ++def get_owner(path: str) -> str: ++ """ ++ Returns the owner of the file/folder pointed by the path. ++ ++ @param path: The full path of the file/folder. ++ """ ++ st = os.stat(path) ++ return pwd.getpwuid(st.st_uid).pw_name ++ ++ ++def get_group(path: str) -> str: ++ """ ++ Returns the group of the file/folder pointed by the path. ++ ++ @param path: The full path of the file/folder. ++ """ ++ st = os.stat(path) ++ return grp.getgrgid(st.st_gid).gr_name ++ ++ ++def get_user_groups(username: str) -> List[str]: ++ """ ++ Returns a list of all groups to which the user belongs ++ ++ @param username: the user we want to check ++ """ ++ groups = [] ++ for group in grp.getgrall(): ++ if username in group.gr_mem: ++ groups.append(group.gr_name) ++ ++ gid = pwd.getpwnam(username).pw_gid ++ groups.append(grp.getgrgid(gid).gr_name) ++ return groups ++ ++ + def write_file( + filename, + content, +@@ -1856,8 +1904,7 @@ def write_file( + + if preserve_mode: + try: +- file_stat = os.stat(filename) +- mode = stat.S_IMODE(file_stat.st_mode) ++ mode = get_permissions(filename) + except OSError: + pass + +diff --git a/tests/unittests/test_sshutil.py b/tests/unittests/test_sshutil.py +index bcb8044f..a66788bf 100644 +--- a/tests/unittests/test_sshutil.py ++++ b/tests/unittests/test_sshutil.py +@@ -1,6 +1,9 @@ + # This file is part of cloud-init. See LICENSE file for license information. + ++import os ++ + from collections import namedtuple ++from functools import partial + from unittest.mock import patch + + from cloudinit import ssh_util +@@ -8,13 +11,48 @@ from cloudinit.tests import helpers as test_helpers + from cloudinit import util + + # https://stackoverflow.com/questions/11351032/ +-FakePwEnt = namedtuple( +- 'FakePwEnt', +- ['pw_dir', 'pw_gecos', 'pw_name', 'pw_passwd', 'pw_shell', 'pwd_uid']) ++FakePwEnt = namedtuple('FakePwEnt', [ ++ 'pw_name', ++ 'pw_passwd', ++ 'pw_uid', ++ 'pw_gid', ++ 'pw_gecos', ++ 'pw_dir', ++ 'pw_shell', ++]) + FakePwEnt.__new__.__defaults__ = tuple( + "UNSET_%s" % n for n in FakePwEnt._fields) + + ++def mock_get_owner(updated_permissions, value): ++ try: ++ return updated_permissions[value][0] ++ except ValueError: ++ return util.get_owner(value) ++ ++ ++def mock_get_group(updated_permissions, value): ++ try: ++ return updated_permissions[value][1] ++ except ValueError: ++ return util.get_group(value) ++ ++ ++def mock_get_user_groups(username): ++ return username ++ ++ ++def mock_get_permissions(updated_permissions, value): ++ try: ++ return updated_permissions[value][2] ++ except ValueError: ++ return util.get_permissions(value) ++ ++ ++def mock_getpwnam(users, username): ++ return users[username] ++ ++ + # Do not use these public keys, most of them are fetched from + # the testdata for OpenSSH, and their private keys are available + # https://github.com/openssh/openssh-portable/tree/master/regress/unittests/sshkey/testdata +@@ -552,12 +590,30 @@ class TestBasicAuthorizedKeyParse(test_helpers.CiTestCase): + ssh_util.render_authorizedkeysfile_paths( + "/opt/%u/keys", "/home/bobby", "bobby")) + ++ def test_user_file(self): ++ self.assertEqual( ++ ["/opt/bobby"], ++ ssh_util.render_authorizedkeysfile_paths( ++ "/opt/%u", "/home/bobby", "bobby")) ++ ++ def test_user_file2(self): ++ self.assertEqual( ++ ["/opt/bobby/bobby"], ++ ssh_util.render_authorizedkeysfile_paths( ++ "/opt/%u/%u", "/home/bobby", "bobby")) ++ + def test_multiple(self): + self.assertEqual( + ["/keys/path1", "/keys/path2"], + ssh_util.render_authorizedkeysfile_paths( + "/keys/path1 /keys/path2", "/home/bobby", "bobby")) + ++ def test_multiple2(self): ++ self.assertEqual( ++ ["/keys/path1", "/keys/bobby"], ++ ssh_util.render_authorizedkeysfile_paths( ++ "/keys/path1 /keys/%u", "/home/bobby", "bobby")) ++ + def test_relative(self): + self.assertEqual( + ["/home/bobby/.secret/keys"], +@@ -581,269 +637,763 @@ class TestBasicAuthorizedKeyParse(test_helpers.CiTestCase): + + class TestMultipleSshAuthorizedKeysFile(test_helpers.CiTestCase): + +- @patch("cloudinit.ssh_util.pwd.getpwnam") +- def test_multiple_authorizedkeys_file_order1(self, m_getpwnam): +- fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') +- m_getpwnam.return_value = fpw +- user_ssh_folder = "%s/.ssh" % fpw.pw_dir +- +- # /tmp/home2/bobby/.ssh/authorized_keys = rsa +- authorized_keys = self.tmp_path('authorized_keys', dir=user_ssh_folder) +- util.write_file(authorized_keys, VALID_CONTENT['rsa']) +- +- # /tmp/home2/bobby/.ssh/user_keys = dsa +- user_keys = self.tmp_path('user_keys', dir=user_ssh_folder) +- util.write_file(user_keys, VALID_CONTENT['dsa']) +- +- # /tmp/sshd_config ++ def create_fake_users(self, names, mock_permissions, ++ m_get_group, m_get_owner, m_get_permissions, ++ m_getpwnam, users): ++ homes = [] ++ ++ root = '/tmp/root' ++ fpw = FakePwEnt(pw_name="root", pw_dir=root) ++ users["root"] = fpw ++ ++ for name in names: ++ home = '/tmp/home/' + name ++ fpw = FakePwEnt(pw_name=name, pw_dir=home) ++ users[name] = fpw ++ homes.append(home) ++ ++ m_get_permissions.side_effect = partial( ++ mock_get_permissions, mock_permissions) ++ m_get_owner.side_effect = partial(mock_get_owner, mock_permissions) ++ m_get_group.side_effect = partial(mock_get_group, mock_permissions) ++ m_getpwnam.side_effect = partial(mock_getpwnam, users) ++ return homes ++ ++ def create_user_authorized_file(self, home, filename, content_key, keys): ++ user_ssh_folder = "%s/.ssh" % home ++ # /tmp/home//.ssh/authorized_keys = content_key ++ authorized_keys = self.tmp_path(filename, dir=user_ssh_folder) ++ util.write_file(authorized_keys, VALID_CONTENT[content_key]) ++ keys[authorized_keys] = content_key ++ return authorized_keys ++ ++ def create_global_authorized_file(self, filename, content_key, keys): ++ authorized_keys = self.tmp_path(filename, dir='/tmp') ++ util.write_file(authorized_keys, VALID_CONTENT[content_key]) ++ keys[authorized_keys] = content_key ++ return authorized_keys ++ ++ def create_sshd_config(self, authorized_keys_files): + sshd_config = self.tmp_path('sshd_config', dir="/tmp") + util.write_file( + sshd_config, +- "AuthorizedKeysFile %s %s" % (authorized_keys, user_keys) ++ "AuthorizedKeysFile " + authorized_keys_files + ) ++ return sshd_config + ++ def execute_and_check(self, user, sshd_config, solution, keys, ++ delete_keys=True): + (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( +- fpw.pw_name, sshd_config) ++ user, sshd_config) + content = ssh_util.update_authorized_keys(auth_key_entries, []) + +- self.assertEqual(user_keys, auth_key_fn) +- self.assertTrue(VALID_CONTENT['rsa'] in content) +- self.assertTrue(VALID_CONTENT['dsa'] in content) ++ self.assertEqual(auth_key_fn, solution) ++ for path, key in keys.items(): ++ if path == solution: ++ self.assertTrue(VALID_CONTENT[key] in content) ++ else: ++ self.assertFalse(VALID_CONTENT[key] in content) ++ ++ if delete_keys and os.path.isdir("/tmp/home/"): ++ util.delete_dir_contents("/tmp/home/") + + @patch("cloudinit.ssh_util.pwd.getpwnam") +- def test_multiple_authorizedkeys_file_order2(self, m_getpwnam): +- fpw = FakePwEnt(pw_name='suzie', pw_dir='/tmp/home/suzie') +- m_getpwnam.return_value = fpw +- user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_single_user_two_local_files( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam ++ ): ++ user_bobby = 'bobby' ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/user_keys': ('bobby', 'bobby', 0o600), ++ '/tmp/home/bobby/.ssh/authorized_keys': ('bobby', 'bobby', 0o600), ++ } ++ ++ homes = self.create_fake_users( ++ [user_bobby], mock_permissions, m_get_group, m_get_owner, ++ m_get_permissions, m_getpwnam, users ++ ) ++ home = homes[0] + +- # /tmp/home/suzie/.ssh/authorized_keys = rsa +- authorized_keys = self.tmp_path('authorized_keys', dir=user_ssh_folder) +- util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ # /tmp/home/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.create_user_authorized_file( ++ home, 'authorized_keys', 'rsa', keys ++ ) + +- # /tmp/home/suzie/.ssh/user_keys = dsa +- user_keys = self.tmp_path('user_keys', dir=user_ssh_folder) +- util.write_file(user_keys, VALID_CONTENT['dsa']) ++ # /tmp/home/bobby/.ssh/user_keys = dsa ++ user_keys = self.create_user_authorized_file( ++ home, 'user_keys', 'dsa', keys ++ ) + + # /tmp/sshd_config +- sshd_config = self.tmp_path('sshd_config', dir="/tmp") +- util.write_file( +- sshd_config, +- "AuthorizedKeysFile %s %s" % (user_keys, authorized_keys) ++ options = "%s %s" % (authorized_keys, user_keys) ++ sshd_config = self.create_sshd_config(options) ++ ++ self.execute_and_check(user_bobby, sshd_config, authorized_keys, keys) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_single_user_two_local_files_inverted( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam ++ ): ++ user_bobby = 'bobby' ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/user_keys': ('bobby', 'bobby', 0o600), ++ '/tmp/home/bobby/.ssh/authorized_keys': ('bobby', 'bobby', 0o600), ++ } ++ ++ homes = self.create_fake_users( ++ [user_bobby], mock_permissions, m_get_group, m_get_owner, ++ m_get_permissions, m_getpwnam, users + ) ++ home = homes[0] + +- (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( +- fpw.pw_name, sshd_config) +- content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ # /tmp/home/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.create_user_authorized_file( ++ home, 'authorized_keys', 'rsa', keys ++ ) + +- self.assertEqual(authorized_keys, auth_key_fn) +- self.assertTrue(VALID_CONTENT['rsa'] in content) +- self.assertTrue(VALID_CONTENT['dsa'] in content) ++ # /tmp/home/bobby/.ssh/user_keys = dsa ++ user_keys = self.create_user_authorized_file( ++ home, 'user_keys', 'dsa', keys ++ ) + +- @patch("cloudinit.ssh_util.pwd.getpwnam") +- def test_multiple_authorizedkeys_file_local_global(self, m_getpwnam): +- fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') +- m_getpwnam.return_value = fpw +- user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ # /tmp/sshd_config ++ options = "%s %s" % (user_keys, authorized_keys) ++ sshd_config = self.create_sshd_config(options) + +- # /tmp/home2/bobby/.ssh/authorized_keys = rsa +- authorized_keys = self.tmp_path('authorized_keys', dir=user_ssh_folder) +- util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ self.execute_and_check(user_bobby, sshd_config, user_keys, keys) + +- # /tmp/home2/bobby/.ssh/user_keys = dsa +- user_keys = self.tmp_path('user_keys', dir=user_ssh_folder) +- util.write_file(user_keys, VALID_CONTENT['dsa']) ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_single_user_local_global_files( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam ++ ): ++ user_bobby = 'bobby' ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/user_keys': ('bobby', 'bobby', 0o600), ++ '/tmp/home/bobby/.ssh/authorized_keys': ('bobby', 'bobby', 0o600), ++ } ++ ++ homes = self.create_fake_users( ++ [user_bobby], mock_permissions, m_get_group, m_get_owner, ++ m_get_permissions, m_getpwnam, users ++ ) ++ home = homes[0] + +- # /tmp/etc/ssh/authorized_keys = ecdsa +- authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys', +- dir="/tmp") +- util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ # /tmp/home/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.create_user_authorized_file( ++ home, 'authorized_keys', 'rsa', keys ++ ) + +- # /tmp/sshd_config +- sshd_config = self.tmp_path('sshd_config', dir="/tmp") +- util.write_file( +- sshd_config, +- "AuthorizedKeysFile %s %s %s" % (authorized_keys_global, +- user_keys, authorized_keys) ++ # /tmp/home/bobby/.ssh/user_keys = dsa ++ user_keys = self.create_user_authorized_file( ++ home, 'user_keys', 'dsa', keys + ) + +- (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( +- fpw.pw_name, sshd_config) +- content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ authorized_keys_global = self.create_global_authorized_file( ++ 'etc/ssh/authorized_keys', 'ecdsa', keys ++ ) + +- self.assertEqual(authorized_keys, auth_key_fn) +- self.assertTrue(VALID_CONTENT['rsa'] in content) +- self.assertTrue(VALID_CONTENT['ecdsa'] in content) +- self.assertTrue(VALID_CONTENT['dsa'] in content) ++ options = "%s %s %s" % (authorized_keys_global, user_keys, ++ authorized_keys) ++ sshd_config = self.create_sshd_config(options) + +- @patch("cloudinit.ssh_util.pwd.getpwnam") +- def test_multiple_authorizedkeys_file_local_global2(self, m_getpwnam): +- fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') +- m_getpwnam.return_value = fpw +- user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ self.execute_and_check(user_bobby, sshd_config, user_keys, keys) + +- # /tmp/home2/bobby/.ssh/authorized_keys2 = rsa +- authorized_keys = self.tmp_path('authorized_keys2', +- dir=user_ssh_folder) +- util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_single_user_local_global_files_inverted( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam ++ ): ++ user_bobby = 'bobby' ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/user_keys3': ('bobby', 'bobby', 0o600), ++ '/tmp/home/bobby/.ssh/authorized_keys2': ('bobby', 'bobby', 0o600), ++ } ++ ++ homes = self.create_fake_users( ++ [user_bobby], mock_permissions, m_get_group, m_get_owner, ++ m_get_permissions, m_getpwnam, users ++ ) ++ home = homes[0] + +- # /tmp/home2/bobby/.ssh/user_keys3 = dsa +- user_keys = self.tmp_path('user_keys3', dir=user_ssh_folder) +- util.write_file(user_keys, VALID_CONTENT['dsa']) ++ # /tmp/home/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.create_user_authorized_file( ++ home, 'authorized_keys2', 'rsa', keys ++ ) + +- # /tmp/etc/ssh/authorized_keys = ecdsa +- authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys', +- dir="/tmp") +- util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ # /tmp/home/bobby/.ssh/user_keys = dsa ++ user_keys = self.create_user_authorized_file( ++ home, 'user_keys3', 'dsa', keys ++ ) + +- # /tmp/sshd_config +- sshd_config = self.tmp_path('sshd_config', dir="/tmp") +- util.write_file( +- sshd_config, +- "AuthorizedKeysFile %s %s %s" % (authorized_keys_global, +- authorized_keys, user_keys) ++ authorized_keys_global = self.create_global_authorized_file( ++ 'etc/ssh/authorized_keys', 'ecdsa', keys + ) + +- (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( +- fpw.pw_name, sshd_config) +- content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ options = "%s %s %s" % (authorized_keys_global, authorized_keys, ++ user_keys) ++ sshd_config = self.create_sshd_config(options) + +- self.assertEqual(user_keys, auth_key_fn) +- self.assertTrue(VALID_CONTENT['rsa'] in content) +- self.assertTrue(VALID_CONTENT['ecdsa'] in content) +- self.assertTrue(VALID_CONTENT['dsa'] in content) ++ self.execute_and_check(user_bobby, sshd_config, authorized_keys, keys) + + @patch("cloudinit.ssh_util.pwd.getpwnam") +- def test_multiple_authorizedkeys_file_global(self, m_getpwnam): +- fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') +- m_getpwnam.return_value = fpw ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_single_user_global_file( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam ++ ): ++ user_bobby = 'bobby' ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/authorized_keys': ('bobby', 'bobby', 0o600), ++ } ++ ++ homes = self.create_fake_users( ++ [user_bobby], mock_permissions, m_get_group, m_get_owner, ++ m_get_permissions, m_getpwnam, users ++ ) ++ home = homes[0] + + # /tmp/etc/ssh/authorized_keys = rsa +- authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys', +- dir="/tmp") +- util.write_file(authorized_keys_global, VALID_CONTENT['rsa']) ++ authorized_keys_global = self.create_global_authorized_file( ++ 'etc/ssh/authorized_keys', 'rsa', keys ++ ) + +- # /tmp/sshd_config +- sshd_config = self.tmp_path('sshd_config') +- util.write_file( +- sshd_config, +- "AuthorizedKeysFile %s" % (authorized_keys_global) ++ options = "%s" % authorized_keys_global ++ sshd_config = self.create_sshd_config(options) ++ ++ default = "%s/.ssh/authorized_keys" % home ++ self.execute_and_check(user_bobby, sshd_config, default, keys) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_two_users_local_file_standard( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam ++ ): ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/authorized_keys': ('bobby', 'bobby', 0o600), ++ '/tmp/home/suzie': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh/authorized_keys': ('suzie', 'suzie', 0o600), ++ } ++ ++ user_bobby = 'bobby' ++ user_suzie = 'suzie' ++ homes = self.create_fake_users( ++ [user_bobby, user_suzie], mock_permissions, m_get_group, ++ m_get_owner, m_get_permissions, m_getpwnam, users + ) ++ home_bobby = homes[0] ++ home_suzie = homes[1] + +- (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( +- fpw.pw_name, sshd_config) +- content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ # /tmp/home/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.create_user_authorized_file( ++ home_bobby, 'authorized_keys', 'rsa', keys ++ ) + +- self.assertEqual("%s/.ssh/authorized_keys" % fpw.pw_dir, auth_key_fn) +- self.assertTrue(VALID_CONTENT['rsa'] in content) ++ # /tmp/home/suzie/.ssh/authorized_keys = rsa ++ authorized_keys2 = self.create_user_authorized_file( ++ home_suzie, 'authorized_keys', 'ssh-xmss@openssh.com', keys ++ ) ++ ++ options = ".ssh/authorized_keys" ++ sshd_config = self.create_sshd_config(options) ++ ++ self.execute_and_check( ++ user_bobby, sshd_config, authorized_keys, keys, delete_keys=False ++ ) ++ self.execute_and_check(user_suzie, sshd_config, authorized_keys2, keys) + + @patch("cloudinit.ssh_util.pwd.getpwnam") +- def test_multiple_authorizedkeys_file_multiuser(self, m_getpwnam): +- fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') +- m_getpwnam.return_value = fpw +- user_ssh_folder = "%s/.ssh" % fpw.pw_dir +- # /tmp/home2/bobby/.ssh/authorized_keys2 = rsa +- authorized_keys = self.tmp_path('authorized_keys2', +- dir=user_ssh_folder) +- util.write_file(authorized_keys, VALID_CONTENT['rsa']) +- # /tmp/home2/bobby/.ssh/user_keys3 = dsa +- user_keys = self.tmp_path('user_keys3', dir=user_ssh_folder) +- util.write_file(user_keys, VALID_CONTENT['dsa']) +- +- fpw2 = FakePwEnt(pw_name='suzie', pw_dir='/tmp/home/suzie') +- user_ssh_folder = "%s/.ssh" % fpw2.pw_dir +- # /tmp/home/suzie/.ssh/authorized_keys2 = ssh-xmss@openssh.com +- authorized_keys2 = self.tmp_path('authorized_keys2', +- dir=user_ssh_folder) +- util.write_file(authorized_keys2, +- VALID_CONTENT['ssh-xmss@openssh.com']) ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_two_users_local_file_custom( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam ++ ): ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/authorized_keys2': ('bobby', 'bobby', 0o600), ++ '/tmp/home/suzie': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh/authorized_keys2': ('suzie', 'suzie', 0o600), ++ } ++ ++ user_bobby = 'bobby' ++ user_suzie = 'suzie' ++ homes = self.create_fake_users( ++ [user_bobby, user_suzie], mock_permissions, m_get_group, ++ m_get_owner, m_get_permissions, m_getpwnam, users ++ ) ++ home_bobby = homes[0] ++ home_suzie = homes[1] + +- # /tmp/etc/ssh/authorized_keys = ecdsa +- authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys2', +- dir="/tmp") +- util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ # /tmp/home/bobby/.ssh/authorized_keys2 = rsa ++ authorized_keys = self.create_user_authorized_file( ++ home_bobby, 'authorized_keys2', 'rsa', keys ++ ) + +- # /tmp/sshd_config +- sshd_config = self.tmp_path('sshd_config', dir="/tmp") +- util.write_file( +- sshd_config, +- "AuthorizedKeysFile %s %%h/.ssh/authorized_keys2 %s" % +- (authorized_keys_global, user_keys) ++ # /tmp/home/suzie/.ssh/authorized_keys2 = rsa ++ authorized_keys2 = self.create_user_authorized_file( ++ home_suzie, 'authorized_keys2', 'ssh-xmss@openssh.com', keys + ) + +- # process first user +- (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( +- fpw.pw_name, sshd_config) +- content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ options = ".ssh/authorized_keys2" ++ sshd_config = self.create_sshd_config(options) ++ ++ self.execute_and_check( ++ user_bobby, sshd_config, authorized_keys, keys, delete_keys=False ++ ) ++ self.execute_and_check(user_suzie, sshd_config, authorized_keys2, keys) + +- self.assertEqual(user_keys, auth_key_fn) +- self.assertTrue(VALID_CONTENT['rsa'] in content) +- self.assertTrue(VALID_CONTENT['ecdsa'] in content) +- self.assertTrue(VALID_CONTENT['dsa'] in content) +- self.assertFalse(VALID_CONTENT['ssh-xmss@openssh.com'] in content) ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_two_users_local_global_files( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam ++ ): ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/authorized_keys2': ('bobby', 'bobby', 0o600), ++ '/tmp/home/bobby/.ssh/user_keys3': ('bobby', 'bobby', 0o600), ++ '/tmp/home/suzie': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh/authorized_keys2': ('suzie', 'suzie', 0o600), ++ '/tmp/home/suzie/.ssh/user_keys3': ('suzie', 'suzie', 0o600), ++ } ++ ++ user_bobby = 'bobby' ++ user_suzie = 'suzie' ++ homes = self.create_fake_users( ++ [user_bobby, user_suzie], mock_permissions, m_get_group, ++ m_get_owner, m_get_permissions, m_getpwnam, users ++ ) ++ home_bobby = homes[0] ++ home_suzie = homes[1] + +- m_getpwnam.return_value = fpw2 +- # process second user +- (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( +- fpw2.pw_name, sshd_config) +- content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ # /tmp/home/bobby/.ssh/authorized_keys2 = rsa ++ self.create_user_authorized_file( ++ home_bobby, 'authorized_keys2', 'rsa', keys ++ ) ++ # /tmp/home/bobby/.ssh/user_keys3 = dsa ++ user_keys = self.create_user_authorized_file( ++ home_bobby, 'user_keys3', 'dsa', keys ++ ) ++ ++ # /tmp/home/suzie/.ssh/authorized_keys2 = rsa ++ authorized_keys2 = self.create_user_authorized_file( ++ home_suzie, 'authorized_keys2', 'ssh-xmss@openssh.com', keys ++ ) ++ ++ # /tmp/etc/ssh/authorized_keys = ecdsa ++ authorized_keys_global = self.create_global_authorized_file( ++ 'etc/ssh/authorized_keys2', 'ecdsa', keys ++ ) ++ ++ options = "%s %s %%h/.ssh/authorized_keys2" % \ ++ (authorized_keys_global, user_keys) ++ sshd_config = self.create_sshd_config(options) + +- self.assertEqual(authorized_keys2, auth_key_fn) +- self.assertTrue(VALID_CONTENT['ssh-xmss@openssh.com'] in content) +- self.assertTrue(VALID_CONTENT['ecdsa'] in content) +- self.assertTrue(VALID_CONTENT['dsa'] in content) +- self.assertFalse(VALID_CONTENT['rsa'] in content) ++ self.execute_and_check( ++ user_bobby, sshd_config, user_keys, keys, delete_keys=False ++ ) ++ self.execute_and_check(user_suzie, sshd_config, authorized_keys2, keys) + ++ @patch("cloudinit.util.get_user_groups") + @patch("cloudinit.ssh_util.pwd.getpwnam") +- def test_multiple_authorizedkeys_file_multiuser2(self, m_getpwnam): +- fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home/bobby') +- m_getpwnam.return_value = fpw +- user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_two_users_local_global_files_badguy( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam, ++ m_get_user_groups ++ ): ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/authorized_keys2': ('bobby', 'bobby', 0o600), ++ '/tmp/home/bobby/.ssh/user_keys3': ('bobby', 'bobby', 0o600), ++ '/tmp/home/badguy': ('root', 'root', 0o755), ++ '/tmp/home/badguy/home': ('root', 'root', 0o755), ++ '/tmp/home/badguy/home/bobby': ('root', 'root', 0o655), ++ } ++ ++ user_bobby = 'bobby' ++ user_badguy = 'badguy' ++ home_bobby, *_ = self.create_fake_users( ++ [user_bobby, user_badguy], mock_permissions, m_get_group, ++ m_get_owner, m_get_permissions, m_getpwnam, users ++ ) ++ m_get_user_groups.side_effect = mock_get_user_groups ++ + # /tmp/home/bobby/.ssh/authorized_keys2 = rsa +- authorized_keys = self.tmp_path('authorized_keys2', +- dir=user_ssh_folder) +- util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ authorized_keys = self.create_user_authorized_file( ++ home_bobby, 'authorized_keys2', 'rsa', keys ++ ) + # /tmp/home/bobby/.ssh/user_keys3 = dsa +- user_keys = self.tmp_path('user_keys3', dir=user_ssh_folder) +- util.write_file(user_keys, VALID_CONTENT['dsa']) ++ user_keys = self.create_user_authorized_file( ++ home_bobby, 'user_keys3', 'dsa', keys ++ ) + +- fpw2 = FakePwEnt(pw_name='badguy', pw_dir='/tmp/home/badguy') +- user_ssh_folder = "%s/.ssh" % fpw2.pw_dir + # /tmp/home/badguy/home/bobby = "" + authorized_keys2 = self.tmp_path('home/bobby', dir="/tmp/home/badguy") ++ util.write_file(authorized_keys2, '') + + # /tmp/etc/ssh/authorized_keys = ecdsa +- authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys2', +- dir="/tmp") +- util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ authorized_keys_global = self.create_global_authorized_file( ++ 'etc/ssh/authorized_keys2', 'ecdsa', keys ++ ) + + # /tmp/sshd_config +- sshd_config = self.tmp_path('sshd_config', dir="/tmp") +- util.write_file( +- sshd_config, +- "AuthorizedKeysFile %s %%h/.ssh/authorized_keys2 %s %s" % +- (authorized_keys_global, user_keys, authorized_keys2) ++ options = "%s %%h/.ssh/authorized_keys2 %s %s" % \ ++ (authorized_keys2, authorized_keys_global, user_keys) ++ sshd_config = self.create_sshd_config(options) ++ ++ self.execute_and_check( ++ user_bobby, sshd_config, authorized_keys, keys, delete_keys=False ++ ) ++ self.execute_and_check( ++ user_badguy, sshd_config, authorized_keys2, keys + ) + +- # process first user +- (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( +- fpw.pw_name, sshd_config) +- content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ @patch("cloudinit.util.get_user_groups") ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_two_users_unaccessible_file( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam, ++ m_get_user_groups ++ ): ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/authorized_keys': ('bobby', 'bobby', 0o600), ++ ++ '/tmp/etc': ('root', 'root', 0o755), ++ '/tmp/etc/ssh': ('root', 'root', 0o755), ++ '/tmp/etc/ssh/userkeys': ('root', 'root', 0o700), ++ '/tmp/etc/ssh/userkeys/bobby': ('bobby', 'bobby', 0o600), ++ '/tmp/etc/ssh/userkeys/badguy': ('badguy', 'badguy', 0o600), ++ ++ '/tmp/home/badguy': ('badguy', 'badguy', 0o700), ++ '/tmp/home/badguy/.ssh': ('badguy', 'badguy', 0o700), ++ '/tmp/home/badguy/.ssh/authorized_keys': ++ ('badguy', 'badguy', 0o600), ++ } ++ ++ user_bobby = 'bobby' ++ user_badguy = 'badguy' ++ homes = self.create_fake_users( ++ [user_bobby, user_badguy], mock_permissions, m_get_group, ++ m_get_owner, m_get_permissions, m_getpwnam, users ++ ) ++ m_get_user_groups.side_effect = mock_get_user_groups ++ home_bobby = homes[0] ++ home_badguy = homes[1] + +- self.assertEqual(user_keys, auth_key_fn) +- self.assertTrue(VALID_CONTENT['rsa'] in content) +- self.assertTrue(VALID_CONTENT['ecdsa'] in content) +- self.assertTrue(VALID_CONTENT['dsa'] in content) ++ # /tmp/home/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.create_user_authorized_file( ++ home_bobby, 'authorized_keys', 'rsa', keys ++ ) ++ # /tmp/etc/ssh/userkeys/bobby = dsa ++ # assume here that we can bypass userkeys, despite permissions ++ self.create_global_authorized_file( ++ 'etc/ssh/userkeys/bobby', 'dsa', keys ++ ) + +- m_getpwnam.return_value = fpw2 +- # process second user +- (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( +- fpw2.pw_name, sshd_config) +- content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ # /tmp/home/badguy/.ssh/authorized_keys = ssh-xmss@openssh.com ++ authorized_keys2 = self.create_user_authorized_file( ++ home_badguy, 'authorized_keys', 'ssh-xmss@openssh.com', keys ++ ) + +- # badguy should not take the key from the other user! +- self.assertEqual(authorized_keys2, auth_key_fn) +- self.assertTrue(VALID_CONTENT['ecdsa'] in content) +- self.assertTrue(VALID_CONTENT['dsa'] in content) +- self.assertFalse(VALID_CONTENT['rsa'] in content) ++ # /tmp/etc/ssh/userkeys/badguy = ecdsa ++ self.create_global_authorized_file( ++ 'etc/ssh/userkeys/badguy', 'ecdsa', keys ++ ) ++ ++ # /tmp/sshd_config ++ options = "/tmp/etc/ssh/userkeys/%u .ssh/authorized_keys" ++ sshd_config = self.create_sshd_config(options) ++ ++ self.execute_and_check( ++ user_bobby, sshd_config, authorized_keys, keys, delete_keys=False ++ ) ++ self.execute_and_check( ++ user_badguy, sshd_config, authorized_keys2, keys ++ ) ++ ++ @patch("cloudinit.util.get_user_groups") ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_two_users_accessible_file( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam, ++ m_get_user_groups ++ ): ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/authorized_keys': ('bobby', 'bobby', 0o600), ++ ++ '/tmp/etc': ('root', 'root', 0o755), ++ '/tmp/etc/ssh': ('root', 'root', 0o755), ++ '/tmp/etc/ssh/userkeys': ('root', 'root', 0o755), ++ '/tmp/etc/ssh/userkeys/bobby': ('bobby', 'bobby', 0o600), ++ '/tmp/etc/ssh/userkeys/badguy': ('badguy', 'badguy', 0o600), ++ ++ '/tmp/home/badguy': ('badguy', 'badguy', 0o700), ++ '/tmp/home/badguy/.ssh': ('badguy', 'badguy', 0o700), ++ '/tmp/home/badguy/.ssh/authorized_keys': ++ ('badguy', 'badguy', 0o600), ++ } ++ ++ user_bobby = 'bobby' ++ user_badguy = 'badguy' ++ homes = self.create_fake_users( ++ [user_bobby, user_badguy], mock_permissions, m_get_group, ++ m_get_owner, m_get_permissions, m_getpwnam, users ++ ) ++ m_get_user_groups.side_effect = mock_get_user_groups ++ home_bobby = homes[0] ++ home_badguy = homes[1] ++ ++ # /tmp/home/bobby/.ssh/authorized_keys = rsa ++ self.create_user_authorized_file( ++ home_bobby, 'authorized_keys', 'rsa', keys ++ ) ++ # /tmp/etc/ssh/userkeys/bobby = dsa ++ # assume here that we can bypass userkeys, despite permissions ++ authorized_keys = self.create_global_authorized_file( ++ 'etc/ssh/userkeys/bobby', 'dsa', keys ++ ) ++ ++ # /tmp/home/badguy/.ssh/authorized_keys = ssh-xmss@openssh.com ++ self.create_user_authorized_file( ++ home_badguy, 'authorized_keys', 'ssh-xmss@openssh.com', keys ++ ) ++ ++ # /tmp/etc/ssh/userkeys/badguy = ecdsa ++ authorized_keys2 = self.create_global_authorized_file( ++ 'etc/ssh/userkeys/badguy', 'ecdsa', keys ++ ) ++ ++ # /tmp/sshd_config ++ options = "/tmp/etc/ssh/userkeys/%u .ssh/authorized_keys" ++ sshd_config = self.create_sshd_config(options) ++ ++ self.execute_and_check( ++ user_bobby, sshd_config, authorized_keys, keys, delete_keys=False ++ ) ++ self.execute_and_check( ++ user_badguy, sshd_config, authorized_keys2, keys ++ ) ++ ++ @patch("cloudinit.util.get_user_groups") ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_two_users_hardcoded_single_user_file( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam, ++ m_get_user_groups ++ ): ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/authorized_keys': ('bobby', 'bobby', 0o600), ++ ++ '/tmp/home/suzie': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh/authorized_keys': ('suzie', 'suzie', 0o600), ++ } ++ ++ user_bobby = 'bobby' ++ user_suzie = 'suzie' ++ homes = self.create_fake_users( ++ [user_bobby, user_suzie], mock_permissions, m_get_group, ++ m_get_owner, m_get_permissions, m_getpwnam, users ++ ) ++ home_bobby = homes[0] ++ home_suzie = homes[1] ++ m_get_user_groups.side_effect = mock_get_user_groups ++ ++ # /tmp/home/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.create_user_authorized_file( ++ home_bobby, 'authorized_keys', 'rsa', keys ++ ) ++ ++ # /tmp/home/suzie/.ssh/authorized_keys = ssh-xmss@openssh.com ++ self.create_user_authorized_file( ++ home_suzie, 'authorized_keys', 'ssh-xmss@openssh.com', keys ++ ) ++ ++ # /tmp/sshd_config ++ options = "%s" % (authorized_keys) ++ sshd_config = self.create_sshd_config(options) ++ ++ self.execute_and_check( ++ user_bobby, sshd_config, authorized_keys, keys, delete_keys=False ++ ) ++ default = "%s/.ssh/authorized_keys" % home_suzie ++ self.execute_and_check(user_suzie, sshd_config, default, keys) ++ ++ @patch("cloudinit.util.get_user_groups") ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_two_users_hardcoded_single_user_file_inverted( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam, ++ m_get_user_groups ++ ): ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/authorized_keys': ('bobby', 'bobby', 0o600), ++ ++ '/tmp/home/suzie': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh/authorized_keys': ('suzie', 'suzie', 0o600), ++ } ++ ++ user_bobby = 'bobby' ++ user_suzie = 'suzie' ++ homes = self.create_fake_users( ++ [user_bobby, user_suzie], mock_permissions, m_get_group, ++ m_get_owner, m_get_permissions, m_getpwnam, users ++ ) ++ home_bobby = homes[0] ++ home_suzie = homes[1] ++ m_get_user_groups.side_effect = mock_get_user_groups ++ ++ # /tmp/home/bobby/.ssh/authorized_keys = rsa ++ self.create_user_authorized_file( ++ home_bobby, 'authorized_keys', 'rsa', keys ++ ) ++ ++ # /tmp/home/suzie/.ssh/authorized_keys = ssh-xmss@openssh.com ++ authorized_keys2 = self.create_user_authorized_file( ++ home_suzie, 'authorized_keys', 'ssh-xmss@openssh.com', keys ++ ) ++ ++ # /tmp/sshd_config ++ options = "%s" % (authorized_keys2) ++ sshd_config = self.create_sshd_config(options) ++ ++ default = "%s/.ssh/authorized_keys" % home_bobby ++ self.execute_and_check( ++ user_bobby, sshd_config, default, keys, delete_keys=False ++ ) ++ self.execute_and_check(user_suzie, sshd_config, authorized_keys2, keys) ++ ++ @patch("cloudinit.util.get_user_groups") ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ @patch("cloudinit.util.get_permissions") ++ @patch("cloudinit.util.get_owner") ++ @patch("cloudinit.util.get_group") ++ def test_two_users_hardcoded_user_files( ++ self, m_get_group, m_get_owner, m_get_permissions, m_getpwnam, ++ m_get_user_groups ++ ): ++ keys = {} ++ users = {} ++ mock_permissions = { ++ '/tmp/home/bobby': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh': ('bobby', 'bobby', 0o700), ++ '/tmp/home/bobby/.ssh/authorized_keys': ('bobby', 'bobby', 0o600), ++ ++ '/tmp/home/suzie': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh': ('suzie', 'suzie', 0o700), ++ '/tmp/home/suzie/.ssh/authorized_keys': ('suzie', 'suzie', 0o600), ++ } ++ ++ user_bobby = 'bobby' ++ user_suzie = 'suzie' ++ homes = self.create_fake_users( ++ [user_bobby, user_suzie], mock_permissions, m_get_group, ++ m_get_owner, m_get_permissions, m_getpwnam, users ++ ) ++ home_bobby = homes[0] ++ home_suzie = homes[1] ++ m_get_user_groups.side_effect = mock_get_user_groups ++ ++ # /tmp/home/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.create_user_authorized_file( ++ home_bobby, 'authorized_keys', 'rsa', keys ++ ) ++ ++ # /tmp/home/suzie/.ssh/authorized_keys = ssh-xmss@openssh.com ++ authorized_keys2 = self.create_user_authorized_file( ++ home_suzie, 'authorized_keys', 'ssh-xmss@openssh.com', keys ++ ) ++ ++ # /tmp/etc/ssh/authorized_keys = ecdsa ++ authorized_keys_global = self.create_global_authorized_file( ++ 'etc/ssh/authorized_keys', 'ecdsa', keys ++ ) ++ ++ # /tmp/sshd_config ++ options = "%s %s %s" % \ ++ (authorized_keys_global, authorized_keys, authorized_keys2) ++ sshd_config = self.create_sshd_config(options) ++ ++ self.execute_and_check( ++ user_bobby, sshd_config, authorized_keys, keys, delete_keys=False ++ ) ++ self.execute_and_check(user_suzie, sshd_config, authorized_keys2, keys) + + # vi: ts=4 expandtab +-- +2.27.0 + diff --git a/SOURCES/ci-rhel-cloud.cfg-remove-ssh_genkeytypes-in-settings.py.patch b/SOURCES/ci-rhel-cloud.cfg-remove-ssh_genkeytypes-in-settings.py.patch new file mode 100644 index 0000000..b88a099 --- /dev/null +++ b/SOURCES/ci-rhel-cloud.cfg-remove-ssh_genkeytypes-in-settings.py.patch @@ -0,0 +1,65 @@ +From 5069e58c009bc8c689f00de35391ae6d860197a4 Mon Sep 17 00:00:00 2001 +From: Emanuele Giuseppe Esposito +Date: Thu, 20 May 2021 08:53:55 +0200 +Subject: [PATCH 1/2] rhel/cloud.cfg: remove ssh_genkeytypes in settings.py and + set in cloud.cfg + +RH-Author: Emanuele Giuseppe Esposito +RH-MergeRequest: 16: rhel/cloud.cfg: remove ssh_genkeytypes in settings.py and set in cloud.cfg +RH-Commit: [1/1] 67a4904f4d7918be4c9b3c3dbf340b3ecb9e8786 +RH-Bugzilla: 1970909 +RH-Acked-by: Mohamed Gamal Morsy +RH-Acked-by: Eduardo Otubo +RH-Acked-by: Vitaly Kuznetsov + +Currently genkeytypes in cloud.cfg is set to None, so together with +ssh_deletekeys=1 cloudinit on first boot it will just delete the existing +keys and not generate new ones. + +Just removing that property in cloud.cfg is not enough, because +settings.py provides another empty default value that will be used +instead, resulting to no key generated even when the property is not defined. + +Removing genkeytypes also in settings.py will default to GENERATE_KEY_NAMES, +but since we want only 'rsa', 'ecdsa' and 'ed25519', add back genkeytypes in +cloud.cfg with the above defaults. + +Also remove ssh_deletekeys in settings.py as we always need +to 1 (and it also defaults to 1). + +Signed-off-by: Emanuele Giuseppe Esposito +Signed-off-by: Miroslav Rezanina +--- + cloudinit/settings.py | 2 -- + rhel/cloud.cfg | 2 +- + 2 files changed, 1 insertion(+), 3 deletions(-) + +diff --git a/cloudinit/settings.py b/cloudinit/settings.py +index 43a1490c..2acf2615 100644 +--- a/cloudinit/settings.py ++++ b/cloudinit/settings.py +@@ -49,8 +49,6 @@ CFG_BUILTIN = { + 'def_log_file_mode': 0o600, + 'log_cfgs': [], + 'mount_default_fields': [None, None, 'auto', 'defaults,nofail', '0', '2'], +- 'ssh_deletekeys': False, +- 'ssh_genkeytypes': [], + 'syslog_fix_perms': [], + 'system_info': { + 'paths': { +diff --git a/rhel/cloud.cfg b/rhel/cloud.cfg +index 9ecba215..cbee197a 100644 +--- a/rhel/cloud.cfg ++++ b/rhel/cloud.cfg +@@ -7,7 +7,7 @@ ssh_pwauth: 0 + mount_default_fields: [~, ~, 'auto', 'defaults,nofail,x-systemd.requires=cloud-init.service', '0', '2'] + resize_rootfs_tmp: /dev + ssh_deletekeys: 1 +-ssh_genkeytypes: ~ ++ssh_genkeytypes: ['rsa', 'ecdsa', 'ed25519'] + syslog_fix_perms: ~ + disable_vmware_customization: false + +-- +2.27.0 + diff --git a/SOURCES/ci-ssh-util-allow-cloudinit-to-merge-all-ssh-keys-into-.patch b/SOURCES/ci-ssh-util-allow-cloudinit-to-merge-all-ssh-keys-into-.patch new file mode 100644 index 0000000..38be3f4 --- /dev/null +++ b/SOURCES/ci-ssh-util-allow-cloudinit-to-merge-all-ssh-keys-into-.patch @@ -0,0 +1,651 @@ +From 857009723f14e9ad2f5f4c8614d72982b00ec27d Mon Sep 17 00:00:00 2001 +From: Emanuele Giuseppe Esposito +Date: Mon, 12 Jul 2021 21:47:37 +0200 +Subject: [PATCH 2/2] ssh-util: allow cloudinit to merge all ssh keys into a + custom user file, defined in AuthorizedKeysFile (#937) + +RH-Author: Emanuele Giuseppe Esposito +RH-MergeRequest: 5: ssh-util: allow cloudinit to merge all ssh keys into a custom user file, defined in AuthorizedKeysFile (#937) +RH-Commit: [1/1] 3ed352e47c34e2ed2a1f9f5d68bc8b8f9a1365a6 (eesposit/cloud-init-centos-) +RH-Bugzilla: 1979099 +RH-Acked-by: Miroslav Rezanina +RH-Acked-by: Mohamed Gamal Morsy + +Conflicts: upstream patch modifies tests/integration_tests/util.py, that is +not present in RHEL. + +commit 9b52405c6f0de5e00d5ee9c1d13540425d8f6bf5 +Author: Emanuele Giuseppe Esposito +Date: Mon Jul 12 20:21:02 2021 +0200 + + ssh-util: allow cloudinit to merge all ssh keys into a custom user file, defined in AuthorizedKeysFile (#937) + + This patch aims to fix LP1911680, by analyzing the files provided + in sshd_config and merge all keys into an user-specific file. Also + introduces additional tests to cover this specific case. + + The file is picked by analyzing the path given in AuthorizedKeysFile. + + If it points inside the current user folder (path is /home/user/*), it + means it is an user-specific file, so we can copy all user-keys there. + If it contains a %u or %h, it means that there will be a specific + authorized_keys file for each user, so we can copy all user-keys there. + If no path points to an user-specific file, for example when only + /etc/ssh/authorized_keys is given, default to ~/.ssh/authorized_keys. + Note that if there are more than a single user-specific file, the last + one will be picked. + + Signed-off-by: Emanuele Giuseppe Esposito + Co-authored-by: James Falcon + + LP: #1911680 + RHBZ:1862967 + +Signed-off-by: Emanuele Giuseppe Esposito +Signed-off-by: Miroslav Rezanina +--- + cloudinit/ssh_util.py | 22 +- + .../assets/keys/id_rsa.test1 | 38 +++ + .../assets/keys/id_rsa.test1.pub | 1 + + .../assets/keys/id_rsa.test2 | 38 +++ + .../assets/keys/id_rsa.test2.pub | 1 + + .../assets/keys/id_rsa.test3 | 38 +++ + .../assets/keys/id_rsa.test3.pub | 1 + + .../modules/test_ssh_keysfile.py | 85 ++++++ + tests/unittests/test_sshutil.py | 246 +++++++++++++++++- + 9 files changed, 456 insertions(+), 14 deletions(-) + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test1 + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test1.pub + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test2 + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test2.pub + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test3 + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test3.pub + create mode 100644 tests/integration_tests/modules/test_ssh_keysfile.py + +diff --git a/cloudinit/ssh_util.py b/cloudinit/ssh_util.py +index c08042d6..89057262 100644 +--- a/cloudinit/ssh_util.py ++++ b/cloudinit/ssh_util.py +@@ -252,13 +252,15 @@ def render_authorizedkeysfile_paths(value, homedir, username): + def extract_authorized_keys(username, sshd_cfg_file=DEF_SSHD_CFG): + (ssh_dir, pw_ent) = users_ssh_info(username) + default_authorizedkeys_file = os.path.join(ssh_dir, 'authorized_keys') ++ user_authorizedkeys_file = default_authorizedkeys_file + auth_key_fns = [] + with util.SeLinuxGuard(ssh_dir, recursive=True): + try: + ssh_cfg = parse_ssh_config_map(sshd_cfg_file) ++ key_paths = ssh_cfg.get("authorizedkeysfile", ++ "%h/.ssh/authorized_keys") + auth_key_fns = render_authorizedkeysfile_paths( +- ssh_cfg.get("authorizedkeysfile", "%h/.ssh/authorized_keys"), +- pw_ent.pw_dir, username) ++ key_paths, pw_ent.pw_dir, username) + + except (IOError, OSError): + # Give up and use a default key filename +@@ -267,8 +269,22 @@ def extract_authorized_keys(username, sshd_cfg_file=DEF_SSHD_CFG): + "config from %r, using 'AuthorizedKeysFile' file " + "%r instead", DEF_SSHD_CFG, auth_key_fns[0]) + ++ # check if one of the keys is the user's one ++ for key_path, auth_key_fn in zip(key_paths.split(), auth_key_fns): ++ if any([ ++ '%u' in key_path, ++ '%h' in key_path, ++ auth_key_fn.startswith('{}/'.format(pw_ent.pw_dir)) ++ ]): ++ user_authorizedkeys_file = auth_key_fn ++ ++ if user_authorizedkeys_file != default_authorizedkeys_file: ++ LOG.debug( ++ "AuthorizedKeysFile has an user-specific authorized_keys, " ++ "using %s", user_authorizedkeys_file) ++ + # always store all the keys in the user's private file +- return (default_authorizedkeys_file, parse_authorized_keys(auth_key_fns)) ++ return (user_authorizedkeys_file, parse_authorized_keys(auth_key_fns)) + + + def setup_user_keys(keys, username, options=None): +diff --git a/tests/integration_tests/assets/keys/id_rsa.test1 b/tests/integration_tests/assets/keys/id_rsa.test1 +new file mode 100644 +index 00000000..bd4c822e +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test1 +@@ -0,0 +1,38 @@ ++-----BEGIN OPENSSH PRIVATE KEY----- ++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 ++CHbTWbsFrHR1qFAAAACmphbWVzQG5ld3Q= ++-----END OPENSSH PRIVATE KEY----- +diff --git a/tests/integration_tests/assets/keys/id_rsa.test1.pub b/tests/integration_tests/assets/keys/id_rsa.test1.pub +new file mode 100644 +index 00000000..3d2e26e1 +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test1.pub +@@ -0,0 +1 @@ ++ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC1GUb3ponbdRG8CA79sGy4uX6Wq5zfnRpLBX3z+Ly+W86fkZVwfJ63+tdCDiVnizSjAbeRbJAVjye9OT4b5Q1Tx83RqcDMEOL7gzOx6OQX2xyNMwWZdWsikgKpXdthA2M+/qv65tB2QPTV27d4H23n/OXziC2yGnSEF2h/LPy7X8Dbmzt9VTrsbIjs90q9pL7K8lIidmdvZ71VDN8kL6oCc3uBRlq0EqN2BS0l6JRR4NpWdpLXtvRqhP2IE7SwhxC05MGViUKMW3VMAwtnrgePdJ8ZkHtiPRn4vMUHYhZfXPOyMjrxiRg8o5Iv81iFBT17nmQlDtaeeA0dj/af8Dt00pIUOaAzlz0haB5nt+Omx8hgCgn8IDEUg0P48Qd3WAJLrGgSFpRXKHlYf9eTAq/WfHgCJnYEUIT7vmTJJEtlQn7nLSvcNTmS0xd5RHNUg/um7RHwMK6X4ZT4z/5MfJa0AQ6B5o0np9imjaKkOFuZNi3eSTaUwhqlHEYEWz7fux0= test1@host +diff --git a/tests/integration_tests/assets/keys/id_rsa.test2 b/tests/integration_tests/assets/keys/id_rsa.test2 +new file mode 100644 +index 00000000..5854d901 +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test2 +@@ -0,0 +1,38 @@ ++-----BEGIN OPENSSH PRIVATE KEY----- ++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 ++tZRSm2GEgnDGAvAAAACmphbWVzQG5ld3Q= ++-----END OPENSSH PRIVATE KEY----- +diff --git a/tests/integration_tests/assets/keys/id_rsa.test2.pub b/tests/integration_tests/assets/keys/id_rsa.test2.pub +new file mode 100644 +index 00000000..f3831a57 +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test2.pub +@@ -0,0 +1 @@ ++ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC8rnQPY9Y5ziKTIdVElLq0OGrOMvlwqKK8gPinVfwFgJXDzdcAQY4uci1TJVcc0AOWHp+lWrU1joDYlW3KCg8XpkXHymHshYyaeEN2fEsvIaxuF3UzW2JckP9H7dacYdEng8qtBq8wuCodGuJ5XdBVV+MVJ6jqNf/hOu4/pma8hMxlYmtdoamHEn+k/KQR2Q6LwCYpT0U2+KiPI9Lac22P0H/rfkh2Ba+t3tV/lhyzD0xHnktZKJ3BrznpEfrZiXoukQZMebWUmIyTW7zwk9Kq+iGFpSQsqQWlxDBwHSbvpbUo7KWUmyZfxs1euVmwj5aKJgjteXm9CbbXsMS415q+08C0MzG7weZODZQnnk1rF6Ft97aXHaTmRgYbDeLU7U5U3alnb84HvUu5xh3/mrE9rPTfCzBwY4lgY+Q1zjS90RL9Jxzti3weydm7OqTI6DOfQeJQLOhhRgthOkt/7IabDFLIARjTnpo5+QKxugoc4qI6aUnE1plkVCRfV696ngM= test2@host +diff --git a/tests/integration_tests/assets/keys/id_rsa.test3 b/tests/integration_tests/assets/keys/id_rsa.test3 +new file mode 100644 +index 00000000..2596c762 +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test3 +@@ -0,0 +1,38 @@ ++-----BEGIN OPENSSH PRIVATE KEY----- ++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 ++eauhOY52PS/BcAAAAKamFtZXNAbmV3dAE= ++-----END OPENSSH PRIVATE KEY----- +diff --git a/tests/integration_tests/assets/keys/id_rsa.test3.pub b/tests/integration_tests/assets/keys/id_rsa.test3.pub +new file mode 100644 +index 00000000..057db632 +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test3.pub +@@ -0,0 +1 @@ ++ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCk8bgx2RhAoPnv+qt4WuH0ZELbbLrqpk5ZlZGMLzzuutu8HM7r2FXImlMheV4kS+laIdhyMxHkbo00Wyc21nh/EOqZJzi9wWPncmwT9c2osqrqmxdp0Jvm+Q2kZvED5nlsmXMn3r0+AkggRuBzzqx6LiSBHYy49aq1ltjkaSk0JOpa4th4Ur7XigWu4DafJYf0w4hEMcr93n3umwfL4tMy3KY7tk+E7nvVpsrDqO+/CJJ4PFUT2RXsoBAi4z39LeB+2+BBCuifcYvfimCYhpt1IZ1t8eoYOiivImGVuq4lkQsE3bfoqBE1MICNW9TOONCvV5E/fGywbBU7oQIEYJAuX9piiACC3CfCP2Jeq1+IsrcTCWCOq5Tj4lDQJW0uEbeAhkH6qgxOFyjSmYdOuO19ALCvO2wEEW6dJrb8Ky25XWZDk7lmtnoAKj3I9EE2Sezr4KIp20y0K3OLeTZEPP2OliY9xqTTcuvsiR0LVOnr6MD7//I8dI7WjgKpbz0hDM0= test3@host +diff --git a/tests/integration_tests/modules/test_ssh_keysfile.py b/tests/integration_tests/modules/test_ssh_keysfile.py +new file mode 100644 +index 00000000..f82d7649 +--- /dev/null ++++ b/tests/integration_tests/modules/test_ssh_keysfile.py +@@ -0,0 +1,85 @@ ++import paramiko ++import pytest ++from io import StringIO ++from paramiko.ssh_exception import SSHException ++ ++from tests.integration_tests.instances import IntegrationInstance ++from tests.integration_tests.util import get_test_rsa_keypair ++ ++TEST_USER1_KEYS = get_test_rsa_keypair('test1') ++TEST_USER2_KEYS = get_test_rsa_keypair('test2') ++TEST_DEFAULT_KEYS = get_test_rsa_keypair('test3') ++ ++USERDATA = """\ ++#cloud-config ++bootcmd: ++ - sed -i 's;#AuthorizedKeysFile.*;AuthorizedKeysFile /etc/ssh/authorized_keys %h/.ssh/authorized_keys2;' /etc/ssh/sshd_config ++ssh_authorized_keys: ++ - {default} ++users: ++- default ++- name: test_user1 ++ ssh_authorized_keys: ++ - {user1} ++- name: test_user2 ++ ssh_authorized_keys: ++ - {user2} ++""".format( # noqa: E501 ++ default=TEST_DEFAULT_KEYS.public_key, ++ user1=TEST_USER1_KEYS.public_key, ++ user2=TEST_USER2_KEYS.public_key, ++) ++ ++ ++@pytest.mark.ubuntu ++@pytest.mark.user_data(USERDATA) ++def test_authorized_keys(client: IntegrationInstance): ++ expected_keys = [ ++ ('test_user1', '/home/test_user1/.ssh/authorized_keys2', ++ TEST_USER1_KEYS), ++ ('test_user2', '/home/test_user2/.ssh/authorized_keys2', ++ TEST_USER2_KEYS), ++ ('ubuntu', '/home/ubuntu/.ssh/authorized_keys2', ++ TEST_DEFAULT_KEYS), ++ ('root', '/root/.ssh/authorized_keys2', TEST_DEFAULT_KEYS), ++ ] ++ ++ for user, filename, keys in expected_keys: ++ contents = client.read_from_file(filename) ++ if user in ['ubuntu', 'root']: ++ # Our personal public key gets added by pycloudlib ++ lines = contents.split('\n') ++ assert len(lines) == 2 ++ assert keys.public_key.strip() in contents ++ else: ++ assert contents.strip() == keys.public_key.strip() ++ ++ # Ensure we can actually connect ++ ssh = paramiko.SSHClient() ++ ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ++ paramiko_key = paramiko.RSAKey.from_private_key(StringIO( ++ keys.private_key)) ++ ++ # Will fail with AuthenticationException if ++ # we cannot connect ++ ssh.connect( ++ client.instance.ip, ++ username=user, ++ pkey=paramiko_key, ++ look_for_keys=False, ++ allow_agent=False, ++ ) ++ ++ # Ensure other uses can't connect using our key ++ other_users = [u[0] for u in expected_keys if u[2] != keys] ++ for other_user in other_users: ++ with pytest.raises(SSHException): ++ print('trying to connect as {} with key from {}'.format( ++ other_user, user)) ++ ssh.connect( ++ client.instance.ip, ++ username=other_user, ++ pkey=paramiko_key, ++ look_for_keys=False, ++ allow_agent=False, ++ ) +diff --git a/tests/unittests/test_sshutil.py b/tests/unittests/test_sshutil.py +index fd1d1bac..bcb8044f 100644 +--- a/tests/unittests/test_sshutil.py ++++ b/tests/unittests/test_sshutil.py +@@ -570,20 +570,33 @@ class TestBasicAuthorizedKeyParse(test_helpers.CiTestCase): + ssh_util.render_authorizedkeysfile_paths( + "%h/.keys", "/homedirs/bobby", "bobby")) + ++ def test_all(self): ++ self.assertEqual( ++ ["/homedirs/bobby/.keys", "/homedirs/bobby/.secret/keys", ++ "/keys/path1", "/opt/bobby/keys"], ++ ssh_util.render_authorizedkeysfile_paths( ++ "%h/.keys .secret/keys /keys/path1 /opt/%u/keys", ++ "/homedirs/bobby", "bobby")) ++ + + class TestMultipleSshAuthorizedKeysFile(test_helpers.CiTestCase): + + @patch("cloudinit.ssh_util.pwd.getpwnam") + def test_multiple_authorizedkeys_file_order1(self, m_getpwnam): +- fpw = FakePwEnt(pw_name='bobby', pw_dir='/home2/bobby') ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') + m_getpwnam.return_value = fpw +- authorized_keys = self.tmp_path('authorized_keys') ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ ++ # /tmp/home2/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.tmp_path('authorized_keys', dir=user_ssh_folder) + util.write_file(authorized_keys, VALID_CONTENT['rsa']) + +- user_keys = self.tmp_path('user_keys') ++ # /tmp/home2/bobby/.ssh/user_keys = dsa ++ user_keys = self.tmp_path('user_keys', dir=user_ssh_folder) + util.write_file(user_keys, VALID_CONTENT['dsa']) + +- sshd_config = self.tmp_path('sshd_config') ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") + util.write_file( + sshd_config, + "AuthorizedKeysFile %s %s" % (authorized_keys, user_keys) +@@ -593,33 +606,244 @@ class TestMultipleSshAuthorizedKeysFile(test_helpers.CiTestCase): + fpw.pw_name, sshd_config) + content = ssh_util.update_authorized_keys(auth_key_entries, []) + +- self.assertEqual("%s/.ssh/authorized_keys" % fpw.pw_dir, auth_key_fn) ++ self.assertEqual(user_keys, auth_key_fn) + self.assertTrue(VALID_CONTENT['rsa'] in content) + self.assertTrue(VALID_CONTENT['dsa'] in content) + + @patch("cloudinit.ssh_util.pwd.getpwnam") + def test_multiple_authorizedkeys_file_order2(self, m_getpwnam): +- fpw = FakePwEnt(pw_name='suzie', pw_dir='/home/suzie') ++ fpw = FakePwEnt(pw_name='suzie', pw_dir='/tmp/home/suzie') + m_getpwnam.return_value = fpw +- authorized_keys = self.tmp_path('authorized_keys') ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ ++ # /tmp/home/suzie/.ssh/authorized_keys = rsa ++ authorized_keys = self.tmp_path('authorized_keys', dir=user_ssh_folder) + util.write_file(authorized_keys, VALID_CONTENT['rsa']) + +- user_keys = self.tmp_path('user_keys') ++ # /tmp/home/suzie/.ssh/user_keys = dsa ++ user_keys = self.tmp_path('user_keys', dir=user_ssh_folder) + util.write_file(user_keys, VALID_CONTENT['dsa']) + +- sshd_config = self.tmp_path('sshd_config') ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") + util.write_file( + sshd_config, +- "AuthorizedKeysFile %s %s" % (authorized_keys, user_keys) ++ "AuthorizedKeysFile %s %s" % (user_keys, authorized_keys) + ) + + (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( +- fpw.pw_name, sshd_config ++ fpw.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(authorized_keys, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['rsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ def test_multiple_authorizedkeys_file_local_global(self, m_getpwnam): ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') ++ m_getpwnam.return_value = fpw ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ ++ # /tmp/home2/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.tmp_path('authorized_keys', dir=user_ssh_folder) ++ util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ ++ # /tmp/home2/bobby/.ssh/user_keys = dsa ++ user_keys = self.tmp_path('user_keys', dir=user_ssh_folder) ++ util.write_file(user_keys, VALID_CONTENT['dsa']) ++ ++ # /tmp/etc/ssh/authorized_keys = ecdsa ++ authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys', ++ dir="/tmp") ++ util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") ++ util.write_file( ++ sshd_config, ++ "AuthorizedKeysFile %s %s %s" % (authorized_keys_global, ++ user_keys, authorized_keys) ++ ) ++ ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(authorized_keys, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['rsa'] in content) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ def test_multiple_authorizedkeys_file_local_global2(self, m_getpwnam): ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') ++ m_getpwnam.return_value = fpw ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ ++ # /tmp/home2/bobby/.ssh/authorized_keys2 = rsa ++ authorized_keys = self.tmp_path('authorized_keys2', ++ dir=user_ssh_folder) ++ util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ ++ # /tmp/home2/bobby/.ssh/user_keys3 = dsa ++ user_keys = self.tmp_path('user_keys3', dir=user_ssh_folder) ++ util.write_file(user_keys, VALID_CONTENT['dsa']) ++ ++ # /tmp/etc/ssh/authorized_keys = ecdsa ++ authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys', ++ dir="/tmp") ++ util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") ++ util.write_file( ++ sshd_config, ++ "AuthorizedKeysFile %s %s %s" % (authorized_keys_global, ++ authorized_keys, user_keys) ++ ) ++ ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(user_keys, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['rsa'] in content) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ def test_multiple_authorizedkeys_file_global(self, m_getpwnam): ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') ++ m_getpwnam.return_value = fpw ++ ++ # /tmp/etc/ssh/authorized_keys = rsa ++ authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys', ++ dir="/tmp") ++ util.write_file(authorized_keys_global, VALID_CONTENT['rsa']) ++ ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config') ++ util.write_file( ++ sshd_config, ++ "AuthorizedKeysFile %s" % (authorized_keys_global) + ) ++ ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw.pw_name, sshd_config) + content = ssh_util.update_authorized_keys(auth_key_entries, []) + + self.assertEqual("%s/.ssh/authorized_keys" % fpw.pw_dir, auth_key_fn) + self.assertTrue(VALID_CONTENT['rsa'] in content) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ def test_multiple_authorizedkeys_file_multiuser(self, m_getpwnam): ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') ++ m_getpwnam.return_value = fpw ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ # /tmp/home2/bobby/.ssh/authorized_keys2 = rsa ++ authorized_keys = self.tmp_path('authorized_keys2', ++ dir=user_ssh_folder) ++ util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ # /tmp/home2/bobby/.ssh/user_keys3 = dsa ++ user_keys = self.tmp_path('user_keys3', dir=user_ssh_folder) ++ util.write_file(user_keys, VALID_CONTENT['dsa']) ++ ++ fpw2 = FakePwEnt(pw_name='suzie', pw_dir='/tmp/home/suzie') ++ user_ssh_folder = "%s/.ssh" % fpw2.pw_dir ++ # /tmp/home/suzie/.ssh/authorized_keys2 = ssh-xmss@openssh.com ++ authorized_keys2 = self.tmp_path('authorized_keys2', ++ dir=user_ssh_folder) ++ util.write_file(authorized_keys2, ++ VALID_CONTENT['ssh-xmss@openssh.com']) ++ ++ # /tmp/etc/ssh/authorized_keys = ecdsa ++ authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys2', ++ dir="/tmp") ++ util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") ++ util.write_file( ++ sshd_config, ++ "AuthorizedKeysFile %s %%h/.ssh/authorized_keys2 %s" % ++ (authorized_keys_global, user_keys) ++ ) ++ ++ # process first user ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(user_keys, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['rsa'] in content) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ self.assertFalse(VALID_CONTENT['ssh-xmss@openssh.com'] in content) ++ ++ m_getpwnam.return_value = fpw2 ++ # process second user ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw2.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(authorized_keys2, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['ssh-xmss@openssh.com'] in content) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ self.assertFalse(VALID_CONTENT['rsa'] in content) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ def test_multiple_authorizedkeys_file_multiuser2(self, m_getpwnam): ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home/bobby') ++ m_getpwnam.return_value = fpw ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ # /tmp/home/bobby/.ssh/authorized_keys2 = rsa ++ authorized_keys = self.tmp_path('authorized_keys2', ++ dir=user_ssh_folder) ++ util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ # /tmp/home/bobby/.ssh/user_keys3 = dsa ++ user_keys = self.tmp_path('user_keys3', dir=user_ssh_folder) ++ util.write_file(user_keys, VALID_CONTENT['dsa']) ++ ++ fpw2 = FakePwEnt(pw_name='badguy', pw_dir='/tmp/home/badguy') ++ user_ssh_folder = "%s/.ssh" % fpw2.pw_dir ++ # /tmp/home/badguy/home/bobby = "" ++ authorized_keys2 = self.tmp_path('home/bobby', dir="/tmp/home/badguy") ++ ++ # /tmp/etc/ssh/authorized_keys = ecdsa ++ authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys2', ++ dir="/tmp") ++ util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") ++ util.write_file( ++ sshd_config, ++ "AuthorizedKeysFile %s %%h/.ssh/authorized_keys2 %s %s" % ++ (authorized_keys_global, user_keys, authorized_keys2) ++ ) ++ ++ # process first user ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(user_keys, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['rsa'] in content) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ ++ m_getpwnam.return_value = fpw2 ++ # process second user ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw2.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ # badguy should not take the key from the other user! ++ self.assertEqual(authorized_keys2, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) + self.assertTrue(VALID_CONTENT['dsa'] in content) ++ self.assertFalse(VALID_CONTENT['rsa'] in content) + + # vi: ts=4 expandtab +-- +2.27.0 + diff --git a/SOURCES/ci-write-passwords-only-to-serial-console-lock-down-clo.patch b/SOURCES/ci-write-passwords-only-to-serial-console-lock-down-clo.patch new file mode 100644 index 0000000..272d903 --- /dev/null +++ b/SOURCES/ci-write-passwords-only-to-serial-console-lock-down-clo.patch @@ -0,0 +1,371 @@ +From f9564bd4477782e8cffe4be1d3c31c0338fb03b1 Mon Sep 17 00:00:00 2001 +From: Eduardo Otubo +Date: Mon, 5 Jul 2021 14:07:21 +0200 +Subject: [PATCH 1/2] write passwords only to serial console, lock down + cloud-init-output.log (#847) + +RH-Author: Eduardo Otubo +RH-MergeRequest: 4: write passwords only to serial console, lock down cloud-init-output.log (#847) +RH-Commit: [1/1] 7543b3458c01ea988e987336d84510157c00390d (otubo/cloud-init-src) +RH-Bugzilla: 1945892 +RH-Acked-by: Emanuele Giuseppe Esposito +RH-Acked-by: Miroslav Rezanina +RH-Acked-by: Mohamed Gamal Morsy + +commit b794d426b9ab43ea9d6371477466070d86e10668 +Author: Daniel Watkins +Date: Fri Mar 19 10:06:42 2021 -0400 + + write passwords only to serial console, lock down cloud-init-output.log (#847) + + Prior to this commit, when a user specified configuration which would + generate random passwords for users, cloud-init would cause those + passwords to be written to the serial console by emitting them on + stderr. In the default configuration, any stdout or stderr emitted by + cloud-init is also written to `/var/log/cloud-init-output.log`. This + file is world-readable, meaning that those randomly-generated passwords + were available to be read by any user with access to the system. This + presents an obvious security issue. + + This commit responds to this issue in two ways: + + * We address the direct issue by moving from writing the passwords to + sys.stderr to writing them directly to /dev/console (via + util.multi_log); this means that the passwords will never end up in + cloud-init-output.log + * To avoid future issues like this, we also modify the logging code so + that any files created in a log sink subprocess will only be + owner/group readable and, if it exists, will be owned by the adm + group. This results in `/var/log/cloud-init-output.log` no longer + being world-readable, meaning that if there are other parts of the + codebase that are emitting sensitive data intended for the serial + console, that data is no longer available to all users of the system. + + LP: #1918303 + +Signed-off-by: Eduardo Otubo +Signed-off-by: Miroslav Rezanina +--- + cloudinit/config/cc_set_passwords.py | 5 +- + cloudinit/config/tests/test_set_passwords.py | 40 +++++++++---- + cloudinit/tests/test_util.py | 56 +++++++++++++++++++ + cloudinit/util.py | 38 +++++++++++-- + .../modules/test_set_password.py | 24 ++++++++ + tests/integration_tests/test_logging.py | 22 ++++++++ + tests/unittests/test_util.py | 4 ++ + 7 files changed, 173 insertions(+), 16 deletions(-) + create mode 100644 tests/integration_tests/test_logging.py + +diff --git a/cloudinit/config/cc_set_passwords.py b/cloudinit/config/cc_set_passwords.py +index d6b5682d..433de751 100755 +--- a/cloudinit/config/cc_set_passwords.py ++++ b/cloudinit/config/cc_set_passwords.py +@@ -78,7 +78,6 @@ password. + """ + + import re +-import sys + + from cloudinit.distros import ug_util + from cloudinit import log as logging +@@ -214,7 +213,9 @@ def handle(_name, cfg, cloud, log, args): + if len(randlist): + blurb = ("Set the following 'random' passwords\n", + '\n'.join(randlist)) +- sys.stderr.write("%s\n%s\n" % blurb) ++ util.multi_log( ++ "%s\n%s\n" % blurb, stderr=False, fallback_to_stdout=False ++ ) + + if expire: + expired_users = [] +diff --git a/cloudinit/config/tests/test_set_passwords.py b/cloudinit/config/tests/test_set_passwords.py +index daa1ef51..bbe2ee8f 100644 +--- a/cloudinit/config/tests/test_set_passwords.py ++++ b/cloudinit/config/tests/test_set_passwords.py +@@ -74,10 +74,6 @@ class TestSetPasswordsHandle(CiTestCase): + + with_logs = True + +- def setUp(self): +- super(TestSetPasswordsHandle, self).setUp() +- self.add_patch('cloudinit.config.cc_set_passwords.sys.stderr', 'm_err') +- + def test_handle_on_empty_config(self, *args): + """handle logs that no password has changed when config is empty.""" + cloud = self.tmp_cloud(distro='ubuntu') +@@ -129,10 +125,12 @@ class TestSetPasswordsHandle(CiTestCase): + mock.call(['pw', 'usermod', 'ubuntu', '-p', '01-Jan-1970'])], + m_subp.call_args_list) + ++ @mock.patch(MODPATH + "util.multi_log") + @mock.patch(MODPATH + "util.is_BSD") + @mock.patch(MODPATH + "subp.subp") +- def test_handle_on_chpasswd_list_creates_random_passwords(self, m_subp, +- m_is_bsd): ++ def test_handle_on_chpasswd_list_creates_random_passwords( ++ self, m_subp, m_is_bsd, m_multi_log ++ ): + """handle parses command set random passwords.""" + m_is_bsd.return_value = False + cloud = self.tmp_cloud(distro='ubuntu') +@@ -146,10 +144,32 @@ class TestSetPasswordsHandle(CiTestCase): + self.assertIn( + 'DEBUG: Handling input for chpasswd as list.', + self.logs.getvalue()) +- self.assertNotEqual( +- [mock.call(['chpasswd'], +- '\n'.join(valid_random_pwds) + '\n')], +- m_subp.call_args_list) ++ ++ self.assertEqual(1, m_subp.call_count) ++ args, _kwargs = m_subp.call_args ++ self.assertEqual(["chpasswd"], args[0]) ++ ++ stdin = args[1] ++ user_pass = { ++ user: password ++ for user, password ++ in (line.split(":") for line in stdin.splitlines()) ++ } ++ ++ self.assertEqual(1, m_multi_log.call_count) ++ self.assertEqual( ++ mock.call(mock.ANY, stderr=False, fallback_to_stdout=False), ++ m_multi_log.call_args ++ ) ++ ++ self.assertEqual(set(["root", "ubuntu"]), set(user_pass.keys())) ++ written_lines = m_multi_log.call_args[0][0].splitlines() ++ for password in user_pass.values(): ++ for line in written_lines: ++ if password in line: ++ break ++ else: ++ self.fail("Password not emitted to console") + + + # vi: ts=4 expandtab +diff --git a/cloudinit/tests/test_util.py b/cloudinit/tests/test_util.py +index b7a302f1..e811917e 100644 +--- a/cloudinit/tests/test_util.py ++++ b/cloudinit/tests/test_util.py +@@ -851,4 +851,60 @@ class TestEnsureFile: + assert "ab" == kwargs["omode"] + + ++@mock.patch("cloudinit.util.grp.getgrnam") ++@mock.patch("cloudinit.util.os.setgid") ++@mock.patch("cloudinit.util.os.umask") ++class TestRedirectOutputPreexecFn: ++ """This tests specifically the preexec_fn used in redirect_output.""" ++ ++ @pytest.fixture(params=["outfmt", "errfmt"]) ++ def preexec_fn(self, request): ++ """A fixture to gather the preexec_fn used by redirect_output. ++ ++ This enables simpler direct testing of it, and parameterises any tests ++ using it to cover both the stdout and stderr code paths. ++ """ ++ test_string = "| piped output to invoke subprocess" ++ if request.param == "outfmt": ++ args = (test_string, None) ++ elif request.param == "errfmt": ++ args = (None, test_string) ++ with mock.patch("cloudinit.util.subprocess.Popen") as m_popen: ++ util.redirect_output(*args) ++ ++ assert 1 == m_popen.call_count ++ _args, kwargs = m_popen.call_args ++ assert "preexec_fn" in kwargs, "preexec_fn not passed to Popen" ++ return kwargs["preexec_fn"] ++ ++ def test_preexec_fn_sets_umask( ++ self, m_os_umask, _m_setgid, _m_getgrnam, preexec_fn ++ ): ++ """preexec_fn should set a mask that avoids world-readable files.""" ++ preexec_fn() ++ ++ assert [mock.call(0o037)] == m_os_umask.call_args_list ++ ++ def test_preexec_fn_sets_group_id_if_adm_group_present( ++ self, _m_os_umask, m_setgid, m_getgrnam, preexec_fn ++ ): ++ """We should setgrp to adm if present, so files are owned by them.""" ++ fake_group = mock.Mock(gr_gid=mock.sentinel.gr_gid) ++ m_getgrnam.return_value = fake_group ++ ++ preexec_fn() ++ ++ assert [mock.call("adm")] == m_getgrnam.call_args_list ++ assert [mock.call(mock.sentinel.gr_gid)] == m_setgid.call_args_list ++ ++ def test_preexec_fn_handles_absent_adm_group_gracefully( ++ self, _m_os_umask, m_setgid, m_getgrnam, preexec_fn ++ ): ++ """We should handle an absent adm group gracefully.""" ++ m_getgrnam.side_effect = KeyError("getgrnam(): name not found: 'adm'") ++ ++ preexec_fn() ++ ++ assert 0 == m_setgid.call_count ++ + # vi: ts=4 expandtab +diff --git a/cloudinit/util.py b/cloudinit/util.py +index 769f3425..4e0a72db 100644 +--- a/cloudinit/util.py ++++ b/cloudinit/util.py +@@ -359,7 +359,7 @@ def find_modules(root_dir): + + + def multi_log(text, console=True, stderr=True, +- log=None, log_level=logging.DEBUG): ++ log=None, log_level=logging.DEBUG, fallback_to_stdout=True): + if stderr: + sys.stderr.write(text) + if console: +@@ -368,7 +368,7 @@ def multi_log(text, console=True, stderr=True, + with open(conpath, 'w') as wfh: + wfh.write(text) + wfh.flush() +- else: ++ elif fallback_to_stdout: + # A container may lack /dev/console (arguably a container bug). If + # it does not exist, then write output to stdout. this will result + # in duplicate stderr and stdout messages if stderr was True. +@@ -623,6 +623,26 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None): + if not o_err: + o_err = sys.stderr + ++ # pylint: disable=subprocess-popen-preexec-fn ++ def set_subprocess_umask_and_gid(): ++ """Reconfigure umask and group ID to create output files securely. ++ ++ This is passed to subprocess.Popen as preexec_fn, so it is executed in ++ the context of the newly-created process. It: ++ ++ * sets the umask of the process so created files aren't world-readable ++ * if an adm group exists in the system, sets that as the process' GID ++ (so that the created file(s) are owned by root:adm) ++ """ ++ os.umask(0o037) ++ try: ++ group_id = grp.getgrnam("adm").gr_gid ++ except KeyError: ++ # No adm group, don't set a group ++ pass ++ else: ++ os.setgid(group_id) ++ + if outfmt: + LOG.debug("Redirecting %s to %s", o_out, outfmt) + (mode, arg) = outfmt.split(" ", 1) +@@ -632,7 +652,12 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None): + owith = "wb" + new_fp = open(arg, owith) + elif mode == "|": +- proc = subprocess.Popen(arg, shell=True, stdin=subprocess.PIPE) ++ proc = subprocess.Popen( ++ arg, ++ shell=True, ++ stdin=subprocess.PIPE, ++ preexec_fn=set_subprocess_umask_and_gid, ++ ) + new_fp = proc.stdin + else: + raise TypeError("Invalid type for output format: %s" % outfmt) +@@ -654,7 +679,12 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None): + owith = "wb" + new_fp = open(arg, owith) + elif mode == "|": +- proc = subprocess.Popen(arg, shell=True, stdin=subprocess.PIPE) ++ proc = subprocess.Popen( ++ arg, ++ shell=True, ++ stdin=subprocess.PIPE, ++ preexec_fn=set_subprocess_umask_and_gid, ++ ) + new_fp = proc.stdin + else: + raise TypeError("Invalid type for error format: %s" % errfmt) +diff --git a/tests/integration_tests/modules/test_set_password.py b/tests/integration_tests/modules/test_set_password.py +index b13f76fb..d7cf91a5 100644 +--- a/tests/integration_tests/modules/test_set_password.py ++++ b/tests/integration_tests/modules/test_set_password.py +@@ -116,6 +116,30 @@ class Mixin: + # Which are not the same + assert shadow_users["harry"] != shadow_users["dick"] + ++ def test_random_passwords_not_stored_in_cloud_init_output_log( ++ self, class_client ++ ): ++ """We should not emit passwords to the in-instance log file. ++ ++ LP: #1918303 ++ """ ++ cloud_init_output = class_client.read_from_file( ++ "/var/log/cloud-init-output.log" ++ ) ++ assert "dick:" not in cloud_init_output ++ assert "harry:" not in cloud_init_output ++ ++ def test_random_passwords_emitted_to_serial_console(self, class_client): ++ """We should emit passwords to the serial console. (LP: #1918303)""" ++ try: ++ console_log = class_client.instance.console_log() ++ except NotImplementedError: ++ # Assume that an exception here means that we can't use the console ++ # log ++ pytest.skip("NotImplementedError when requesting console log") ++ assert "dick:" in console_log ++ assert "harry:" in console_log ++ + def test_explicit_password_set_correctly(self, class_client): + """Test that an explicitly-specified password is set correctly.""" + shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client) +diff --git a/tests/integration_tests/test_logging.py b/tests/integration_tests/test_logging.py +new file mode 100644 +index 00000000..b31a0434 +--- /dev/null ++++ b/tests/integration_tests/test_logging.py +@@ -0,0 +1,22 @@ ++"""Integration tests relating to cloud-init's logging.""" ++ ++ ++class TestVarLogCloudInitOutput: ++ """Integration tests relating to /var/log/cloud-init-output.log.""" ++ ++ def test_var_log_cloud_init_output_not_world_readable(self, client): ++ """ ++ The log can contain sensitive data, it shouldn't be world-readable. ++ ++ LP: #1918303 ++ """ ++ # Check the file exists ++ assert client.execute("test -f /var/log/cloud-init-output.log").ok ++ ++ # Check its permissions are as we expect ++ perms, user, group = client.execute( ++ "stat -c %a:%U:%G /var/log/cloud-init-output.log" ++ ).split(":") ++ assert "640" == perms ++ assert "root" == user ++ assert "adm" == group +diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py +index 857629f1..e5292001 100644 +--- a/tests/unittests/test_util.py ++++ b/tests/unittests/test_util.py +@@ -572,6 +572,10 @@ class TestMultiLog(helpers.FilesystemMockingTestCase): + util.multi_log(logged_string) + self.assertEqual(logged_string, self.stdout.getvalue()) + ++ def test_logs_dont_go_to_stdout_if_fallback_to_stdout_is_false(self): ++ util.multi_log('something', fallback_to_stdout=False) ++ self.assertEqual('', self.stdout.getvalue()) ++ + def test_logs_go_to_log_if_given(self): + log = mock.MagicMock() + logged_string = 'something very important' +-- +2.27.0 + diff --git a/SOURCES/cloud-init-tmpfiles.conf b/SOURCES/cloud-init-tmpfiles.conf new file mode 100644 index 0000000..0c6d2a3 --- /dev/null +++ b/SOURCES/cloud-init-tmpfiles.conf @@ -0,0 +1 @@ +d /run/cloud-init 0700 root root - - diff --git a/SPECS/cloud-init.spec b/SPECS/cloud-init.spec new file mode 100644 index 0000000..fd76b0e --- /dev/null +++ b/SPECS/cloud-init.spec @@ -0,0 +1,438 @@ +Name: cloud-init +Version: 21.1 +Release: 7%{?dist} +Summary: Cloud instance init scripts +License: ASL 2.0 or GPLv3 +URL: http://launchpad.net/cloud-init +Source0: https://launchpad.net/cloud-init/trunk/%{version}/+download/%{name}-%{version}.tar.gz +Source1: cloud-init-tmpfiles.conf + +Patch0001: 0001-Add-initial-redhat-setup.patch +Patch0002: 0002-Do-not-write-NM_CONTROLLED-no-in-generated-interface.patch +Patch0003: 0003-limit-permissions-on-def_log_file.patch +# For bz#1970909 - [cloud-init] From RHEL 82+ cloud-init no longer displays sshd keys fingerprints from instance launched from a backup image[rhel-9] +Patch4: ci-rhel-cloud.cfg-remove-ssh_genkeytypes-in-settings.py.patch +# For bz#1943511 - [Aliyun][RHEL9.0][cloud-init] cloud-init service failed to start with Alibaba instance +Patch5: ci-Fix-requiring-device-number-on-EC2-derivatives-836.patch +# For bz#1945892 - CVE-2021-3429 cloud-init: randomly generated passwords logged in clear-text to world-readable file [rhel-9.0] +Patch6: ci-write-passwords-only-to-serial-console-lock-down-clo.patch +# For bz#1979099 - [cloud-init]Customize ssh AuthorizedKeysFile causes login failure[RHEL-9.0] +Patch7: ci-ssh-util-allow-cloudinit-to-merge-all-ssh-keys-into-.patch +# For bz#1979099 - [cloud-init]Customize ssh AuthorizedKeysFile causes login failure[RHEL-9.0] +Patch8: ci-Stop-copying-ssh-system-keys-and-check-folder-permis.patch + +# Source-git patches + +BuildArch: noarch + +BuildRequires: pkgconfig(systemd) +BuildRequires: python3-devel +BuildRequires: python3-setuptools +BuildRequires: systemd + +# For tests +BuildRequires: iproute +BuildRequires: python3-configobj +# https://bugzilla.redhat.com/show_bug.cgi?id=1695953 +BuildRequires: python3-distro +# https://bugzilla.redhat.com/show_bug.cgi?id=1417029 +BuildRequires: python3-httpretty >= 0.8.14-2 +BuildRequires: python3-jinja2 +BuildRequires: python3-jsonpatch +BuildRequires: python3-oauthlib +BuildRequires: python3-prettytable +BuildRequires: python3-pyserial +BuildRequires: python3-PyYAML +BuildRequires: python3-requests +BuildRequires: python3-six +# dnf is needed to make cc_ntp unit tests work +# https://bugs.launchpad.net/cloud-init/+bug/1721573 +BuildRequires: /usr/bin/dnf + +Requires: e2fsprogs +Requires: iproute +Requires: libselinux-python3 +Requires: policycoreutils-python3 +Requires: procps +Requires: python3-configobj +# https://bugzilla.redhat.com/show_bug.cgi?id=1695953 +Requires: python3-distro +Requires: python3-jinja2 +Requires: python3-jsonpatch +Requires: python3-oauthlib +Requires: python3-prettytable +Requires: python3-pyserial +Requires: python3-PyYAML +Requires: python3-requests +Requires: python3-six +Requires: shadow-utils +Requires: util-linux +Requires: xfsprogs +Requires: dhcp-client + +%{?systemd_requires} + +%description +Cloud-init is a set of init scripts for cloud instances. Cloud instances +need special scripts to run during initialization to retrieve and install +ssh keys and to let the user run various scripts. + + +%prep +%autosetup -p1 + +# Change shebangs +sed -i -e 's|#!/usr/bin/env python|#!/usr/bin/env python3|' \ + -e 's|#!/usr/bin/python|#!/usr/bin/python3|' tools/* cloudinit/ssh_util.py + +%build +%py3_build + + +%install +%py3_install -- + +%if 0%{?fedora} +python3 tools/render-cloudcfg --variant fedora > $RPM_BUILD_ROOT/%{_sysconfdir}/cloud/cloud.cfg +%elif 0%{?rhel} +cp -p rhel/cloud.cfg $RPM_BUILD_ROOT/%{_sysconfdir}/cloud/cloud.cfg +%endif + +sed -i "s,@@PACKAGED_VERSION@@,%{version}-%{release}," $RPM_BUILD_ROOT/%{python3_sitelib}/cloudinit/version.py + +mkdir -p $RPM_BUILD_ROOT/var/lib/cloud + +# /run/cloud-init needs a tmpfiles.d entry +mkdir -p $RPM_BUILD_ROOT/run/cloud-init +mkdir -p $RPM_BUILD_ROOT/%{_tmpfilesdir} +cp -p %{SOURCE1} $RPM_BUILD_ROOT/%{_tmpfilesdir}/%{name}.conf + +# We supply our own config file since our software differs from Ubuntu's. +cp -p rhel/cloud.cfg $RPM_BUILD_ROOT/%{_sysconfdir}/cloud/cloud.cfg + +mkdir -p $RPM_BUILD_ROOT/%{_sysconfdir}/rsyslog.d +cp -p tools/21-cloudinit.conf $RPM_BUILD_ROOT/%{_sysconfdir}/rsyslog.d/21-cloudinit.conf + +# Make installed NetworkManager hook name less generic +mv $RPM_BUILD_ROOT/etc/NetworkManager/dispatcher.d/hook-network-manager \ + $RPM_BUILD_ROOT/etc/NetworkManager/dispatcher.d/cloud-init-azure-hook + +# Install our own systemd units (rhbz#1440831) +mkdir -p $RPM_BUILD_ROOT%{_unitdir} +cp rhel/systemd/* $RPM_BUILD_ROOT%{_unitdir}/ + +[ ! -d $RPM_BUILD_ROOT%{_systemdgeneratordir} ] && mkdir -p $RPM_BUILD_ROOT%{_systemdgeneratordir} +python3 tools/render-cloudcfg --variant rhel systemd/cloud-init-generator.tmpl > $RPM_BUILD_ROOT%{_systemdgeneratordir}/cloud-init-generator +chmod 755 $RPM_BUILD_ROOT%{_systemdgeneratordir}/cloud-init-generator + +[ ! -d $RPM_BUILD_ROOT/usr/lib/%{name} ] && mkdir -p $RPM_BUILD_ROOT/usr/lib/%{name} +cp -p tools/ds-identify $RPM_BUILD_ROOT%{_libexecdir}/%{name}/ds-identify + +# installing man pages +mkdir -p ${RPM_BUILD_ROOT}%{_mandir}/man1/ +for man in cloud-id.1 cloud-init.1 cloud-init-per.1; do + install -c -m 0644 doc/man/${man} ${RPM_BUILD_ROOT}%{_mandir}/man1/${man} + chmod -x ${RPM_BUILD_ROOT}%{_mandir}/man1/* +done + +%clean +rm -rf $RPM_BUILD_ROOT + + +%post +if [ $1 -eq 1 ] ; then + # Initial installation + # Enabled by default per "runs once then goes away" exception + /bin/systemctl enable cloud-config.service >/dev/null 2>&1 || : + /bin/systemctl enable cloud-final.service >/dev/null 2>&1 || : + /bin/systemctl enable cloud-init.service >/dev/null 2>&1 || : + /bin/systemctl enable cloud-init-local.service >/dev/null 2>&1 || : + /bin/systemctl enable cloud-init.target >/dev/null 2>&1 || : +elif [ $1 -eq 2 ]; then + # Upgrade. If the upgrade is from a version older than 0.7.9-8, + # there will be stale systemd config + /bin/systemctl is-enabled cloud-config.service >/dev/null 2>&1 && + /bin/systemctl reenable cloud-config.service >/dev/null 2>&1 || : + + /bin/systemctl is-enabled cloud-final.service >/dev/null 2>&1 && + /bin/systemctl reenable cloud-final.service >/dev/null 2>&1 || : + + /bin/systemctl is-enabled cloud-init.service >/dev/null 2>&1 && + /bin/systemctl reenable cloud-init.service >/dev/null 2>&1 || : + + /bin/systemctl is-enabled cloud-init-local.service >/dev/null 2>&1 && + /bin/systemctl reenable cloud-init-local.service >/dev/null 2>&1 || : + + /bin/systemctl is-enabled cloud-init.target >/dev/null 2>&1 && + /bin/systemctl reenable cloud-init.target >/dev/null 2>&1 || : +fi + +%preun +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + /bin/systemctl --no-reload disable cloud-config.service >/dev/null 2>&1 || : + /bin/systemctl --no-reload disable cloud-final.service >/dev/null 2>&1 || : + /bin/systemctl --no-reload disable cloud-init.service >/dev/null 2>&1 || : + /bin/systemctl --no-reload disable cloud-init-local.service >/dev/null 2>&1 || : + /bin/systemctl --no-reload disable cloud-init.target >/dev/null 2>&1 || : + # One-shot services -> no need to stop +fi + +%postun +%systemd_postun cloud-config.service cloud-config.target cloud-final.service cloud-init.service cloud-init.target cloud-init-local.service + + +%files +%license LICENSE +%doc ChangeLog rhel/README.rhel +%config(noreplace) %{_sysconfdir}/cloud/cloud.cfg +%dir %{_sysconfdir}/cloud/cloud.cfg.d +%config(noreplace) %{_sysconfdir}/cloud/cloud.cfg.d/*.cfg +%doc %{_sysconfdir}/cloud/cloud.cfg.d/README +%dir %{_sysconfdir}/cloud/templates +%config(noreplace) %{_sysconfdir}/cloud/templates/* +%{_unitdir}/cloud-config.service +%{_unitdir}/cloud-config.target +%{_unitdir}/cloud-final.service +%{_unitdir}/cloud-init-local.service +%{_unitdir}/cloud-init.service +%{_unitdir}/cloud-init.target +%{_tmpfilesdir}/%{name}.conf +%{python3_sitelib}/* +%{_libexecdir}/%{name} +%{_bindir}/cloud-init* +%doc %{_datadir}/doc/%{name} +%{_mandir}/man1/* +%dir %verify(not mode) /run/cloud-init +%dir /var/lib/cloud +/etc/NetworkManager/dispatcher.d/cloud-init-azure-hook +%{_udevrulesdir}/66-azure-ephemeral.rules +%{_sysconfdir}/bash_completion.d/cloud-init +%{_bindir}/cloud-id +%{_libexecdir}/%{name}/ds-identify +%{_systemdgeneratordir}/cloud-init-generator + + +%dir %{_sysconfdir}/rsyslog.d +%config(noreplace) %{_sysconfdir}/rsyslog.d/21-cloudinit.conf + +%changelog +* Mon Aug 16 2021 Miroslav Rezanina - 21.1-7 +- ci-Stop-copying-ssh-system-keys-and-check-folder-permis.patch [bz#1979099] +- ci-Report-full-specific-version-with-cloud-init-version.patch [bz#1971002] +- Resolves: bz#1979099 + ([cloud-init]Customize ssh AuthorizedKeysFile causes login failure[RHEL-9.0]) +- Resolves: bz#1971002 + (cloud-init should report full specific full version with "cloud-init --version" [rhel-9]) + +* Mon Aug 09 2021 Mohan Boddu - 21.1-6 +- Rebuilt for IMA sigs, glibc 2.34, aarch64 flags + Related: rhbz#1991688 + +* Fri Aug 06 2021 Miroslav Rezanina - 21.1-5 +- ci-Add-dhcp-client-as-a-dependency.patch [bz#1964900] +- Resolves: bz#1964900 + ([Azure][RHEL-9] cloud-init must require dhcp-client on Azure) + +* Thu Jul 15 2021 Miroslav Rezanina - 21.1-4 +- ci-write-passwords-only-to-serial-console-lock-down-clo.patch [bz#1945892] +- ci-ssh-util-allow-cloudinit-to-merge-all-ssh-keys-into-.patch [bz#1979099] +- Resolves: bz#1945892 + (CVE-2021-3429 cloud-init: randomly generated passwords logged in clear-text to world-readable file [rhel-9.0]) +- Resolves: bz#1979099 + ([cloud-init]Customize ssh AuthorizedKeysFile causes login failure[RHEL-9.0]) + +* Fri Jul 02 2021 Miroslav Rezanina - 21.1-3 +- ci-Fix-requiring-device-number-on-EC2-derivatives-836.patch [bz#1943511] +- Resolves: bz#1943511 + ([Aliyun][RHEL9.0][cloud-init] cloud-init service failed to start with Alibaba instance) + +* Mon Jun 21 2021 Miroslav Rezanina - 21.1-2 +- ci-rhel-cloud.cfg-remove-ssh_genkeytypes-in-settings.py.patch [bz#1970909] +- ci-Use-_systemdgeneratordir-macro-for-cloud-init-genera.patch [bz#1971480] +- Resolves: bz#1970909 + ([cloud-init] From RHEL 82+ cloud-init no longer displays sshd keys fingerprints from instance launched from a backup image[rhel-9]) +- Resolves: bz#1971480 + (Use systemdgenerators macro in spec file) + +* Thu Jun 10 2021 Miroslav Rezanina - 21.1-1 +- Rebase to 21.1 [bz#1958209] +- Resolves: bz#1958209 + ([RHEL-9.0] Rebase cloud-init to 21.1) + +* Wed Apr 21 2021 Miroslav Rezanina - 20.4-5 +- Removing python-mock dependency +- Resolves: bz#1922323 + +* Thu Apr 15 2021 Mohan Boddu - 20.4-4 +- Rebuilt for RHEL 9 BETA on Apr 15th 2021. Related: rhbz#1947937 + +* Wed Apr 07 2021 Miroslav Rezanina - 20.4-3.el9 +- ci-Removing-python-nose-and-python-tox-as-dependency.patch [bz#1916777 bz#1918892] +- Resolves: bz#1916777 + (cloud-init requires python-nose) +- Resolves: bz#1918892 + (cloud-init requires tox) + +* Tue Jan 26 2021 Fedora Release Engineering - 20.4-2 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_34_Mass_Rebuild + +* Thu Dec 03 2020 Eduardo Otubo - 20.4-2 +- Updated to 20.4 [bz#1902250] + +* Mon Sep 07 2020 Eduardo Otubo - 19.4-7 +- Fix execution fail with backtrace + +* Mon Sep 07 2020 Eduardo Otubo - 19.4-6 +- Adding missing patches to spec file + +* Mon Jul 27 2020 Fedora Release Engineering - 19.4-5 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_33_Mass_Rebuild + +* Mon May 25 2020 Miro Hrončok - 19.4-4 +- Rebuilt for Python 3.9 + +* Tue Apr 14 2020 Eduardo Otubo - 19.4-3 +- Fix BZ#1798729 - CVE-2020-8632 cloud-init: Too short random password length + in cc_set_password in config/cc_set_passwords.py +- Fix BZ#1798732 - CVE-2020-8631 cloud-init: Use of random.choice when + generating random password + +* Sun Feb 23 2020 Dusty Mabe - 19.4-2 +- Fix sed substitutions for unittest2 and assertItemsEqual +- Fix failing unittests by including `BuildRequires: passwd` + - The unittests started failing because of upstream commit + 7c07af2 where cloud-init can now support using `usermod` to + lock an account if `passwd` isn't installed. Since `passwd` + wasn't installed in our mock buildroot it was choosing to + use `usermod` and the unittests were failing. See: + https://github.com/canonical/cloud-init/commit/7c07af2 +- Add missing files to package + - /usr/bin/cloud-id + - /usr/share/bash-completion/completions/cloud-init + +* Fri Feb 14 2020 Eduardo Otubo - 19.4-1 +- Updated to 19.4 +- Rebasing the Fedora specific patches but removing patches that don't apply anymore + +* Tue Jan 28 2020 Fedora Release Engineering - 17.1-15 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_32_Mass_Rebuild + +* Fri Nov 08 2019 Miro Hrončok - 17.1-14 +- Drop unneeded build dependency on python3-unittest2 + +* Thu Oct 03 2019 Miro Hrončok - 17.1-13 +- Rebuilt for Python 3.8.0rc1 (#1748018) + +* Sun Aug 18 2019 Miro Hrončok - 17.1-12 +- Rebuilt for Python 3.8 + +* Wed Jul 24 2019 Fedora Release Engineering - 17.1-11 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_31_Mass_Rebuild + +* Tue Apr 23 2019 Björn Esser - 17.1-10 +- Add patch to replace platform.dist() [RH:1695953] +- Add (Build)Requires: python3-distro + +* Tue Apr 23 2019 Björn Esser - 17.1-9 +- Fix %%systemd_postun macro [RH:1695953] +- Add patch to fix failing test for EPOCHREALTIME bash env [RH:1695953] + +* Thu Jan 31 2019 Fedora Release Engineering - 17.1-8 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_30_Mass_Rebuild + +* Thu Jul 12 2018 Fedora Release Engineering - 17.1-7 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_29_Mass_Rebuild + +* Mon Jun 18 2018 Miro Hrončok - 17.1-6 +- Rebuilt for Python 3.7 + +* Sat Apr 21 2018 Lars Kellogg-Stedman - 17.1-5 +- Enable dhcp on EC2 interfaces with only local ipv4 addresses [RH:1569321] + (cherry pick upstream commit eb292c1) + +* Mon Mar 26 2018 Patrick Uiterwijk - 17.1-4 +- Make sure the patch does not add infinitely many entries + +* Mon Mar 26 2018 Patrick Uiterwijk - 17.1-3 +- Add patch to retain old values of /etc/sysconfig/network + +* Wed Feb 07 2018 Fedora Release Engineering - 17.1-2 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_28_Mass_Rebuild + +* Wed Oct 4 2017 Garrett Holmstrom - 17.1-1 +- Updated to 17.1 + +* Tue Sep 26 2017 Ryan McCabe 0.7.9-10 +- AliCloud: Add support for the Alibaba Cloud datasource (rhbz#1482547) + +* Thu Jun 22 2017 Lars Kellogg-Stedman 0.7.9-9 +- RHEL/CentOS: Fix default routes for IPv4/IPv6 configuration. (rhbz#1438082) +- azure: ensure that networkmanager hook script runs (rhbz#1440831 rhbz#1460206) +- Fix ipv6 subnet detection (rhbz#1438082) + +* Tue May 23 2017 Lars Kellogg-Stedman 0.7.9-8 +- Update patches + +* Mon May 22 2017 Lars Kellogg-Stedman 0.7.9-7 +- Add missing sysconfig unit test data (rhbz#1438082) +- Fix dual stack IPv4/IPv6 configuration for RHEL (rhbz#1438082) +- sysconfig: Raise ValueError when multiple default gateways are present. (rhbz#1438082) +- Bounce network interface for Azure when using the built-in path. (rhbz#1434109) +- Do not write NM_CONTROLLED=no in generated interface config files (rhbz#1385172) + +* Wed May 10 2017 Lars Kellogg-Stedman 0.7.9-6 +- add power-state-change module to cloud_final_modules (rhbz#1252477) +- remove 'tee' command from logging configuration (rhbz#1424612) +- limit permissions on def_log_file (rhbz#1424612) +- Bounce network interface for Azure when using the built-in path. (rhbz#1434109) +- OpenStack: add 'dvs' to the list of physical link types. (rhbz#1442783) + +* Wed May 10 2017 Lars Kellogg-Stedman 0.7.9-5 +- systemd: replace generator with unit conditionals (rhbz#1440831) + +* Thu Apr 13 2017 Charalampos Stratakis 0.7.9-4 +- Import to RHEL 7 +Resolves: rhbz#1427280 + +* Tue Mar 07 2017 Lars Kellogg-Stedman 0.7.9-3 +- fixes for network config generation +- avoid dependency cycle at boot (rhbz#1420946) + +* Tue Jan 17 2017 Lars Kellogg-Stedman 0.7.9-2 +- use timeout from datasource config in openstack get_data (rhbz#1408589) + +* Thu Dec 01 2016 Lars Kellogg-Stedman - 0.7.9-1 +- Rebased on upstream 0.7.9. +- Remove dependency on run-parts + +* Wed Jan 06 2016 Lars Kellogg-Stedman - 0.7.6-8 +- make rh_subscription plugin do nothing in the absence of a valid + configuration [RH:1295953] +- move rh_subscription module to cloud_config stage + +* Wed Jan 06 2016 Lars Kellogg-Stedman - 0.7.6-7 +- correct permissions on /etc/ssh/sshd_config [RH:1296191] + +* Thu Sep 03 2015 Lars Kellogg-Stedman - 0.7.6-6 +- rebuild for ppc64le + +* Tue Jul 07 2015 Lars Kellogg-Stedman - 0.7.6-5 +- bump revision for new build + +* Tue Jul 07 2015 Lars Kellogg-Stedman - 0.7.6-4 +- ensure rh_subscription plugin is enabled by default + +* Wed Apr 29 2015 Lars Kellogg-Stedman - 0.7.6-3 +- added dependency on python-jinja2 [RH:1215913] +- added rhn_subscription plugin [RH:1227393] +- require pyserial to support smartos data source [RH:1226187] + +* Fri Jan 16 2015 Lars Kellogg-Stedman - 0.7.6-2 +- Rebased RHEL version to Fedora rawhide +- Backported fix for https://bugs.launchpad.net/cloud-init/+bug/1246485 +- Backported fix for https://bugs.launchpad.net/cloud-init/+bug/1411829 + +* Fri Nov 14 2014 Colin Walters - 0.7.6-1 +- New upstream version [RH:974327] +- Drop python-cheetah dependency (same as above bug)