Merge ~chad.smith/cloud-init:dhclient-from-var-tmp into cloud-init:master

Proposed by Chad Smith
Status: Merged
Approved by: Scott Moser
Approved revision: 8aac9a7f85c0f20064ffd3b6374de1230ec0f42b
Merged at revision: 7eb3460b0d6d3e362a246958a7ea0a9ee5d91d5e
Proposed branch: ~chad.smith/cloud-init:dhclient-from-var-tmp
Merge into: cloud-init:master
Diff against target: 249 lines (+132/-16)
5 files modified
cloudinit/net/dhcp.py (+3/-2)
cloudinit/net/tests/test_dhcp.py (+12/-6)
cloudinit/temp_utils.py (+15/-7)
cloudinit/tests/test_temp_utils.py (+101/-0)
cloudinit/util.py (+1/-1)
Reviewer Review Type Date Requested Status
Scott Moser Approve
Server Team CI bot continuous-integration Approve
Dimitri John Ledkov (community) Needs Information
Review via email: mp+330875@code.launchpad.net

Description of the change

ec2: Fix maybe_perform_dhcp_discovery to use /var/tmp as a tmpdir

/run/cloud-init/tmp is on a filesystem mounted noexec, so running
dchlient in Ec2Local during discovery breaks with 'Permission denied'.
This branch allows us to run from a different tmp dir so we have exec
rights.

LP: #1717627

To post a comment you must log in.
Revision history for this message
Scott Moser (smoser) wrote :

Requesting Dimitri's' review as he helped us with bug 1707222

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:356586cb4e1adb2a4f12e087f45415e0cacff163
https://jenkins.ubuntu.com/server/job/cloud-init-ci/316/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/316/rebuild

review: Approve (continuous-integration)
Revision history for this message
Chad Smith (chad.smith) wrote :

Test ran this on ass instances and cloud-init properly found Ec2Local datasource with no traceback.

Revision history for this message
Dimitri John Ledkov (xnox) wrote :

Reading the bug report, and this merge proposal, I am slightly concerned and would want to more information.

Ec2Local datasource is called "local" yet it clearly brings network up. Can you explain a bit how it works? Does it discover something else behind dhcp and chain loads something else? Instead of dhclient can it generate "dhcp first interface" netplan config and use that to bring networking up? Note that default dhcp client in artful and up is simply systemd-networkd internal implementation. It would be interesting to see if cloud-init can switch to that away from dhclient.

The security issue concerns me a lot of how dhclient is run. It seems like you are hitting default ubuntu security policy and trying to circumvent that. In particular, why can you not run /sbin/dhclient from where it is, and use /run/ configs / auxiliary files for it? Do we need to bring in security team to assist in operating dhclient safely and in a simple manner? Imho executing /sbin/dhclient as root, and pointing it to use state files in /run is still architecturally right solution, even if we need to change apparmor policy etc. If need be, we can even ship a custom systemd dhclient-cloud-init.service which will self create /run directories and self-apply an appropriate apparmor profile which e.g. Ec2Local can start if and when needed.

review: Needs Information
a3a33b4... by Chad Smith

add needs_exe param to temp_utils functions. Sprinkle some unit test goodness around temp_utils. Make callsites which need exe perms provide needs_exe

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:a3a33b43c3c62b82c30050a78c69a11a5eca8c40
https://jenkins.ubuntu.com/server/job/cloud-init-ci/328/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/328/rebuild

review: Needs Fixing (continuous-integration)
78674e9... by Chad Smith

fake root user for dhcp test

Revision history for this message
Chad Smith (chad.smith) wrote :

Tested on AWS with success using needs_exe param:
2017-09-18 21:45:19,929 - util.py[DEBUG]: Copying /sbin/dhclient to /var/tmp/cloud-init/cloud-init-dhcp-plxz9ijh/dhclient
2017-09-18 21:45:20,240 - handlers.py[DEBUG]: finish: init-local/search-Ec2Local: SUCCESS: found local data from DataSourceEc2Local

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:78674e9c8c585ad92f9d95da9289bb78cf4dcd1d
https://jenkins.ubuntu.com/server/job/cloud-init-ci/329/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/329/rebuild

