]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Adopt systemd-firstboot
authorDaan De Meyer <daan.j.demeyer@gmail.com>
Fri, 5 May 2023 20:14:18 +0000 (22:14 +0200)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Sun, 7 May 2023 17:17:18 +0000 (19:17 +0200)
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.

action.yaml
mkosi.md
mkosi/__init__.py
mkosi/config.py
mkosi/run.py
mkosi/util.py

index a9f32f3e16af8e460105d831f326599558ec86b1..447b19a3876d3d812fa4eb4dc1a4ff97cc2c70ed 100644 (file)
@@ -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
index 26e9bb1ea933c97e0799729aa8ca27bc8acebbbc..919d1f399fa6082a52dcb3a719f2bc06743f8eff 100644 (file)
--- 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`
index 13dbd9a56e489b894a81a6e8cf62c7727d443da9..a985153abfb595bf2d49e728ac072f5e3bf68149 100644 (file)
@@ -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)
 
index d1c200fe60a5dc522759c5fa74fb4ec58ecffc3d..5143856878b6f265d5ce8576156a03cd897cfc48 100644 (file)
@@ -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)
index 32853070a760de416836de2824170acd9a285305..fa8503e93e44dd8c13c7e5be2e4ed63a4db10866 100644 (file)
@@ -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,
index 9b047bb14e273744c72f04474656bd70876f890d..07feb02ada8ebb516ff6428b27a95a5f5e82632e 100644 (file)
@@ -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"""