-
-
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] |
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.
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 :)
@sloonz interesting, would love to see if you get anywhere! I guess you will have a
mounts+and amountsproperty?mountsmounts+Brain dump: Maybe you can have operators too for strict union, and merge union, like
+and~+respectively. Maybe you want to define those as unary operators instead of the property. I am mentioning that because I might see value in being able to undo a previous flag, like thisThis wouldn't fly in a json-compatible language, but it helps to demonstrate that if a user can't define it like an operation, they will depend on you to define those as property variants, like
unMounts.It is not needed for properties you can track as booleans, like
dieWithParent. I remember--share-netas the odd option to undo any previous--unshare-net, in contrast with the other--unshare-*options.Sandbubble is nice. 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.
All in all, I have a hunch that jsonnet is too limited to provide a real improvement.