review: Needs Fixing (continuous-integration)
8aac9a7... by Chad Smith

unittests need to handle global _TMPDIR

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:8aac9a7f85c0f20064ffd3b6374de1230ec0f42b
https://jenkins.ubuntu.com/server/job/cloud-init-ci/330/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/330/rebuild

review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py
2index c842c83..0535063 100644
3--- a/cloudinit/net/dhcp.py
4+++ b/cloudinit/net/dhcp.py
5@@ -48,8 +48,9 @@ def maybe_perform_dhcp_discovery(nic=None):
6 if not dhclient_path:
7 LOG.debug('Skip dhclient configuration: No dhclient command found.')
8 return {}
9- with temp_utils.tempdir(prefix='cloud-init-dhcp-') as tmpdir:
10- return dhcp_discovery(dhclient_path, nic, tmpdir)
11+ with temp_utils.tempdir(prefix='cloud-init-dhcp-', needs_exe=True) as tdir:
12+ # Use /var/tmp because /run/cloud-init/tmp is mounted noexec
13+ return dhcp_discovery(dhclient_path, nic, tdir)
14
15
16 def parse_dhcp_lease_file(lease_file):
17diff --git a/cloudinit/net/tests/test_dhcp.py b/cloudinit/net/tests/test_dhcp.py
18index 4a37e98..1324c3d 100644
19--- a/cloudinit/net/tests/test_dhcp.py
20+++ b/cloudinit/net/tests/test_dhcp.py
21@@ -8,7 +8,7 @@ from cloudinit.net.dhcp import (
22 InvalidDHCPLeaseFileError, maybe_perform_dhcp_discovery,
23 parse_dhcp_lease_file, dhcp_discovery)
24 from cloudinit.util import ensure_file, write_file
25-from cloudinit.tests.helpers import CiTestCase
26+from cloudinit.tests.helpers import CiTestCase, wrap_and_call
27
28
29 class TestParseDHCPLeasesFile(CiTestCase):
30@@ -91,21 +91,27 @@ class TestDHCPDiscoveryClean(CiTestCase):
31 'Skip dhclient configuration: No dhclient command found.',
32 self.logs.getvalue())
33
34+ @mock.patch('cloudinit.temp_utils.os.getuid')
35 @mock.patch('cloudinit.net.dhcp.dhcp_discovery')
36 @mock.patch('cloudinit.net.dhcp.util.which')
37 @mock.patch('cloudinit.net.dhcp.find_fallback_nic')
38- def test_dhclient_run_with_tmpdir(self, m_fallback, m_which, m_dhcp):
39+ def test_dhclient_run_with_tmpdir(self, m_fback, m_which, m_dhcp, m_uid):
40 """maybe_perform_dhcp_discovery passes tmpdir to dhcp_discovery."""
41- m_fallback.return_value = 'eth9'
42+ m_uid.return_value = 0 # Fake root user for tmpdir
43+ m_fback.return_value = 'eth9'
44 m_which.return_value = '/sbin/dhclient'
45 m_dhcp.return_value = {'address': '192.168.2.2'}
46- self.assertEqual(
47- {'address': '192.168.2.2'}, maybe_perform_dhcp_discovery())
48+ retval = wrap_and_call(
49+ 'cloudinit.temp_utils',
50+ {'_TMPDIR': {'new': None},
51+ 'os.getuid': 0},
52+ maybe_perform_dhcp_discovery)
53+ self.assertEqual({'address': '192.168.2.2'}, retval)
54 m_dhcp.assert_called_once()
55 call = m_dhcp.call_args_list[0]
56 self.assertEqual('/sbin/dhclient', call[0][0])
57 self.assertEqual('eth9', call[0][1])
58- self.assertIn('/tmp/cloud-init-dhcp-', call[0][2])
59+ self.assertIn('/var/tmp/cloud-init/cloud-init-dhcp-', call[0][2])
60
61 @mock.patch('cloudinit.net.dhcp.util.subp')
62 def test_dhcp_discovery_run_in_sandbox(self, m_subp):
63diff --git a/cloudinit/temp_utils.py b/cloudinit/temp_utils.py
64index 0355f19..5d7adf7 100644
65--- a/cloudinit/temp_utils.py
66+++ b/cloudinit/temp_utils.py
67@@ -8,9 +8,10 @@ import tempfile
68
69 _TMPDIR = None
70 _ROOT_TMPDIR = "/run/cloud-init/tmp"
71+_EXE_ROOT_TMPDIR = "/var/tmp/cloud-init"
72
73
74-def _tempfile_dir_arg(odir=None):
75+def _tempfile_dir_arg(odir=None, needs_exe=False):
76 """Return the proper 'dir' argument for tempfile functions.
77
78 When root, cloud-init will use /run/cloud-init/tmp to avoid
79@@ -20,8 +21,10 @@ def _tempfile_dir_arg(odir=None):
80 If the caller of this function (mkdtemp or mkstemp) was provided
81 with a 'dir' argument, then that is respected.
82
83- @param odir: original 'dir' arg to 'mkdtemp' or other."""
84-
85+ @param odir: original 'dir' arg to 'mkdtemp' or other.
86+ @param needs_exe: Boolean specifying whether or not exe permissions are
87+ needed for tempdir. This is needed because /run is mounted noexec.
88+ """
89 if odir is not None:
90 return odir
91
92@@ -29,7 +32,9 @@ def _tempfile_dir_arg(odir=None):
93 if _TMPDIR:
94 return _TMPDIR
95
96- if os.getuid() == 0:
97+ if needs_exe:
98+ tdir = _EXE_ROOT_TMPDIR
99+ elif os.getuid() == 0:
100 tdir = _ROOT_TMPDIR
101 else:
102 tdir = os.environ.get('TMPDIR', '/tmp')
103@@ -42,7 +47,8 @@ def _tempfile_dir_arg(odir=None):
104
105
106 def ExtendedTemporaryFile(**kwargs):
107- kwargs['dir'] = _tempfile_dir_arg(kwargs.pop('dir', None))
108+ kwargs['dir'] = _tempfile_dir_arg(
109+ kwargs.pop('dir', None), kwargs.pop('needs_exe', False))
110 fh = tempfile.NamedTemporaryFile(**kwargs)
111 # Replace its unlink with a quiet version
112 # that does not raise errors when the
113@@ -82,12 +88,14 @@ def tempdir(**kwargs):
114
115
116 def mkdtemp(**kwargs):
117- kwargs['dir'] = _tempfile_dir_arg(kwargs.pop('dir', None))
118+ kwargs['dir'] = _tempfile_dir_arg(
119+ kwargs.pop('dir', None), kwargs.pop('needs_exe', False))
120 return tempfile.mkdtemp(**kwargs)
121
122
123 def mkstemp(**kwargs):
124- kwargs['dir'] = _tempfile_dir_arg(kwargs.pop('dir', None))
125+ kwargs['dir'] = _tempfile_dir_arg(
126+ kwargs.pop('dir', None), kwargs.pop('needs_exe', False))
127 return tempfile.mkstemp(**kwargs)
128
129 # vi: ts=4 expandtab
130diff --git a/cloudinit/tests/test_temp_utils.py b/cloudinit/tests/test_temp_utils.py
131new file mode 100644
132index 0000000..ffbb92c
133--- /dev/null
134+++ b/cloudinit/tests/test_temp_utils.py
135@@ -0,0 +1,101 @@
136+# This file is part of cloud-init. See LICENSE file for license information.
137+
138+"""Tests for cloudinit.temp_utils"""
139+
140+from cloudinit.temp_utils import mkdtemp, mkstemp
141+from cloudinit.tests.helpers import CiTestCase, wrap_and_call
142+
143+
144+class TestTempUtils(CiTestCase):
145+
146+ def test_mkdtemp_default_non_root(self):
147+ """mkdtemp creates a dir under /tmp for the unprivileged."""
148+ calls = []
149+
150+ def fake_mkdtemp(*args, **kwargs):
151+ calls.append(kwargs)
152+ return '/fake/return/path'
153+
154+ retval = wrap_and_call(
155+ 'cloudinit.temp_utils',
156+ {'os.getuid': 1000,
157+ 'tempfile.mkdtemp': {'side_effect': fake_mkdtemp},
158+ '_TMPDIR': {'new': None},
159+ 'os.path.isdir': True},
160+ mkdtemp)
161+ self.assertEqual('/fake/return/path', retval)
162+ self.assertEqual([{'dir': '/tmp'}], calls)
163+
164+ def test_mkdtemp_default_non_root_needs_exe(self):
165+ """mkdtemp creates a dir under /var/tmp/cloud-init when needs_exe."""
166+ calls = []
167+
168+ def fake_mkdtemp(*args, **kwargs):
169+ calls.append(kwargs)
170+ return '/fake/return/path'
171+
172+ retval = wrap_and_call(
173+ 'cloudinit.temp_utils',
174+ {'os.getuid': 1000,
175+ 'tempfile.mkdtemp': {'side_effect': fake_mkdtemp},
176+ '_TMPDIR': {'new': None},
177+ 'os.path.isdir': True},
178+ mkdtemp, needs_exe=True)
179+ self.assertEqual('/fake/return/path', retval)
180+ self.assertEqual([{'dir': '/var/tmp/cloud-init'}], calls)
181+
182+ def test_mkdtemp_default_root(self):
183+ """mkdtemp creates a dir under /run/cloud-init for the privileged."""
184+ calls = []
185+
186+ def fake_mkdtemp(*args, **kwargs):
187+ calls.append(kwargs)
188+ return '/fake/return/path'
189+
190+ retval = wrap_and_call(
191+ 'cloudinit.temp_utils',
192+ {'os.getuid': 0,
193+ 'tempfile.mkdtemp': {'side_effect': fake_mkdtemp},
194+ '_TMPDIR': {'new': None},
195+ 'os.path.isdir': True},
196+ mkdtemp)
197+ self.assertEqual('/fake/return/path', retval)
198+ self.assertEqual([{'dir': '/run/cloud-init/tmp'}], calls)
199+
200+ def test_mkstemp_default_non_root(self):
201+ """mkstemp creates secure tempfile under /tmp for the unprivileged."""
202+ calls = []
203+
204+ def fake_mkstemp(*args, **kwargs):
205+ calls.append(kwargs)
206+ return '/fake/return/path'
207+
208+ retval = wrap_and_call(
209+ 'cloudinit.temp_utils',
210+ {'os.getuid': 1000,
211+ 'tempfile.mkstemp': {'side_effect': fake_mkstemp},
212+ '_TMPDIR': {'new': None},
213+ 'os.path.isdir': True},
214+ mkstemp)
215+ self.assertEqual('/fake/return/path', retval)
216+ self.assertEqual([{'dir': '/tmp'}], calls)
217+
218+ def test_mkstemp_default_root(self):
219+ """mkstemp creates a secure tempfile in /run/cloud-init for root."""
220+ calls = []
221+
222+ def fake_mkstemp(*args, **kwargs):
223+ calls.append(kwargs)
224+ return '/fake/return/path'
225+
226+ retval = wrap_and_call(
227+ 'cloudinit.temp_utils',
228+ {'os.getuid': 0,
229+ 'tempfile.mkstemp': {'side_effect': fake_mkstemp},
230+ '_TMPDIR': {'new': None},
231+ 'os.path.isdir': True},
232+ mkstemp)
233+ self.assertEqual('/fake/return/path', retval)
234+ self.assertEqual([{'dir': '/run/cloud-init/tmp'}], calls)
235+
236+# vi: ts=4 expandtab
237diff --git a/cloudinit/util.py b/cloudinit/util.py
238index 7e9d94f..4c01f44 100644
239--- a/cloudinit/util.py
240+++ b/cloudinit/util.py
241@@ -1755,7 +1755,7 @@ def subp_blob_in_tempfile(blob, *args, **kwargs):
242 args = [tuple()]
243
244 # Use tmpdir over tmpfile to avoid 'text file busy' on execute
245- with temp_utils.tempdir() as tmpd:
246+ with temp_utils.tempdir(needs_exe=True) as tmpd:
247 tmpf = os.path.join(tmpd, basename)
248 if 'args' in kwargs:
249 kwargs['args'] = [tmpf] + list(kwargs['args'])

Subscribers

People subscribed via source and target branches