]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
firstboot: add new systemd.firstboot=headless mode
authorMichael Vogt <michael@amutable.com>
Tue, 30 Jun 2026 16:08:47 +0000 (18:08 +0200)
committerLuca Boccassi <luca.boccassi@gmail.com>
Thu, 2 Jul 2026 08:52:21 +0000 (09:52 +0100)
This adds a new systemd.firstboot=headless mode. It differs from the
existing systemd.firstboot=no mode in that it still performs the
non-interactive auto-configuration that requires no user input (such
as selecting the only installed locale, or applying settings provided
via credentials), and only skips the prompts that would otherwise
block waiting for user input. In contrast, =no disables that
auto-configuration along with the prompts.

The option is also honoured by homectls firstboot logic and
systemd-cryptenroll, where headless behaves the same as no
(because the is no auto-configuration).

12 files changed:
NEWS
man/kernel-command-line.xml
man/systemd-firstboot.xml
src/cryptenroll/cryptenroll-interactive.c
src/firstboot/firstboot.c
src/home/homectl.c
src/shared/firstboot-util.c [new file with mode: 0644]
src/shared/firstboot-util.h [new file with mode: 0644]
src/shared/meson.build
src/test/meson.build
src/test/test-firstboot-util.c [new file with mode: 0644]
src/test/test-tables.c

diff --git a/NEWS b/NEWS
index d34215377ebc90b3f8ce173f354a1704fe2f31c0..ef3d070df1939e781a54b3dafec231933317a88a 100644 (file)
--- a/NEWS
+++ b/NEWS
@@ -16,6 +16,17 @@ CHANGES WITH 262:
           have been provided. This clears the way for a new
           "systemd-sysupdate@.service" unit for varlink activation of sysupdate.
 
+        Changes in systemd-firstboot:
+
+        * The "systemd.firstboot=" kernel command line option now accepts the
+          special value "headless" in addition to a boolean. Like "no", it
+          suppresses all interactive prompts, but unlike "no" it still performs
+          non-interactive auto-configuration that requires no user input (such
+          as selecting the sole installed locale, or applying settings provided
+          via credentials). This is useful for unattended installations that
+          should be provisioned as far as possible without ever blocking on a
+          prompt.
+
 CHANGES WITH 261:
 
         Announcements of Future Feature Removals and Incompatible Changes:
index 8253f0fafd4012c4ea87224e6312f3f88e35333b..3a6a5df8200cd49ebd81d1bede9c490998a088f5 100644 (file)
       <varlistentry>
         <term><varname>systemd.firstboot=</varname></term>
 
-        <listitem><para>Takes a boolean argument, defaults to on. If off,
+        <listitem><para>Takes a boolean argument or the special value <literal>headless</literal>, defaults to
+        on. If off,
         <citerefentry><refentrytitle>systemd-firstboot.service</refentrytitle><manvolnum>8</manvolnum></citerefentry>
         and
         <citerefentry><refentrytitle>systemd-homed-firstboot.service</refentrytitle><manvolnum>1</manvolnum></citerefentry>
         will not query the user for basic system settings, even if the system boots up for the first time and
-        the relevant settings are not initialized yet. Not to be confused with
-        <varname>systemd.condition_first_boot=</varname> (see below), which overrides the result of the
-        <varname>ConditionFirstBoot=</varname> unit file condition, and thus controls more than just
-        <filename>systemd-firstboot.service</filename> behaviour.</para>
+        the relevant settings are not initialized yet. If set to <literal>headless</literal>, the same
+        interactive prompts are suppressed as with <literal>no</literal>, but non-interactive
+        auto-configuration that requires no user input is still performed (for example, selecting the only
+        installed locale, or applying settings provided via credentials). This is useful for unattended
+        installations that should be provisioned as far as possible without ever blocking on a prompt. Not to
+        be confused with <varname>systemd.condition_first_boot=</varname> (see below), which overrides the
+        result of the <varname>ConditionFirstBoot=</varname> unit file condition, and thus controls more than
+        just <filename>systemd-firstboot.service</filename> behaviour.</para>
 
         <xi:include href="version-info.xml" xpointer="v233"/></listitem>
       </varlistentry>
