summaryrefslogtreecommitdiff
path: root/sandboxlib
diff options
context:
space:
mode:
authorSam Thursfield <sam.thursfield@codethink.co.uk>2015-05-22 18:33:44 +0100
committerSam Thursfield <sam.thursfield@codethink.co.uk>2015-05-26 18:16:07 +0100
commit16378a5d83267dd49bbce5dd3e8e4588c86e7686 (patch)
treed82fbab2535f7517940257947076469be773a245 /sandboxlib
parent89ebc5fb7cd89ffc5598d31284db026e09408982 (diff)
downloadsandboxlib-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__.py33
-rw-r--r--sandboxlib/chroot.py88
-rw-r--r--sandboxlib/linux_user_chroot.py110
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