From bbca861320d80dbfe2bf2b3f8e809a6e96f2f5b9 Mon Sep 17 00:00:00 2001 From: Skye Soss Date: Fri, 16 Oct 2026 21:59:10 -0500 Subject: [PATCH] nspawn: add SetCredential= and LoadCredential= settings Add SetCredential= and LoadCredential= to the [Exec] section of the .nspawn settings file. They take the same arguments as the command line switches. LoadCredential= is privileged, but SetCredential= is not. The man page claimed that nspawn propagates the credentials of its service to the container, which only happens when they are passed on explicitly, so say so. Fixes: #38966 --- man/systemd-nspawn.xml | 14 +- man/systemd.nspawn.xml | 37 ++++++ src/nspawn/nspawn-gperf.gperf | 2 + src/nspawn/nspawn-settings.c | 135 ++++++++++++++++++++ src/nspawn/nspawn-settings.h | 5 + src/nspawn/nspawn.c | 78 ++++++++++- src/shared/machine-credential.c | 122 ++++++++++++------ src/shared/machine-credential.h | 4 + test/fuzz/fuzz-nspawn-settings/basic-config | 6 + test/units/TEST-13-NSPAWN.nspawn.sh | 113 ++++++++++++++++ 10 files changed, 474 insertions(+), 42 deletions(-) diff --git a/man/systemd-nspawn.xml b/man/systemd-nspawn.xml index c75afe7cae4..0ce2293b640 100644 --- a/man/systemd-nspawn.xml +++ b/man/systemd-nspawn.xml @@ -1973,10 +1973,16 @@ After=sys-subsystem-net-devices-ens1.device Note: when systemd-nspawn runs as systemd system service it can propagate the credentials it received via LoadCredential=/SetCredential= - to the container payload. A systemd service manager running as PID 1 in the container can further - propagate them to the services it itself starts. It is thus possible to easily propagate credentials - from a parent service manager to a container manager service and from there into its payload. This - can even be done recursively. + to the container payload, if they are passed on with + , + i.e. if PATH is the name of a credential rather than a path. A systemd + service manager running as PID 1 in the container can further propagate them to the services it + itself starts. It is thus possible to easily propagate credentials from a parent service manager to a + container manager service and from there into its payload. This can even be done recursively. For + systemd-nspawn@.service, credentials may also be passed with the + SetCredential= and LoadCredential= settings in + systemd.nspawn5 + files. In order to embed binary data into the credential data for , use C-style escaping (i.e. \n to embed a newline, or \x00 to diff --git a/man/systemd.nspawn.xml b/man/systemd.nspawn.xml index 97e854d23a5..202afe7ef4c 100644 --- a/man/systemd.nspawn.xml +++ b/man/systemd.nspawn.xml @@ -352,6 +352,43 @@ + + SetCredential= + LoadCredential= + + Pass a credential to the container. These settings are equivalent to the + and command line switches, + and take ID:VALUE and + ID:PATH respectively, where + PATH is either an absolute path or the name of a credential passed to + systemd-nspawn itself. Each may be specified more than once to pass multiple + credentials. If the same credential ID is assigned more than once by the same setting, the last + assignment takes effect. If the empty string is assigned, the list of credentials set by the + respective setting so far is reset. Credentials from the settings file and from the command line are + not combined: if any credential is passed on the command line, these settings are ignored, unless + is used, in which case the credentials from the settings file + replace those from the command line. See + systemd-nspawn1 for + details. + + As with the settings of the same names in unit files (see + systemd.exec5), + if the same credential ID is specified with both settings, LoadCredential= takes + priority, and SetCredential= acts as a fallback if the file does not exist. A + credential that is to be inherited from systemd-nspawn itself, i.e. if + PATH is a credential name, is skipped if it was not passed. In both of + these cases, a missing credential is not considered fatal. + + LoadCredential= is privileged (see above), as it reads files on the host. + For example, the credentials of systemd-nspawn@.service may be propagated to + the container with LoadCredential=ID:ID + in a settings file in /etc/systemd/nspawn/. Do not use + SetCredential= for secret data, as settings files are usually readable by + unprivileged users. Use LoadCredential= instead. + + + + LimitCPU= LimitFSIZE= diff --git a/src/nspawn/nspawn-gperf.gperf b/src/nspawn/nspawn-gperf.gperf index 439e176e458..c3be58c3037 100644 --- a/src/nspawn/nspawn-gperf.gperf +++ b/src/nspawn/nspawn-gperf.gperf @@ -62,6 +62,8 @@ Exec.LinkJournal, config_parse_link_journal, 0, Exec.Timezone, config_parse_timezone_mode, 0, offsetof(Settings, timezone) Exec.SuppressSync, config_parse_tristate, 0, offsetof(Settings, suppress_sync) Exec.RestrictAddressFamilies, config_parse_restrict_address_families, 0, 0 +Exec.SetCredential, config_parse_set_credential, 0, 0 +Exec.LoadCredential, config_parse_load_credential, 0, 0 Files.ReadOnly, config_parse_tristate, 0, offsetof(Settings, read_only) Files.Volatile, config_parse_volatile_mode, 0, offsetof(Settings, volatile_mode) Files.Bind, config_parse_bind, 0, 0 diff --git a/src/nspawn/nspawn-settings.c b/src/nspawn/nspawn-settings.c index 969516037e0..63d879ccf32 100644 --- a/src/nspawn/nspawn-settings.c +++ b/src/nspawn/nspawn-settings.c @@ -6,7 +6,10 @@ #include "capability-list.h" #include "conf-parser.h" #include "cpu-set-util.h" +#include "creds-util.h" +#include "escape.h" #include "extract-word.h" +#include "memory-util.h" #include "namespace-util.h" #include "nspawn-expose-ports.h" #include "nspawn-mount.h" @@ -14,6 +17,7 @@ #include "nspawn-settings.h" #include "parse-helpers.h" #include "parse-util.h" +#include "path-util.h" #include "process-util.h" #include "rlimit-util.h" #include "set.h" @@ -142,6 +146,8 @@ Settings* settings_free(Settings *s) { free(s->hostname); cpu_set_done(&s->cpu_set); set_free(s->restrict_address_families); + machine_credential_context_done(&s->credentials); + strv_free(s->load_credentials); strv_free(s->bind_user); free(s->bind_user_shell); @@ -1093,3 +1099,132 @@ int config_parse_restrict_address_families( return 0; } + +int config_parse_set_credential( + const char *unit, + const char *filename, + unsigned line, + const char *section, + unsigned section_line, + const char *lvalue, + int ltype, + const char *rvalue, + void *data, + void *userdata) { + + Settings *settings = ASSERT_PTR(data); + _cleanup_(erase_and_freep) char *value = NULL; + _cleanup_free_ char *id = NULL; + const char *p; + ssize_t l; + int r; + + assert(rvalue); + + /* Don't log the assignment or the data on failure, they might be secret. */ + + if (isempty(rvalue)) { + machine_credential_context_done(&settings->credentials); + settings->credentials = (MachineCredentialContext) {}; + return 0; + } + + r = machine_credential_split(rvalue, &id, &p); + if (r == -ENOMEM) + return log_oom(); + if (r < 0) { + log_syntax(unit, LOG_WARNING, filename, line, r, "Invalid credential assignment, ignoring."); + return 0; + } + + if (!credential_name_valid(id)) { + log_syntax(unit, LOG_WARNING, filename, line, 0, "Credential name is not valid, ignoring: %s", id); + return 0; + } + + l = cunescape(p, UNESCAPE_ACCEPT_NUL, &value); + if (l == -ENOMEM) + return log_oom(); + if (l < 0) { + log_syntax(unit, LOG_WARNING, filename, line, l, "Failed to unescape data of credential '%s', ignoring: %m", id); + return 0; + } + + /* As in unit files, a later assignment overrides an earlier one for the same credential. */ + MachineCredential *cred = machine_credential_find(&settings->credentials, id); + if (cred) { + erase_and_free(cred->data); + cred->data = TAKE_PTR(value); + cred->size = l; + return 0; + } + + r = machine_credential_add(&settings->credentials, id, value, l); + if (r < 0) + return log_oom(); + + return 0; +} + +int config_parse_load_credential( + const char *unit, + const char *filename, + unsigned line, + const char *section, + unsigned section_line, + const char *lvalue, + int ltype, + const char *rvalue, + void *data, + void *userdata) { + + Settings *settings = ASSERT_PTR(data); + _cleanup_free_ char *id = NULL, *source = NULL; + const char *p; + int r; + + assert(rvalue); + + /* The credentials are only loaded once we know that the settings file is trusted, hence only + * validate the syntax for now. */ + + if (isempty(rvalue)) { + settings->load_credentials = strv_free(settings->load_credentials); + return 0; + } + + r = machine_credential_split(rvalue, &id, &p); + if (r == -ENOMEM) + return log_oom(); + if (r < 0) { + log_syntax(unit, LOG_WARNING, filename, line, r, "Invalid credential assignment, ignoring: %s", rvalue); + return 0; + } + + if (!credential_name_valid(id)) { + log_syntax(unit, LOG_WARNING, filename, line, 0, "Credential name is not valid, ignoring: %s", id); + return 0; + } + + if (path_is_absolute(p) ? !path_is_normalized(p) : !credential_name_valid(p)) { + log_syntax(unit, LOG_WARNING, filename, line, 0, + "Credential source is neither a normalized absolute path nor a credential name, ignoring: %s", p); + return 0; + } + + source = strdup(p); + if (!source) + return log_oom(); + + /* As in unit files, a later assignment overrides an earlier one for the same credential. */ + STRV_FOREACH_PAIR(i, s, settings->load_credentials) + if (streq(*i, id)) { + free_and_replace(*s, source); + return 0; + } + + if (strv_consume_pair(&settings->load_credentials, TAKE_PTR(id), TAKE_PTR(source)) < 0) + return log_oom(); + + return 0; +} diff --git a/src/nspawn/nspawn-settings.h b/src/nspawn/nspawn-settings.h index f157121de94..8ea26a3127f 100644 --- a/src/nspawn/nspawn-settings.h +++ b/src/nspawn/nspawn-settings.h @@ -7,6 +7,7 @@ #include "conf-parser-forward.h" #include "cpu-set-util.h" #include "forward.h" +#include "machine-credential.h" #include "rlimit-util.h" #include "seccomp-util.h" #include "volatile-util.h" @@ -195,6 +196,8 @@ typedef struct Settings { int suppress_sync; Set *restrict_address_families; bool restrict_address_families_is_allowlist; + MachineCredentialContext credentials; + char **load_credentials; /* pairs of credential ID and source */ /* [Files] */ int read_only; @@ -283,6 +286,8 @@ CONFIG_PARSER_PROTOTYPE(config_parse_userns_ownership); CONFIG_PARSER_PROTOTYPE(config_parse_bind_user); CONFIG_PARSER_PROTOTYPE(config_parse_bind_user_shell); CONFIG_PARSER_PROTOTYPE(config_parse_restrict_address_families); +CONFIG_PARSER_PROTOTYPE(config_parse_set_credential); +CONFIG_PARSER_PROTOTYPE(config_parse_load_credential); DECLARE_STRING_TABLE_LOOKUP(resolv_conf_mode, ResolvConfMode); diff --git a/src/nspawn/nspawn.c b/src/nspawn/nspawn.c index 4b52c799ed1..2613008cfe2 100644 --- a/src/nspawn/nspawn.c +++ b/src/nspawn/nspawn.c @@ -65,6 +65,7 @@ #include "image-policy.h" #include "in-addr-util.h" #include "io-util.h" +#include "iovec-util.h" #include "libmount-util.h" #include "log.h" #include "loop-util.h" @@ -4754,6 +4755,73 @@ static int ptyfwd_hotkey(PTYForward *f, char c, void *userdata) { return 0; } +static int merge_settings_credentials(Settings *settings, const char *path) { + _cleanup_(machine_credential_context_done) MachineCredentialContext credentials = {}; + int r; + + assert(settings); + assert(path); + + if (settings->credentials.n_credentials == 0 && strv_isempty(settings->load_credentials)) + return 0; + + /* Only allow LoadCredential= in trusted files */ + char **load_credentials = settings->load_credentials; + if (!arg_settings_trusted && !strv_isempty(load_credentials)) { + log_warning("Ignoring LoadCredential= settings, file %s is not trusted.", path); + load_credentials = NULL; + } + + STRV_FOREACH_PAIR(id, source, load_credentials) { + _cleanup_(iovec_done_erase) struct iovec iov = {}; + + r = machine_credential_read(*source, &iov); + if (r < 0) { + /* As in unit files, a missing credential is not fatal if it would have been + * inherited from our own credentials, or if SetCredential= provides a fallback for + * it. The source is either an absolute path or a credential name. */ + bool inherited = !path_is_absolute(*source); + bool fallback = machine_credential_find(&settings->credentials, *id) != NULL; + bool missing = r == -ENOENT || (inherited && r == -ENXIO); + + if (missing && inherited) { + log_full(fallback ? LOG_DEBUG : LOG_INFO, + "Credential '%s' was not passed to systemd-nspawn, %s credential '%s'.", + *source, fallback ? "using SetCredential= fallback for" : "skipping", *id); + continue; + } + if (missing && fallback) { + log_debug_errno(r, "Couldn't read credential '%s' from '%s', using SetCredential= fallback: %m", + *id, *source); + continue; + } + + return log_error_errno(r, "Failed to read credential '%s' from '%s': %m", *id, *source); + } + + r = machine_credential_add(&credentials, *id, iov.iov_base, iov.iov_len); + if (r < 0) + return log_error_errno(r, "Failed to add credential '%s': %m", *id); + } + + /* LoadCredential= takes precedence, SetCredential= only acts as fallback. */ + FOREACH_ARRAY(cred, settings->credentials.credentials, settings->credentials.n_credentials) { + if (machine_credential_find(&credentials, cred->id)) + continue; + + r = machine_credential_add(&credentials, cred->id, cred->data, cred->size); + if (r < 0) + return log_error_errno(r, "Failed to add credential '%s': %m", cred->id); + } + + if (credentials.n_credentials > 0 || !strv_isempty(load_credentials)) { + machine_credential_context_done(&arg_credentials); + arg_credentials = TAKE_STRUCT(credentials); + } + + return 0; +} + static int merge_settings(Settings *settings, const char *path) { int rl; @@ -5099,6 +5167,14 @@ static int merge_settings(Settings *settings, const char *path) { arg_restrict_address_families_is_allowlist = settings->restrict_address_families_is_allowlist; } + if (!FLAGS_SET(arg_settings_mask, SETTING_CREDENTIALS)) { + int r; + + r = merge_settings_credentials(settings, path); + if (r < 0) + return r; + } + /* The following properties can only be set through the OCI settings logic, not from the command line, hence we * don't consult arg_settings_mask for them. */ @@ -6735,7 +6811,7 @@ static int run(int argc, char *argv[]) { r = machine_credential_add(&arg_credentials, "journal.forward_to_socket", NSPAWN_JOURNAL_SOCKET_PATH, SIZE_MAX); if (r == -EEXIST) { - log_error_errno(r, "Credential 'journal.forward_to_socket' already set via --set-credential=, refusing --forward-journal=."); + log_error_errno(r, "Credential 'journal.forward_to_socket' already set via --set-credential= or SetCredential=, refusing --forward-journal=."); goto finish; } if (r < 0) { diff --git a/src/shared/machine-credential.c b/src/shared/machine-credential.c index ace10b43dba..37880cc1f30 100644 --- a/src/shared/machine-credential.c +++ b/src/shared/machine-credential.c @@ -102,71 +102,119 @@ static int machine_credential_add_and_log( return 0; } +int machine_credential_split(const char *s, char **ret_id, const char **ret_value) { + _cleanup_free_ char *id = NULL; + const char *p = ASSERT_PTR(s); + int r; + + assert(ret_id); + assert(ret_value); + + /* Splits "ID:VALUE" at the first ':'. Doesn't validate the ID, so that callers can log it. Returns + * -EBADMSG if there's no separator. */ + + r = extract_first_word(&p, &id, ":", EXTRACT_DONT_COALESCE_SEPARATORS); + if (r < 0) + return r; + if (r == 0 || !p) + return -EBADMSG; + + *ret_id = TAKE_PTR(id); + *ret_value = p; + return 0; +} + +static bool machine_credential_source_valid(const char *source) { + assert(source); + + return (is_path(source) && path_is_valid(source)) || credential_name_valid(source); +} + +int machine_credential_read(const char *source, struct iovec *ret) { + ReadFullFileFlags flags = READ_FULL_FILE_SECURE; + _cleanup_free_ char *path_alloc = NULL; + int r; + + assert(source); + assert(ret); + + /* Reads a credential from a path (which may refer to an AF_UNIX socket), or from a credential passed + * to us, if the source is a credential name. Returns -ENXIO in the latter case if we didn't receive + * any credentials. Doesn't log. */ + + if (is_path(source) && path_is_valid(source)) + flags |= READ_FULL_FILE_CONNECT_SOCKET; + else if (credential_name_valid(source)) { + const char *e; + + r = get_credentials_dir(&e); + if (r < 0) + return r; + + path_alloc = path_join(e, source); + if (!path_alloc) + return -ENOMEM; + + source = path_alloc; + } else + return -EINVAL; + + return read_full_file_full( + AT_FDCWD, source, + /* offset= */ UINT64_MAX, + /* size= */ SIZE_MAX, + flags, + /* bind_name= */ NULL, + (char**) &ret->iov_base, &ret->iov_len); +} int machine_credential_set(MachineCredentialContext *ctx, const char *cred_str) { + _cleanup_free_ char *id = NULL; + const char *p; int r; assert(ctx); + assert(cred_str); - const char *p = ASSERT_PTR(cred_str); - _cleanup_free_ char *id = NULL; - r = extract_first_word(&p, &id, ":", EXTRACT_DONT_COALESCE_SEPARATORS); + /* Don't log the argument or the data on failure, they might be secret. */ + + r = machine_credential_split(cred_str, &id, &p); + if (r == -EBADMSG) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Missing ':' separator in --set-credential= argument."); if (r < 0) return log_error_errno(r, "Failed to parse --set-credential= parameter: %m"); - if (r == 0 || !p) - return log_error_errno(SYNTHETIC_ERRNO(EINVAL), - "Missing value for --set-credential=: %s", cred_str); - _cleanup_free_ char *data = NULL; + _cleanup_(erase_and_freep) char *data = NULL; ssize_t l; l = cunescape(p, UNESCAPE_ACCEPT_NUL, &data); if (l < 0) - return log_error_errno(l, "Failed to unescape credential data: %s", p); + return log_error_errno(l, "Failed to unescape data of credential '%s': %m", id); return machine_credential_add_and_log(ctx, id, data, l); } int machine_credential_load(MachineCredentialContext *ctx, const char *cred_path) { + _cleanup_free_ char *id = NULL; + const char *p; int r; assert(ctx); + assert(cred_path); - const char *p = ASSERT_PTR(cred_path); - _cleanup_free_ char *id = NULL; - r = extract_first_word(&p, &id, ":", EXTRACT_DONT_COALESCE_SEPARATORS); + r = machine_credential_split(cred_path, &id, &p); + if (r == -EBADMSG) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Missing value for --load-credential=: %s", cred_path); if (r < 0) return log_error_errno(r, "Failed to parse --load-credential= parameter: %m"); - if (r == 0 || !p) - return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Missing value for --load-credential=: %s", cred_path); - ReadFullFileFlags flags = READ_FULL_FILE_SECURE; - _cleanup_free_ char *path_alloc = NULL; - if (is_path(p) && path_is_valid(p)) - flags |= READ_FULL_FILE_CONNECT_SOCKET; - else if (credential_name_valid(p)) { - const char *e; - - r = get_credentials_dir(&e); - if (r < 0) - return log_error_errno(r, "Credential not available (no credentials passed at all): %s", p); - - path_alloc = path_join(e, p); - if (!path_alloc) - return log_oom(); - - p = path_alloc; - } else + if (!machine_credential_source_valid(p)) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Credential source appears to be neither a valid path nor a credential name: %s", p); _cleanup_(iovec_done_erase) struct iovec iov = {}; - r = read_full_file_full( - AT_FDCWD, p, - /* offset= */ UINT64_MAX, - /* size= */ SIZE_MAX, - flags, - /* bind_name= */ NULL, - (char**) &iov.iov_base, &iov.iov_len); + r = machine_credential_read(p, &iov); + if (r == -ENXIO && !is_path(p)) + return log_error_errno(r, "Credential not available (no credentials passed at all): %s", p); if (r < 0) return log_error_errno(r, "Failed to read credential '%s': %m", p); diff --git a/src/shared/machine-credential.h b/src/shared/machine-credential.h index 244ebd6192a..32cd5db7edb 100644 --- a/src/shared/machine-credential.h +++ b/src/shared/machine-credential.h @@ -19,5 +19,9 @@ void machine_credential_context_done(MachineCredentialContext *ctx); MachineCredential* machine_credential_find(MachineCredentialContext *ctx, const char *id); int machine_credential_add(MachineCredentialContext *ctx, const char *id, const char *value, size_t size); + +int machine_credential_split(const char *s, char **ret_id, const char **ret_value); +int machine_credential_read(const char *source, struct iovec *ret); + int machine_credential_set(MachineCredentialContext *ctx, const char *cred_str); int machine_credential_load(MachineCredentialContext *ctx, const char *cred_path); diff --git a/test/fuzz/fuzz-nspawn-settings/basic-config b/test/fuzz/fuzz-nspawn-settings/basic-config index abc8b20d3dc..2b1103f8afb 100644 --- a/test/fuzz/fuzz-nspawn-settings/basic-config +++ b/test/fuzz/fuzz-nspawn-settings/basic-config @@ -4,6 +4,12 @@ ProcessTwo=off Ephemeral=yes Parameters=/sbin/init -x=1 Environment=THIS=that +SetCredential=firstboot.locale:C.UTF-8 +SetCredential=binary:a\x00b +LoadCredential=passwd.hashed-password.root:/etc/credstore/root-password +LoadCredential=from-service:from-service +SetCredential=firstboot.locale:de_DE.UTF-8 +LoadCredential=from-service:/etc/credstore/from-service User=user WorkingDirectory=/cwd PivotRoot=/newroot diff --git a/test/units/TEST-13-NSPAWN.nspawn.sh b/test/units/TEST-13-NSPAWN.nspawn.sh index 6e6b8028b71..36c1b6c70d8 100755 --- a/test/units/TEST-13-NSPAWN.nspawn.sh +++ b/test/units/TEST-13-NSPAWN.nspawn.sh @@ -640,6 +640,119 @@ EOF rm -fr "$root" "/run/systemd/nspawn/$container.nspawn" } +testcase_nspawn_settings_credentials() { + local root container + + mkdir -p /run/systemd/nspawn + root="$(mktemp -d /var/lib/machines/TEST-13-NSPAWN.nspawn-settings-credentials.XXX)" + container="$(basename "$root")" + create_dummy_container "$root" + rm -f "/etc/systemd/nspawn/$container.nspawn" + echo "foo bar" >/tmp/cred.path + rm -f /tmp/cred.missing + + # Settings files in /run/systemd/nspawn/ are trusted, hence may load credentials from the host + cat >"/run/systemd/nspawn/$container.nspawn" <"$root/creds.sh" <<\EOF +#!/usr/bin/env bash +set -ex + +[[ "$CREDENTIALS_DIRECTORY" == /run/host/credentials ]] +[[ "$("/run/systemd/nspawn/$container.nspawn" <