-
-
Save sloonz/4b7f5f575a96b6fe338534dbc2480a5d to your computer and use it in GitHub Desktop.
| #!/usr/bin/python | |
| import argparse | |
| import os | |
| import shlex | |
| import sys | |
| import tempfile | |
| import yaml | |
| config = yaml.full_load(open(os.path.expanduser("~/.config/sandbox.yml"))) | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--name", "-n", action="store") | |
| parser.add_argument("--preset", "-p", nargs=1, action="append") | |
| parser.add_argument("--as", "-a", action="store") | |
| bwrap_args0 = ("unshare-all", "share-net", "unshare-user", "unshare-user-try", "unshare-ipc", "unshare-net", "unshare-uts", "unshare-cgroup", "unshare-cgroup-try", "clearenv", "new-session", "die-with-parent", "as-pid-1") | |
| bwrap_args1 = ("args", "userns", "userns2", "pidns", "uid", "gid", "hostname", "chdir", "unsetenv", "lock-file", "sync-fd", "remount-ro", "exec-label", "file-label", "proc", "dev", "tmpfs", "mqueue", "dir", "seccomp", "add-seccomp-fd", "block-fd", "userns-block-fd", "json-status-fd", "cap-add", "cap-drop", "perms") | |
| bwrap_args2 = ("setenv", "bind", "bind-try", "dev-bind", "dev-bind-try", "ro-bind", "ro-bind-try", "file", "bind-data", "ro-bind-data", "symlink", "chmod") | |
| for a in bwrap_args0: | |
| parser.add_argument("--" + a, action="store_true") | |
| for a in bwrap_args1: | |
| parser.add_argument("--" + a, nargs=1, action="append") | |
| for a in bwrap_args2: | |
| parser.add_argument("--" + a, nargs=2, action="append") | |
| parser.add_argument("command", nargs="+") | |
| args = parser.parse_args() | |
| bwrap_command = ["bwrap"] | |
| system_bus_args = set() | |
| session_bus_args = set() | |
| executable = getattr(args, "as") or args.command[0] | |
| executable = executable.split("/")[-1] | |
| def expand(s, extra_env): | |
| return str(s).format(env={**os.environ, **extra_env}, command=args.command, executable=executable, pid=os.getpid()) | |
| def ensure_list(l): | |
| if isinstance(l, list): | |
| return l | |
| else: | |
| return [l] | |
| def handle_bind(params, create, typ, extra_env): | |
| if isinstance(params, str): | |
| params = [params, params] | |
| src, dst = params | |
| src = expand(src, extra_env) | |
| dst = expand(dst, extra_env) | |
| if create: | |
| os.makedirs(src, exist_ok=True) | |
| return ("--" + typ, src, dst) | |
| def handle_setup(config, setup, extra_env): | |
| setup = setup.copy() | |
| setup_args = [] | |
| use_params = setup.pop("use", None) | |
| if use_params: | |
| for preset in use_params: | |
| for preset_setup in config["presets"][preset]: | |
| setup_args.extend(handle_setup(config, preset_setup, extra_env)) | |
| args_params = setup.pop("args", None) | |
| if args_params: | |
| setup_args.extend(expand(a, extra_env) for a in args_params) | |
| setenv_params = setup.pop("setenv", None) | |
| if isinstance(setenv_params, dict): | |
| for k, v in setenv_params.items(): | |
| extra_env[k] = expand(v, extra_env) | |
| setup_args.extend(("--setenv", k, extra_env[k])) | |
| elif isinstance(setenv_params, list): | |
| for k in setenv_params: | |
| if k in os.environ: | |
| setup_args.extend(("--setenv", k, os.environ[k])) | |
| for bind_type in ("bind", "bind-try", "dev-bind", "dev-bind-try", "ro-bind", "ro-bind-try"): | |
| bind_params = setup.pop(bind_type, None) | |
| if bind_params: | |
| setup_args.extend(handle_bind(bind_params, setup.pop("bind-create", None), bind_type, extra_env)) | |
| for dbus_setup in ("see", "talk", "own", "call", "broadcast"): | |
| dbus_setup_params = setup.pop("dbus-" + dbus_setup, None) | |
| if dbus_setup_params: | |
| is_system = setup.pop("system-bus", False) | |
| if is_system: | |
| system_bus_args.add("--%s=%s" % (dbus_setup, dbus_setup_params)) | |
| else: | |
| session_bus_args.add("--%s=%s" % (dbus_setup, dbus_setup_params)) | |
| file_params = setup.pop("file", None) | |
| if file_params: | |
| data, dst = file_params | |
| pr, pw = os.pipe2(0) | |
| if os.fork() == 0: | |
| os.close(pr) | |
| os.write(pw, data.encode()) | |
| sys.exit(0) | |
| else: | |
| os.close(pw) | |
| setup_args.extend(("--file", str(pr), expand(dst, extra_env))) | |
| dir_params = setup.pop("dir", None) | |
| if dir_params: | |
| setup_args.extend(("--dir", expand(dir_params, extra_env))) | |
| bind_args_params = setup.pop("bind-args", None) | |
| if bind_args_params: | |
| added_paths = set() | |
| strict = setup.pop("strict", True) | |
| ro = setup.pop("ro", True) | |
| for a in args.command[1:]: | |
| if os.path.exists(a): | |
| path = os.path.abspath(a) | |
| if not strict: | |
| path = os.path.dirname(path) | |
| if not path in added_paths: | |
| setup_args.extend((ro and "--ro-bind" or "--bind", path, path)) | |
| added_paths.add(path) | |
| cwd = os.getcwd() | |
| bind_cwd_params = setup.pop("bind-cwd", None) | |
| if bind_cwd_params is not None: | |
| ro = setup.pop("ro", False) | |
| setup_args.extend((ro and "--ro-bind" or "--bind", cwd, cwd)) | |
| cwd_params = setup.pop("cwd", None) | |
| if cwd_params is not None: | |
| if type(cwd_params) == "str": | |
| setup_args.extend(("--chdir", expand(cwd_params, extra_env))) | |
| elif cwd_params: | |
| setup_args.extend(("--chdir", cwd)) | |
| if setup.pop("restrict-tty", None): | |
| # --new-session breaks interactive sessions, this is an alternative way of fixing CVE-2017-5226 | |
| import seccomp | |
| import termios | |
| f = seccomp.SyscallFilter(defaction=seccomp.ALLOW) | |
| f.add_rule(seccomp.KILL_PROCESS, "ioctl", seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCSTI)) | |
| f.add_rule(seccomp.KILL_PROCESS, "ioctl", seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCLINUX)) | |
| f.load() | |
| if len(setup) != 0: | |
| print("unknown setup actions: %s" % list(setup.keys())) | |
| sys.exit(1) | |
| return setup_args | |
| def exec_bwrap(rule): | |
| extra_env = {} | |
| for setup in rule.get("setup", []): | |
| bwrap_command.extend(handle_setup(config, setup, extra_env)) | |
| for (preset,) in args.preset or []: | |
| for preset_setup in config["presets"][preset]: | |
| bwrap_command.extend(handle_setup(config, preset_setup, extra_env)) | |
| for a in bwrap_args0: | |
| if getattr(args, a.replace("-", "_")): | |
| bwrap_command.append("--" + a) | |
| for a in bwrap_args1: | |
| for (val,) in getattr(args, a.replace("-", "_")) or []: | |
| bwrap_command.extend(("--" + a, val)) | |
| for a in bwrap_args2: | |
| for (v1, v2) in getattr(args, a.replace("-", "_")) or []: | |
| bwrap_command.extend(("--" + a, v1, v2)) | |
| dbus_proxy_args = [] | |
| dbus_proxy_dir = f"{os.environ['XDG_RUNTIME_DIR']}/xdg-dbus-proxy" | |
| if session_bus_args or system_bus_args: | |
| os.makedirs(dbus_proxy_dir, exist_ok=True) | |
| if session_bus_args: | |
| proxy_socket = tempfile.mktemp(prefix="session-", dir=dbus_proxy_dir) | |
| dbus_proxy_args.extend((os.environ["DBUS_SESSION_BUS_ADDRESS"], proxy_socket)) | |
| dbus_proxy_args.append("--filter") | |
| dbus_proxy_args.extend(session_bus_args) | |
| bwrap_command.extend(("--bind", proxy_socket, os.environ["DBUS_SESSION_BUS_ADDRESS"].removeprefix("unix:path="), | |
| "--setenv", "DBUS_SESSION_BUS_ADDRESS", os.environ["DBUS_SESSION_BUS_ADDRESS"])) | |
| if system_bus_args: | |
| proxy_socket = tempfile.mktemp(prefix="system-", dir=dbus_proxy_dir) | |
| dbus_proxy_args.extend(("/run/dbus/system_bus_socket", proxy_socket)) | |
| dbus_proxy_args.append("--filter") | |
| dbus_proxy_args.extend(system_bus_args) | |
| bwrap_command.extend(("--bind", "/run/dbus/system_bus_socket", "/run/dbus/system_bus_socket")) | |
| if dbus_proxy_args: | |
| pr, pw = os.pipe2(0) | |
| if os.fork() == 0: | |
| os.close(pr) | |
| dbus_proxy_command = ["xdg-dbus-proxy", "--fd=%d" % pw] + list(dbus_proxy_args) | |
| os.execlp(dbus_proxy_command[0], *dbus_proxy_command) | |
| # I would like to use bwrap's --block-fd, but bwrap setups then wait, and therefore may try to bind an non-existent socket | |
| assert os.read(pr, 1) == b"x" # wait for xdg-dbus-proxy to be ready | |
| bwrap_command.extend(("--sync-fd", str(pr))) | |
| bwrap_command.extend(args.command) | |
| if os.getenv("SANDBOX_DEBUG") == "1": | |
| print(bwrap_command, file=sys.stderr) | |
| os.execvp(bwrap_command[0], bwrap_command) | |
| for rule in config["rules"]: | |
| is_match = False | |
| assert not (set(rule.keys()) - {"match", "no-sandbox", "setup"}) | |
| if "match" in rule: | |
| assert not (set(rule["match"].keys()) - {"bin", "name"}) | |
| if executable and executable in ensure_list(rule["match"].get("bin", [])): | |
| is_match = True | |
| if args.name and args.name in ensure_list(rule["match"].get("name", [])): | |
| is_match = True | |
| else: | |
| is_match = True | |
| if is_match: | |
| if rule.get("no-sandbox"): | |
| os.execvp(args.command[0], args.command) | |
| else: | |
| exec_bwrap(rule) | |
| break |
| presets: | |
| common: | |
| - args: [--clearenv, --unshare-pid, --die-with-parent, --proc, /proc, --dev, /dev, --tmpfs, /tmp, --new-session] | |
| - setenv: [PATH, LANG, XDG_RUNTIME_DIR, XDG_SESSION_TYPE, TERM, HOME, LOGNAME, USER] | |
| - ro-bind: /etc | |
| - ro-bind: /usr | |
| - args: [--symlink, usr/bin, /bin, --symlink, usr/bin, /sbin, --symlink, usr/lib, /lib, --symlink, usr/lib, /lib64, --tmpfs, "{env[XDG_RUNTIME_DIR]}"] | |
| - bind: /run/systemd/resolve | |
| private-home: | |
| - bind: ["{env[HOME]}/sandboxes/{executable}/", "{env[HOME]}"] | |
| bind-create: true | |
| - dir: "{env[HOME]}/.config" | |
| - dir: "{env[HOME]}/.cache" | |
| - dir: "{env[HOME]}/.local/share" | |
| x11: | |
| - setenv: [DISPLAY] | |
| - ro-bind: /tmp/.X11-unix/ | |
| wayland: | |
| - setenv: [WAYLAND_DISPLAY] | |
| - ro-bind: "{env[XDG_RUNTIME_DIR]}/{env[WAYLAND_DISPLAY]}" | |
| pulseaudio: | |
| - ro-bind: "{env[XDG_RUNTIME_DIR]}/pulse/native" | |
| - ro-bind-try: "{env[HOME]}/.config/pulse/cookie" | |
| - ro-bind-try: "{env[XDG_RUNTIME_DIR]}/pipewire-0" | |
| drm: | |
| - dev-bind: /dev/dri | |
| - ro-bind: /sys | |
| portal: | |
| - file: ["", "{env[XDG_RUNTIME_DIR]}/flatpak-info"] | |
| - file: ["", "/.flatpak-info"] | |
| - dbus-call: "org.freedesktop.portal.*=*" | |
| - dbus-broadcast: "org.freedesktop.portal.*=@/org/freedesktop/portal/*" | |
| rules: | |
| - match: | |
| bin: firefox | |
| setup: | |
| - setenv: | |
| MOZ_ENABLE_WAYLAND: 1 | |
| - use: [common, private-home, wayland, portal] | |
| - dbus-own: org.mozilla.firefox.* | |
| - bind: "{env[HOME]}/Downloads" | |
| - bind: ["{env[HOME]}/.config/mozilla", "{env[HOME]}/.mozilla"] | |
| - match: | |
| name: shell | |
| setup: | |
| - use: [common, private-home] | |
| - match: | |
| bin: node | |
| setup: | |
| - use: [common, private-home] | |
| - bind-cwd: {} | |
| - cwd: true | |
| - match: | |
| bin: npx | |
| setup: | |
| - use: [common, private-home] | |
| - bind-cwd: {} | |
| - cwd: true | |
| - match: | |
| bin: npm | |
| setup: | |
| - use: [common, private-home] | |
| - bind-cwd: {} | |
| - cwd: true | |
| - match: | |
| name: none | |
| # Fallback: anything else fall backs to a sandboxed empty home | |
| - setup: | |
| - use: [common, private-home, x11, wayland, pulseaudio, portal] |
All in all, I have a hunch that jsonnet is too limited to provide a real improvement.
My hunch that jsonnet was not even necessary, just a simple generic merge semantic would suffice.
I think my hunch was right, I have been running with the "merge" variant for a few days, couldn’t be happier : https://gist.github.com/sloonz/ef282a1f53366e1ed6f5cb848de015ba (except a sharp edge around diamond inheritance, when both B and C include A and D include B and C : if B override some key of A, then D will get the value of A (because of C), not B (as naively expected)). Erasing the distinction between preset/rule/action is a bigger win that it looks like, because that makes reuse simpler and easier to reason about.
No jsonnet involved, pure json/yaml, just some (hardcoded) "merge policy" https://gist.github.com/sloonz/ef282a1f53366e1ed6f5cb848de015ba#file-sandbox2-py-L24
So, basic working : define bwrap options :
clearenv: true
dieWithParent: trueenvironment (true means "inherits") :
env:
PATH: true
LANG: trueand mounts :
mounts:
/proc: proc
/dev: devto define a sandbox.
Name it with name, you can include it in another sandbox definition, and it will be merged :
include: [base, x11, wayland, rust, shell]You can then use any combination of named sanboxes
sandbox -n base,wayland footOr you can match executable name in the sandboxes rules for the sandbox config to be automatically included (note that now match is not exclusive : multiple sandboxes can match an executable, and they will be merged) :
matches: [thunderbird]If nothing matches, the sandbox named "default" will be used.
You can also add sandbox configs from cli :
sandbox -n base -f /tmp/test.ymlor even inline :
sandbox -n base -j '{"unsharePid":true}'with a bit of syntactic sugar :
sandbox -n base -s 'mounts."/tmp/.X11-unix"=tmpfs' # equivalent to -j '{"mounts":{"/tmp/.X11-unix":"tmpfs"}}'
sandbox -n base -s 'mounts."/tmp/.X11-unix".bind.try=true' # equivalent to -j '{"mounts":{"/tmp/.X11-unix":bind:{try:true}}}'
sandbox -n base -s @include=shell # @ is for array, perl-style : equivalent to -j '{"include":["shell"]}'
sandbox -n base -s :project=test # : is for vars. : equivalent to -j '{"vars":{"project":"test"}}'
sandbox -n base -s '$PROJECT=test' # $ is for env. : equivalent to -j '{"env":{"PROJECT:"test"}}'Which allows me to have some simple commands like :
enter-project () {
project=$1
shift
sandbox -MDs ":project=$project" -n project -j "$(printf '{"include":[{"path":"%s","try":true}]}' "/opt/data/projects/$project/sandbox.yaml")" "$@"
}
alias p=enter-projectYou can configure the sandbox wrapping xdg-dbus-proxy : https://gist.github.com/sloonz/ef282a1f53366e1ed6f5cb848de015ba#file-sandbox-yml-L110
Also, no more restrict-tty rule : https://gist.github.com/sloonz/ef282a1f53366e1ed6f5cb848de015ba#file-sandbox-yml-L36
One limitation is the scriptability though. Like passing in a variable and using that as a selector inside the config. Yes, that is why any config language eventually wants to become a scripting language, but something more advanced than yaml might give enough runway to avoid that in not-too-complex cases.
Just pick your favorite language, generate a .yaml (I’ll probably add json later), and use -f :)
This reminds me a bit of Nix. The language takes care of merging configs (https://mirosval.sk/blog/2023/nix-merging-configs/) but you also have the option to override a value like this https://discourse.nixos.org/t/what-does-mkdefault-do-exactly/9028/2. Maybe it'd make sense to associate options similarly with values indicating their priority.