]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
creds,firstboot: add support for ? and $ via credentials
authorMichael Vogt <michael@amutable.com>
Fri, 12 Jun 2026 10:43:15 +0000 (12:43 +0200)
committerMichael Vogt <michael@amutable.com>
Sat, 20 Jun 2026 12:26:41 +0000 (14:26 +0200)
Now that we support the `$` we want to also make this available
inside the system.hostname and firstboot.hostname credentials and
the firstboot --hostname option. This commit adds it (and also `?`).

man/hostname.xml
man/systemd.system-credentials.xml
src/firstboot/firstboot.c
src/shared/hostname-setup.c
test/units/TEST-74-AUX-UTILS.firstboot.sh

index 04d6769411ab72c2ea85872d0a67b561a10e656c..708581c841df9d5658582d64ecbae7f288b820a8 100644 (file)
     <literal>?</literal> characters and separators) stays within this limit. An expanded name that exceeds
     the limit is considered invalid and the built-in fallback hostname is used.</para>
 
+    <para>Because the name only stays stable as long as the pattern and word lists are unchanged, the
+    preferred way to obtain a stable, generated hostname is to provide the pattern through the
+    <varname>firstboot.hostname</varname> credential (see
+    <citerefentry><refentrytitle>systemd.system-credentials</refentrytitle><manvolnum>7</manvolnum></citerefentry>)
+    rather than placing it directly in this file. Concretely, write the pattern to
+    <filename>/etc/credstore/firstboot.hostname</filename> (see the description of
+    <varname>LoadCredential=</varname> in
+    <citerefentry><refentrytitle>systemd.exec</refentrytitle><manvolnum>5</manvolnum></citerefentry> for the
+    credential store): when firstboot runs on the live system the credential is resolved once and the
+    concrete result is persisted here, so the word lists may be updated afterwards without changing the
+    hostname. A pattern placed directly in <filename>/etc/hostname</filename> (or provisioned into an offline
+    image) is instead re-evaluated on every boot, which only really makes sense for systems that keep the
+    wordlist and the pattern stable.</para>
+
     <xi:include href="version-info.xml" xpointer="v262"/>
 
     <para>You may use
index f316961cd728ed7afc95cb63c4d0937f979af2fe..eba06bc9ca26be7ffa98d0eeec80092e936993af 100644 (file)
         hostname, and only has an effect on first boot, unlike <varname>system.hostname</varname> (see
         below). Read by
         <citerefentry><refentrytitle>systemd-firstboot</refentrytitle><manvolnum>1</manvolnum></citerefentry>
-        and only honoured if no static hostname has been configured before.</para>
+        and only honoured if no static hostname has been configured before. The value may use the wildcard
+        patterns documented in
+        <citerefentry><refentrytitle>hostname</refentrytitle><manvolnum>5</manvolnum></citerefentry> (e.g.
+        <literal>$-$</literal> or <literal>foo-????</literal>). When the credential is applied on the running
+        system (the usual case, by <filename>systemd-firstboot.service</filename> on first boot), the wildcards
+        are resolved against the machine ID and the resulting concrete name is written to
+        <filename>/etc/hostname</filename>. The name is thus <emphasis>persisted</emphasis> once at first boot and
+        does not change on subsequent boots even if the word lists are later updated or the pattern is changed
+        (for example to add more <literal>?</literal>/<literal>$</literal> tokens for additional entropy), which is the recommended
+        way to obtain a stable, per-machine generated hostname. (When
+        <citerefentry><refentrytitle>systemd-firstboot</refentrytitle><manvolnum>1</manvolnum></citerefentry>
+        operates on an offline image via <option>--root=</option>/<option>--image=</option>, the target's
+        machine ID is not yet known, so the pattern is written verbatim and, like a pattern placed directly in
+        <filename>/etc/hostname</filename>, re-derived on every boot rather than persisted.)</para>
 
         <xi:include href="version-info.xml" xpointer="v261"/>
         </listitem>
           in <filename>/etc/hostname</filename>, if configured, takes precedence over this setting.
           Interpreted by the service manager (PID 1). For details see
           <citerefentry><refentrytitle>systemd</refentrytitle><manvolnum>1</manvolnum></citerefentry>. Also
-          see <varname>firstboot.hostname</varname> above.</para>
+          see <varname>firstboot.hostname</varname> above. The value may use the wildcard patterns documented
+          in <citerefentry><refentrytitle>hostname</refentrytitle><manvolnum>5</manvolnum></citerefentry>
+          (<literal>?</literal> and <literal>$</literal>), which are expanded deterministically from the
+          machine ID when the credential is applied. As this is a transient hostname that is re-applied on
+          every boot, a wildcard value is re-derived each boot (and may change if the word lists change) and
+          is never persisted; pass an already-resolved name if a stable hostname is required. Note also that
+          the word-list tokens require the word lists to be present, which is generally not the case in the
+          initrd so during initrd the default hostname is used and in late boot the resolved one becomes
+          available.</para>
 
           <xi:include href="version-info.xml" xpointer="v254"/>
         </listitem>