index 252ec73feddc3ebee4d63b2f9d2b0f0dc5a98f9c..62e7c36dfe44e3ed44b1c78977ebf3b3b5c08872 100644 (file)
       <varlistentry>
         <term><varname>systemd.firstboot=</varname></term>
 
-        <listitem><para>Takes a boolean argument, defaults to on. If off, <filename>systemd-firstboot.service</filename>
-        will not interactively query the user for basic settings at first boot, even if those settings are not
-        initialized yet.</para>
+        <listitem><para>Takes a boolean argument or the special value <literal>headless</literal>, defaults to
+        on. If off, <filename>systemd-firstboot.service</filename> will not interactively query the user for
+        basic settings at first boot, even if those settings are not initialized yet. If set to
+        <literal>headless</literal>, interactive prompts are suppressed just like with <literal>no</literal>,
+        but non-interactive auto-configuration that requires no user input is still performed (for example,
+        selecting the sole installed locale, or applying settings passed in via credentials).</para>
 
         <xi:include href="version-info.xml" xpointer="v233"/></listitem>
       </varlistentry>
index 6dc584b18b46277b91907606950818d14c998510..c4b8c998bf55c082ee0dc9fc8d7b99f04d04965a 100644 (file)
@@ -8,10 +8,10 @@
 #include "cryptenroll-interactive.h"
 #include "cryptenroll-list.h"
 #include "cryptsetup-util.h"
+#include "firstboot-util.h"
 #include "glyph-util.h"
 #include "libfido2-util.h"
 #include "log.h"
-#include "proc-cmdline.h"
 #include "prompt-util.h"
 #include "string-util.h"
 #include "strv.h"
