diff --git a/fish/functions/nspawnm.fish b/fish/functions/nspawnm.fish new file mode 100644 index 00000000..42136230 --- /dev/null +++ b/fish/functions/nspawnm.fish @@ -0,0 +1,7 @@ +#!/usr/bin/env fish +# SPDX-License-Identifier: MIT +# Copyright (C) 2024 Nathan Chancellor + +function nspawnm -d "Wrapper for nspawnm.py" + $PYTHON_SCRIPTS_FOLDER/nspawnm.py $argv +end diff --git a/python/lib/utils.py b/python/lib/utils.py index 91281ce7..b9f8e2fa 100755 --- a/python/lib/utils.py +++ b/python/lib/utils.py @@ -169,7 +169,7 @@ def run(*args, **kwargs): raise err -def run_as_root(full_cmd): +def run_as_root(full_cmd, **kwargs): cmd_copy = [full_cmd] if isinstance(full_cmd, (str, os.PathLike)) else full_cmd.copy() if os.geteuid() != 0: @@ -177,7 +177,7 @@ def run_as_root(full_cmd): # If we have to escalate via 'sudo', print the command so it can be audited # if necessary. - run(cmd_copy, show_cmd_location=cmd_copy[0] == 'sudo') + run(cmd_copy, show_cmd_location=cmd_copy[0] == 'sudo', **kwargs) def run_check_rc_zero(*args, **kwargs): diff --git a/python/scripts/nspawnm.py b/python/scripts/nspawnm.py new file mode 100755 index 00000000..e1668854 --- /dev/null +++ b/python/scripts/nspawnm.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 + +from argparse import ArgumentParser +from collections import UserDict +import os +from pathlib import Path +import platform +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) +# pylint: disable=wrong-import-position +import lib.utils +# pylint: enable=wrong-import-position + +# This should not change after import +USER = os.environ['USER'] + + +class NspawnConfig(UserDict): + + def __init__(self, name): + # Initial static defaults + super().__init__({ + 'Exec': { + 'Boot': 'yes', + # Machine name will be accessed via IMAGE_ID within the + # container, use the host's hostname for easy identification + 'Hostname': platform.uname().node, + 'PrivateUsers': 'pick', + # Necessary for 'perf record' + 'SystemCallFilter': 'perf_event_open', + }, + 'Files': { + # Bind my /home directory and user into the container + 'BindUser': USER, + # Mounts will be added dynamically + 'Bind': [], + 'BindReadOnly': [], + 'PrivateUsersOwnership': 'auto', + }, + 'Network': { + # Use host networking, as my use of nspawn is not around + # isolation + 'VirtualEthernet': 'no', + }, + }) + + # Add dynamic bind mounts + self._add_dynamic_mounts(name) + + # Set machine path based on the name + self.machine_dir = Path('/var/lib/machines', name) + + def _add_dynamic_mounts(self, name): + rw_mounts = { + '/dev/kvm', + # We may be in a virtual machine + os.environ['HOST_FOLDER'], + os.environ['NVME_FOLDER'], + } + ro_mounts = { + # Allow interacting with the host tmux socket + f"/tmp/tmux-{os.getuid()}", # noqa: S108 + } + + if 'arch' in name: + # Share the host's mirrorlist so that reflector does not have to be + # run in the container (even if it could) + ro_mounts.add('/etc/pacman.d/mirrorlist') + + # This one should really be a tmpfs overlay to avoid polluting the + # host but it does not look like nspawn's overlay configuration + # allows idmapping? https://github.com/systemd/systemd/issues/25886 + rw_mounts.add('/var/cache/pacman/pkg') + + for mount in rw_mounts: + # We need idmapping otherwise to ensure our user in the container + # is treated as the user on the host. + # While idmapping /dev mounts should be possible after + # https://git.kernel.org/linus/7a80e5b8c6fa7d0ae6624bd6aedc4a6a1cfc62fa, + # systemd-nspawn does not appear to support it and it should not be + # necessary due to our kvm.conf. + item = mount if mount.startswith('/dev') else f"{mount}:{mount}:idmap" + + # The mount must exist on the host otherwise the container will not + # start + if Path(mount).exists(): + self.data['Files']['Bind'].append(item) + + for mount in ro_mounts: + if Path(mount).exists(): + self.data['Files']['BindReadOnly'].append(mount) + + def _gen_cfg_str(self): + parts = [] + + for key, values in self.data.items(): + parts.append(f"[{key}]") + for subkey, subval in values.items(): + if isinstance(subval, list): + for item in subval: + parts.append(f"{subkey}={item}") + elif subval: + parts.append(f"{subkey}={subval}") + parts.append('') + + return '\n'.join(parts) + + def _gen_eph_cmd(self): + cfg_to_cmd = { + 'Bind': '--bind', + 'BindReadOnly': '--bind-ro', + 'BindUser': '--bind-user', + 'Boot': '--boot', + 'Hostname': '--hostname', + 'PrivateUsers': '--private-users', + 'PrivateUsersOwnership': '--private-users-ownership', + 'SystemCallFilter': '--system-call-filter', + } + + # Generate our command line arguments + nspawn_cmd = [ + 'systemd-nspawn', + f"--directory={self.machine_dir}", + '--ephemeral', + # This script is the ultimate source of truth for arguments, not + # our configuration files, which may be stale (but should still be + # updated) + '--settings=no', + # Bind mount an empty file to /etc/ephemeral to allow notating via + # our prompt that we are in an ephemeral environment (so any + # changes to /usr or other paths will not be persistent) + '--bind-ro=:/etc/ephemeral', + # Avoid using CBL tools in ephemeral environments by default (they + # can still be manually used via their full path or temporarily be + # added if needed). This allows config.fish to test if the file is + # readable before adding the tools to the environment. + '--inaccessible=/etc/use-cbl', + ] + for values in self.data.values(): + for key, value in values.items(): + # If there is no corresponding command line flag, skip it + # This namely affects VirtualEthernet, as host networking is + # the default with systemd-nspawn but virtual networking is the + # default with systemd-nspawn@.service. + if not (flag := cfg_to_cmd.get(key)): + continue + + if isinstance(value, list): + nspawn_cmd += [f"{flag}={item}" for item in value] + # certain configuration options are booleans but the commmand + # line option is just a simple flag + elif flag in ('--boot', ) and value == 'yes': + nspawn_cmd.append(flag) + else: + nspawn_cmd.append(f"{flag}={value}") + + return nspawn_cmd + + def gen_files(self): + lib.utils.print_green('Requesting sudo permissions for file creation...') + lib.utils.run_as_root('true') + + # Allow containers to access /dev/kvm to run accelerated VMs, which + # allows avoiding installing QEMU in the host environment. + if not (kvm_conf := + Path('/etc/systemd/system/systemd-nspawn@.service.d/kvm.conf')).exists(): + kvm_conf_txt = ('[Service]\n' + 'DeviceAllow=/dev/kvm rw\n') + if not kvm_conf.parent.exists(): + lib.utils.run_as_root(['mkdir', '-p', kvm_conf.parent]) + lib.utils.run_as_root(['tee', kvm_conf], input=kvm_conf_txt) + + # Allow my user to access 'machinectl shell' without authentication + # rules.d can only be read by root so we need to use sudo to test + polkit_rule = Path('/etc/polkit-1/rules.d', f"50-permit-{USER}-machinectl-shell.rules") + if not lib.utils.run_check_rc_zero(['sudo', 'test', '-e', polkit_rule]): + polkit_rule_txt = ('polkit.addRule(function(action, subject) {\n' + ' if (action.id == "org.freedesktop.machine1.shell" &&\n' + f' subject.user == "{USER}") {{\n' + ' return polkit.Result.YES;\n' + ' }\n' + '});\n') + if not polkit_rule.parent.exists(): + lib.utils.run_as_root(['mkdir', '-p', polkit_rule.parent]) + lib.utils.run_as_root(['tee', polkit_rule], input=polkit_rule_txt) + + # Write configuration file. We allow the user to interactively remove + # an old one if so desired. + if (nspawn_conf := Path('/etc/systemd/nspawn', f"{self.machine_dir.name}.nspawn")).exists(): + answer = input(f"\033[01;33m{nspawn_conf} already exists, remove it? [y/N]\033[0m ") + if answer.lower() in ('y', 'yes'): + lib.utils.run_as_root(['rm', nspawn_conf]) + if not nspawn_conf.exists(): + if not nspawn_conf.parent.exists(): + lib.utils.run_as_root(['mkdir', '-p', nspawn_conf.parent]) + lib.utils.run_as_root(['tee', nspawn_conf], input=self._gen_cfg_str()) + + # Print a warning if the machine does not already exist + # /var/lib/machines can only be read by root so we need to use sudo to test + if not lib.utils.run_check_rc_zero(['sudo', 'test', '-e', self.machine_dir]): + lib.utils.print_yellow( + f"WARNING: {self.machine_dir} does not exist, machine will not start without it") + + def run_eph_cmd(self): + lib.utils.run_as_root(self._gen_eph_cmd()) + + +def parse_arguments(): + parser = ArgumentParser(description='Dynamically generate or run .nspawn files or commands') + + default_machine = { + 'x86_64': 'dev-arch', + } + parser.add_argument('-n', + '--name', + default=default_machine.get(platform.machine()), + help='Name of machine (default: %(default)s)') + + mode_group = parser.add_mutually_exclusive_group(required=True) + mode_group.add_argument('-f', '--files', action='store_true', help='Generate .nspawn files') + mode_group.add_argument('-e', + '--ephemeral', + action='store_true', + help='Run "systemd-nspawn -x" command') + + return parser.parse_args() + + +if __name__ == '__main__': + args = parse_arguments() + + if os.geteuid() == 0: + raise RuntimeError('This script should not be run as root!') + + if lib.utils.in_container(): + raise RuntimeError('This script should be run on the host!') + + if not args.name: + raise RuntimeError('No name specified and architecture has no default!') + + config = NspawnConfig(args.name) + + if args.files: + config.gen_files() + if args.ephemeral: + config.run_eph_cmd()