index e922abdd63cd3a1e4c16f64bfe0a878dc86761f8..2200235e1d1cc5835a56fd003271340d06d182a2 100644 (file)
@@ -28,6 +28,7 @@
 #include "fs-util.h"
 #include "glyph-util.h"
 #include "help-util.h"
+#include "hostname-setup.h"
 #include "hostname-util.h"
 #include "image-policy.h"
 #include "kbd-util.h"
@@ -676,7 +677,7 @@ static int prompt_hostname(int rfd, sd_varlink **mute_console_link) {
         r = read_credential("firstboot.hostname", (void**) &hn, NULL);
         if (r < 0)
                 log_debug_errno(r, "Failed to read credential firstboot.hostname, ignoring: %m");
-        else if (!hostname_is_valid(hn, VALID_HOSTNAME_TRAILING_DOT|VALID_HOSTNAME_QUESTION_MARK))
+        else if (!hostname_is_valid(hn, VALID_HOSTNAME_TRAILING_DOT|VALID_HOSTNAME_QUESTION_MARK|VALID_HOSTNAME_WORD_TOKEN))
                 log_warning_errno(SYNTHETIC_ERRNO(EINVAL), "Hostname '%s' supplied via credential is not valid, ignoring.", hn);
         else {
                 log_debug("Acquired hostname from credentials.");
@@ -738,7 +739,23 @@ static int process_hostname(int rfd, sd_varlink **mute_console_link) {
         if (isempty(arg_hostname))
                 return 0;
 
-        r = write_string_file_at(pfd, f, arg_hostname,
+        /* On running systems we have a machine ID, so resolve any '?'/'$' wildcards now and persist them.
+         * This "freezes" the name, so later word list updates do not change it. When operating on an offline
+         * image (--root=/--image=) the target's machine ID is not known yet, so write the template verbatim
+         * and let it be resolved on each first boot. */
+        const char *hostname = arg_hostname;
+        _cleanup_free_ char *resolved = NULL;
+        if (!arg_root) {
+                r = hostname_substitute_wildcards(arg_hostname, &resolved);
+                if (r < 0)
+                        log_warning_errno(r, "Failed to resolve wildcards in hostname '%s', writing it verbatim: %m", arg_hostname);
+                else if (!hostname_is_valid(resolved, VALID_HOSTNAME_TRAILING_DOT))
+                        log_warning("Resolved hostname '%s' is invalid, writing template '%s' verbatim instead.", resolved, arg_hostname);
+                else
+                        hostname = resolved;
+        }
+
+        r = write_string_file_at(pfd, f, hostname,
                                  WRITE_STRING_FILE_CREATE|WRITE_STRING_FILE_SYNC|WRITE_STRING_FILE_ATOMIC|WRITE_STRING_FILE_LABEL);
         if (r < 0)
                 return log_error_errno(r, "Failed to write /etc/hostname: %m");
@@ -1407,7 +1424,7 @@ static int parse_argv(int argc, char *argv[]) {
                         break;
 
                 OPTION_LONG("hostname", "NAME", "Set hostname"):
-                        if (!hostname_is_valid(opts.arg, VALID_HOSTNAME_TRAILING_DOT|VALID_HOSTNAME_QUESTION_MARK))
+                        if (!hostname_is_valid(opts.arg, VALID_HOSTNAME_TRAILING_DOT|VALID_HOSTNAME_QUESTION_MARK|VALID_HOSTNAME_WORD_TOKEN))
                                 return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
                                                        "Host name %s is not valid.", opts.arg);
 
index 4a1850ae2897231c95a652da168f9d94251a7013..63464f26cfc664b2b03b4adb416673bfcb0f41d6 100644 (file)
@@ -96,11 +96,19 @@ static int acquire_hostname_from_credential(char **ret) {
         if (r == 0) /* not found */
                 return -ENXIO;
 
-        if (!hostname_is_valid(cred, VALID_HOSTNAME_TRAILING_DOT)) /* check that the hostname we return is valid */
+        if (!hostname_is_valid(cred, VALID_HOSTNAME_TRAILING_DOT|VALID_HOSTNAME_QUESTION_MARK|VALID_HOSTNAME_WORD_TOKEN))
                 return log_warning_errno(SYNTHETIC_ERRNO(EBADMSG), "Hostname specified in system.hostname credential is invalid, ignoring: %s", cred);
 
+        _cleanup_free_ char *substituted = NULL;
+        r = hostname_substitute_wildcards(cred, &substituted);
+        if (r < 0)
+                return log_warning_errno(r, "Failed to substitute wildcards in system.hostname credential, ignoring: %m");
+
+        if (!hostname_is_valid(substituted, VALID_HOSTNAME_TRAILING_DOT)) /* check that the expanded hostname is valid */
+                return log_warning_errno(SYNTHETIC_ERRNO(EBADMSG), "Hostname from system.hostname credential is invalid after expansion, ignoring: %s", substituted);
+
         log_info("Initializing hostname from credential.");
-        *ret = TAKE_PTR(cred);
+        *ret = TAKE_PTR(substituted);
         return 0;
 }
 
index 557bd3af0120447dd9dbfe9f66cd55308be0598a..8c3c222c884d727cc1dc49f8e07415d1ee1a032f 100755 (executable)
@@ -11,6 +11,14 @@ if ! command -v systemd-firstboot >/dev/null; then
     exit 0
 fi
 
+restore_etc_hostname() {
+    if [[ -e /tmp/etc-hostname.bak ]]; then
+        mv /tmp/etc-hostname.bak /etc/hostname
+    elif [[ -e /tmp/etc-hostname.absent ]]; then
+        rm -f /etc/hostname /tmp/etc-hostname.absent
+    fi
+}
+
 at_exit() {
     if [[ -n "${ROOT:-}" ]]; then
         ls -lR "$ROOT"
@@ -22,6 +30,11 @@ at_exit() {
         rm -rf /etc/otherpath
     fi
 
+    [[ -n "${WROOT:-}" ]] && rm -rf "$WROOT"
+    [[ -n "${WCREDS:-}" ]] && rm -rf "$WCREDS"
+    [[ -n "${WWORDS:-}" ]] && rm -rf "$WWORDS"
+    restore_etc_hostname
+
     restore_locale
 }
 
@@ -82,6 +95,57 @@ readlink "$ROOT/etc/localtime" | grep "Europe/Berlin" >/dev/null
 systemd-firstboot --root="$ROOT" --hostname "foobar"
 grep -q "foobar" "$ROOT/etc/hostname"
 
+# Hostname wildcard templates (see hostname(5)) must be accepted by both the --hostname option and the
+# firstboot.hostname credential (both go through hostname_is_valid() with the wildcard flags). Since these
+# invocations operate on an offline image (--root=), the template is written verbatim and only resolved
+# later, on the target's first boot (the resolve-and-freeze path is taken only when running on the live
+# system, where the machine ID is final).
+WROOT=test-root-hostname-wildcard
+
+# '$' word token via --hostname, stored as-is
+rm -rf "$WROOT"; mkdir -p "$WROOT"
+systemd-firstboot --root="$WROOT" --hostname='$-$'
+assert_eq "$(cat "$WROOT/etc/hostname")" '$-$'
+
+# '?' hex token via --hostname, stored as-is
+rm -rf "$WROOT"; mkdir -p "$WROOT"
+systemd-firstboot --root="$WROOT" --hostname='foo-????'
+assert_eq "$(cat "$WROOT/etc/hostname")" 'foo-????'
+
+# the firstboot.hostname credential is accepted and written verbatim too
+rm -rf "$WROOT"; mkdir -p "$WROOT"
+WCREDS="$(mktemp -d)"
+echo -n '$-$-????' >"$WCREDS/firstboot.hostname"
+CREDENTIALS_DIRECTORY="$WCREDS" systemd-firstboot --root="$WROOT"
+assert_eq "$(cat "$WROOT/etc/hostname")" '$-$-????'
+rm -rf "$WCREDS"
+
+# an invalid hostname (disallowed character) is refused and nothing is written
+rm -rf "$WROOT"; mkdir -p "$WROOT"
+(! systemd-firstboot --root="$WROOT" --hostname='foo_bar')
+[[ ! -e "$WROOT/etc/hostname" ]]
+
+rm -rf "$WROOT"
+
+# When run on the live system (no --root=) the machine ID is final, so the wildcards are resolved
+# immediately and the concrete result is persisted, "freezing" the generated name (see hostname(5)).
+if [[ -f /etc/hostname ]]; then
+    cp /etc/hostname /tmp/etc-hostname.bak
+else
+    touch /tmp/etc-hostname.absent
+fi
+WWORDS="$(mktemp -d)"
+printf 'wildly\nquietly\n' >"$WWORDS/1"
+printf 'happy\nsad\n'      >"$WWORDS/2"
+SYSTEMD_HOSTNAME_WORDLIST_PATH="$WWORDS" systemd-firstboot --force --hostname='$-$-????'
+H="$(cat /etc/hostname)"
+IFS='-' read -r w1 w2 suffix <<<"$H"
+grep -Fx -- "$w1" "$WWORDS/1" >/dev/null
+grep -Fx -- "$w2" "$WWORDS/2" >/dev/null
+[[ "$suffix" =~ ^[0-9a-f]{4}$ ]]
+rm -rf "$WWORDS"
+restore_etc_hostname
+
 systemd-firstboot --root="$ROOT" --machine-id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
 grep -q "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$ROOT/etc/machine-id"