diff --git a/README.md b/README.md index af95645..feb91ab 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,72 @@ # lockings +lockings is a simple sandbox manager written in Python that uses [bubblewrap](https://github.com/containers/bubblewrap) and [xdg-dbus-proxy](https://github.com/flatpak/xdg-dbus-proxy). It allows the creation of sandboxes using a JSON file, with a simple permission system. It generates "launcher" shell scripts which launch the sandboxed program, thus increasing customizability. + +## quick start + +Clone the repository on your machine and run the `lockings` script. Keep reading below to learn how to use it. + +Lockings itself has no dependencies aside from Python, however, the launcher scripts generated by it require `bubblewrap`, and, if using the dbus-proxy permission, `xdg-dbus-proxy` as well. + +## configuration + +Configuration is done through a JSON file located at `~/.config/lockings/config.json`. If you'd like, you can use a different path by passing it as an argument (`lockings ~/some/path/config.json`). + +The default configuration looks like this: +```json +{ + "sandboxes_path": "/home/user/.sandboxes", + "scripts_path": "/home/user/.local/bin", + "sandboxes": [] +} +``` + +The `sandboxes_path` is the directory where the sandboxed "jail" home folders will be created. Each program will see its own "artificial" home folder. + +The `scripts_path` is the directory where the automatically generated shell scripts will be dropped by lockings. These shell scripts launch the sandboxed program - lockings itself never actually launches anything. + +The `sandboxes` property is an array of an object that follows the following structure: +``` +{ + "name": "name of the sandbox", + "bin": "the binary of the program which you want to sandbox", + "permissions": [ list of permissions, check out the "permissions" section below for more info ] +} +``` + +Here is an example configuration which has a sandbox for Mozilla Firefox: +```json +{ + "sandboxes_path": "/home/user/.sandboxes", + "scripts_path": "/home/user/.local/bin", + "sandboxes": [ + { + "name": "firefox", + "bin": "firefox", + "permissions": [ + "net", + "x11", + "sys", + "dbus-proxy" + ] + } + ] +} +``` + +## permissions + +Currently, the following permissions are implemented: +| Permission | Description | +| --- | --- | +| net | Network permission | +| x11 | Exposes the x11 socket. Please note that [x11 is a weak point for sandboxing, and thus wayland is recommended](https://wiki.archlinux.org/title/Bubblewrap#Sandboxing_X11) | +| sys | Exposes some subdirectories in `/sys` and `/dev/dri`. Especially useful for hardware accelerated programs. | +| microphone | Exposes the microphone in `/dev/snd` | +| share:[permission]:[host path]:[sandbox path] | Binds [host path] from the host to [sandbox path] in the sandbox. If [permission] is `r`, it will be bound as read-only. Useful for sharing files with the sandbox | +| dbus-proxy | Enables `xdg-dbus-proxy` and exposes the bus to the host. **This permission is required for all subsequent dbus-proxy- permissions.** | +| dbus-proxy-talk | Set the talk policy for the given name. Read the `xdg-dbus-proxy` manpage for more information. | +| dbus-proxy-own | Set the own policy for the given name. Read the `xdg-dbus-proxy` manpage for more information. | +| dbus-proxy-see | Set the see policy for the given name. Read the `xdg-dbus-proxy` manpage for more information. | +| dbus-proxy-call | Set the call policy for the given name. Read the `xdg-dbus-proxy` manpage for more information. | +| dbus-proxy-broadcast | Set the broadcast policy for the given name. Read the `xdg-dbus-proxy` manpage for more information. | diff --git a/bwrap-generator.py b/bwrap-generator.py deleted file mode 100644 index e3d376d..0000000 --- a/bwrap-generator.py +++ /dev/null @@ -1,80 +0,0 @@ -def generate_bwrap_command(user, app_home_path, app_binary, permissions): - command = [ - "/usr/bin/bwrap", - "--ro-bind", "/bin", "/bin", - "--ro-bind", "/usr/bin", "/usr/bin", - "--ro-bind", "/lib", "/lib", - "--ro-bind-try", "/lib32", "/lib32", - "--ro-bind-try", "/lib64", "/lib64", - "--ro-bind", "/usr/lib", "/usr/lib", - "--ro-bind-try", "/usr/local/lib", "/usr/local/lib", - "--ro-bind", "/usr/share", "/usr/share", - "--ro-bind-try", "/usr/local/share", "/usr/local/share", - "--ro-bind", "/usr/include", "/usr/include", - "--ro-bind", "/etc", "/etc", - "--ro-bind-try", f"{app_home_path}/.machine-id", "/etc/machine-id", - "--ro-bind-try", f"{app_home_path}/.machine-id", "/var/lib/dbus/machine-id", - "--ro-bind", "/var/lib", "/var/lib", - "--tmpfs", "/var/lib/dbus", - "--dev", "/dev", - "--bind", app_home_path, f"/home/{user}", - "--proc", "/proc", - "--tmpfs", "/tmp", - "--tmpfs", "/var/tmp", - "--tmpfs", "/var/cache", - "--tmpfs", "/run", - "--symlink", "/run", "/var/run", - "--chdir", f"/home/{user}", - "--setenv", "HOME", f"/home/{user}", - "--setenv", "SHELL", "/sbin/nologin", - "--unsetenv", "SUDO_USER", - "--unsetenv", "SUDO_UID", - "--unsetenv", "SUDO_GID", - "--unsetenv", "SUDO_COMMAND", - "--unsetenv", "OLDPWD", - "--unsetenv", "MAIL", - "--unshare-all", - "--new-session", - "--cap-drop", "all", - ] - - for p in permissions: - if p == "network": - command.append("--share-net") - elif p == "x11": - command.extend([ - "--ro-bind-try", f"/home/{user}/.Xauthority", f"/home/{user}/.Xauthority", - "--ro-bind-try", "/tmp/.X11-unix", "/tmp/.X11-unix" - ]) - elif p == "sys": - command.extend([ - "--ro-bind", "/sys/dev", "/sys/dev", - "--ro-bind", "/sys/devices", "/sys/devices", - "--ro-bind", "/sys/class", "/sys/class", - "--ro-bind", "/sys/bus", "/sys/bus", - "--ro-bind", "/sys/fs/cgroup", "/sys/fs/cgroup", - "--dev-bind", "/dev/dri", "/dev/dri", - ]) - elif p == "microphone": - command.extend([ - "--dev-bind-try", "/dev/snd", "/dev/snd", - ]) - else: - raise RuntimeError(f"unknown permission '{p}'") - - command.append(app_binary) - - return command - -bash_command = generate_command( - "hippoz", - "/home/hippoz/sandbox", - "/bin/bash", - [ - "x11", - "network", - "microphone", - "sys" - ] -) -print(" ".join(bash_command)) diff --git a/lockings b/lockings new file mode 100755 index 0000000..f9cee7e --- /dev/null +++ b/lockings @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import sys +import os +import json + +class Sandbox: + def __init__(self, user, directory, permissions, binary, sandbox_id): + self.user = user + self.directory = directory + self.permissions = permissions + self.binary = binary + self.sandbox_id = sandbox_id + self.using_dbus_proxy = False + def dbus_proxy_path(self): + return '$XDG_RUNTIME_DIR/lockings-dbus_' + self.sandbox_id + def dbus_system_proxy_path(self): + return '$XDG_RUNTIME_DIR/lockings-sysdbus_' + self.sandbox_id + def _generate_bwrap_command(self): + home = f"/home/{self.user}" + + command = [ + "env -i /usr/bin/bwrap", + "--ro-bind", "/bin", "/bin", + "--ro-bind", "/usr/bin", "/usr/bin", + "--ro-bind", "/usr/local/bin", "/usr/local/bin", + "--ro-bind", "/lib", "/lib", + "--ro-bind-try", "/lib32", "/lib32", + "--ro-bind-try", "/lib64", "/lib64", + "--ro-bind", "/usr/lib", "/usr/lib", + "--ro-bind-try", "/usr/local/lib", "/usr/local/lib", + "--ro-bind", "/usr/share", "/usr/share", + "--ro-bind-try", "/usr/local/share", "/usr/local/share", + "--ro-bind", "/usr/include", "/usr/include", + "--ro-bind", "/etc", "/etc", + "--ro-bind", f"{self.directory}/.machine-id", "/etc/machine-id", + "--ro-bind", f"{self.directory}/.machine-id", "/var/lib/dbus/machine-id", + "--ro-bind", "/var/lib", "/var/lib", + "--dev", "/dev", + "--bind", self.directory, home, + "--proc", "/proc", + "--tmpfs", "/tmp", + "--tmpfs", "/var/tmp", + "--tmpfs", "/var/cache", + "--tmpfs", "/run", + "--symlink", "/run", "/var/run", + "--chdir", home, + "--setenv", "HOME", home, + "--setenv", "PATH", "$PATH", + "--setenv", "SHELL", "/sbin/nologin", + "--setenv", "XDG_RUNTIME_DIR", "/run/user/$UID", + "--unshare-all", + "--new-session", + "--cap-drop", "all", + ] + + if self.using_dbus_proxy: + command.extend([ + "--ro-bind-try", self.dbus_proxy_path(), self.dbus_proxy_path(), + "--ro-bind-try", self.dbus_system_proxy_path(), "/run/dbus/system_bus_socket", + "--setenv", "DBUS_SESSION_BUS_ADDRESS", f"unix:path={self.dbus_proxy_path()}", + ]) + + for p in self.permissions: + if p == "net": + command.append("--share-net") + elif p == "x11": + command.extend([ + "--ro-bind-try", f"/home/{self.user}/.Xauthority", f"/home/{self.user}/.Xauthority", + "--ro-bind-try", "/tmp/.X11-unix", "/tmp/.X11-unix", + "--setenv", "DISPLAY", "$DISPLAY" + ]) + elif p == "sys": + command.extend([ + "--ro-bind", "/sys/dev", "/sys/dev", + "--ro-bind", "/sys/devices", "/sys/devices", + "--ro-bind", "/sys/class", "/sys/class", + "--ro-bind", "/sys/bus", "/sys/bus", + "--ro-bind", "/sys/fs/cgroup", "/sys/fs/cgroup", + "--dev-bind", "/dev/dri", "/dev/dri", + ]) + elif p == "microphone": + command.extend([ + "--dev-bind-try", "/dev/snd", "/dev/snd", + ]) + elif p.startswith("share:"): + # example: `share:rw:/home/user/Documents:/home/user/Documents` + parts = p.split(":", 4) + if len(parts) != 4: + raise RuntimeError("invalid ro-bind expression, expected 4 parts separated by colon") + + switch = "--bind" + if (parts[1] == "r"): + switch = "--ro-bind" + + command.extend([ + switch, parts[2], parts[3] + ]) + + + command.append(self.binary) + + return command + def _generate_xdg_dbus_proxy_command(self): + if "dbus-proxy" not in self.permissions: + return [] + + self.using_dbus_proxy = True + + command = [ + "/usr/bin/xdg-dbus-proxy", + "${DBUS_SESSION_BUS_ADDRESS}", + self.dbus_proxy_path(), + "--filter", + ] + + for p in self.permissions: + elements = p.split(':', 1) + if len(elements) != 2: + continue + + op = elements[0] + arg = elements[1] + if op == "dbus-proxy-talk": + command.append(f"--talk={arg}") + elif op == "dbus-proxy-own": + command.append(f"--own={arg}") + elif op == "dbus-proxy-see": + command.append(f"--see={arg}") + elif op == "dbus-proxy-call": + command.append(f"--call={arg}") + elif op == "dbus-proxy-broadcast": + command.append(f"--broadcast={arg}") + + command.extend([ + "unix:path=/run/dbus/system_bus_socket", + self.dbus_system_proxy_path(), + "--filter", + ]) + + command.append("&") + + return command + def _generate_script_header(self): + return f"#!/bin/sh\n\n# This script was automatically generated by lockings. Parameters:\n# user: {self.user}\n# directory: {self.directory}\n# permissions: {' '.join(self.permissions)}\n# binary: {self.binary}\n# sandbox_id: {self.sandbox_id}\n" + def generate_script(self): + script = [ + self._generate_script_header(), + " ".join(self._generate_xdg_dbus_proxy_command()), + " ".join(self._generate_bwrap_command()), + ] + return "\n\n".join(script) + def write_to_file(self, file: Path): + file.touch(exist_ok=True, mode=0o770) + file.write_text(self.generate_script()) + + +def load_from_json(path: Path): + with path.open("r") as f: + config = json.load(f) + sandboxes = Path(config["sandboxes_path"]) + scripts = Path(config["scripts_path"]) + script_filename_prefix = config.get("script_filename_prefix", "") + + sandboxes.mkdir(exist_ok=True, parents=True) + scripts.mkdir(exist_ok=True, parents=True) + + for s in config["sandboxes"]: + sandbox = Sandbox( + os.environ.get("USER"), + str(sandboxes / s["name"]), + s["permissions"], + s["bin"], + s["name"] + ) + + (sandboxes / s["name"]).mkdir(exist_ok=True) + (sandboxes / s["name"] / ".machine-id").touch(exist_ok=True) + + sandbox.write_to_file(scripts / f"{script_filename_prefix}{s['name']}") + + +def main(argv, argc): + if argc > 1: + input_file = Path(argv[1]) + if input_file.exists(): + load_from_json(input_file) + else: + print("lockings: fatal: input file does not exist", file=sys.stderr) + return 1 + else: + config_path = os.environ.get("XDG_CONFIG_HOME") + home_directory = os.environ.get("HOME") + if not home_directory: + print("lockings: fatal: HOME environment variable is not set", file=sys.stderr) + return 1 + if not config_path: + config_path = f"{home_directory}/.config" + + config_path = Path(config_path) + if not config_path.exists(): + print("lockings: fatal: config home does not exist, make sure the XDG_CONFIG_HOME or HOME environment variables are properly configured", file=sys.stderr) + return 1 + + program_config_folder = config_path / "lockings" + program_config_folder.mkdir(exist_ok=True, parents=True) + + json_file_path = program_config_folder / "config.json" + if not json_file_path.exists(): + print("lockings: note: config file does not exist, creating one right now", file=sys.stderr) + json_file_path.touch() + json_file_path.write_text(json.dumps({ + "sandboxes_path": f"{home_directory}/.sandboxes", + "scripts_path": f"{home_directory}/.local/bin", + "sandboxes": [] + }, indent=4)) + + load_from_json(json_file_path) + +if __name__ == "__main__": + sys.exit(main(sys.argv, len(sys.argv)))