@@ -176,12 +176,15 @@ int cryptenroll_run_interactive(
         assert(c->node);
 
         /* Honour the systemd.firstboot= kernel command line option, just like systemd-firstboot. */
-        bool enabled;
-        r = proc_cmdline_get_bool("systemd.firstboot", PROC_CMDLINE_TRUE_WHEN_MISSING, &enabled);
+        FirstBootMode mode;
+        _cleanup_free_ char *bad = NULL;
+        r = firstboot_mode_from_cmdline(&mode, &bad);
         if (r < 0)
-                log_warning_errno(r, "Failed to parse systemd.firstboot= kernel command line option, ignoring: %m");
-        else if (!enabled) {
-                log_debug("systemd.firstboot=no set, skipping interactive enrollment.");
+                log_warning_errno(r, "Failed to parse systemd.firstboot= kernel command line option, ignoring%s: %m",
+                                  bad ? strjoina(" (invalid value '", bad, "')") : "");
+        else if (IN_SET(mode, FIRSTBOOT_NO, FIRSTBOOT_HEADLESS)) {
+                log_debug("systemd.firstboot=%s set, skipping interactive enrollment.",
+                          firstboot_mode_to_string(mode));
                 return 0;
         }
 
index 2200235e1d1cc5835a56fd003271340d06d182a2..e81923beb5bde67f8fc60a2f5582d8c53c334456 100644 (file)
@@ -24,6 +24,7 @@
 #include "errno-util.h"
 #include "fd-util.h"
 #include "fileio.h"
+#include "firstboot-util.h"
 #include "format-table.h"
 #include "fs-util.h"
 #include "glyph-util.h"
@@ -47,7 +48,6 @@
 #include "password-quality-util.h"
 #include "path-util.h"
 #include "plymouth-util.h"
-#include "proc-cmdline.h"
 #include "prompt-util.h"
 #include "runtime-scope.h"
 #include "smack-util.h"
@@ -79,6 +79,7 @@ static bool arg_prompt_timezone = false;
 static bool arg_prompt_hostname = false;
 static bool arg_prompt_root_password = false;
 static bool arg_prompt_root_shell = false;
+static bool arg_headless = false;
 static bool arg_copy_locale = false;
 static bool arg_copy_keymap = false;
 static bool arg_copy_timezone = false;
@@ -244,6 +245,16 @@ static int locale_is_ok(const char *name, void *userdata) {
         return r != 0 ? locale_is_installed(name) > 0 : locale_is_valid(name);
 }
 
+static bool headless_skips_prompt_for(const char *what) {
+        assert(what);
+
+        if (!arg_headless)
+                return false;
+
+        log_debug("Running headless, not prompting for %s.", what);
+        return true;
+}
+
 static int prompt_locale(int rfd, sd_varlink **mute_console_link) {
         _cleanup_strv_free_ char **locales = NULL;
         bool acquired_from_creds = false;
@@ -296,6 +307,9 @@ static int prompt_locale(int rfd, sd_varlink **mute_console_link) {
                         /* Not setting arg_locale_message here, since it defaults to LANG anyway */
                 }
         } else {
+                if (headless_skips_prompt_for("locale"))
+                        return 0;
+
                 print_welcome(rfd, mute_console_link);
 
                 _cleanup_free_ char *prefill = NULL;
@@ -456,6 +470,9 @@ static int prompt_keymap(int rfd, sd_varlink **mute_console_link) {
                 return 0;
         }
 
+        if (headless_skips_prompt_for("keymap"))
+                return 0;
+
         r = get_keymaps(&kmaps);
         if (r == -ENOENT) /* no keymaps installed */
                 return log_debug_errno(r, "No keymaps are installed.");
@@ -578,6 +595,9 @@ static int prompt_timezone(int rfd, sd_varlink **mute_console_link) {
                 return 0;
         }
 
+        if (headless_skips_prompt_for("timezone"))
+                return 0;
+
         r = get_timezones(&zones);
         if (r < 0)
                 return log_error_errno(r, "Cannot query timezone list: %m");
@@ -691,6 +711,9 @@ static int prompt_hostname(int rfd, sd_varlink **mute_console_link) {
                 return 0;
         }
 
+        if (headless_skips_prompt_for("hostname"))
+                return 0;
+
         print_welcome(rfd, mute_console_link);
 
         r = prompt_loop("Please enter the new hostname",
@@ -879,6 +902,9 @@ static int prompt_root_password(int rfd, sd_varlink **mute_console_link) {
                 return 0;
         }
 
+        if (headless_skips_prompt_for("root password"))
+                return 0;
+
         print_welcome(rfd, mute_console_link);
 
         msg1 = "Please enter the new root password (empty to skip):";
@@ -980,6 +1006,9 @@ static int prompt_root_shell(int rfd, sd_varlink **mute_console_link) {
                 return 0;
         }
 
+        if (headless_skips_prompt_for("root shell"))
+                return 0;
+
         print_welcome(rfd, mute_console_link);
 
         return prompt_loop(
@@ -1688,13 +1717,18 @@ static int run(int argc, char *argv[]) {
                  * command line option, because we are called to provision the host with basic settings (as
                  * opposed to some other file system tree/image) */
 
-                bool enabled;
-                r = proc_cmdline_get_bool("systemd.firstboot", /* flags= */ 0, &enabled);
+                FirstBootMode mode;
+                _cleanup_free_ char *bad = NULL;
+                r = firstboot_mode_from_cmdline(&mode, &bad);
                 if (r < 0)
-                        return log_error_errno(r, "Failed to parse systemd.firstboot= kernel command line argument, ignoring: %m");
-                if (r > 0 && !enabled) {
+                        return log_error_errno(r, "Failed to parse systemd.firstboot= kernel command line argument%s: %m",
+                                               bad ? strjoina(" (invalid value '", bad, "')") : "");
+                if (mode == FIRSTBOOT_NO) {
                         log_debug("Found systemd.firstboot=no kernel command line argument, turning off all prompts.");
                         arg_prompt_locale = arg_prompt_keymap = arg_prompt_keymap_auto = arg_prompt_timezone = arg_prompt_hostname = arg_prompt_root_password = arg_prompt_root_shell = false;
+                } else if (mode == FIRSTBOOT_HEADLESS) {
+                        log_debug("Found systemd.firstboot=headless kernel command line argument, skipping interactive prompts but keeping non-interactive auto-configuration.");
+                        arg_headless = true;
                 }
         }
 
index c0dab67989d353545e483bc9ce34e5520399f88e..0da671bca655d45bd673b5f21990522e4d4ff5c8 100644 (file)
@@ -25,6 +25,7 @@
 #include "extract-word.h"
 #include "fd-util.h"
 #include "fileio.h"
+#include "firstboot-util.h"
 #include "format-table.h"
 #include "format-util.h"
 #include "fs-util.h"
@@ -52,7 +53,6 @@
 #include "pkcs11-util.h"
 #include "plymouth-util.h"
 #include "polkit-agent.h"
-#include "proc-cmdline.h"
 #include "process-util.h"
 #include "prompt-util.h"
 #include "recurse-dir.h"
@@ -3120,12 +3120,15 @@ static int verb_firstboot(int argc, char *argv[], uintptr_t _data, void *userdat
         /* Let's honour the systemd.firstboot kernel command line option, just like the systemd-firstboot
          * tool. */
 
-        bool enabled;
-        r = proc_cmdline_get_bool("systemd.firstboot", /* flags= */ 0, &enabled);
+        FirstBootMode mode;
+        _cleanup_free_ char *bad = NULL;
+        r = firstboot_mode_from_cmdline(&mode, &bad);
         if (r < 0)
-                return log_error_errno(r, "Failed to parse systemd.firstboot= kernel command line argument, ignoring: %m");
-        if (r > 0 && !enabled) {
-                log_debug("Found systemd.firstboot=no kernel command line argument, turning off all prompts.");
+                return log_error_errno(r, "Failed to parse systemd.firstboot= kernel command line argument%s: %m",
+                                       bad ? strjoina(" (invalid value '", bad, "')") : "");
+        if (IN_SET(mode, FIRSTBOOT_NO, FIRSTBOOT_HEADLESS)) {
+                log_debug("Found systemd.firstboot=%s kernel command line argument, turning off all prompts.",
+                          firstboot_mode_to_string(mode));
                 arg_prompt_new_user = false;
         }
 
diff --git a/src/shared/firstboot-util.c b/src/shared/firstboot-util.c
new file mode 100644 (file)
index 0000000..c848a4a
--- /dev/null
@@ -0,0 +1,45 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include "alloc-util.h"
+#include "firstboot-util.h"
+#include "proc-cmdline.h"
+#include "string-table.h"
+
+static const char* const firstboot_mode_table[_FIRSTBOOT_MODE_MAX] = {
+        [FIRSTBOOT_NO]          = "no",
+        [FIRSTBOOT_INTERACTIVE] = "interactive",
+        [FIRSTBOOT_HEADLESS]    = "headless",
+};
+
+assert_cc(FIRSTBOOT_NO == 0);
+
+DEFINE_STRING_TABLE_LOOKUP_WITH_BOOLEAN(firstboot_mode, FirstBootMode, FIRSTBOOT_INTERACTIVE);
+
+int firstboot_mode_from_cmdline(FirstBootMode *ret, char **reterr_value) {
+        _cleanup_free_ char *value = NULL;
+        int r;
+
+        assert(ret);
+
+        r = proc_cmdline_get_key("systemd.firstboot", PROC_CMDLINE_VALUE_OPTIONAL, &value);
+        if (r < 0)
+                return r;
+        if (r == 0) { /* not specified at all */
+                *ret = FIRSTBOOT_INTERACTIVE;
+                return 0;
+        }
+        if (!value) { /* key without parameter, i.e. bare "systemd.firstboot" */
+                *ret = FIRSTBOOT_INTERACTIVE;
+                return 1;
+        }
+
+        FirstBootMode m = firstboot_mode_from_string(value);
+        if (m < 0) {
+                if (reterr_value)
+                        *reterr_value = TAKE_PTR(value);
+                return m;
+        }
+
+        *ret = m;
+        return 1;
+}
diff --git a/src/shared/firstboot-util.h b/src/shared/firstboot-util.h
new file mode 100644 (file)
index 0000000..e87da6f
--- /dev/null
@@ -0,0 +1,18 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#pragma once
+
+#include "shared-forward.h"
+
+/* Parsed value of the systemd.firstboot= kernel command line option, honoured by systemd-firstboot,
+ * homectl's firstboot logic and systemd-cryptenroll. */
+typedef enum FirstBootMode {
+        FIRSTBOOT_NO,           /* "no": don't prompt, don't auto-configure */
+        FIRSTBOOT_INTERACTIVE,  /* "interactive" or unset: prompt as needed */
+        FIRSTBOOT_HEADLESS,     /* "headless": auto-configure, but never prompt */
+        _FIRSTBOOT_MODE_MAX,
+        _FIRSTBOOT_MODE_INVALID = -EINVAL,
+} FirstBootMode;
+
+DECLARE_STRING_TABLE_LOOKUP(firstboot_mode, FirstBootMode);
+
+int firstboot_mode_from_cmdline(FirstBootMode *ret, char **reterr_value);
index 7c6505e015131597c8601345d54c1de7211654e6..f9367ac2265f05396536eb5e65bd6b818ba47af8 100644 (file)
@@ -85,6 +85,7 @@ shared_sources = files(
         'fido2-util.c',
         'find-esp.c',
         'firewall-util.c',
+        'firstboot-util.c',
         'fork-notify.c',
         'format-table.c',
         'fsprg-openssl.c',
index 9d5a79c78e58e5018839434caefcb16fe35cf3c7..c90235e078949b5d70f03eabce26f46dfefd1c92 100644 (file)
@@ -108,6 +108,7 @@ simple_tests += files(
         'test-fiemap.c',
         'test-fileio.c',
         'test-firewall-util.c',
+        'test-firstboot-util.c',
         'test-format-table.c',
         'test-format-util.c',
         'test-fs-util.c',
diff --git a/src/test/test-firstboot-util.c b/src/test/test-firstboot-util.c
new file mode 100644 (file)
index 0000000..bb3c303
--- /dev/null
@@ -0,0 +1,26 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include "firstboot-util.h"
+#include "tests.h"
+
+TEST(firstboot_mode_from_string) {
+        assert_se(firstboot_mode_from_string("yes") == FIRSTBOOT_INTERACTIVE);
+        assert_se(firstboot_mode_from_string("1") == FIRSTBOOT_INTERACTIVE);
+        assert_se(firstboot_mode_from_string("on") == FIRSTBOOT_INTERACTIVE);
+        assert_se(firstboot_mode_from_string("true") == FIRSTBOOT_INTERACTIVE);
+        assert_se(firstboot_mode_from_string("interactive") == FIRSTBOOT_INTERACTIVE);
+
+        assert_se(firstboot_mode_from_string("no") == FIRSTBOOT_NO);
+        assert_se(firstboot_mode_from_string("0") == FIRSTBOOT_NO);
+        assert_se(firstboot_mode_from_string("off") == FIRSTBOOT_NO);
+        assert_se(firstboot_mode_from_string("false") == FIRSTBOOT_NO);
+
+        assert_se(firstboot_mode_from_string("headless") == FIRSTBOOT_HEADLESS);
+
+        assert_se(firstboot_mode_from_string("") == _FIRSTBOOT_MODE_INVALID);
+        assert_se(firstboot_mode_from_string(NULL) == _FIRSTBOOT_MODE_INVALID);
+        assert_se(firstboot_mode_from_string("Headless") == _FIRSTBOOT_MODE_INVALID);
+        assert_se(firstboot_mode_from_string("maybe") == _FIRSTBOOT_MODE_INVALID);
+}
+
+DEFINE_TEST_MAIN(LOG_INFO);
index a70e5b342d5bf93088390754aa230e9bae6e62e0..b85ea33acf538c4a89f373a9e54eab15e3e3d9ad 100644 (file)
@@ -12,6 +12,7 @@
 #include "device-private.h"
 #include "discover-image.h"
 #include "execute.h"
+#include "firstboot-util.h"
 #include "gpt.h"
 #include "import-util.h"
 #include "install.h"
@@ -62,6 +63,7 @@ int main(int argc, char **argv) {
         test_table(ExecOutput, exec_output, EXEC_OUTPUT);
         test_table(ExecPreserveMode, exec_preserve_mode, EXEC_PRESERVE_MODE);
         test_table(ExecUtmpMode, exec_utmp_mode, EXEC_UTMP_MODE);
+        test_table(FirstBootMode, firstboot_mode, FIRSTBOOT_MODE);
         test_table(ImageType, image_type, IMAGE_TYPE);
         test_table(ImportVerify, import_verify, IMPORT_VERIFY);
         test_table(JobMode, job_mode, JOB_MODE);