# # 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 . # # Authors: # Tristan Maat # Tristan Van Berkom 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