1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
|
#
# Copyright (C) 2017 Codethink Limited
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library. If not, see <http://www.gnu.org/licenses/>.
#
# Authors:
# Tristan Maat <tristan.maat@codethink.co.uk>
# Tristan Van Berkom <tristan.vanberkom@codethink.co.uk>
import os
import sys
import stat
import signal
import subprocess
from contextlib import contextmanager, ExitStack
import psutil
from .._exceptions import SandboxError
from .. import utils
from .. import _signals
from ._mounter import Mounter
from ._mount import MountMap
from . import Sandbox, SandboxFlags
class SandboxChroot(Sandbox):
_FUSE_MOUNT_OPTIONS = {'dev': True}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
uid = self._get_config().build_uid
gid = self._get_config().build_gid
if uid != 0 or gid != 0:
raise SandboxError("Chroot sandboxes cannot specify a non-root uid/gid "
"({},{} were supplied via config)".format(uid, gid))
self.mount_map = None
def run(self, command, flags, *, cwd=None, env=None):
# Fallback to the sandbox default settings for
# the cwd and env.
#
cwd = self._get_work_directory(cwd=cwd)
env = self._get_environment(cwd=cwd, env=env)
# Convert single-string argument to a list
if isinstance(command, str):
command = [command]
if not self._has_command(command[0], env):
raise SandboxError("Staged artifacts do not provide command "
"'{}'".format(command[0]),
reason='missing-command')
stdout, stderr = self._get_output()
# Create the mount map, this will tell us where
# each mount point needs to be mounted from and to
self.mount_map = MountMap(self, flags & SandboxFlags.ROOT_READ_ONLY,
self._FUSE_MOUNT_OPTIONS)
# Create a sysroot and run the command inside it
with ExitStack() as stack:
os.makedirs('/var/run/buildstream', exist_ok=True)
# FIXME: While we do not currently do anything to prevent
# network access, we also don't copy /etc/resolv.conf to
# the new rootfs.
#
# This effectively disables network access, since DNs will
# never resolve, so anything a normal process wants to do
# will fail. Malicious processes could gain rights to
# anything anyway.
#
# Nonetheless a better solution could perhaps be found.
rootfs = stack.enter_context(utils._tempdir(dir='/var/run/buildstream'))
stack.enter_context(self.create_devices(self._root, flags))
stack.enter_context(self.mount_dirs(rootfs, flags, stdout, stderr))
if flags & SandboxFlags.INTERACTIVE:
stdin = sys.stdin
else:
stdin = stack.enter_context(open(os.devnull, 'r'))
# Ensure the cwd exists
if cwd is not None:
workdir = os.path.join(rootfs, cwd.lstrip(os.sep))
os.makedirs(workdir, exist_ok=True)
status = self.chroot(rootfs, command, stdin, stdout,
stderr, cwd, env, flags)
self._vdir._mark_changed()
return status
# chroot()
#
# A helper function to chroot into the rootfs.
#
# Args:
# rootfs (str): The path of the sysroot to chroot into
# command (list): The command to execute in the chroot env
# stdin (file): The stdin
# stdout (file): The stdout
# stderr (file): The stderr
# cwd (str): The current working directory
# env (dict): The environment variables to use while executing the command
# flags (:class:`SandboxFlags`): The flags to enable on the sandbox
#
# Returns:
# (int): The exit code of the executed command
#
def chroot(self, rootfs, command, stdin, stdout, stderr, cwd, env, flags):
def kill_proc():
if process:
# First attempt to gracefully terminate
proc = psutil.Process(process.pid)
proc.terminate()
try:
proc.wait(20)
except psutil.TimeoutExpired:
utils._kill_process_tree(process.pid)
def suspend_proc():
group_id = os.getpgid(process.pid)
os.killpg(group_id, signal.SIGSTOP)
def resume_proc():
group_id = os.getpgid(process.pid)
os.killpg(group_id, signal.SIGCONT)
try:
with _signals.suspendable(suspend_proc, resume_proc), _signals.terminator(kill_proc):
process = subprocess.Popen( # pylint: disable=subprocess-popen-preexec-fn
command,
close_fds=True,
cwd=os.path.join(rootfs, cwd.lstrip(os.sep)),
env=env,
stdin=stdin,
stdout=stdout,
stderr=stderr,
# If you try to put gtk dialogs here Tristan (either)
# will personally scald you
preexec_fn=lambda: (os.chroot(rootfs), os.chdir(cwd)),
start_new_session=flags & SandboxFlags.INTERACTIVE
)
# Wait for the child process to finish, ensuring that
# a SIGINT has exactly the effect the user probably
# expects (i.e. let the child process handle it).
try:
while True:
try:
_, status = os.waitpid(process.pid, 0)
# If the process exits due to a signal, we
# brutally murder it to avoid zombies
if not os.WIFEXITED(status):
utils._kill_process_tree(process.pid)
# Unlike in the bwrap case, here only the main
# process seems to receive the SIGINT. We pass
# on the signal to the child and then continue
# to wait.
except KeyboardInterrupt:
process.send_signal(signal.SIGINT)
continue
break
# If we can't find the process, it has already died of
# its own accord, and therefore we don't need to check
# or kill anything.
except psutil.NoSuchProcess:
pass
# Return the exit code - see the documentation for
# os.WEXITSTATUS to see why this is required.
if os.WIFEXITED(status):
code = os.WEXITSTATUS(status)
else:
code = -1
except subprocess.SubprocessError as e:
# Exceptions in preexec_fn are simply reported as
# 'Exception occurred in preexec_fn', turn these into
# a more readable message.
if '{}'.format(e) == 'Exception occurred in preexec_fn.':
raise SandboxError('Could not chroot into {} or chdir into {}. '
'Ensure you are root and that the relevant directory exists.'
.format(rootfs, cwd)) from e
else:
raise SandboxError('Could not run command {}: {}'.format(command, e)) from e
return code
# create_devices()
#
# Create the nodes in /dev/ usually required for builds (null,
# none, etc.)
#
# Args:
# rootfs (str): The path of the sysroot to prepare
# flags (:class:`.SandboxFlags`): The sandbox flags
#
@contextmanager
def create_devices(self, rootfs, flags):
devices = []
# When we are interactive, we'd rather mount /dev due to the
# sheer number of devices
if not flags & SandboxFlags.INTERACTIVE:
for device in Sandbox.DEVICES:
location = os.path.join(rootfs, device.lstrip(os.sep))
os.makedirs(os.path.dirname(location), exist_ok=True)
try:
if os.path.exists(location):
os.remove(location)
devices.append(self.mknod(device, location))
except OSError as err:
if err.errno == 1:
raise SandboxError("Permission denied while creating device node: {}.".format(err) +
"BuildStream reqiures root permissions for these setttings.")
else:
raise
yield
for device in devices:
os.remove(device)
# mount_dirs()
#
# Mount paths required for the command.
#
# Args:
# rootfs (str): The path of the sysroot to prepare
# flags (:class:`.SandboxFlags`): The sandbox flags
# stdout (file): The stdout
# stderr (file): The stderr
#
@contextmanager
def mount_dirs(self, rootfs, flags, stdout, stderr):
# FIXME: This should probably keep track of potentially
# already existing files a la _sandboxwrap.py:239
@contextmanager
def mount_point(point, **kwargs):
mount_source_overrides = self._get_mount_sources()
if point in mount_source_overrides: # pylint: disable=consider-using-get
mount_source = mount_source_overrides[point]
else:
mount_source = self.mount_map.get_mount_source(point)
mount_point = os.path.join(rootfs, point.lstrip(os.sep))
with Mounter.bind_mount(mount_point, src=mount_source, stdout=stdout, stderr=stderr, **kwargs):
yield
@contextmanager
def mount_src(src, **kwargs):
mount_point = os.path.join(rootfs, src.lstrip(os.sep))
os.makedirs(mount_point, exist_ok=True)
with Mounter.bind_mount(mount_point, src=src, stdout=stdout, stderr=stderr, **kwargs):
yield
with ExitStack() as stack:
stack.enter_context(self.mount_map.mounted(self))
stack.enter_context(mount_point('/'))
if flags & SandboxFlags.INTERACTIVE:
stack.enter_context(mount_src('/dev'))
stack.enter_context(mount_src('/tmp'))
stack.enter_context(mount_src('/proc'))
for mark in self._get_marked_directories():
stack.enter_context(mount_point(mark['directory']))
# Remount root RO if necessary
if flags & flags & SandboxFlags.ROOT_READ_ONLY:
root_mount = Mounter.mount(rootfs, stdout=stdout, stderr=stderr, remount=True, ro=True, bind=True)
# Since the exit stack has already registered a mount
# for this path, we do not need to register another
# umount call.
root_mount.__enter__()
yield
# mknod()
#
# Create a device node equivalent to the given source node
#
# Args:
# source (str): Path of the device to mimic (e.g. '/dev/null')
# target (str): Location to create the new device in
#
# Returns:
# target (str): The location of the created node
#
def mknod(self, source, target):
try:
dev = os.stat(source)
major = os.major(dev.st_rdev)
minor = os.minor(dev.st_rdev)
target_dev = os.makedev(major, minor)
os.mknod(target, mode=stat.S_IFCHR | dev.st_mode, device=target_dev)
except PermissionError as e:
raise SandboxError('Could not create device {}, ensure that you have root permissions: {}')
except OSError as e:
raise SandboxError('Could not create device {}: {}'
.format(target, e)) from e
return target
|