diff options
author | Sam Thursfield <sam.thursfield@codethink.co.uk> | 2015-05-22 18:33:44 +0100 |
---|---|---|
committer | Sam Thursfield <sam.thursfield@codethink.co.uk> | 2015-05-26 18:16:07 +0100 |
commit | 16378a5d83267dd49bbce5dd3e8e4588c86e7686 (patch) | |
tree | d82fbab2535f7517940257947076469be773a245 /sandboxlib | |
parent | 89ebc5fb7cd89ffc5598d31284db026e09408982 (diff) | |
download | sandboxlib-16378a5d83267dd49bbce5dd3e8e4588c86e7686.tar.gz |
Initial work to allow configuring mount sharing and mounting
This is far from complete and has probably numerous issues right now.
Diffstat (limited to 'sandboxlib')
-rw-r--r-- | sandboxlib/__init__.py | 33 | ||||
-rw-r--r-- | sandboxlib/chroot.py | 88 | ||||
-rw-r--r-- | sandboxlib/linux_user_chroot.py | 110 |
3 files changed, 216 insertions, 15 deletions
diff --git a/sandboxlib/__init__.py b/sandboxlib/__init__.py index db8f34a..be302c6 100644 --- a/sandboxlib/__init__.py +++ b/sandboxlib/__init__.py @@ -66,8 +66,13 @@ def run_sandbox(rootfs_path, command, cwd=None, extra_env=None, directory of the calling process otherwise. - extra_env: environment variables to set in addition to BASE_ENVIRONMENT. + - mounts: configures mount sharing. Defaults to 'undefined', where no + no attempt is made to isolate mounts. Backends may support + 'isolated' as well. + - extra_mounts: a list of locations to mount inside 'rootfs_path', with + type and options specified in a backend-specific way. - network: configures network sharing. Defaults to 'undefined', where - case no attempt is made to either prevent or provide networking + no attempt is made to either prevent or provide networking inside the sandbox. Backends may support 'isolated' and/or other values as well. @@ -95,6 +100,32 @@ def environment_vars(extra_env=None): return env +def validate_extra_mounts(extra_mounts): + '''Validate and fill in default values for 'extra_mounts' setting.''' + if extra_mounts == None: + return [] + + new_extra_mounts = [] + + for mount_entry in extra_mounts: + if len(mount_entry) == 3: + new_mount_entry = list(mount_entry) + [''] + elif len(mount_entry) == 4: + new_mount_entry = list(mount_entry) + else: + raise AssertionError( + "Invalid mount entry in 'extra_mounts': %s" % mount_entry) + + if new_mount_entry[0] is None: + new_mount_entry[0] = '' + #new_mount_entry[0] = 'none' + if new_mount_entry[3] is None: + new_mount_entry[3] = '' + new_extra_mounts.append(new_mount_entry) + + return new_extra_mounts + + def _run_command(argv, cwd=None, env=None, preexec_fn=None): '''Wrapper around subprocess.Popen() with common settings. diff --git a/sandboxlib/chroot.py b/sandboxlib/chroot.py index 986c7c2..2c696fb 100644 --- a/sandboxlib/chroot.py +++ b/sandboxlib/chroot.py @@ -21,6 +21,11 @@ This backend should work on any POSIX-compliant operating system. It has been tested on Linux only. The calling process must be able to use the chroot() syscall, which is likely to require 'root' priviliges. +If any 'extra_mounts' are specified, there must be a working 'mount' binary in +the host system. + +Supported mounts settings: 'undefined'. + Supported network settings: 'undefined'. The code would be simpler if we just used the 'chroot' program, but it's not @@ -34,16 +39,31 @@ that the sandbox contains a shell and we do some hack like running import multiprocessing import os +import subprocess +import warnings import sandboxlib def maximum_possible_isolation(): return { - 'network': 'undefined' + 'mounts': 'undefined', + 'network': 'undefined', } +def process_mount_config(mounts, extra_mounts): + supported_values = ['undefined', 'isolated'] + + assert mounts in supported_values, \ + "'%s' is an unsupported value for 'mounts' in the 'chroot' " \ + "Mount sharing cannot be configured in this backend." % mounts + + extra_mounts = sandboxlib.validate_extra_mounts(extra_mounts) + + return extra_mounts + + def process_network_config(network): # It'd be possible to implement network isolation on Linux using the # clone() syscall. However, I prefer to have the 'chroot' backend behave @@ -55,7 +75,31 @@ def process_network_config(network): "Network sharing cannot be be configured in this backend." % network -def _run_command_in_chroot(pipe, rootfs_path, command, cwd, env): +def _mount(source, path, mount_type, mount_options): + # We depend on the host system's 'mount' program here, which is a + # little sad. It's possible to call the libc's mount() function + # directly from Python using the 'ctypes' library, and perhaps we + # should do that instead. + argv = [ + '/bin/mount', '-t', mount_type, '-o', mount_options, source, path] + exit, out, err = sandboxlib._run_command(argv) + + if exit != 0: + raise RuntimeError( + "%s failed: %s" % ( + argv, err.decode('utf-8'))) + + +def _unmount(path): + argv = ['/bin/umount', path] + exit, out, err = sandboxlib._run_command(argv) + + if exit != 0: + warnings.warn("%s failed: %s" % ( + argv, err.decode('utf-8'))) + + +def _run_command_in_chroot(pipe, extra_mounts, rootfs_path, command, cwd, env): # This function should be run in a multiprocessing.Process() subprocess, # because it calls os.chroot(). There's no 'unchroot()' function! After # chrooting, it calls sandboxlib._run_command(), which uses the @@ -72,36 +116,68 @@ def _run_command_in_chroot(pipe, rootfs_path, command, cwd, env): try: # You have most likely got to be the 'root' user in order for this to # work. + + mounted = [] + + for source, mount_point, mount_type, mount_options in extra_mounts: + # Strip the preceeding '/' from mount_point, because it'll break + # os.path.join(). + mount_point_no_slash = os.path.relpath(mount_point, start='/') + + path = os.path.join(rootfs_path, mount_point_no_slash) + if not os.path.exists(path): + os.makedirs(path) + + _mount(source, path, mount_type, mount_options) + mounted.append(path) + try: os.chroot(rootfs_path) except OSError as e: raise RuntimeError("Unable to chroot: %s" % e) + mounted = [os.path.relpath(path, start=rootfs_path) + for path in mounted] + if cwd is not None: - os.chdir(cwd) + try: + os.chdir(cwd) + except OSError as e: + raise RuntimeError( + "Unable to set current working directory: %s" % e) exit, out, err = sandboxlib._run_command(command, env=env) pipe.send([exit, out, err]) - os._exit(0) + result = 0 except Exception as e: pipe.send(e) - os._exit(1) + result = 1 + finally: + # FIXME: this only works if there's actually an 'unmount' available in + # the chroot!!! + for mountpoint in mounted: + _unmount(mountpoint) + + os._exit(result) def run_sandbox(rootfs_path, command, cwd=None, extra_env=None, + mounts='undefined', extra_mounts=None, network='undefined'): if type(command) == str: command = [command] env = sandboxlib.environment_vars(extra_env) + extra_mounts = process_mount_config(mounts, extra_mounts) + process_network_config(network) pipe_parent, pipe_child = multiprocessing.Pipe() process = multiprocessing.Process( target=_run_command_in_chroot, - args=(pipe_child, rootfs_path, command, cwd, env)) + args=(pipe_child, extra_mounts, rootfs_path, command, cwd, env)) process.start() process.join() diff --git a/sandboxlib/linux_user_chroot.py b/sandboxlib/linux_user_chroot.py index 0dfbe4d..dc8c4e8 100644 --- a/sandboxlib/linux_user_chroot.py +++ b/sandboxlib/linux_user_chroot.py @@ -17,24 +17,116 @@ This implements an API defined in sandboxlib/__init__.py. -This backend requires the `linux-user-chroot` program. This program is -Linux-only. It is intended to be a 'setuid', and thus usable by non-'root' -users that have explicitly been given permission to use it. +This backend requires the 'linux-user-chroot' program, which can only be used +with Linux. It also requires the 'unshare' program from the 'util-linux' +package, a 'mount' program that supports the `--make-rprivate` flag, and a 'sh' +program with certain standard features. + +The 'linux-user-chroot' program is intended to be 'setuid', and thus usable by +non-'root' users at the discretion of the system administrator. However, the +implementation here also uses 'unshare --mount', which can only be run as +'root'. So this backend can only be run as 'root' at present. Modifying +linux-user-chroot to handle creating the new mount namespace and processing +any extra mounts would be a useful fix. + +Supported mounts settings: 'undefined', 'isolated'. Supported network settings: 'undefined', 'isolated'. ''' +import os +import textwrap + import sandboxlib def maximum_possible_isolation(): return { + 'mounts': 'isolated', 'network': 'isolated', } +def process_mount_config(root, mounts, extra_mounts): + # FIXME: currently errors in the generated shell script will appear in the + # same way as errors from the actual command that the caller wanted to run. + # That's pretty boneheaded. Could be fixed by setting a flag at the end of + # the shell script, perhaps. + + supported_values = ['undefined', 'isolated'] + + assert mounts in supported_values, \ + "'%s' is an unsupported value for 'mounts' in the " \ + "'linux-user-chroot' backend. Supported values: %s" \ + % (mounts, ', '.join(supported_values)) + + extra_mounts = sandboxlib.validate_extra_mounts(extra_mounts) + + # Use 'unshare' to create a new mount namespace. + # + # In order to mount the things specified in 'extra_mounts' inside the + # sandbox's mount namespace, we add a script that runs bunch of 'mount' + # commands to the 'unshare' commandline. The mounts it creates are + # unmounted automatically when the namespace is deleted, which is done when + # 'unshare' exits. + # + # The 'undefined' and 'isolated' options are treated the same in this + # backend, which avoids having a separate, useless code path. + + unshare_command = ['unshare', '--mount', '--', 'sh', '-e', '-c'] + + # The single - is just a shell convention to fill $0 when using -c, + # since ordinarily $0 contains the program name. + mount_script_args = ['-'] + + # This command marks any existing mounts inside the sandboxed filesystem + # as 'private'. If they were pre-existing 'shared' or 'slave' mounts, it'd + # be possible to change what is mounted in the sandbox from outside the + # sandbox, or to change a mountpoint outside the sandbox from within it. + mount_script = textwrap.dedent(r''' + mount --make-rprivate / + root="$1" + shift + ''') + mount_script_args.append(root) + + # The rest of this script processes the items from 'extra_mounts'. + mount_script += textwrap.dedent(r''' + while true; do + case "$1" in + --) + shift + break + ;; + *) + mount_point="$1" + mount_type="$2" + mount_source="$3" + mount_options="$4" + shift 4 + path="$root/$mount_point" + mount -t "$mount_type" -o "$mount_options" "$mount_source" "$path" + ;; + esac + done + ''') + + for source, mount_point, mount_type, mount_options in extra_mounts: + path = os.path.join(root, mount_point) + if not os.path.exists(path): + os.makedirs(path) + mount_script_args.extend((mount_point, mount_type, source, mount_options)) + mount_script_args.append('--') + + mount_script += textwrap.dedent(r''' + exec "$@" + ''') + + return unshare_command + [mount_script] + mount_script_args + + def process_network_config(network): # Network isolation is pretty easy, we 'unshare' the network namespace, and # nothing can access the network. @@ -60,22 +152,24 @@ def process_network_config(network): def run_sandbox(rootfs_path, command, cwd=None, extra_env=None, + mounts='undefined', extra_mounts=None, network='undefined'): if type(command) == str: command = [command] - linux_user_chroot = 'linux-user-chroot' + linux_user_chroot_command = ['linux-user-chroot'] - linux_user_chroot_args = [] + unshare_command = process_mount_config( + root=rootfs_path, mounts=mounts, extra_mounts=extra_mounts or []) - linux_user_chroot_args += process_network_config(network) + linux_user_chroot_command += process_network_config(network) if cwd is not None: - linux_user_chroot_args.extend(['--chdir', cwd]) + linux_user_chroot_command.extend(['--chdir', cwd]) env = sandboxlib.environment_vars(extra_env) argv = ( - [linux_user_chroot] + linux_user_chroot_args + [rootfs_path] + command) + unshare_command + linux_user_chroot_command + [rootfs_path] + command) exit, out, err = sandboxlib._run_command(argv, env=env) return exit, out, err |