pthbs

Packaging Through Hashed Build Scripts
git clone https://ccx.te2000.cz/git/pthbs
Log | Files | Refs | Submodules | README

commit c9ee942a7a197756eef551ba7e4a065d6ad46fe2
parent 368fd4ed6390bdecbd9eac09c6d7bd5d573e7cbc
Author: ccx <ccx@te2000.cz>
Date:   Mon, 26 Feb 2024 02:17:37 +0000

Root / no userns sandbox mode

Diffstat:
Mcommand/pthbs-build | 30++++++++++++++++++++++++++----
Ans_sandbox.py | 628+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Duserns_sandbox.py | 430-------------------------------------------------------------------------------
3 files changed, 654 insertions(+), 434 deletions(-)

diff --git a/command/pthbs-build b/command/pthbs-build @@ -30,12 +30,24 @@ if test -f "make/package.sha256.${bsh}.env"; then fi mkdir -p "$workdir" +case $(id -u) in + (0) + export UID=$(id -u pthbs) || exit $? + export GID=$(id -g pthbs) || exit $? + chgrp pthbs "$workdir" + sandbox_mode=root + ;; + (*) + sandbox_mode=userns + ;; +esac + env \ basedir="$basedir" \ workdir="$workdir" \ script="$script" \ envdir="$pthbs_build_environment" \ - awk -v single_quote="'" >"$workdir/pthbs-setup" ' + awk -v single_quote="'" sandbox_mode="$sandbox_mode" >"$workdir/pthbs-setup" ' BEGIN { settings["sandbox"] = 1 settings["set_path"] = 1 @@ -143,16 +155,26 @@ function at_filehash(hash_type, file_hash, dst, dstdir){ sandbox_cmd=sandbox_cmd " -mbind+" q(ENVIRON["basedir"]"/work/bin:/bin:ro,nosuid,nodev") sandbox_cmd=sandbox_cmd " -m " q("allow/read+/bin/***") sandbox_cmd=sandbox_cmd " -munshare/net:1 -munshare/ipc:1" - } else { - sandbox_cmd=" "q(ENVIRON["basedir"]"/userns_sandbox.py") + } else if(sandbox_mode == "userns") { + sandbox_cmd=" "q(ENVIRON["basedir"]"/ns_sandbox.py")" --mode=userns" + sandbox_cmd=sandbox_cmd" --vars="q(ENVIRON["basedir"]"/vars.yaml") + sandbox_cmd=sandbox_cmd" --extra-mount=tmpfs:"q(ENVIRON["basedir"]"/work") + sandbox_cmd=sandbox_cmd" --extra-mount=ro_bind:"q(ENVIRON["basedir"]"/packages:"ENVIRON["basedir"]"/packages") + sandbox_cmd=sandbox_cmd" --extra-mount=rw_bind:"q(ENVIRON["workdir"]":"ENVIRON["workdir"]) + sandbox_cmd=sandbox_cmd" --extra-mount=rw_bind:"q(ENVIRON["workdir"]"/.tmp:/tmp") + sandbox_cmd=sandbox_cmd" -- "q(ENVIRON["basedir"]"/work/root") + printf "%s\n", "mkdir -p "q(ENVIRON["workdir"]"/.tmp") + } else if(sandbox_mode == "root") { + sandbox_cmd=" "q(ENVIRON["basedir"]"/userns_sandbox.py")" --mode=root" sandbox_cmd=sandbox_cmd" --vars="q(ENVIRON["basedir"]"/vars.yaml") sandbox_cmd=sandbox_cmd" --extra-mount=tmpfs:"q(ENVIRON["basedir"]"/work") sandbox_cmd=sandbox_cmd" --extra-mount=ro_bind:"q(ENVIRON["basedir"]"/packages:"ENVIRON["basedir"]"/packages") sandbox_cmd=sandbox_cmd" --extra-mount=rw_bind:"q(ENVIRON["workdir"]":"ENVIRON["workdir"]) sandbox_cmd=sandbox_cmd" --extra-mount=rw_bind:"q(ENVIRON["workdir"]"/.tmp:/tmp") sandbox_cmd=sandbox_cmd" -- "q(ENVIRON["basedir"]"/work/root") - sandbox_cmd=sandbox_cmd" "q(basename(ENVIRON["envdir"])) printf "%s\n", "mkdir -p "q(ENVIRON["workdir"]"/.tmp") + } else { + fatal("unrecognized sanbox_mode " sandbox_mode) } } else { sandbox_cmd="" diff --git a/ns_sandbox.py b/ns_sandbox.py @@ -0,0 +1,628 @@ +#!/usr/bin/python3 +import argparse +import ctypes +import dataclasses +import enum +import errno +import fcntl +import os +import os.path +import pathlib +import select +import stat +import subprocess +import sys + +libc = ctypes.CDLL(None, use_errno=True) +CLONE_NEWNS = 0x00020000 # New mount namespace group +CLONE_NEWCGROUP = 0x02000000 # New cgroup namespace +CLONE_NEWUTS = 0x04000000 # New utsname namespace +CLONE_NEWIPC = 0x08000000 # New ipc namespace +CLONE_NEWUSER = 0x10000000 # New user namespace +CLONE_NEWPID = 0x20000000 # New pid namespace +CLONE_NEWNET = 0x40000000 # New network namespace +CLONE_NEWTIME = 0x00000080 # New time namespace + +SYS_pivot_root = 155 + +MNT_FORCE = 1 +MNT_DETACH = 2 +MNT_EXPIRE = 4 +UMOUNT_NOFOLLOW = 8 + +class MountFlag(int, enum.Enum): + """Mount flags.""" + + #: Mount read-only. + RDONLY = 1 + #: Ignore suid and sgid bits. + NOSUID = 2 + #: Disallow access to device special files. + NODEV = 4 + #: Disallow program execution. + NOEXEC = 8 + #: Writes are synced at once. + SYNCHRONOUS = 16 + #: Alter flags of a mounted FS. + REMOUNT = 32 + #: Allow mandatory locks on an FS. + MANDLOCK = 64 + #: Directory modifications are synchronous. + DIRSYNC = 128 + #: Do not follow symlinks. + NOSYMFOLLOW = 256 + #: Do not update access times. + NOATIME = 1024 + #: Do not update directory access times. + NODIRATIME = 2048 + #: Bind directory at different place. + BIND = 4096 + MOVE = 8192 + REC = 16384 + SILENT = 32768 + #: VFS does not apply the umask. + POSIXACL = 1 << 16 + #: Change to unbindable. + UNBINDABLE = 1 << 17 + #: Change to private. + PRIVATE = 1 << 18 + #: Change to slave. + SLAVE = 1 << 19 + #: Change to shared. + SHARED = 1 << 20 + #: Update atime relative to mtime/ctime. + RELATIME = 1 << 21 + #: This is a kern_mount call. + KERNMOUNT = 1 << 22 + #: Update inode I_version field. + I_VERSION = 1 << 23 + #: Always perform atime updates. + STRICTATIME = 1 << 24 + #: Update the on-disk [acm]times lazily. + LAZYTIME = 1 << 25 + ACTIVE = 1 << 30 + NOUSER = 1 << 31 + + +_mount = libc.mount +_mount.restype = ctypes.c_int +_mount.argtypes = ( + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.c_ulong, + ctypes.c_void_p, +) + +_umount = libc.umount +_umount.restype = ctypes.c_int +_umount.argtypes = (ctypes.c_char_p,) + +_umount2 = libc.umount2 +_umount2.restype = ctypes.c_int +_umount2.argtypes = (ctypes.c_char_p, ctypes.c_int) + +_unshare = libc.unshare +_unshare.restype = ctypes.c_int +_unshare.argtypes = (ctypes.c_int,) + + +def c_path(path): + if path is None: + return path + if isinstance(path, pathlib.PosixPath): + path = path.as_posix() + if isinstance(path, str): + path = path.encode() + return path + + +def c_error(): + return OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno())) + +def unshare(flags): + if libc.unshare(flags) != 0: + raise c_error() + + +def pivot_root(new_root, put_old): + if libc.syscall(SYS_pivot_root, c_path(new_root), c_path(put_old)) != 0: + raise c_error() + + +def mount( + source: str, + target: str, + fstype: str, + flags: int = 0, + data: str = None, +): + """Mount filesystem. + + :param source: Device/source to mount. + :param target: Mountpoint. + :param fstype: Filesystem type. Available filesystem types can be found in /proc/filesystems. + :param flags: Mount flags. + :param data: Mount options for specified filesystem. + :raises OSError: If mount call failed with nonzero return code. + """ + if ( + _mount( + c_path(source), + c_path(target), + fstype.encode() if fstype is not None else fstype, + int(flags), + data.encode() if data is not None else data, + ) + != 0 + ): + raise c_error() + + +def bind_mount( + source: str, + target: str, + write: bool = False, +): + return mount( + source, + target, + None, + ( + MountFlag.BIND + | (0 if write else MountFlag.RDONLY) + | MountFlag.NOSUID + | MountFlag.NODEV + ), + ) + + +def umount(target: str): + """Unmount filesystem. + + :param target: Mountpoint. + :raises OSError: If umount call failed with nonzero return code. + """ + if _umount(c_path(target)) != 0: + raise c_error() + + +def lazy_umount(target): + target = c_path(target) + if _umount(target) != 0: + if _umount2(target, MNT_DETACH) != 0: + raise c_error() + + +@dataclasses.dataclass(frozen=True) +class MountInfo: + id: int + parent: int + dev: tuple + root: str + mountpoint: str + + def __post_init__(self): + assert isinstance(self.id, int) + assert isinstance(self.parent, int) + assert isinstance(self.dev, tuple) + minor, major = self.dev + assert isinstance(minor, int) + assert isinstance(major, int) + assert isinstance(self.root, str) + assert self.root[0] == '/' + assert isinstance(self.mountpoint, str) + assert self.mountpoint[0] == '/' + + @classmethod + def from_line(cls, line): + rec = line.split(maxsplit=5) + major, minor = rec[2].split(':') + return cls( + id=int(rec[0]), + parent=int(rec[1]), + dev=(int(major), int(minor)), + root=rec[3], + mountpoint=rec[4], + ) + + +def parse_mountinfo(mountinfo_path='/proc/self/mountinfo'): + root_id = None + mountinfo = {} + with open(mountinfo_path, 'rt') as f: + for line in f: + mi = MountInfo.from_line(line) + if mi.mountpoint == '/': + assert root_id is None + root_id = mi.id + assert mi.id not in mountinfo + mountinfo[mi.id] = mi + assert root_id is not None + return (root_id, mountinfo) + + +def umount_order(mount_id, mountinfo): + for mi in mountinfo.values(): + if mi.parent == mount_id: + yield from umount_order(mi.id, mountinfo) + yield mountinfo[mount_id] + + +def pivot_and_umount(new_root, put_old, umount_list): + mtp_prefix = '/' + put_old.relative_to(new_root).as_posix() + pivot_root(new_root, put_old) + os.chdir('/') # so we don't stand in the old root + for mtp in umount_list: + try: + lazy_umount(mtp_prefix + mtp) + except OSError as exc: + sys.stderr.write(f'Error: failed to umount {mtp_prefix}{mtp} {exc}\n') + + +def nonblock_cloexec(fd): + return fcntl.fcntl( + fd, + fcntl.F_SETFD, + fcntl.fcntl(fd, fcntl.F_GETFD) | os.O_NONBLOCK | fcntl.FD_CLOEXEC, + ) + + +def exit_status(status): + sig = status & 0xFF + ret = status >> 8 + if sig: + raise SystemExit(128 + sig) + if ret >= 128: + raise SystemExit(128) + raise SystemExit(ret) + + +def exec_command(argv): + if argv[0][0] == '/': + os.execv(argv[0], argv) + for d in os.environ['PATH'].split(':'): + try: + os.execv(os.path.join(d, argv[0]), argv) + except FileNotFoundError: + continue + raise SystemExit(127) + + +def map_uid_gid(orig_uid, orig_gid): + with open('/proc/self/uid_map', 'wt') as f: + f.write(f'{orig_uid} {orig_uid} 1\n') + + with open('/proc/self/setgroups', 'wt') as f: + f.write('deny\n') + + with open('/proc/self/gid_map', 'wt') as f: + f.write(f'{orig_gid} {orig_gid} 1\n') + + os.setuid(orig_uid) + os.setgid(orig_gid) + + +def pidns_run(unshare_flags, run_pid1=True): + (parent_rfd, parent_wfd) = os.pipe() + nonblock_cloexec(parent_rfd) + nonblock_cloexec(parent_wfd) + orig_uid = os.getuid() + orig_gid = os.getgid() + unshare(CLONE_NEWPID | unshare_flags) + if unshare_flags & CLONE_NEWUSER: + map_uid_gid(orig_uid, orig_gid) + fork_pid = os.fork() + if fork_pid == 0: + # child + assert os.getpid() == 1 + os.close(parent_wfd) + if run_pid1: + return pidns_pid1(parent_rfd) + else: + return parent_rfd + else: + # parent + os.close(parent_rfd) + (pid, status) = os.waitpid(fork_pid, 0) + exit_status(status) + + +def pidns_pid1(parent_rfd): + fork2_pid = os.fork() + if fork2_pid == 0: + # child + return + else: + # parent + rlist, wlist, elist = (parent_rfd,), (), () + while True: + (pid, status) = os.waitpid(0, os.WNOHANG) + if pid == fork2_pid: + exit_status(status) + try: + r, w, x = select.select(rlist, wlist, elist, 1.0) + except select.error as e: + code, msg = e.args + # We might get interrupted by SIGCHLD here + if code != errno.EINTR: + raise + + +@dataclasses.dataclass(frozen=True) +class MountTMPFS: + path: pathlib.PosixPath + + def __post_init__(self): + assert isinstance(self.path, pathlib.PosixPath) + assert not self.path.is_absolute() + + def mount(self, root): + dst = root / self.path + dst.mkdir(parents=True, exist_ok=True) + mount('tmpfs', dst, 'tmpfs', MountFlag.NOSUID | MountFlag.NODEV) + + +@dataclasses.dataclass(frozen=True) +class MountBind: + src: pathlib.PosixPath + dst: pathlib.PosixPath + write: bool = False + + def __post_init__(self): + assert isinstance(self.src, pathlib.PosixPath) + assert self.src.is_absolute() + assert isinstance(self.dst, pathlib.PosixPath) + assert not self.dst.is_absolute() + + def mount(self, root): + dst = root / self.dst + if self.src.is_dir(): + dst.mkdir(parents=True, exist_ok=True) + bind_mount(self.src, dst, self.write) + + +def relpath(s): + p = pathlib.PosixPath(s) + return p.relative_to('/') if p.is_absolute() else p + + +def parse_mount(s): + m_type, rest = s.split(':', maxsplit=1) + if m_type == 'tmpfs': + return MountTMPFS(relpath(rest)) + elif m_type in ('rw_bind', 'ro_bind'): + write = m_type == 'rw_bind' + src, dst = rest.split(':', maxsplit=1) + return MountBind(pathlib.PosixPath(src), relpath(dst), write) + raise ValueError(m_type) + + +@dataclasses.dataclass(frozen=True) +class Settings: + versions: pathlib.PosixPath + root: pathlib.PosixPath + chdir: pathlib.PosixPath + vars: dict + command: tuple + extra_mount: tuple + drop_to: tuple = None + untar: pathlib.PosixPath = None + + def __post_init__(self): + assert isinstance(self.command, tuple) + assert all(isinstance(arg, (str, bytes)) for arg in self.command) + + assert isinstance(self.extra_mount, tuple) + assert all(isinstance(arg, (MountTMPFS, MountBind)) for arg in self.extra_mount) + + assert isinstance(self.chdir, pathlib.PosixPath) + assert self.chdir.is_absolute() + + assert isinstance(self.versions, pathlib.PosixPath) + assert self.versions.is_absolute() + assert self.versions.is_dir() + + if self.drop_to is not None: + assert isinstance(self.drop_to, tuple) + uid, gid = self.drop_to + assert isinstance(uid, int) + assert isinstance(gid, int) + + assert isinstance(self.untar, (pathlib.PosixPath, type(None))) + + assert isinstance(self.root, pathlib.PosixPath) + assert self.root.is_absolute() + assert self.root.is_dir() + if self.untar is None: + self._check_root() + + def _check_root(self): + assert (self.root / 'oldroot').is_dir() + assert (self.root / 'proc').is_dir() + assert (self.root / 'dev').is_dir() + assert (self.root / 'bin').is_dir() + assert (self.root / 'bin/sh').exists() + + @classmethod + def from_args_and_env(cls, args, env): + if args.vars: + import yaml + + with args.vars.open('rt') as f: + v = yaml.safe_load(f) + else: + v = {} + + return cls( + versions=(args.versions or pathlib.PosixPath(v['versions'])), + root=args.root_dir, + chdir=args.chdir, + vars=v, + command=tuple(args.command), + extra_mount=tuple(args.extra_mount) if args.extra_mount is not None else (), + drop_to=(int(env['UID']), int(env['GID'])) if args.mode == 'root' else None, + untar=args.untar and pathlib.PosixPath(args.untar), + ) + + +def userns_sandbox_run(settings): + assert settings.untar is None + assert settings.drop_to is None + mount('proc', settings.root / 'proc', 'proc', MountFlag.NOSUID | MountFlag.NODEV) + if not (settings.root / 'dev/null').is_char_device(): + mount( + '/dev', + settings.root / 'dev', + None, + (MountFlag.BIND | MountFlag.NOSUID | MountFlag.REC), + ) + + mountpoints = [ + MountTMPFS(relpath('/dev/shm')), + ] + mountpoints.extend(settings.extra_mount) + mountpoints.append(MountBind(settings.versions, settings.versions.relative_to('/'))) + for m in mountpoints: + m.mount(settings.root) + + os.chroot(str(settings.root)) + os.chdir(settings.chdir) + exec_command(settings.command) + + +def mkchardev(path, major, minor, mode): + if isinstance(path, pathlib.PosixPath): + path = path.as_posix() + os.mknod( + path, + mode=mode | stat.S_IFCHR, + device=os.makedev(major, minor), + ) + + +def mkblockdev(path, major, minor, mode): + if isinstance(path, pathlib.PosixPath): + path = path.as_posix() + os.mknod( + path, + mode=mode | stat.S_IFBLK, + device=os.makedev(major, minor), + ) + + +def mknod_dev(dev): + mkchardev(mode=0o666, major=1, minor=3, path=dev / "null") + mkchardev(mode=0o666, major=1, minor=7, path=dev / "full") + mkchardev(mode=0o666, major=5, minor=2, path=dev / "ptmx") + mkchardev(mode=0o644, major=1, minor=8, path=dev / "random") + mkchardev(mode=0o644, major=1, minor=9, path=dev / "urandom") + mkchardev(mode=0o666, major=1, minor=5, path=dev / "zero") + mkchardev(mode=0o666, major=5, minor=0, path=dev / "tty") + (dev / "fd").symlink_to("/proc/self/fd") + (dev / "stdin").symlink_to("/proc/self/fd/0") + (dev / "stdout").symlink_to("/proc/self/fd/1") + (dev / "stderr").symlink_to("/proc/self/fd/2") + + +def root_sandbox_setup(settings): + uid, gid = settings.drop_to + to_umount = [mi.mountpoint for mi in umount_order(*parse_mountinfo())] + r = settings.root + if settings.untar: + mount('sandbox_root', r, 'tmpfs', MountFlag.NOSUID) + (r / 'oldroot').mkdir() + subprocess.check_call( + ('tar', 'xpf', settings.untar.absolute()), + shell=False, + cwd=r, + ) + mount('proc', r / 'proc', 'proc', MountFlag.NOSUID | MountFlag.NODEV) + if not (r / 'dev/null').is_char_device(): + mknod_dev(r / 'dev') + + mountpoints = [ + MountTMPFS(relpath('/dev/shm')), + ] + mountpoints.extend(settings.extra_mount) + mountpoints.append(MountBind(settings.versions, settings.versions.relative_to('/'))) + for m in mountpoints: + m.mount(r) + + if settings.untar: + mount( + 'tmpfs', + r, + '', + (MountFlag.REMOUNT | MountFlag.RDONLY | MountFlag.NOSUID), + ) + pivot_and_umount(r, r / 'oldroot', to_umount) + os.setgid(gid) + os.setuid(uid) + os.chdir(settings.chdir) + + +def main(args, env): + settings = Settings.from_args_and_env(args, env) + if args.mode == 'userns': + pidns_run( + CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWIPC | CLONE_NEWPID, + ) + userns_sandbox_run(settings) + else: + pidns_run( + CLONE_NEWNET | CLONE_NEWIPC | CLONE_NEWPID, + ) + unshare(CLONE_NEWNS) + root_sandbox_setup(settings) + exec_command(settings.command) + + +argument_parser = argparse.ArgumentParser( + description="Linux namespaces based sandbox for pthbs", + allow_abbrev=False, +) +argument_parser.add_argument( + '--mode', + '-m', + required=True, + choices=('userns', 'root'), + help="sandbox mode", +) +argument_parser.add_argument( + '--vars', + '-y', + type=pathlib.PosixPath, + help="vars.yaml to read configuration from", +) +argument_parser.add_argument( + '--versions', + '-V', + type=pathlib.PosixPath, + help="versions dir (e.g. /versions)", +) +argument_parser.add_argument( + '--chdir', + '-C', + type=pathlib.PosixPath, + default=pathlib.PosixPath('/'), + help="set working directory inside sandbox", +) +argument_parser.add_argument( + '--untar', + '-f', + type=pathlib.PosixPath, + default=pathlib.PosixPath(os.getcwd()), + help="initial structure for build tmpfs", +) +argument_parser.add_argument('--extra-mount', action='append', type=parse_mount) +argument_parser.add_argument('root_dir', type=pathlib.PosixPath) +argument_parser.add_argument('command', nargs='+') + + +if __name__ == '__main__': + args = argument_parser.parse_args() + main(args, os.environ) + +# pylama:linters=pycodestyle,pyflakes:ignore=D212,D203,D100,D101,D102,D107 +# vim: sts=4 ts=4 sw=4 et tw=88 efm=%A%f\:%l\:%c\ %t%n\ %m diff --git a/userns_sandbox.py b/userns_sandbox.py @@ -1,430 +0,0 @@ -#!/usr/bin/python3 -import argparse -import ctypes -import dataclasses -import enum -import errno -import fcntl -import os -import os.path -import pathlib -import select - -libc = ctypes.CDLL(None, use_errno=True) -CLONE_NEWNS = 0x00020000 # New mount namespace group -CLONE_NEWCGROUP = 0x02000000 # New cgroup namespace -CLONE_NEWUTS = 0x04000000 # New utsname namespace -CLONE_NEWIPC = 0x08000000 # New ipc namespace -CLONE_NEWUSER = 0x10000000 # New user namespace -CLONE_NEWPID = 0x20000000 # New pid namespace -CLONE_NEWNET = 0x40000000 # New network namespace -CLONE_NEWTIME = 0x00000080 # New time namespace - -SYS_pivot_root = 155 - - -class MountFlag(int, enum.Enum): - """Mount flags.""" - - #: Mount read-only. - RDONLY = 1 - #: Ignore suid and sgid bits. - NOSUID = 2 - #: Disallow access to device special files. - NODEV = 4 - #: Disallow program execution. - NOEXEC = 8 - #: Writes are synced at once. - SYNCHRONOUS = 16 - #: Alter flags of a mounted FS. - REMOUNT = 32 - #: Allow mandatory locks on an FS. - MANDLOCK = 64 - #: Directory modifications are synchronous. - DIRSYNC = 128 - #: Do not follow symlinks. - NOSYMFOLLOW = 256 - #: Do not update access times. - NOATIME = 1024 - #: Do not update directory access times. - NODIRATIME = 2048 - #: Bind directory at different place. - BIND = 4096 - MOVE = 8192 - REC = 16384 - SILENT = 32768 - #: VFS does not apply the umask. - POSIXACL = 1 << 16 - #: Change to unbindable. - UNBINDABLE = 1 << 17 - #: Change to private. - PRIVATE = 1 << 18 - #: Change to slave. - SLAVE = 1 << 19 - #: Change to shared. - SHARED = 1 << 20 - #: Update atime relative to mtime/ctime. - RELATIME = 1 << 21 - #: This is a kern_mount call. - KERNMOUNT = 1 << 22 - #: Update inode I_version field. - I_VERSION = 1 << 23 - #: Always perform atime updates. - STRICTATIME = 1 << 24 - #: Update the on-disk [acm]times lazily. - LAZYTIME = 1 << 25 - ACTIVE = 1 << 30 - NOUSER = 1 << 31 - - -_mount = libc.mount -_mount.restype = ctypes.c_int -_mount.argtypes = ( - ctypes.c_char_p, - ctypes.c_char_p, - ctypes.c_char_p, - ctypes.c_ulong, - ctypes.c_void_p, -) - -_umount = libc.umount -_umount.restype = ctypes.c_int -_umount.argtypes = (ctypes.c_char_p,) - - -def c_path(path): - if path is None: - return path - if isinstance(path, pathlib.PosixPath): - path = path.as_posix() - if isinstance(path, str): - path = path.encode() - return path - - -def c_error(): - return OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno())) - - -def mount( - source: str, - target: str, - fstype: str, - flags: int = 0, - data: str = None, -): - """Mount filesystem. - - :param source: Device/source to mount. - :param target: Mountpoint. - :param fstype: Filesystem type. Available filesystem types can be found in /proc/filesystems. - :param flags: Mount flags. - :param data: Mount options for specified filesystem. - :raises OSError: If mount call failed with nonzero return code. - """ - if ( - _mount( - c_path(source), - c_path(target), - fstype.encode() if fstype is not None else fstype, - int(flags), - data.encode() if data is not None else data, - ) - != 0 - ): - raise c_error() - - -def bind_mount( - source: str, - target: str, - write: bool = False, -): - return mount( - source, - target, - None, - ( - MountFlag.BIND - | (0 if write else MountFlag.RDONLY) - | MountFlag.NOSUID - | MountFlag.NODEV - ), - ) - - -def umount(target: str): - """Unmount filesystem. - - :param target: Mountpoint. - :raises OSError: If umount call failed with nonzero return code. - """ - if _umount(c_path(target)) != 0: - raise c_error() - - -def parse_mountinfo(mountinfo_path='/proc/self/mountinfo'): - raise NotImplementedError() - - -def recursive_umount(mount_id, mountinfo): - raise NotImplementedError() - - -def nonblock_cloexec(fd): - return fcntl.fcntl( - fd, - fcntl.F_SETFD, - fcntl.fcntl(fd, fcntl.F_GETFD) | os.O_NONBLOCK | fcntl.FD_CLOEXEC, - ) - - -def exit_status(status): - sig = status & 0xFF - ret = status >> 8 - if sig: - raise SystemExit(128 + sig) - if ret >= 128: - raise SystemExit(128) - raise SystemExit(ret) - - -def exec_command(argv): - if argv[0][0] == '/': - os.execv(argv[0], argv) - for d in os.environ['PATH'].split(':'): - try: - os.execv(os.path.join(d, argv[0]), argv) - except FileNotFoundError: - continue - raise SystemExit(127) - - -def map_uid_gid(orig_uid, orig_gid): - with open('/proc/self/uid_map', 'wt') as f: - f.write(f'{orig_uid} {orig_uid} 1\n') - - with open('/proc/self/setgroups', 'wt') as f: - f.write('deny\n') - - with open('/proc/self/gid_map', 'wt') as f: - f.write(f'{orig_gid} {orig_gid} 1\n') - - os.setuid(orig_uid) - os.setgid(orig_gid) - - -def pidns_run(unshare_flags, continuation, *args, **kwargs): - (parent_rfd, parent_wfd) = os.pipe() - nonblock_cloexec(parent_rfd) - nonblock_cloexec(parent_wfd) - orig_uid = os.getuid() - orig_gid = os.getgid() - if libc.unshare(CLONE_NEWPID | unshare_flags) != 0: - raise c_error() - if unshare_flags & CLONE_NEWUSER: - map_uid_gid(orig_uid, orig_gid) - fork_pid = os.fork() - if fork_pid == 0: - # child - assert os.getpid() == 1 - os.close(parent_wfd) - fork2_pid = os.fork() - if fork2_pid == 0: - # child - continuation(*args, **kwargs) - else: - # parent - rlist, wlist, elist = (parent_rfd,), (), () - while True: - (pid, status) = os.waitpid(0, os.WNOHANG) - if pid == fork2_pid: - exit_status(status) - try: - r, w, x = select.select(rlist, wlist, elist, 1.0) - except select.error as e: - code, msg = e.args - # We might get interrupted by SIGCHLD here - if code != errno.EINTR: - raise - else: - # parent - os.close(parent_rfd) - (pid, status) = os.waitpid(fork_pid, 0) - exit_status(status) - - -@dataclasses.dataclass(frozen=True) -class MountTMPFS: - path: pathlib.PosixPath - - def __post_init__(self): - assert isinstance(self.path, pathlib.PosixPath) - assert not self.path.is_absolute() - - def mount(self, root): - dst = root / self.path - dst.mkdir(parents=True, exist_ok=True) - mount('tmpfs', dst, 'tmpfs', MountFlag.NOSUID | MountFlag.NODEV) - - -@dataclasses.dataclass(frozen=True) -class MountBind: - src: pathlib.PosixPath - dst: pathlib.PosixPath - write: bool = False - - def __post_init__(self): - assert isinstance(self.src, pathlib.PosixPath) - assert self.src.is_absolute() - assert isinstance(self.dst, pathlib.PosixPath) - assert not self.dst.is_absolute() - - def mount(self, root): - dst = root / self.dst - if self.src.is_dir(): - dst.mkdir(parents=True, exist_ok=True) - bind_mount(self.src, dst, self.write) - - -def relpath(s): - p = pathlib.PosixPath(s) - return p.relative_to('/') if p.is_absolute() else p - - -def parse_mount(s): - m_type, rest = s.split(':', maxsplit=1) - if m_type == 'tmpfs': - return MountTMPFS(relpath(rest)) - elif m_type in ('rw_bind', 'ro_bind'): - write = m_type == 'rw_bind' - src, dst = rest.split(':', maxsplit=1) - return MountBind(pathlib.PosixPath(src), relpath(dst), write) - raise ValueError(m_type) - - -@dataclasses.dataclass(frozen=True) -class Settings: - versions: pathlib.PosixPath - root: pathlib.PosixPath - chdir: pathlib.PosixPath - environment: str - vars: dict - command: tuple - extra_mount: tuple - - def __post_init__(self): - assert isinstance(self.command, tuple) - assert all(isinstance(arg, (str, bytes)) for arg in self.command) - - assert isinstance(self.extra_mount, tuple) - assert all(isinstance(arg, (MountTMPFS, MountBind)) for arg in self.extra_mount) - - assert isinstance(self.chdir, pathlib.PosixPath) - assert self.chdir.is_absolute() - - assert isinstance(self.versions, pathlib.PosixPath) - assert self.versions.is_absolute() - assert self.versions.is_dir() - assert (self.versions / self.environment).is_dir() - - self._check_root() - - def _check_root(self): - assert isinstance(self.root, pathlib.PosixPath) - assert self.root.is_absolute() - assert self.root.is_dir() - assert (self.root / 'oldroot').is_dir() - assert (self.root / 'proc').is_dir() - assert (self.root / 'dev').is_dir() - assert (self.root / 'bin').is_dir() - assert (self.root / 'bin/sh').exists() - - @classmethod - def from_args(cls, args): - if args.vars: - import yaml - - with args.vars.open('rt') as f: - v = yaml.safe_load(f) - else: - v = {} - - return cls( - versions=(args.versions or pathlib.PosixPath(v['versions'])), - root=args.root_dir, - chdir=args.chdir, - environment=args.environment, - vars=v, - command=tuple(args.command), - extra_mount=tuple(args.extra_mount), - ) - - -def sandbox_run(settings, command): - mount('proc', settings.root / 'proc', 'proc', MountFlag.NOSUID | MountFlag.NODEV) - if not (settings.root / 'dev/null').is_char_device(): - mount( - '/dev', - settings.root / 'dev', - None, - (MountFlag.BIND | MountFlag.NOSUID | MountFlag.REC), - ) - - mountpoints = [ - MountTMPFS(relpath('/dev/shm')), - ] - mountpoints.extend(settings.extra_mount) - mountpoints.append(MountBind(settings.versions, settings.versions.relative_to('/'))) - for m in mountpoints: - m.mount(settings.root) - - os.chroot(str(settings.root)) - os.chdir(settings.chdir) - exec_command(command) - - -def main(args): - pidns_run( - CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWIPC | CLONE_NEWPID, - sandbox_run, - Settings.from_args(args), - args.command, - ) - - -argument_parser = argparse.ArgumentParser( - description="User namespaces based sandbox for pthbs", - allow_abbrev=False, -) -argument_parser.add_argument( - '--vars', - '-y', - type=pathlib.PosixPath, - help="vars.yaml to read configuration from", -) -argument_parser.add_argument( - '--versions', - '-V', - type=pathlib.PosixPath, - help="versions dir (e.g. /versions)", -) -argument_parser.add_argument( - '--chdir', - '-C', - type=pathlib.PosixPath, - default=pathlib.PosixPath(os.getcwd()), - help="set working directory inside sandbox", -) -argument_parser.add_argument('--extra-mount', action='append', type=parse_mount) -argument_parser.add_argument('root_dir', type=pathlib.PosixPath) -argument_parser.add_argument('environment') -argument_parser.add_argument('command', nargs='+') - - -if __name__ == '__main__': - args = argument_parser.parse_args() - main(args) - -# pylama:linters=pycodestyle,pyflakes:ignore=D212,D203,D100,D101,D102,D107 -# vim: sts=4 ts=4 sw=4 et tw=88 efm=%A%f\:%l\:%c\ %t%n\ %m