| /* SPDX-License-Identifier: LGPL-2.1-or-later */ |
| |
| #include "chase-symlinks.h" |
| #include "fd-util.h" |
| #include "fileio.h" |
| #include "format-util.h" |
| #include "nspawn-bind-user.h" |
| #include "nspawn.h" |
| #include "path-util.h" |
| #include "user-util.h" |
| #include "userdb.h" |
| |
| static int check_etc_passwd_collisions( |
| const char *directory, |
| const char *name, |
| uid_t uid) { |
| |
| _cleanup_fclose_ FILE *f = NULL; |
| int r; |
| |
| assert(directory); |
| assert(name || uid_is_valid(uid)); |
| |
| r = chase_symlinks_and_fopen_unlocked("/etc/passwd", directory, CHASE_PREFIX_ROOT, "re", NULL, &f); |
| if (r == -ENOENT) |
| return 0; /* no user database? then no user, hence no collision */ |
| if (r < 0) |
| return log_error_errno(r, "Failed to open /etc/passwd of container: %m"); |
| |
| for (;;) { |
| struct passwd *pw; |
| |
| r = fgetpwent_sane(f, &pw); |
| if (r < 0) |
| return log_error_errno(r, "Failed to iterate through /etc/passwd of container: %m"); |
| if (r == 0) /* EOF */ |
| return 0; /* no collision */ |
| |
| if (name && streq_ptr(pw->pw_name, name)) |
| return 1; /* name collision */ |
| if (uid_is_valid(uid) && pw->pw_uid == uid) |
| return 1; /* UID collision */ |
| } |
| } |
| |
| static int check_etc_group_collisions( |
| const char *directory, |
| const char *name, |
| gid_t gid) { |
| |
| _cleanup_fclose_ FILE *f = NULL; |
| int r; |
| |
| assert(directory); |
| assert(name || gid_is_valid(gid)); |
| |
| r = chase_symlinks_and_fopen_unlocked("/etc/group", directory, CHASE_PREFIX_ROOT, "re", NULL, &f); |
| if (r == -ENOENT) |
| return 0; /* no group database? then no group, hence no collision */ |
| if (r < 0) |
| return log_error_errno(r, "Failed to open /etc/group of container: %m"); |
| |
| for (;;) { |
| struct group *gr; |
| |
| r = fgetgrent_sane(f, &gr); |
| if (r < 0) |
| return log_error_errno(r, "Failed to iterate through /etc/group of container: %m"); |
| if (r == 0) |
| return 0; /* no collision */ |
| |
| if (name && streq_ptr(gr->gr_name, name)) |
| return 1; /* name collision */ |
| if (gid_is_valid(gid) && gr->gr_gid == gid) |
| return 1; /* gid collision */ |
| } |
| } |
| |
| static int convert_user( |
| const char *directory, |
| UserRecord *u, |
| GroupRecord *g, |
| uid_t allocate_uid, |
| UserRecord **ret_converted_user, |
| GroupRecord **ret_converted_group) { |
| |
| _cleanup_(group_record_unrefp) GroupRecord *converted_group = NULL; |
| _cleanup_(user_record_unrefp) UserRecord *converted_user = NULL; |
| _cleanup_free_ char *h = NULL; |
| JsonVariant *p, *hp = NULL; |
| int r; |
| |
| assert(u); |
| assert(g); |
| assert(u->gid == g->gid); |
| |
| r = check_etc_passwd_collisions(directory, u->user_name, UID_INVALID); |
| if (r < 0) |
| return r; |
| if (r > 0) |
| return log_error_errno(SYNTHETIC_ERRNO(EBUSY), |
| "Sorry, the user '%s' already exists in the container.", u->user_name); |
| |
| r = check_etc_group_collisions(directory, g->group_name, GID_INVALID); |
| if (r < 0) |
| return r; |
| if (r > 0) |
| return log_error_errno(SYNTHETIC_ERRNO(EBUSY), |
| "Sorry, the group '%s' already exists in the container.", g->group_name); |
| |
| h = path_join("/run/host/home/", u->user_name); |
| if (!h) |
| return log_oom(); |
| |
| /* Acquire the source hashed password array as-is, so that it retains the JSON_VARIANT_SENSITIVE flag */ |
| p = json_variant_by_key(u->json, "privileged"); |
| if (p) |
| hp = json_variant_by_key(p, "hashedPassword"); |
| |
| r = user_record_build( |
| &converted_user, |
| JSON_BUILD_OBJECT( |
| JSON_BUILD_PAIR("userName", JSON_BUILD_STRING(u->user_name)), |
| JSON_BUILD_PAIR("uid", JSON_BUILD_UNSIGNED(allocate_uid)), |
| JSON_BUILD_PAIR("gid", JSON_BUILD_UNSIGNED(allocate_uid)), |
| JSON_BUILD_PAIR_CONDITION(u->disposition >= 0, "disposition", JSON_BUILD_STRING(user_disposition_to_string(u->disposition))), |
| JSON_BUILD_PAIR("homeDirectory", JSON_BUILD_STRING(h)), |
| JSON_BUILD_PAIR("service", JSON_BUILD_CONST_STRING("io.systemd.NSpawn")), |
| JSON_BUILD_PAIR_CONDITION(!strv_isempty(u->hashed_password), "privileged", JSON_BUILD_OBJECT( |
| JSON_BUILD_PAIR("hashedPassword", JSON_BUILD_VARIANT(hp)))))); |
| if (r < 0) |
| return log_error_errno(r, "Failed to build container user record: %m"); |
| |
| r = group_record_build( |
| &converted_group, |
| JSON_BUILD_OBJECT( |
| JSON_BUILD_PAIR("groupName", JSON_BUILD_STRING(g->group_name)), |
| JSON_BUILD_PAIR("gid", JSON_BUILD_UNSIGNED(allocate_uid)), |
| JSON_BUILD_PAIR_CONDITION(g->disposition >= 0, "disposition", JSON_BUILD_STRING(user_disposition_to_string(g->disposition))), |
| JSON_BUILD_PAIR("service", JSON_BUILD_CONST_STRING("io.systemd.NSpawn")))); |
| if (r < 0) |
| return log_error_errno(r, "Failed to build container group record: %m"); |
| |
| *ret_converted_user = TAKE_PTR(converted_user); |
| *ret_converted_group = TAKE_PTR(converted_group); |
| |
| return 0; |
| } |
| |
| static int find_free_uid(const char *directory, uid_t max_uid, uid_t *current_uid) { |
| int r; |
| |
| assert(directory); |
| assert(current_uid); |
| |
| for (;; (*current_uid) ++) { |
| if (*current_uid > MAP_UID_MAX || *current_uid > max_uid) |
| return log_error_errno( |
| SYNTHETIC_ERRNO(EBUSY), |
| "No suitable available UID in range " UID_FMT "…" UID_FMT " in container detected, can't map user.", |
| MAP_UID_MIN, MAP_UID_MAX); |
| |
| r = check_etc_passwd_collisions(directory, NULL, *current_uid); |
| if (r < 0) |
| return r; |
| if (r > 0) /* already used */ |
| continue; |
| |
| /* We want to use the UID also as GID, hence check for it in /etc/group too */ |
| r = check_etc_group_collisions(directory, NULL, (gid_t) *current_uid); |
| if (r <= 0) |
| return r; |
| } |
| } |
| |
| BindUserContext* bind_user_context_free(BindUserContext *c) { |
| if (!c) |
| return NULL; |
| |
| assert(c->n_data == 0 || c->data); |
| |
| for (size_t i = 0; i < c->n_data; i++) { |
| user_record_unref(c->data[i].host_user); |
| group_record_unref(c->data[i].host_group); |
| user_record_unref(c->data[i].payload_user); |
| group_record_unref(c->data[i].payload_group); |
| } |
| |
| return mfree(c); |
| } |
| |
| int bind_user_prepare( |
| const char *directory, |
| char **bind_user, |
| uid_t uid_shift, |
| uid_t uid_range, |
| CustomMount **custom_mounts, |
| size_t *n_custom_mounts, |
| BindUserContext **ret) { |
| |
| _cleanup_(bind_user_context_freep) BindUserContext *c = NULL; |
| uid_t current_uid = MAP_UID_MIN; |
| int r; |
| |
| assert(custom_mounts); |
| assert(n_custom_mounts); |
| assert(ret); |
| |
| /* This resolves the users specified in 'bind_user', generates a minimalized JSON user + group record |
| * for it to stick in the container, allocates a UID/GID for it, and updates the custom mount table, |
| * to include an appropriate bind mount mapping. |
| * |
| * This extends the passed custom_mounts/n_custom_mounts with the home directories, and allocates a |
| * new BindUserContext for the user records */ |
| |
| if (strv_isempty(bind_user)) { |
| *ret = NULL; |
| return 0; |
| } |
| |
| c = new0(BindUserContext, 1); |
| if (!c) |
| return log_oom(); |
| |
| STRV_FOREACH(n, bind_user) { |
| _cleanup_(user_record_unrefp) UserRecord *u = NULL, *cu = NULL; |
| _cleanup_(group_record_unrefp) GroupRecord *g = NULL, *cg = NULL; |
| _cleanup_free_ char *sm = NULL, *sd = NULL; |
| CustomMount *cm; |
| |
| r = userdb_by_name(*n, USERDB_DONT_SYNTHESIZE, &u); |
| if (r < 0) |
| return log_error_errno(r, "Failed to resolve user '%s': %m", *n); |
| |
| /* For now, let's refuse mapping the root/nobody users explicitly. The records we generate |
| * are strictly additive, nss-systemd is typically placed last in /etc/nsswitch.conf. Thus |
| * even if we wanted, we couldn't override the root or nobody user records. Note we also |
| * check for name conflicts in /etc/passwd + /etc/group later on, which would usually filter |
| * out root/nobody too, hence these checks might appear redundant — but they actually are |
| * not, as we want to support environments where /etc/passwd and /etc/group are non-existent, |
| * and the user/group databases fully synthesized at runtime. Moreover, the name of the |
| * user/group name of the "nobody" account differs between distros, hence a check by numeric |
| * UID is safer. */ |
| if (u->uid == 0 || streq(u->user_name, "root")) |
| return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Mapping 'root' user not supported, sorry."); |
| if (u->uid == UID_NOBODY || STR_IN_SET(u->user_name, NOBODY_USER_NAME, "nobody")) |
| return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Mapping 'nobody' user not supported, sorry."); |
| |
| if (u->uid >= uid_shift && u->uid < uid_shift + uid_range) |
| return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "UID of user '%s' to map is already in container UID range, refusing.", u->user_name); |
| |
| r = groupdb_by_gid(u->gid, USERDB_DONT_SYNTHESIZE, &g); |
| if (r < 0) |
| return log_error_errno(r, "Failed to resolve group of user '%s': %m", u->user_name); |
| |
| if (g->gid >= uid_shift && g->gid < uid_shift + uid_range) |
| return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "GID of group '%s' to map is already in container GID range, refusing.", g->group_name); |
| |
| /* We want to synthesize exactly one user + group from the host into the container. This only |
| * makes sense if the user on the host has its own private group. We can't reasonably check |
| * this, so we just check of the name of user and group match. |
| * |
| * One of these days we might want to support users in a shared/common group too, but it's |
| * not clear to me how this would have to be mapped, precisely given that the common group |
| * probably already exists in the container. */ |
| if (!streq(u->user_name, g->group_name)) |
| return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), |
| "Sorry, mapping users without private groups is currently not supported."); |
| |
| r = find_free_uid(directory, uid_range, ¤t_uid); |
| if (r < 0) |
| return r; |
| |
| r = convert_user(directory, u, g, current_uid, &cu, &cg); |
| if (r < 0) |
| return r; |
| |
| if (!GREEDY_REALLOC(c->data, c->n_data + 1)) |
| return log_oom(); |
| |
| sm = strdup(u->home_directory); |
| if (!sm) |
| return log_oom(); |
| |
| sd = strdup(cu->home_directory); |
| if (!sd) |
| return log_oom(); |
| |
| cm = reallocarray(*custom_mounts, sizeof(CustomMount), *n_custom_mounts + 1); |
| if (!cm) |
| return log_oom(); |
| |
| *custom_mounts = cm; |
| |
| (*custom_mounts)[(*n_custom_mounts)++] = (CustomMount) { |
| .type = CUSTOM_MOUNT_BIND, |
| .source = TAKE_PTR(sm), |
| .destination = TAKE_PTR(sd), |
| }; |
| |
| c->data[c->n_data++] = (BindUserData) { |
| .host_user = TAKE_PTR(u), |
| .host_group = TAKE_PTR(g), |
| .payload_user = TAKE_PTR(cu), |
| .payload_group = TAKE_PTR(cg), |
| }; |
| |
| current_uid++; |
| } |
| |
| *ret = TAKE_PTR(c); |
| return 1; |
| } |
| |
| static int write_and_symlink( |
| const char *root, |
| JsonVariant *v, |
| const char *name, |
| uid_t uid, |
| const char *suffix, |
| WriteStringFileFlags extra_flags) { |
| |
| _cleanup_free_ char *j = NULL, *f = NULL, *p = NULL, *q = NULL; |
| int r; |
| |
| assert(root); |
| assert(v); |
| assert(name); |
| assert(uid_is_valid(uid)); |
| assert(suffix); |
| |
| r = json_variant_format(v, JSON_FORMAT_NEWLINE, &j); |
| if (r < 0) |
| return log_error_errno(r, "Failed to format user record JSON: %m"); |
| |
| f = strjoin(name, suffix); |
| if (!f) |
| return log_oom(); |
| |
| p = path_join(root, "/run/host/userdb/", f); |
| if (!p) |
| return log_oom(); |
| |
| if (asprintf(&q, "%s/run/host/userdb/" UID_FMT "%s", root, uid, suffix) < 0) |
| return log_oom(); |
| |
| if (symlink(f, q) < 0) |
| return log_error_errno(errno, "Failed to create symlink '%s': %m", q); |
| |
| r = userns_lchown(q, 0, 0); |
| if (r < 0) |
| return log_error_errno(r, "Failed to adjust access mode of '%s': %m", q); |
| |
| r = write_string_file(p, j, WRITE_STRING_FILE_CREATE|extra_flags); |
| if (r < 0) |
| return log_error_errno(r, "Failed to write %s: %m", p); |
| |
| r = userns_lchown(p, 0, 0); |
| if (r < 0) |
| return log_error_errno(r, "Failed to adjust access mode of '%s': %m", p); |
| |
| return 0; |
| } |
| |
| int bind_user_setup( |
| const BindUserContext *c, |
| const char *root) { |
| |
| static const UserRecordLoadFlags strip_flags = /* Removes privileged info */ |
| USER_RECORD_REQUIRE_REGULAR| |
| USER_RECORD_STRIP_PRIVILEGED| |
| USER_RECORD_ALLOW_PER_MACHINE| |
| USER_RECORD_ALLOW_BINDING| |
| USER_RECORD_ALLOW_SIGNATURE| |
| USER_RECORD_PERMISSIVE; |
| static const UserRecordLoadFlags shadow_flags = /* Extracts privileged info */ |
| USER_RECORD_STRIP_REGULAR| |
| USER_RECORD_ALLOW_PRIVILEGED| |
| USER_RECORD_STRIP_PER_MACHINE| |
| USER_RECORD_STRIP_BINDING| |
| USER_RECORD_STRIP_SIGNATURE| |
| USER_RECORD_EMPTY_OK| |
| USER_RECORD_PERMISSIVE; |
| int r; |
| |
| assert(root); |
| |
| if (!c || c->n_data == 0) |
| return 0; |
| |
| r = userns_mkdir(root, "/run/host", 0755, 0, 0); |
| if (r < 0) |
| return log_error_errno(r, "Failed to create /run/host: %m"); |
| |
| r = userns_mkdir(root, "/run/host/home", 0755, 0, 0); |
| if (r < 0) |
| return log_error_errno(r, "Failed to create /run/host/home: %m"); |
| |
| r = userns_mkdir(root, "/run/host/userdb", 0755, 0, 0); |
| if (r < 0) |
| return log_error_errno(r, "Failed to create /run/host/userdb: %m"); |
| |
| for (size_t i = 0; i < c->n_data; i++) { |
| _cleanup_(group_record_unrefp) GroupRecord *stripped_group = NULL, *shadow_group = NULL; |
| _cleanup_(user_record_unrefp) UserRecord *stripped_user = NULL, *shadow_user = NULL; |
| const BindUserData *d = c->data + i; |
| |
| /* First, write shadow (i.e. privileged) data for group record */ |
| r = group_record_clone(d->payload_group, shadow_flags, &shadow_group); |
| if (r < 0) |
| return log_error_errno(r, "Failed to extract privileged information from group record: %m"); |
| |
| if (!json_variant_is_blank_object(shadow_group->json)) { |
| r = write_and_symlink( |
| root, |
| shadow_group->json, |
| d->payload_group->group_name, |
| d->payload_group->gid, |
| ".group-privileged", |
| WRITE_STRING_FILE_MODE_0600); |
| if (r < 0) |
| return r; |
| } |
| |
| /* Second, write main part of group record. */ |
| r = group_record_clone(d->payload_group, strip_flags, &stripped_group); |
| if (r < 0) |
| return log_error_errno(r, "Failed to strip privileged information from group record: %m"); |
| |
| r = write_and_symlink( |
| root, |
| stripped_group->json, |
| d->payload_group->group_name, |
| d->payload_group->gid, |
| ".group", |
| 0); |
| if (r < 0) |
| return r; |
| |
| /* Third, write out user shadow data. i.e. extract privileged info from user record */ |
| r = user_record_clone(d->payload_user, shadow_flags, &shadow_user); |
| if (r < 0) |
| return log_error_errno(r, "Failed to extract privileged information from user record: %m"); |
| |
| if (!json_variant_is_blank_object(shadow_user->json)) { |
| r = write_and_symlink( |
| root, |
| shadow_user->json, |
| d->payload_user->user_name, |
| d->payload_user->uid, |
| ".user-privileged", |
| WRITE_STRING_FILE_MODE_0600); |
| if (r < 0) |
| return r; |
| } |
| |
| /* Finally write out the main part of the user record */ |
| r = user_record_clone(d->payload_user, strip_flags, &stripped_user); |
| if (r < 0) |
| return log_error_errno(r, "Failed to strip privileged information from user record: %m"); |
| |
| r = write_and_symlink( |
| root, |
| stripped_user->json, |
| d->payload_user->user_name, |
| d->payload_user->uid, |
| ".user", |
| 0); |
| if (r < 0) |
| return r; |
| } |
| |
| return 1; |
| } |