Created
July 8, 2026 09:10
-
-
Save ssokolow/4c9364c1d1697ea46622459d85864c63 to your computer and use it in GitHub Desktop.
A Python script to ease generating strict Firejail profiles for GOG.com games and the like
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """Firejail Profile+Launcher Generator for Games""" | |
| __author__ = "Stephan Sokolow (deitarion/SSokolow)" | |
| __appname__ = "Sandboxify" | |
| __version__ = "0.1" | |
| __license__ = "MIT" | |
| import logging | |
| import os | |
| from typing import Optional | |
| log = logging.getLogger(__name__) | |
| SITE_RULES_PATH = os.path.expanduser( | |
| "~/.config/firejail/sandboxify-common.inc") | |
| # TODO: Add an --option for games which need access to either a CD/DVD/BD drive | |
| # or CDEmu. | |
| # TODO: Design options for easing strace use (maybe --strace=actual/binary/path | |
| # which also sets the relevant allow flags) | |
| # TODO: Add a --dns option which can be --dns or --dns=<IP> and document it as | |
| # an addition to --network which adds a nameserver to /etc/resolv.conf so | |
| # games can contact their developer/publisher where, without it, you tend | |
| # to get a *de facto* "LAN games are OK and IP direct connect is OK, but | |
| # the Internet is unavailable otherwise". | |
| # TODO: Decide how to offer an option for `private-etc ssl/certs` since you'll | |
| # probably want it if you're using --dns, but not necessarily, since | |
| # there may be situations where denying a game a set of valid roots will | |
| # break matchmaking or update-checking while still allowing DynDNS-based | |
| # domains to be typed into direct IP connection boxes. | |
| # TODO: Integrate what I learned from setting up Saints Row 3 so I can have | |
| # presets to add to Wine for things like DXVK, Wine Virtual Desktop, | |
| # and generating the base start.sh harness and wineprefix folder from | |
| # templates. | |
| # TODO: Audit /etc/firejail/disable-common.inc to determine whether it should | |
| # be `include`'d or excerpted. | |
| # TODO: Explore how compatible disable-proc.inc is with games | |
| # TODO: Think about options for making the LAN less visible with --network | |
| PROFILE_TMPL = """ | |
| # Enable the usual blanket sandboxing directives | |
| caps.drop all | |
| ipc-namespace | |
| nodbus | |
| nodvd # Remove this if you're playing a game from physical media | |
| nogroups | |
| nonewprivs | |
| noprinters | |
| noroot | |
| notv | |
| nou2f | |
| novideo | |
| seccomp%(seccomp_extra)s | |
| restrict-namespaces | |
| # NOTE: See /etc/firejail/disable-common.inc if removing any of these | |
| private-tmp | |
| private-dev | |
| private-etc %(private_etc)s | |
| private-bin %(private_bin)s | |
| # Give the game its own private $HOME for userdata | |
| private %(game_home)s | |
| # Only grant the game access to its install directory | |
| # and hide and lock down the contents of the sandboxed launch script | |
| whitelist%(gamedir_ro_suffix)s %(game_root)s | |
| blacklist %(run_path)s | |
| # Remove environment variables that may aid exploits at worst and point | |
| # programs at things the sandbox blocks at best | |
| rmenv BROWSER | |
| rmenv CARGO_REGISTRY_TOKEN | |
| rmenv DOCKER_HOST | |
| rmenv EDITOR | |
| rmenv GPG_AGENT_INFO | |
| rmenv GH_TOKEN | |
| rmenv GH_ENTERPRISE_TOKEN | |
| rmenv GITHUB_TOKEN | |
| rmenv GITHUB_ENTERPRISE_TOKEN | |
| rmenv GTK_RC_FILES | |
| rmenv GTK2_RC_FILES | |
| rmenv KONSOLE_DBUS_SERVICE | |
| rmenv KONSOLE_DBUS_SESSION | |
| rmenv MAIL | |
| rmenv MAILER | |
| rmenv MAILPATH | |
| rmenv MANPAGER | |
| rmenv PAGER | |
| rmenv PAM_KWALLET5_LOGIN | |
| rmenv RESTIC_KEY_HINT | |
| rmenv RESTIC_PASSWORD_COMMAND | |
| rmenv RESTIC_PASSWORD_FILE | |
| rmenv SESSION_MANAGER | |
| rmenv SSH_ASKPASS | |
| rmenv SSH_AGENT_PID | |
| rmenv SSH_AUTH_SOCK | |
| rmenv TCLLIBPATH | |
| rmenv VISUAL | |
| # NOTE: DBUS_{SESSION,SYSTEM}_BUS_ADDRESS are omitted because `nodbus` would | |
| # override them anyway. | |
| # Cut /usr/share down to things games have been legitimately observed to need | |
| # NOTE: May crash the game if something tries to open a GTK file chooser dialog | |
| whitelist-ro /usr/share/alsa | |
| whitelist-ro /usr/share/libdrm | |
| whitelist-ro /usr/share/locale | |
| whitelist-ro /usr/share/pipewire | |
| whitelist-ro /usr/share/terminfo | |
| whitelist-ro /usr/share/vulkan | |
| whitelist-ro /usr/share/vulkansc | |
| whitelist-ro /usr/share/X11/locale | |
| whitelist-ro /usr/share/X11/xkb | |
| blacklist /boot | |
| blacklist /opt | |
| blacklist /snap | |
| blacklist /usr/include | |
| blacklist /usr/libexec | |
| blacklist /usr/local | |
| blacklist /usr/src | |
| blacklist /var | |
| blacklist /proc/config.gz | |
| read-only /lib | |
| read-only /usr | |
| # Borrow the bits from Firejail 0.9.72's diable-common.inc which appear to | |
| # still not be covered by my much more whitelist-based approach | |
| blacklist /.fscrypt | |
| blacklist /.snapshots | |
| blacklist /initrd* | |
| blacklist /vmlinuz* | |
| blacklist /run/timeshift | |
| blacklist /usr/lib/chromium/chrome-sandbox | |
| blacklist /usr/lib/dbus-1.0/dbus-daemon-launch-helper | |
| blacklist /usr/lib/eject/dmcrypt-get-device | |
| blacklist /usr/lib/openssh | |
| blacklist /usr/lib/opera/opera_sandbox | |
| blacklist /usr/lib/policykit-1/polkit-agent-helper-1 | |
| blacklist /usr/lib/squid/basic_pam_auth | |
| blacklist /usr/lib/ssh | |
| blacklist /usr/lib/snapd | |
| blacklist /usr/lib/vmware | |
| blacklist /usr/lib/virtualbox | |
| blacklist /usr/lib/xorg/Xorg.wrap | |
| blacklist /usr/lib64/virtualbox | |
| blacklist ${RUNUSER}/*.lock | |
| blacklist ${RUNUSER}/*.slave-socket | |
| blacklist ${RUNUSER}/.dbus-proxy | |
| blacklist ${RUNUSER}/.flatpak | |
| blacklist ${RUNUSER}/.flatpak-cache | |
| blacklist ${RUNUSER}/.flatpak-helper | |
| blacklist ${RUNUSER}/app | |
| blacklist ${RUNUSER}/containers | |
| blacklist ${RUNUSER}/crun | |
| blacklist ${RUNUSER}/doc | |
| blacklist ${RUNUSER}/gnome-session-leader-fifo | |
| blacklist ${RUNUSER}/gnome-shell | |
| blacklist ${RUNUSER}/gsconnect | |
| blacklist ${RUNUSER}/inaccessible | |
| blacklist ${RUNUSER}/kdeinit5__* | |
| blacklist ${RUNUSER}/kdesud_* | |
| #?HAS_NODBUS: blacklist ${RUNUSER}/ksocket-* | |
| blacklist ${RUNUSER}/libpod | |
| blacklist ${RUNUSER}/libvirt | |
| blacklist ${RUNUSER}/pk-debconf-socket | |
| blacklist ${RUNUSER}/runc | |
| blacklist ${RUNUSER}/snapd-session-agent.socket | |
| blacklist ${RUNUSER}/systemd | |
| blacklist ${RUNUSER}/toolbox | |
| blacklist ${RUNUSER}/update-notifier.pid | |
| # Games generally need `unix` to create a window and `netlink` for gamepads | |
| # Network multiplayer uses `inet` and `inet6` | |
| protocol unix,netlink%(extra_protos)s | |
| %(extra_commands)s | |
| # Include for any site-specific customizations (eg. special /mnt rules) | |
| # or upgrades to the ruleset | |
| include %(site_rules_path)s | |
| # vim: set ft=conf : | |
| """ | |
| RUN_TMPL = """ | |
| #!/bin/sh | |
| cd "$(dirname "$(readlink -f "$0")")" || exit | |
| firejail --profile="%(profile_path)s" %(extra_run_args)s %(game_cmd)s | |
| """ | |
| def home_ify(path: str) -> str: | |
| home = os.path.expanduser('~') | |
| path = os.path.abspath(path) | |
| if path.startswith(home): | |
| return os.path.join('${HOME}', os.path.relpath(path, home)) | |
| return path | |
| def process_arg(path: str, network: bool, electron: bool, | |
| game_name: Optional[str], apparmor: bool, lib32: bool, alsa_conf: bool, | |
| memory_deny_write_execute: bool, writable_game_dir: bool, | |
| etc_passwd: bool, wine: bool, crackle_fix: Optional[int] = None | |
| ) -> None: | |
| extra_commands = [] | |
| extra_run_args = [] | |
| private_bin = [] | |
| path = os.path.realpath(path) | |
| if not os.path.isfile(path) and os.access(path, os.X_OK): | |
| log.critical("Not an executable file: %s", path) | |
| return | |
| if '\n' in path: | |
| log.critical( | |
| "Don't know how to escape paths containing newlines: %r", path) | |
| return | |
| if path.endswith('.AppImage'): | |
| extra_run_args.append('--appimage') | |
| if path.endswith('.sh'): | |
| # Commands needed by... | |
| # GOG.com's start.sh (at least two different versions) | |
| private_bin.extend(['cat', 'chmod', 'basename', 'bash', | |
| 'dirname', 'env', 'grep', 'head', 'sh', 'tail', 'uname']) | |
| # Neverwinter Nights's launch.sh | |
| private_bin.append('readlink') | |
| # Things like SMAPI | |
| private_bin.append('xterm') | |
| # Support using /mnt/... paths for game install locations | |
| if path.startswith('/mnt/'): | |
| extra_commands.extend(['blacklist /media', 'blacklist /run/media']) | |
| else: | |
| extra_commands.append('disable-mnt') | |
| if apparmor: | |
| extra_commands.append('apparmor') | |
| if crackle_fix: | |
| extra_commands.append('env PULSE_LATENCY_MSEC=%d' % crackle_fix) | |
| private_etc = [] | |
| if alsa_conf: | |
| private_etc.append('alsa/conf.d') # only needed for Windward so far | |
| if etc_passwd: | |
| private_etc.append('passwd') | |
| if not lib32: | |
| extra_commands.append('blacklist /lib32') | |
| extra_commands.append('blacklist /usr/lib32') | |
| if memory_deny_write_execute: | |
| extra_commands.append('memory-deny-write-execute') | |
| if wine: | |
| extra_commands.append('whitelist-ro /usr/share/wine') | |
| private_bin.extend(['wine', 'winecfg', 'cut']) | |
| if network: | |
| msg = ('If you have issues joining network games, try adding ' | |
| '`dns 1.1.1.1`') | |
| print(msg + " to the game's Firejail profile") | |
| extra_commands.append('# ' + msg) | |
| extra_protos = ',inet,inet6' | |
| else: | |
| extra_commands.append('net none # Deny network to single-player game') | |
| extra_protos = '' | |
| # TODO: Look into ways to optionally make game_root-relative paths portable | |
| # TODO: Options to manually specify game name and root directory | |
| # TODO: If the game executable is a shell script, chmod -x it and invoke it | |
| # by directly calling the shebang command | |
| # TODO: Proper use of XDG Base Directory Spec | |
| game_root = os.path.dirname(path) | |
| if game_name is None: | |
| game_name = os.path.basename(game_root).replace(' ', '_') | |
| game_home = os.path.join(os.path.expanduser("~/.local/share"), | |
| game_name) | |
| profile_path = os.path.join(os.path.expanduser("~/.config/firejail"), | |
| game_name) | |
| run_path = os.path.join(game_root, 'run.sh') | |
| # TODO: Do this properly: | |
| game_cmd = './' + os.path.basename(path) | |
| if game_cmd == './start.sh': | |
| game_cmd = 'bash ./start.sh' | |
| os.chmod(path, 0o644) # Prevent accidentally launching unsandboxed | |
| print("Profile will be written to: ", profile_path) | |
| print("Run script will be written to: ", run_path) | |
| print("Game will store its user data in: ", game_home) | |
| response = '' | |
| while response not in ['y', 'n']: | |
| response = input("Continue [yn]? ").lower()[:1] | |
| if response != 'y': | |
| return | |
| # TODO: Detect pre-existing profiles, run scripts, and private home dirs | |
| with open(profile_path, 'w') as fobj: | |
| fobj.write(PROFILE_TMPL.strip() % { | |
| # TODO: Write a template netfilter config which allows game-hosting | |
| 'extra_commands': '\n'.join(extra_commands), | |
| 'extra_protos': extra_protos, | |
| 'gamedir_ro_suffix': '' if writable_game_dir else '-ro', | |
| 'game_home': home_ify(game_home), | |
| 'game_root': game_root, | |
| 'private_bin': ','.join(private_bin) if private_bin else 'none', | |
| 'private_etc': ','.join(private_etc) if private_etc else 'none', | |
| 'run_path': run_path, | |
| 'seccomp_extra': ' !chroot' if electron else '', | |
| 'site_rules_path': SITE_RULES_PATH, | |
| }) | |
| with open(run_path, 'w') as fobj: | |
| fobj.write(RUN_TMPL.strip() % { | |
| 'extra_run_args': ' '.join(extra_run_args), | |
| 'game_cmd': game_cmd, | |
| 'profile_path': home_ify(profile_path), | |
| }) | |
| if not os.path.exists(game_home): | |
| os.makedirs(game_home) | |
| if not os.path.exists(SITE_RULES_PATH): | |
| parent = os.path.dirname(SITE_RULES_PATH) | |
| if not os.path.exists(parent): | |
| os.makedirs(parent) | |
| with open(SITE_RULES_PATH, 'w') as fobj: | |
| fobj.write("# Put your custom firejail rules for all games here") | |
| os.chmod(run_path, 0o755) | |
| print("OK. You should now be able to run %s to launch the game." % run_path) | |
| def main() -> None: | |
| """The main entry point, compatible with setuptools entry points.""" | |
| from argparse import ArgumentParser, RawDescriptionHelpFormatter | |
| parser = ArgumentParser(formatter_class=RawDescriptionHelpFormatter, | |
| description=__doc__.replace('\r\n', '\n').split('\n--snip--\n')[0]) | |
| parser.add_argument('-V', '--version', action='version', | |
| version="%%(prog)s v%s" % __version__) | |
| parser.add_argument('-v', '--verbose', action="count", | |
| default=2, help="Increase the verbosity. Use twice for extra effect.") | |
| parser.add_argument('-q', '--quiet', action="count", | |
| default=0, help="Decrease the verbosity. Use twice for extra effect.") | |
| parser.add_argument('--allow-memory-write-execute', action="store_true", | |
| default=False, help="Disable forced hardware DEP. Try this if game " | |
| "fails to start. (OpenAL and .NET seem to require this.)") | |
| parser.add_argument('--alsa-conf', action="store_true", default=False, | |
| help="Allow access to /etc/alsa/conf.d (Needed by Windward. Try if you " | |
| "have no audio.)") | |
| parser.add_argument('--crackle-fix', action="store", nargs='?', default=60, | |
| metavar='MSEC', help="Set PULSE_LATENCY_MSEC to the provided value " | |
| "(default if provided without argument: %(default)s)") | |
| parser.add_argument('--electron', action="store_true", default=False, | |
| help="Loosen seccomp filter for compatibility with Electron apps") | |
| parser.add_argument('--etc-passwd', action="store_true", default=False, | |
| help="Allow access to /etc/passwd (required by games like Neverwinter " | |
| "Nights EE, Stellaris, and The Escapists which use calls like " | |
| "getpwuid/getpwnam instead of building their userdata path from " | |
| "$HOME)") | |
| parser.add_argument('--lib32', action="store_true", default=False, | |
| help="Don't blacklist /usr/lib32 (for 32-bit-only games)") | |
| parser.add_argument('--network', action="store_true", default=False, | |
| help="Allow Internet access for multiplayer gaming") | |
| parser.add_argument('--no-apparmor', action="store_true", default=False, | |
| help="Disable the protection most likely to break things. Re-run with " | |
| "this option if the game fails to start.") | |
| parser.add_argument('--writable-game-dir', action="store_true", | |
| default=False, help="Allow game to modify its own install directory " | |
| "instead of just its userdata in its sandboxed $HOME.") | |
| parser.add_argument('--wine', action="store_true", | |
| default=False, help="Allow access to system Wine install") | |
| parser.add_argument('--game-name', action="store", default=None, | |
| metavar='NAME', help="Override the autodetected name for the game") | |
| parser.add_argument('path', action="store", nargs="+", | |
| help="Command to be run inside the sandbox to start the game") | |
| # Reminder: %(default)s can be used in help strings. | |
| args = parser.parse_args() | |
| # Set up clean logging to stderr | |
| log_levels = [logging.CRITICAL, logging.ERROR, logging.WARNING, | |
| logging.INFO, logging.DEBUG] | |
| args.verbose = min(args.verbose - args.quiet, len(log_levels) - 1) | |
| args.verbose = max(args.verbose, 0) | |
| logging.basicConfig(level=log_levels[args.verbose], | |
| format='%(levelname)s: %(message)s') | |
| for path in args.path: | |
| process_arg(path, network=args.network, electron=args.electron, | |
| game_name=args.game_name, apparmor=not args.no_apparmor, | |
| lib32=args.lib32, alsa_conf=args.alsa_conf, | |
| memory_deny_write_execute=not args.allow_memory_write_execute, | |
| writable_game_dir=args.writable_game_dir, | |
| etc_passwd=args.etc_passwd, wine=args.wine, | |
| crackle_fix=args.crackle_fix) | |
| print("\nWhile the generated profiles have been designed to work with a " | |
| "wide variety of games, it's possible to find the odd game that does " | |
| "something extra unusual. If the game crashes on startup, use " | |
| "`--allow-debuggers` in the `run.sh` firejail arguments and `strace` " | |
| "at the beginning of the line to launch the actual binary (not " | |
| "something like `start.sh` or `launch.sh`) to diagnose the problem. " | |
| "(eg. Neverwinter Nights Enhanced Edition turned out to be blindly " | |
| "trusting it could query non-sensitive information about the user " | |
| "account and required `private-etc passwd`.") | |
| if __name__ == '__main__': # pragma: nocover | |
| main() | |
| # vim: set sw=4 sts=4 expandtab : |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment