# # 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, PlatformError from .. import utils from .. import _signals from ._mounter import Mounter from ._mount import MountMap from . import Sandbox, SandboxFlags, SandboxCommandError 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 @classmethod def check_available(cls): cls._uid = os.getuid() cls._gid = os.getgid() available = cls._uid == 0 if not available: cls._dummy_reasons += ["uid is not 0"] raise SandboxError("can not run chroot if uid is not 0") @classmethod def check_sandbox_config(cls, local_platform, config): # With the chroot sandbox, the UID/GID in the sandbox # will match the host UID/GID (typically 0/0). if config.build_uid != cls._uid or config.build_gid != cls._gid: return False host_os = local_platform.get_host_os() host_arch = local_platform.get_host_arch() # Check host os and architecture match if config.build_os != host_os: raise PlatformError("Configured and host OS don't match.") if config.build_arch != host_arch: raise PlatformError("Configured and host architecture don't match.") return True def _run(self, command, flags, *, cwd, env): if not self._has_command(command[0], env): raise SandboxCommandError( "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 str(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 # Otherwise, raise a more general error 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." ) 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