From: Daan De Meyer Date: Fri, 5 May 2023 20:14:18 +0000 (+0200) Subject: Adopt systemd-firstboot X-Git-Tag: v15~177^2~17 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=f11325afa02c987053f9c86f513d6a89453aff6a;p=thirdparty%2Fmkosi.git Adopt systemd-firstboot We keep it simple and just adopt the same settings that firstboot has and pass them through directly. This commit also reworks how we reset the machine ID, random seed and a few other files. --- diff --git a/action.yaml b/action.yaml index a9f32f3e1..447b19a38 100644 --- a/action.yaml +++ b/action.yaml @@ -44,13 +44,14 @@ runs: sudo apt-get install libfdisk-dev git clone https://github.com/systemd/systemd --depth=1 - meson setup systemd/build systemd -Drepart=true -Defi=true -Dbootloader=true -Dukify=true + meson setup systemd/build systemd -Drepart=true -Defi=true -Dbootloader=true -Dukify=true -Dfirstboot=true BINARIES=( bootctl systemctl systemd-analyze systemd-dissect + systemd-firstboot systemd-nspawn systemd-repart ukify diff --git a/mkosi.md b/mkosi.md index 26e9bb1ea..919d1f399 100644 --- a/mkosi.md +++ b/mkosi.md @@ -727,19 +727,6 @@ a boolean argument: either "1", "yes", or "true" to enable, or "0", : Packages are appended to the list. Packages prefixed with "!" are removed from the list. "!\*" removes all packages from the list. -`Password=`, `--password=` - -: Set the password of the `root` user. By default the `root` account - is locked. If this option is not used, but a file `mkosi.rootpw` - exists in the local directory, the root password is automatically - read from it. - -`PasswordIsHashed=`, `--password-is-hashed` - -: Indicate that the password supplied for the `root` user has already been - hashed, so that the string supplied with `Password=` or `mkosi.rootpw` will - be written to `/etc/shadow` literally. - `Autologin=`, `--autologin` : Enable autologin for the `root` user on `/dev/pts/0` (nspawn), @@ -845,6 +832,19 @@ a boolean argument: either "1", "yes", or "true" to enable, or "0", same as `KernelModulesInitrdInclude=` except that all modules that match any of the specified patterns are excluded from the kernel modules initrd. This setting takes priority over `KernelModulesInitrdInclude=`. +`Locale=`, `--locale=`, +`LocaleMessages=`, `--locale-messages=`, +`Keymap=`, `--keymap=`, +`Timezone=`, `--timezone=`, +`Hostname=`, `--hostname=`, +`RootPassword=`, `--root-password=`, +`RootPasswordHashed=`, `--root-password-hashed=`, +`RootPasswordFile=`, `--root-password-file=`, +`RootShell=`, `--root-shell=` + +: These settings correspond to the identically named systemd-firstboot options. See the systemd firstboot + [manpage](https://www.freedesktop.org/software/systemd/man/systemd-firstboot.html) for more information. + ### [Validation] Section `Checksum=`, `--checksum` diff --git a/mkosi/__init__.py b/mkosi/__init__.py index 13dbd9a56..a985153ab 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -2,7 +2,6 @@ import base64 import contextlib -import crypt import datetime import errno import hashlib @@ -57,7 +56,6 @@ from mkosi.util import ( flatten, format_rlimit, is_apt_distribution, - patch_file, prepend_to_environ_path, tmp_dir, ) @@ -282,60 +280,6 @@ def remove_packages(state: MkosiState) -> None: die(f"Removing packages is not supported for {state.config.distribution}") -def reset_machine_id(state: MkosiState) -> None: - """Make /etc/machine-id an empty file. - - This way, on the next boot is either initialized and committed (if /etc is - writable) or the image runs with a transient machine ID, that changes on - each boot (if the image is read-only). - """ - with complete_step("Resetting machine ID"): - machine_id = state.root / "etc/machine-id" - machine_id.unlink(missing_ok=True) - machine_id.write_text("uninitialized\n") - - -def reset_random_seed(root: Path) -> None: - """Remove random seed file, so that it is initialized on first boot""" - random_seed = root / "var/lib/systemd/random-seed" - if not random_seed.exists(): - return - - with complete_step("Removing random seed"): - random_seed.unlink() - - -def configure_root_password(state: MkosiState) -> None: - "Set the root account password, or just delete it so it's easy to log in" - - if state.config.password == "": - with complete_step("Deleting root password"): - - def delete_root_pw(line: str) -> str: - if line.startswith("root:"): - return ":".join(["root", ""] + line.split(":")[2:]) - return line - - patch_file(state.root / "etc/passwd", delete_root_pw) - elif state.config.password: - with complete_step("Setting root password"): - if state.config.password_is_hashed: - password = state.config.password - else: - password = crypt.crypt(state.config.password, crypt.mksalt(crypt.METHOD_SHA512)) - - def set_root_pw(line: str) -> str: - if line.startswith("root:"): - return ":".join(["root", password] + line.split(":")[2:]) - return line - - shadow = state.root / "etc/shadow" - try: - patch_file(shadow, set_root_pw) - except FileNotFoundError: - shadow.write_text(f"root:{password}:0:0:99999:7:::\n") - - def configure_autologin(state: MkosiState) -> None: if not state.config.autologin: return @@ -1326,7 +1270,13 @@ def print_summary(args: MkosiArgs, config: MkosiConfig) -> None: Script Environment: {line_join_list(env)} Scripts with network: {yes_no(config.with_network)} nspawn Settings: {none_to_none(config.nspawn_settings)} - Password: {("(default)" if config.password is None else "(set)")} + Locale: {none_to_default(config.locale)} + Locale Messages: {none_to_default(config.locale_messages)} + Keymap: {none_to_default(config.keymap)} + Timezone: {none_to_default(config.timezone)} + Hostname: {none_to_default(config.hostname)} + Root Password: {("(set)" if config.root_password or config.root_password_hashed or config.root_password_file else "(default)")} + Root Shell: {none_to_default(config.root_shell)} Autologin: {yes_no(config.autologin)} {bold("HOST CONFIGURATION")}: @@ -1450,11 +1400,39 @@ def run_sysusers(state: MkosiState) -> None: run(["systemd-sysusers", "--root", state.root]) -def run_preset_all(state: MkosiState) -> None: +def run_preset(state: MkosiState) -> None: with complete_step("Applying presets…"): run(["systemctl", "--root", state.root, "preset-all"]) +def run_firstboot(state: MkosiState) -> None: + settings = ( + ("--locale", state.config.locale), + ("--locale-messages", state.config.locale_messages), + ("--keymap", state.config.keymap), + ("--timezone", state.config.timezone), + ("--hostname", state.config.hostname), + ("--root-password", state.config.root_password), + ("--root-password-hashed", state.config.root_password_hashed), + ("--root-password-file", state.config.root_password_file), + ("--root-shell", state.config.root_shell), + ) + + options = [] + + for setting, value in settings: + if not value: + continue + + options += [setting, value] + + if not options: + return + + with complete_step("Applying first boot settings"): + run(["systemd-firstboot", "--root", state.root, "--force", *options]) + + def run_selinux_relabel(state: MkosiState) -> None: selinux = state.root / "etc/selinux/config" if not selinux.exists(): @@ -1607,7 +1585,6 @@ def build_image(state: MkosiState, *, for_cache: bool, manifest: Optional[Manife if for_cache: return - configure_root_password(state) configure_autologin(state) configure_initrd(state) run_build_script(state) @@ -1617,8 +1594,9 @@ def build_image(state: MkosiState, *, for_cache: bool, manifest: Optional[Manife configure_ssh(state) run_postinst_script(state) run_sysusers(state) - run_preset_all(state) + run_preset(state) run_depmod(state) + run_firstboot(state) remove_packages(state) if manifest: @@ -1627,8 +1605,6 @@ def build_image(state: MkosiState, *, for_cache: bool, manifest: Optional[Manife clean_package_manager_metadata(state) remove_files(state) - reset_machine_id(state) - reset_random_seed(state.root) run_finalize_script(state) run_selinux_relabel(state) diff --git a/mkosi/config.py b/mkosi/config.py index d1c200fe6..514385687 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -6,7 +6,6 @@ import enum import fnmatch import functools import inspect -import logging import operator import os.path import platform @@ -74,7 +73,8 @@ def parse_path(value: str, required: bool = True, absolute: bool = True, expanduser: bool = True, - expandvars: bool = True) -> Path: + expandvars: bool = True, + secret: bool = False) -> Path: if expandvars: value = os.path.expandvars(value) @@ -91,6 +91,14 @@ def parse_path(value: str, if absolute: path = path.absolute() + if secret: + mode = path.stat().st_mode & 0o777 + if mode & 0o007: + die(textwrap.dedent(f"""\ + Permissions of '{path}' of '{mode:04o}' are too open. + When creating secret files use an access mode that restricts access to the owner only. + """)) + return path @@ -374,13 +382,15 @@ def make_path_parser(*, required: bool = True, absolute: bool = True, expanduser: bool = True, - expandvars: bool = True) -> Callable[[str], Path]: + expandvars: bool = True, + secret: bool = False) -> Callable[[str], Path]: return functools.partial( parse_path, required=required, absolute=absolute, expanduser=expanduser, expandvars=expandvars, + secret=secret, ) @@ -388,7 +398,8 @@ def config_make_path_parser(*, required: bool = True, absolute: bool = True, expanduser: bool = True, - expandvars: bool = True) -> ConfigParseCallback: + expandvars: bool = True, + secret: bool = False) -> ConfigParseCallback: def config_parse_path(dest: str, value: Optional[str], namespace: argparse.Namespace) -> Optional[Path]: if dest in namespace: return getattr(namespace, dest) # type: ignore @@ -400,6 +411,7 @@ def config_make_path_parser(*, absolute=absolute, expanduser=expanduser, expandvars=expandvars, + secret=secret, ) return None @@ -596,8 +608,6 @@ class MkosiConfig: split_artifacts: bool sign: bool key: Optional[str] - password: Optional[str] - password_is_hashed: bool autologin: bool extra_search_paths: list[Path] ephemeral: bool @@ -613,6 +623,15 @@ class MkosiConfig: acl: bool bootable: ConfigFeature use_subvolumes: ConfigFeature + locale: Optional[str] + locale_messages: Optional[str] + keymap: Optional[str] + timezone: Optional[str] + hostname: Optional[str] + root_password: Optional[str] + root_password_hashed: Optional[str] + root_password_file: Optional[Path] + root_shell: Optional[str] # QEMU-specific options qemu_gui: bool @@ -904,15 +923,6 @@ class MkosiConfigParser: parse=config_parse_feature, match=config_make_list_matcher(delimiter=",", parse=parse_feature), ), - MkosiConfigSetting( - dest="password", - section="Content", - ), - MkosiConfigSetting( - dest="password_is_hashed", - section="Content", - parse=config_parse_boolean, - ), MkosiConfigSetting( dest="autologin", section="Content", @@ -1029,6 +1039,52 @@ class MkosiConfigParser: section="Content", parse=config_make_list_parser(delimiter=","), ), + MkosiConfigSetting( + dest="locale", + section="Content", + parse=config_parse_string, + ), + MkosiConfigSetting( + dest="locale_messages", + section="Content", + parse=config_parse_string, + ), + MkosiConfigSetting( + dest="keymap", + section="Content", + parse=config_parse_string, + ), + MkosiConfigSetting( + dest="timezone", + section="Content", + parse=config_parse_string, + ), + MkosiConfigSetting( + dest="hostname", + section="Content", + parse=config_parse_string, + ), + MkosiConfigSetting( + dest="root_password", + section="Content", + parse=config_parse_string, + ), + MkosiConfigSetting( + dest="root_password_hashed", + section="Content", + parse=config_parse_string, + ), + MkosiConfigSetting( + dest="root_password_file", + section="Content", + parse=config_make_path_parser(secret=True), + paths=("mkosi.rootpw",), + ), + MkosiConfigSetting( + dest="root_shell", + section="Content", + parse=config_parse_string, + ), MkosiConfigSetting( dest="checksum", section="Validation", @@ -1516,14 +1572,6 @@ class MkosiConfigParser: nargs="?", action=action, ) - group.add_argument("--password", help="Set the root password", action=action) - group.add_argument( - "--password-is-hashed", - metavar="BOOL", - help="Indicate that the root password has already been hashed", - nargs="?", - action=action, - ) group.add_argument( "--autologin", metavar="BOOL", @@ -1660,6 +1708,60 @@ class MkosiConfigParser: metavar="REGEX", action=action, ) + group.add_argument( + "--locale", + help="Set the system locale", + metavar="LOCALE", + action=action, + ) + group.add_argument( + "--locale-messages", + help="Set the messages locale", + metavar="LOCALE", + action=action, + ) + group.add_argument( + "--keymap", + help="Set the system keymap", + metavar="KEYMAP", + action=action, + ) + group.add_argument( + "--timezone", + help="Set the system timezone", + metavar="TIMEZONE", + action=action, + ) + group.add_argument( + "--hostname", + help="Set the system hostname", + metavar="HOSTNAME", + action=action, + ) + group.add_argument( + "--root-password", + help="Set the system root password", + metavar="PASSWORD", + action=action, + ) + group.add_argument( + "--root-password-hashed", + help="Set the system root password (hashed)", + metavar="PASSWORD-HASHED", + action=action, + ) + group.add_argument( + "--root-password-file", + help="Set the system root password (file)", + metavar="PATH", + action=action, + ) + group.add_argument( + "--root-shell", + help="Set the system root shell", + metavar="SHELL", + action=action, + ) group = parser.add_argument_group("Validation options") group.add_argument( @@ -1909,29 +2011,6 @@ def find_image_version(args: argparse.Namespace) -> None: pass -def require_private_file(name: Path, description: str) -> None: - mode = os.stat(name).st_mode & 0o777 - if mode & 0o007: - logging.warning(textwrap.dedent(f"""\ - Permissions of '{name}' of '{mode:04o}' are too open. - When creating {description} files use an access mode that restricts access to the owner only. - """)) - - -def find_password(args: argparse.Namespace) -> None: - if args.password is not None: - return - - try: - pwfile = Path("mkosi.rootpw") - require_private_file(pwfile, "root password") - - args.password = pwfile.read_text().strip() - - except FileNotFoundError: - pass - - def load_credentials(args: argparse.Namespace) -> dict[str, str]: creds = {} @@ -2059,9 +2138,6 @@ def load_config(args: argparse.Namespace) -> MkosiConfig: if args.sign_expected_pcr is None: args.sign_expected_pcr = bool(shutil.which("systemd-measure")) - # Resolve passwords late so we can accurately determine whether a build is needed - find_password(args) - if args.repo_dirs and not ( is_dnf_distribution(args.distribution) or is_apt_distribution(args.distribution) diff --git a/mkosi/run.py b/mkosi/run.py index 32853070a..fa8503e93 100644 --- a/mkosi/run.py +++ b/mkosi/run.py @@ -286,6 +286,11 @@ def bwrap( ] if apivfs: + if not apivfs.joinpath("etc/machine-id").exists(): + # Uninitialized means we want it to get initialized on first boot. + apivfs.joinpath("etc/machine-id").write_text("uninitialized\n") + apivfs.chmod(0o0444) + cmdline += [ "--tmpfs", apivfs / "run", "--tmpfs", apivfs / "tmp", @@ -312,20 +317,31 @@ def bwrap( with tempfile.TemporaryDirectory(dir="/var/tmp", prefix="mkosi-var-tmp") as var_tmp: if apivfs: - cmdline += ["--bind", var_tmp, apivfs / "var/tmp"] + cmdline += [ + "--bind", var_tmp, apivfs / "var/tmp", + # Make sure /etc/machine-id is not overwritten by any package manager post install scripts. + "--ro-bind", apivfs / "etc/machine-id", apivfs / "etc/machine-id", + ] cmdline += ["sh", "-c"] template = f"{chmod} && exec {{}} || exit $?" try: - return run([*cmdline, template.format(shlex.join(str(s) for s in cmd))], - text=True, stdout=stdout, env=env, log=False) + result = run([*cmdline, template.format(shlex.join(str(s) for s in cmd))], + text=True, stdout=stdout, env=env, log=False) except subprocess.CalledProcessError as e: logging.error(f'"{shlex.join(str(s) for s in cmd)}" returned non-zero exit code {e.returncode}.') if ARG_DEBUG_SHELL.get(): run([*cmdline, template.format("sh")], stdin=sys.stdin, check=False, env=env, log=False) raise e + # Clean up some stuff that might get written by package manager post install scripts. + if apivfs: + for f in ("var/lib/systemd/random-seed", "var/lib/systemd/credential.secret", "etc/machine-info"): + apivfs.joinpath(f).unlink(missing_ok=True) + + return result + def run_workspace_command( root: Path, diff --git a/mkosi/util.py b/mkosi/util.py index 9b047bb14..07feb02ad 100644 --- a/mkosi/util.py +++ b/mkosi/util.py @@ -9,7 +9,6 @@ import os import pwd import re import resource -import shutil import sys import tempfile from collections.abc import Iterable, Iterator, Sequence @@ -190,18 +189,6 @@ def tmp_dir() -> Path: return Path(path) -def patch_file(filepath: Path, line_rewriter: Callable[[str], str]) -> None: - temp_new_filepath = filepath.with_suffix(filepath.suffix + ".tmp.new") - - with filepath.open("r") as old, temp_new_filepath.open("w") as new: - for line in old: - new.write(line_rewriter(line)) - - shutil.copystat(filepath, temp_new_filepath) - os.remove(filepath) - shutil.move(temp_new_filepath, filepath) - - def sort_packages(packages: Iterable[str]) -> list[str]: """Sorts packages: normal first, paths second, conditional third"""