commit c9ee942a7a197756eef551ba7e4a065d6ad46fe2
parent 368fd4ed6390bdecbd9eac09c6d7bd5d573e7cbc
Author: ccx <ccx@te2000.cz>
Date: Mon, 26 Feb 2024 02:17:37 +0000
Root / no userns sandbox mode
Diffstat:
M | command/pthbs-build | | | 30 | ++++++++++++++++++++++++++---- |
A | ns_sandbox.py | | | 628 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
D | userns_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