diff --git a/.gitignore b/.gitignore index cc340c173..c28acfe9b 100644 --- a/.gitignore +++ b/.gitignore @@ -65,5 +65,6 @@ tests/t_acquire tests/t_cred_store tests/t_impersonate tests/t_init +tests/t_kt_loader tests/t_names tests/t_setcredopt diff --git a/Makefile.am b/Makefile.am index 88d8e07d9..ee848a8fb 100644 --- a/Makefile.am +++ b/Makefile.am @@ -173,6 +173,7 @@ if BUILD_PROXY gssproxy_SOURCES = \ src/gp_config.c \ src/gp_init.c \ + src/gp_keytab.c \ src/gp_mgmt.c \ src/gp_socket.c \ src/gp_workers.c \ diff --git a/configure.ac b/configure.ac index fccad922a..11c67402d 100644 --- a/configure.ac +++ b/configure.ac @@ -123,6 +123,10 @@ AC_SUBST([GSSRPC_LIBS]) AC_CHECK_FUNCS([__secure_getenv secure_getenv]) +# keytab_loader: memfd output, pidfd exit notification +AC_CHECK_HEADERS([sys/pidfd.h]) +AC_CHECK_FUNCS([memfd_create pidfd_open close_range]) + AC_ARG_ENABLE([only-gss-module], [AC_HELP_STRING([--enable-only-gss-module], [Whether to build only the gss module code [no]] diff --git a/man/gssproxy.conf.5.xml b/man/gssproxy.conf.5.xml index 4a04a32e7..6f7051c97 100644 --- a/man/gssproxy.conf.5.xml +++ b/man/gssproxy.conf.5.xml @@ -308,6 +308,43 @@ + + keytab_loader (string) + + A program that produces the service's keytab, + so that the keys never touch a filesystem. + The service must use an in-memory keytab: + cred_store = keytab:MEMORY:name. + + The value is an absolute path followed by + arguments, separated by blanks; no shell is + involved. GSS-Proxy runs the program with an + anonymous memory file (memfd) as file + descriptor 3, and the program writes a keytab + in the MIT file format to it. When the program + exits with status 0, the keys replace the + contents of the MEMORY keytab and the memory + file is closed. + + A program that fails (or runs longer than five + minutes) is retried with exponential backoff, + from 5 seconds up to 10 minutes; the keys + loaded before stay in use meanwhile. SIGHUP + runs every loader again. When a keytab is no + longer used by any service its keys are + dropped. + + Services that share a MEMORY keytab must name + the same keytab_loader. While any loader is + configured, GSS-Proxy marks itself as not + dumpable, keeping the keys out of core dumps. + + Example: keytab_loader = + /usr/libexec/kuipa-unseal --memfd + + + + krb5_principal (string) diff --git a/src/gp_config.c b/src/gp_config.c index aecbf4f48..ce7d40611 100644 --- a/src/gp_config.c +++ b/src/gp_config.c @@ -56,6 +56,17 @@ static void free_str_array(const char ***a, int *count) safefree(*a); } +static void free_argv(char ***argv) +{ + if (!*argv) { + return; + } + for (int i = 0; (*argv)[i]; i++) { + safefree((*argv)[i]); + } + safefree(*argv); +} + void free_cred_store_elements(gss_key_value_set_desc *cs) { if (!cs->elements) return; @@ -78,21 +89,25 @@ static void gp_service_free(struct gp_service *svc) } free(svc->socket); free(svc->program); + free_argv(&svc->keytab_loader); SELINUX_context_free(svc->selinux_ctx); memset(svc, 0, sizeof(struct gp_service)); } -static int setup_krb5_creds_handle(struct gp_service *svc) +const char *gp_service_get_keytab(struct gp_service *svc) { - uint32_t ret_maj, ret_min; - const char *keytab = NULL; - for (unsigned i = 0; i < svc->krb5.store.count; i++) { if (strcmp(svc->krb5.store.elements[i].key, "keytab") == 0) { - keytab = svc->krb5.store.elements[i].value; - break; + return svc->krb5.store.elements[i].value; } } + return NULL; +} + +static int setup_krb5_creds_handle(struct gp_service *svc) +{ + uint32_t ret_maj, ret_min; + const char *keytab = gp_service_get_keytab(svc); ret_maj = gp_init_creds_handle(&ret_min, svc->name, keytab, &svc->krb5.creds_handle); @@ -103,6 +118,70 @@ static int setup_krb5_creds_handle(struct gp_service *svc) return 0; } +/* keytab_loader is a command line, split on blanks; no shell is involved */ +static int parse_keytab_loader(struct gp_service *svc, const char *value) +{ +#if defined(HAVE_MEMFD_CREATE) && defined(HAVE_PIDFD_OPEN) + const char *keytab; + char **argv = NULL; + char **tmp; + char *vcopy; + char *token; + char *handle; + int argc = 0; + int ret = 0; + + keytab = gp_service_get_keytab(svc); + if (!keytab || strncmp(keytab, "MEMORY:", 7) != 0) { + GPERROR("keytab_loader in [service/%s] requires " + "\"cred_store = keytab:MEMORY:\".\n", svc->name); + return EINVAL; + } + + vcopy = strdup(value); + if (!vcopy) { + return ENOMEM; + } + + for (token = strtok_r(vcopy, " \t", &handle); + token != NULL; + token = strtok_r(NULL, " \t", &handle)) { + tmp = realloc(argv, (argc + 2) * sizeof(char *)); + if (!tmp) { + ret = ENOMEM; + goto done; + } + argv = tmp; + argv[argc] = strdup(token); + if (!argv[argc]) { + ret = ENOMEM; + goto done; + } + argc++; + argv[argc] = NULL; + } + + if (argc == 0 || argv[0][0] != '/') { + GPERROR("keytab_loader in [service/%s] must start with an " + "absolute path.\n", svc->name); + ret = EINVAL; + goto done; + } + + svc->keytab_loader = argv; + argv = NULL; + +done: + free_argv(&argv); + safefree(vcopy); + return ret; +#else + GPERROR("keytab_loader in [service/%s] is not supported on this " + "platform.\n", svc->name); + return ENOTSUP; +#endif +} + static int get_krb5_mech_cfg(struct gp_service *svc, struct gp_ini_context *ctx, const char *secname) @@ -184,6 +263,15 @@ static int get_krb5_mech_cfg(struct gp_service *svc, ret = 0; } + if (ret == 0) { + ret = gp_config_get_string(ctx, secname, "keytab_loader", &value); + if (ret == 0) { + ret = parse_keytab_loader(svc, value); + } else if (ret == ENOENT) { + ret = 0; + } + } + if (ret == 0) { ret = setup_krb5_creds_handle(svc); } @@ -246,6 +334,18 @@ static int parse_flags(const char *value, uint32_t *storage) return 0; } +static bool same_argv(char **a, char **b) +{ + int i; + + for (i = 0; a[i] && b[i]; i++) { + if (strcmp(a[i], b[i]) != 0) { + return false; + } + } + return a[i] == b[i]; +} + static int check_services(const struct gp_config *cfg) { int i, j; @@ -273,6 +373,18 @@ static int check_services(const struct gp_config *cfg) for (j = 0; j < i; j++) { jsvc = cfg->svcs[j]; + + /* services sharing a loaded keytab must share its loader */ + if (isvc->keytab_loader && jsvc->keytab_loader && + strcmp(gp_service_get_keytab(isvc), + gp_service_get_keytab(jsvc)) == 0 && + !same_argv(isvc->keytab_loader, jsvc->keytab_loader)) { + ret = 1; + GPERROR("%s and %s load keytab %s with different " + "keytab_loader commands!\n", + isvc->name, jsvc->name, gp_service_get_keytab(isvc)); + } + jsock = jsvc->socket; if (!jsock) { jsock = GP_SOCKET_NAME; diff --git a/src/gp_init.c b/src/gp_init.c index 131bf08c9..b0947a47a 100644 --- a/src/gp_init.c +++ b/src/gp_init.c @@ -289,6 +289,9 @@ static void hup_handler(verto_ctx *vctx UNUSED, verto_ev *ev) /* conditionally reload kernel interface */ init_proc_nfsd(gpctx); + /* re-run keytab loaders, start new ones and drop unused keytabs */ + gp_keytab_loaders_update(gpctx); + free_config(&old_config); sd_notifyf(0, "READY=1\n" diff --git a/src/gp_keytab.c b/src/gp_keytab.c new file mode 100644 index 000000000..cfe32138c --- /dev/null +++ b/src/gp_keytab.c @@ -0,0 +1,754 @@ +/* Copyright (C) 2026 the GSS-PROXY contributors, see COPYING for license */ + +/* Keytab loaders. + * + * A service may name a program that produces its keytab: + * + * cred_store = keytab:MEMORY: + * keytab_loader = /path/to/loader [args] + * + * The loader runs with a memfd as file descriptor 3 and writes a keytab in + * the MIT file format into it. When it exits with status 0 the keys are + * copied into the MEMORY keytab and the memfd is closed, so the keys never + * touch a filesystem. A failing loader is retried with exponential backoff + * and keeps the keys loaded before it; SIGHUP runs the loaders again. + * + * MEMORY keytabs are shared by name by all krb5 contexts of the process, so + * the worker threads see the keys through the name in the service's + * cred_store, and so do credentials exported to clients and imported back + * later. The name therefore never changes; the contents are replaced in + * place. A MEMORY keytab is destroyed when its last handle is closed, so + * each loader keeps one open for as long as it exists. + * + * Removing an entry from a MEMORY keytab frees it under any thread that is + * iterating over the keytab, so entries are removed only while no worker is + * processing a request (gp_keytab_lock taken for writing; workers hold it + * for reading around each request). The main thread never blocks on it: if + * workers are busy, new keys are added at once and the old ones are + * removed on a later attempt. A request thus sees the old keys, the new + * keys, or both, and never a keytab missing a key it is about to be given. + */ + +#include "config.h" + +#if defined(HAVE_MEMFD_CREATE) && defined(HAVE_PIDFD_OPEN) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "gp_proxy.h" + +#define GP_KT_LOADER_FD 3 +#define GP_KT_LOADER_TIMEOUT (300 * 1000) /* kill a hung loader */ +#define GP_KT_RETRY_MIN (5 * 1000) /* first retry after failure */ +#define GP_KT_RETRY_MAX (600 * 1000) /* backoff cap */ +#define GP_KT_SWAP_RETRY (1000) /* wait for idle workers */ +#define GP_KT_MAX_SIZE (1024 * 1024) /* sanity limit on output */ + +struct gp_keytab_loader { + struct gp_keytab_loader *next; + struct gssproxy_ctx *gpctx; + + char *ktname; /* MEMORY: */ + krb5_keytab kt; /* held open: keeps the MEMORY keytab alive */ + char **argv; /* NULL once no service uses the keytab */ + bool in_use; /* marker while reconciling with the config */ + bool rerun; /* run again when the current run exits */ + + pid_t pid; /* running loader, 0 if none */ + int memfd; /* its output */ + verto_ev *child_ev; /* pidfd readable: the loader exited */ + verto_ev *timeout_ev; /* the loader runs too long */ + verto_ev *retry_ev; /* next attempt after a failure */ + int backoff; /* next retry delay, in ms */ + + int keys_fd; /* keys waiting to be installed, -1: none */ + verto_ev *swap_ev; /* pending removal of the previous keys */ +}; + +static pthread_rwlock_t gp_keytab_lock; +static pthread_once_t gp_keytab_lock_once = PTHREAD_ONCE_INIT; +static krb5_context gp_kt_ctx; + +static void gp_keytab_lock_init(void) +{ + pthread_rwlockattr_t attr; + + pthread_rwlockattr_init(&attr); + pthread_rwlockattr_setkind_np(&attr, + PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP); + pthread_rwlock_init(&gp_keytab_lock, &attr); + pthread_rwlockattr_destroy(&attr); +} + +void gp_keytab_rdlock(void) +{ + pthread_once(&gp_keytab_lock_once, gp_keytab_lock_init); + pthread_rwlock_rdlock(&gp_keytab_lock); +} + +void gp_keytab_unlock(void) +{ + pthread_rwlock_unlock(&gp_keytab_lock); +} + +static bool gp_keytab_trywrlock(void) +{ + pthread_once(&gp_keytab_lock_once, gp_keytab_lock_init); + return pthread_rwlock_trywrlock(&gp_keytab_lock) == 0; +} + +static void loader_run(struct gp_keytab_loader *l); + +/* ---- keytab manipulation (main thread only) ---- */ + +static int open_keys(int fd, krb5_keytab *kt) +{ + char name[sizeof("FILE:/proc/self/fd/") + 12]; + + snprintf(name, sizeof(name), "FILE:/proc/self/fd/%d", fd); + return krb5_kt_resolve(gp_kt_ctx, name, kt); +} + +static bool same_entry(krb5_keytab_entry *a, krb5_keytab_entry *b) +{ + return a->vno == b->vno && + a->key.enctype == b->key.enctype && + a->key.length == b->key.length && + memcmp(a->key.contents, b->key.contents, a->key.length) == 0 && + krb5_principal_compare(gp_kt_ctx, a->principal, b->principal); +} + +static bool kt_has_entry(krb5_keytab kt, krb5_keytab_entry *entry) +{ + krb5_kt_cursor cursor; + krb5_keytab_entry e; + bool found = false; + + if (krb5_kt_start_seq_get(gp_kt_ctx, kt, &cursor) != 0) { + return false; + } + while (!found && krb5_kt_next_entry(gp_kt_ctx, kt, &e, &cursor) == 0) { + found = same_entry(&e, entry); + krb5_free_keytab_entry_contents(gp_kt_ctx, &e); + } + krb5_kt_end_seq_get(gp_kt_ctx, kt, &cursor); + return found; +} + +/* Copy the entries of the keytab in fd to dst; with only_missing, skip the + * ones dst already has. Counts the entries of the source in *count. */ +static krb5_error_code copy_keys(int fd, krb5_keytab dst, bool only_missing, + int *count) +{ + krb5_keytab src = NULL; + krb5_kt_cursor cursor; + krb5_keytab_entry e; + krb5_error_code ret; + + *count = 0; + + ret = open_keys(fd, &src); + if (ret) { + return ret; + } + ret = krb5_kt_start_seq_get(gp_kt_ctx, src, &cursor); + if (ret) { + goto done; + } + while ((ret = krb5_kt_next_entry(gp_kt_ctx, src, &e, &cursor)) == 0) { + (*count)++; + if (dst && !(only_missing && kt_has_entry(dst, &e))) { + ret = krb5_kt_add_entry(gp_kt_ctx, dst, &e); + } + krb5_free_keytab_entry_contents(gp_kt_ctx, &e); + if (ret) { + break; + } + } + krb5_kt_end_seq_get(gp_kt_ctx, src, &cursor); + if (ret == KRB5_KT_END) { + ret = 0; + } + +done: + krb5_kt_close(gp_kt_ctx, src); + return ret; +} + +/* Remove every entry. Callers hold gp_keytab_lock for writing. */ +static krb5_error_code clear_keys(krb5_keytab kt) +{ + krb5_kt_cursor cursor; + krb5_keytab_entry e; + krb5_error_code ret; + + do { + ret = krb5_kt_start_seq_get(gp_kt_ctx, kt, &cursor); + if (ret) { + return ret; + } + ret = krb5_kt_next_entry(gp_kt_ctx, kt, &e, &cursor); + krb5_kt_end_seq_get(gp_kt_ctx, kt, &cursor); + if (ret == 0) { + ret = krb5_kt_remove_entry(gp_kt_ctx, kt, &e); + krb5_free_keytab_entry_contents(gp_kt_ctx, &e); + } + } while (ret == 0); + + return ret == KRB5_KT_END ? 0 : ret; +} + +static void loader_free(struct gp_keytab_loader *l) +{ + if (l->kt) { + krb5_kt_close(gp_kt_ctx, l->kt); + } + for (int i = 0; l->argv && l->argv[i]; i++) { + free(l->argv[i]); + } + free(l->argv); + free(l->ktname); + free(l); +} + +static void loader_unlink(struct gp_keytab_loader *l) +{ + struct gp_keytab_loader **p; + + for (p = &l->gpctx->kt_loaders; *p; p = &(*p)->next) { + if (*p == l) { + *p = l->next; + break; + } + } +} + +/* Make the MEMORY keytab hold exactly the pending keys (none for a loader + * no longer in use). Returns false if workers are busy and only the new + * keys could be added. */ +static bool install_keys(struct gp_keytab_loader *l, bool locked) +{ + krb5_keytab kt = l->kt; + krb5_error_code ret; + int count = 0; + + if (!locked) { + /* workers are busy: make the new keys usable now */ + if (l->keys_fd != -1) { + ret = copy_keys(l->keys_fd, kt, true, &count); + if (ret) { + GPERROR("Failed to add keys to %s: %d\n", l->ktname, ret); + } + } + return false; + } + + ret = clear_keys(kt); + if (ret == 0 && l->keys_fd != -1) { + ret = copy_keys(l->keys_fd, kt, false, &count); + } + if (ret) { + GPERROR("Failed to replace the keys of %s: %d\n", l->ktname, ret); + } else { + GPDEBUG("Keytab %s now holds %d entries.\n", l->ktname, count); + } + + if (l->keys_fd != -1) { + close(l->keys_fd); + l->keys_fd = -1; + } + return true; +} + +static void swap_keys(struct gp_keytab_loader *l); + +static void swap_retry(verto_ctx *vctx UNUSED, verto_ev *ev) +{ + swap_keys(verto_get_private(ev)); +} + +static void swap_keys(struct gp_keytab_loader *l) +{ + bool locked = gp_keytab_trywrlock(); + bool done; + + done = install_keys(l, locked); + if (locked) { + gp_keytab_unlock(); + } + + if (done) { + if (l->swap_ev) { + verto_del(l->swap_ev); + l->swap_ev = NULL; + } + if (!l->argv) { + /* the keytab is no longer configured and is now empty */ + loader_unlink(l); + loader_free(l); + } + return; + } + + if (!l->swap_ev) { + l->swap_ev = verto_add_timeout(l->gpctx->vctx, VERTO_EV_FLAG_PERSIST, + swap_retry, GP_KT_SWAP_RETRY); + if (!l->swap_ev) { + GPERROR("Failed to schedule key replacement for %s\n", + l->ktname); + return; + } + verto_set_private(l->swap_ev, l, NULL); + } +} + +/* ---- running the loader ---- */ + +static void loader_reset_child(struct gp_keytab_loader *l) +{ + if (l->child_ev) { + verto_del(l->child_ev); /* closes the pidfd */ + l->child_ev = NULL; + } + if (l->timeout_ev) { + verto_del(l->timeout_ev); + l->timeout_ev = NULL; + } + if (l->memfd != -1) { + close(l->memfd); + l->memfd = -1; + } + l->pid = 0; +} + +static void loader_kill(struct gp_keytab_loader *l) +{ + int status; + + if (l->pid > 0) { + kill(l->pid, SIGKILL); + while (waitpid(l->pid, &status, 0) == -1 && errno == EINTR); + } + loader_reset_child(l); +} + +static void loader_retry(verto_ctx *vctx UNUSED, verto_ev *ev) +{ + struct gp_keytab_loader *l = verto_get_private(ev); + + l->retry_ev = NULL; /* not persistent: freed by verto */ + loader_run(l); +} + +static void loader_failed(struct gp_keytab_loader *l) +{ + if (l->retry_ev || !l->argv) { + return; + } + + GPDEBUG("Retrying keytab loader for %s in %d seconds.\n", + l->ktname, l->backoff / 1000); + l->retry_ev = verto_add_timeout(l->gpctx->vctx, VERTO_EV_FLAG_NONE, + loader_retry, l->backoff); + if (!l->retry_ev) { + GPERROR("Failed to schedule keytab loader retry for %s\n", + l->ktname); + return; + } + verto_set_private(l->retry_ev, l, NULL); + + l->backoff *= 2; + if (l->backoff > GP_KT_RETRY_MAX) { + l->backoff = GP_KT_RETRY_MAX; + } +} + +static void loader_timeout(verto_ctx *vctx UNUSED, verto_ev *ev) +{ + struct gp_keytab_loader *l = verto_get_private(ev); + + l->timeout_ev = NULL; /* not persistent: freed by verto */ + GPERROR("Keytab loader for %s timed out, killing it\n", l->ktname); + loader_kill(l); + loader_failed(l); +} + +/* Check the loader's output: a non-empty keytab that parses fully. */ +static int check_output(struct gp_keytab_loader *l, int *count) +{ + struct stat st; + int ret; + + if (fstat(l->memfd, &st) == -1) { + return errno; + } + if (st.st_size == 0 || st.st_size > GP_KT_MAX_SIZE) { + GPERROR("Keytab loader for %s produced %lld bytes\n", + l->ktname, (long long)st.st_size); + return EINVAL; + } + + ret = copy_keys(l->memfd, NULL, false, count); + if (ret == 0 && *count == 0) { + ret = KRB5_KT_NOTFOUND; + } + if (ret) { + GPERROR("Keytab loader for %s produced an unusable keytab: %d\n", + l->ktname, ret); + } + return ret; +} + +static void loader_exited(verto_ctx *vctx UNUSED, verto_ev *ev) +{ + struct gp_keytab_loader *l = verto_get_private(ev); + int status = 0; + int count = 0; + int ret; + + /* not persistent: freed by verto, which closes the pidfd */ + l->child_ev = NULL; + + do { + ret = waitpid(l->pid, &status, 0); + } while (ret == -1 && errno == EINTR); + l->pid = 0; + + if (ret == -1) { + GPERROR("Failed to reap keytab loader for %s: %d\n", + l->ktname, errno); + ret = 1; + } else if (WIFEXITED(status) && WEXITSTATUS(status) == 0) { + ret = check_output(l, &count); + } else { + if (WIFEXITED(status)) { + GPERROR("Keytab loader for %s exited with status %d\n", + l->ktname, WEXITSTATUS(status)); + } else { + GPERROR("Keytab loader for %s killed by signal %d\n", + l->ktname, WTERMSIG(status)); + } + ret = 1; + } + + if (ret == 0) { + GPAUDIT("Keytab loader for %s loaded %d entries\n", + l->ktname, count); + GPDEBUG("Keytab loader for %s loaded %d entries.\n", + l->ktname, count); + if (l->keys_fd != -1) { + close(l->keys_fd); + } + l->keys_fd = l->memfd; + l->memfd = -1; + l->backoff = GP_KT_RETRY_MIN; + } + + loader_reset_child(l); + + if (ret == 0) { + swap_keys(l); + } + + if (l->rerun) { + l->rerun = false; + loader_run(l); + } else if (ret != 0) { + loader_failed(l); + } +} + +/* In the child, between fork() and exec(): async-signal-safe calls only. */ +static void loader_exec(char **argv, int memfd) +{ + struct sigaction sa = { .sa_handler = SIG_DFL }; + sigset_t set; + + sigemptyset(&set); + sigprocmask(SIG_SETMASK, &set, NULL); + /* ignored signals stay ignored across exec */ + sigemptyset(&sa.sa_mask); + sigaction(SIGPIPE, &sa, NULL); + sigaction(SIGHUP, &sa, NULL); + + if (memfd == GP_KT_LOADER_FD) { + if (fcntl(memfd, F_SETFD, 0) == -1) { + _exit(127); + } + } else if (dup2(memfd, GP_KT_LOADER_FD) == -1) { + _exit(127); + } + +#ifdef HAVE_CLOSE_RANGE + close_range(GP_KT_LOADER_FD + 1, ~0U, 0); +#else + for (int fd = GP_KT_LOADER_FD + 1; fd < 65536; fd++) { + close(fd); + } +#endif + + execv(argv[0], argv); + _exit(127); +} + +static void loader_run(struct gp_keytab_loader *l) +{ + int pidfd = -1; + int ret; + + if (l->pid) { + l->rerun = true; + return; + } + if (l->retry_ev) { + verto_del(l->retry_ev); + l->retry_ev = NULL; + } + + GPDEBUG("Running keytab loader %s for %s.\n", l->argv[0], l->ktname); + + l->memfd = memfd_create("gssproxy-keytab", MFD_CLOEXEC); + if (l->memfd == -1) { + ret = errno; + GPERROR("memfd_create failed for %s: %d (%s)\n", + l->ktname, ret, gp_strerror(ret)); + goto fail; + } + + l->pid = fork(); + if (l->pid == -1) { + ret = errno; + l->pid = 0; + GPERROR("fork failed for the keytab loader of %s: %d (%s)\n", + l->ktname, ret, gp_strerror(ret)); + goto fail; + } + if (l->pid == 0) { + loader_exec(l->argv, l->memfd); + } + + /* the child is not reaped until we wait for it, so its pid is stable */ + pidfd = pidfd_open(l->pid, 0); + if (pidfd == -1) { + ret = errno; + GPERROR("pidfd_open failed for the keytab loader of %s: %d (%s)\n", + l->ktname, ret, gp_strerror(ret)); + goto fail; + } + + l->child_ev = verto_add_io(l->gpctx->vctx, + VERTO_EV_FLAG_IO_READ | + VERTO_EV_FLAG_IO_CLOSE_FD, + loader_exited, pidfd); + if (!l->child_ev) { + close(pidfd); + GPERROR("Failed to watch the keytab loader of %s\n", l->ktname); + goto fail; + } + verto_set_private(l->child_ev, l, NULL); + + l->timeout_ev = verto_add_timeout(l->gpctx->vctx, VERTO_EV_FLAG_NONE, + loader_timeout, GP_KT_LOADER_TIMEOUT); + if (l->timeout_ev) { + verto_set_private(l->timeout_ev, l, NULL); + } + return; + +fail: + loader_kill(l); + loader_failed(l); +} + +/* ---- configuration ---- */ + +static struct gp_keytab_loader *find_loader(struct gssproxy_ctx *gpctx, + const char *ktname) +{ + struct gp_keytab_loader *l; + + for (l = gpctx->kt_loaders; l; l = l->next) { + if (strcmp(l->ktname, ktname) == 0) { + return l; + } + } + return NULL; +} + +static char **copy_argv(char **argv) +{ + char **copy; + int n; + + for (n = 0; argv[n]; n++); + copy = calloc(n + 1, sizeof(char *)); + if (!copy) { + return NULL; + } + for (int i = 0; i < n; i++) { + copy[i] = strdup(argv[i]); + if (!copy[i]) { + for (int j = 0; j < i; j++) { + free(copy[j]); + } + free(copy); + return NULL; + } + } + return copy; +} + +static void loader_set_argv(struct gp_keytab_loader *l, char **argv) +{ + for (int i = 0; l->argv && l->argv[i]; i++) { + free(l->argv[i]); + } + free(l->argv); + l->argv = argv; +} + +/* Start, re-run or retire the loaders after (re)reading the configuration. */ +void gp_keytab_loaders_update(struct gssproxy_ctx *gpctx) +{ + struct gp_keytab_loader *l, *next; + struct gp_service *svc; + const char *ktname; + char **argv; + krb5_error_code ret; + + for (l = gpctx->kt_loaders; l; l = l->next) { + l->in_use = false; + } + + for (int i = 0; i < gpctx->config->num_svcs; i++) { + svc = gpctx->config->svcs[i]; + if (!svc->keytab_loader) { + continue; + } + ktname = gp_service_get_keytab(svc); + + if (!gp_kt_ctx) { + ret = krb5_init_context(&gp_kt_ctx); + if (ret) { + GPERROR("Failed to initialize krb5 context for keytab " + "loaders: %d\n", ret); + return; + } + } + + /* keep the keys out of core dumps */ + prctl(PR_SET_DUMPABLE, 0, 0, 0, 0); + + l = find_loader(gpctx, ktname); + if (l && l->in_use) { + /* already handled for another service */ + continue; + } + if (!l) { + l = calloc(1, sizeof(struct gp_keytab_loader)); + if (!l || !(l->ktname = strdup(ktname))) { + free(l); + GPERROR("Out of memory setting up keytab loader for %s\n", + ktname); + continue; + } + ret = krb5_kt_resolve(gp_kt_ctx, ktname, &l->kt); + if (ret) { + GPERROR("Failed to resolve keytab %s: %d\n", ktname, ret); + loader_free(l); + continue; + } + l->gpctx = gpctx; + l->memfd = -1; + l->keys_fd = -1; + l->backoff = GP_KT_RETRY_MIN; + l->next = gpctx->kt_loaders; + gpctx->kt_loaders = l; + } + + argv = copy_argv(svc->keytab_loader); + if (!argv) { + GPERROR("Out of memory setting up keytab loader for %s\n", + ktname); + continue; + } + if (!l->argv && l->swap_ev) { + /* revived before its keys were dropped: keep them */ + verto_del(l->swap_ev); + l->swap_ev = NULL; + } + loader_set_argv(l, argv); + l->in_use = true; + l->backoff = GP_KT_RETRY_MIN; + loader_run(l); + } + + for (l = gpctx->kt_loaders; l; l = next) { + next = l->next; + if (l->in_use || !l->argv) { + continue; + } + /* no service uses this keytab any more: drop its keys */ + GPDEBUG("Keytab %s is no longer configured, dropping it.\n", + l->ktname); + loader_kill(l); + l->rerun = false; + if (l->retry_ev) { + verto_del(l->retry_ev); + l->retry_ev = NULL; + } + loader_set_argv(l, NULL); + if (l->keys_fd != -1) { + close(l->keys_fd); + l->keys_fd = -1; + } + swap_keys(l); + } +} + +void gp_keytab_loaders_fini(struct gssproxy_ctx *gpctx) +{ + struct gp_keytab_loader *l; + + while ((l = gpctx->kt_loaders)) { + gpctx->kt_loaders = l->next; + loader_kill(l); + if (l->retry_ev) { + verto_del(l->retry_ev); + } + if (l->swap_ev) { + verto_del(l->swap_ev); + } + if (l->keys_fd != -1) { + close(l->keys_fd); + } + loader_free(l); + } + + if (gp_kt_ctx) { + krb5_free_context(gp_kt_ctx); + gp_kt_ctx = NULL; + } +} + +#else /* !(HAVE_MEMFD_CREATE && HAVE_PIDFD_OPEN) */ + +#include "gp_proxy.h" + +/* configurations with keytab_loader are rejected when parsed */ +void gp_keytab_loaders_update(struct gssproxy_ctx *gpctx UNUSED) {} +void gp_keytab_loaders_fini(struct gssproxy_ctx *gpctx UNUSED) {} +void gp_keytab_rdlock(void) {} +void gp_keytab_unlock(void) {} + +#endif diff --git a/src/gp_proxy.h b/src/gp_proxy.h index 4e0e9c34b..20e49405f 100644 --- a/src/gp_proxy.h +++ b/src/gp_proxy.h @@ -47,6 +47,7 @@ struct gp_service { uint32_t enforce_flags; uint32_t min_lifetime; char *program; + char **keytab_loader; /* argv of the keytab loader, NULL-terminated */ uint32_t mechs; struct gp_cred_krb5 krb5; @@ -68,6 +69,7 @@ struct gp_config { }; struct gp_workers; +struct gp_keytab_loader; struct gssproxy_ctx { bool userproxymode; @@ -86,6 +88,8 @@ struct gssproxy_ctx { verto_ev *retry_proc_ev; /* retry telling the kernel to use GSS-Proxy */ + struct gp_keytab_loader *kt_loaders; /* keytab loaders, see gp_keytab.c */ + ssize_t readstats; ssize_t writestats; time_t last_activity; @@ -111,6 +115,7 @@ struct gp_call_ctx { struct gp_config *read_config(char *config_file, char *config_dir, char *socket_name, int opt_daemonize); struct gp_creds_handle *gp_service_get_creds_handle(struct gp_service *svc); +const char *gp_service_get_keytab(struct gp_service *svc); void free_config(struct gp_config **config); void free_cred_store_elements(gss_key_value_set_desc *cs); struct gp_config *userproxy_config(char *socket_name, int opt_daemonize); @@ -170,6 +175,12 @@ int gp_rpc_process_call(struct gp_call_ctx *gpcall, uint8_t *inbuf, size_t inlen, uint8_t **outbuf, size_t *outlen); +/* from gp_keytab.c */ +void gp_keytab_loaders_update(struct gssproxy_ctx *gpctx); +void gp_keytab_loaders_fini(struct gssproxy_ctx *gpctx); +void gp_keytab_rdlock(void); +void gp_keytab_unlock(void); + /* from gp_creds.c */ struct gp_service *gp_creds_match_conn(struct gssproxy_ctx *gpctx, struct gp_conn *conn); diff --git a/src/gp_workers.c b/src/gp_workers.c index 05197774a..b38fe0690 100644 --- a/src/gp_workers.c +++ b/src/gp_workers.c @@ -393,7 +393,10 @@ static void *gp_worker_main(void *pvt) /* handle the client request */ GPDEBUGN(3, "[status] Handling query input: %p (%zu)\n", q->buffer, q->buflen); + /* keytab loaders must not remove keys while we use them */ + gp_keytab_rdlock(); gp_handle_query(t->pool, q); + gp_keytab_unlock(); GPDEBUGN(3 ,"[status] Handling query output: %p (%zu)\n", q->buffer, q->buflen); diff --git a/src/gssproxy.c b/src/gssproxy.c index e6f44fa4c..3f9402070 100644 --- a/src/gssproxy.c +++ b/src/gssproxy.c @@ -201,6 +201,9 @@ int main(int argc, const char *argv[]) goto cleanup; } + /* after dropping privileges, so loaders run as the configured user */ + gp_keytab_loaders_update(gpctx); + /* final initialization step */ ret = init_event_fini(gpctx); if (ret) { @@ -209,6 +212,7 @@ int main(int argc, const char *argv[]) } verto_run(gpctx->vctx); + gp_keytab_loaders_fini(gpctx); verto_free(gpctx->vctx); gp_workers_free(gpctx->workers); diff --git a/tests/Makefile.am b/tests/Makefile.am index 3ea5b12bc..60a4e9c5b 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -33,6 +33,9 @@ t_init_SOURCES = \ t_init_LDADD = \ $(GSSAPI_LIBS) +t_kt_loader_SOURCES = \ + t_kt_loader.c + t_names_SOURCES = \ t_utils.c \ t_names.c @@ -53,6 +56,7 @@ check_PROGRAMS = \ t_impersonate \ t_accept \ t_init \ + t_kt_loader \ t_names \ t_setcredopt \ $(NULL) @@ -77,6 +81,7 @@ EXTRA_DIST = \ testlib.py \ t_impersonate.py \ t_interpose.py \ + t_keytab_loader.py \ t_multi_key.py \ t_names.py \ t_program.py \ diff --git a/tests/t_keytab_loader.py b/tests/t_keytab_loader.py new file mode 100755 index 000000000..36254fbb4 --- /dev/null +++ b/tests/t_keytab_loader.py @@ -0,0 +1,110 @@ +#!/usr/bin/python3 +# Copyright (C) 2026 - GSS-Proxy contributors; see COPYING for the license. + +from testlib import * + +from t_basic import run as run_basic_test + +import os +import testlib + +GSSPROXY_KEYTAB_LOADER = ''' +[gssproxy] + debug_level = 3 + +[service/test] + mechs = krb5 + cred_store = keytab:MEMORY:${KTNAME} + cred_store = client_keytab:${GSSPROXY_CLIENT_KEYTAB} + keytab_loader = ${PROGDIR}/t_kt_loader ${TESTDIR}/kt_loader.sealed ${TESTDIR}/kt_loader.fail + trusted = yes + euid = ${UIDNUMBER} + allow_client_ccache_sync = yes +''' + +# must match T_KT_SEAL_BYTE in t_kt_loader.c +SEAL_BYTE = 0x5a + +def seal_keytab(src, dst): + # gssproxy gets the keys only through t_kt_loader: the sealed file is + # not a usable keytab + with open(src, 'rb') as f: + data = f.read() + with open(dst, 'wb') as f: + f.write(bytes(b ^ SEAL_BYTE for b in data)) + +def set_conf(testdir, conf, ktname): + text = GSSPROXY_KEYTAB_LOADER.replace("${KTNAME}", ktname) + update_gssproxy_conf(testdir, conf["keysenv"], text) + +def wait_for(testdir, conf, key, sig=0): + gssproxy_reload(testdir, conf['gpid'], {'signal': sig, 'key': key}) + +def run_basic(testdir, env, conf, expected_failure=False): + r = run_basic_test(testdir, env, conf, expected_failure) + return 1 if r is None else r # None: timed out + +def run(testdir, env, conf): + conf['prefix'] = str(testlib.cmd_index) + prefix = conf["prefix"] + failfile = os.path.join(testdir, "kt_loader.fail") + retval = 0 + + # gssproxy initiates as the service, with the keys of its MEMORY keytab; + # t_accept plays the remote server with the original keytab + seal_keytab(conf['keytab'], os.path.join(testdir, "kt_loader.sealed")) + + print("Testing keytab loader...", file=sys.stderr) + sys.stderr.write(" ") + conf["prefix"] = prefix + "_1" + set_conf(testdir, conf, "gp-loaded") + wait_for(testdir, conf, "Keytab loader for MEMORY:gp-loaded loaded", + signal.SIGHUP) + retval |= run_basic(testdir, env, conf) + + print("Testing keytab loader re-run on SIGHUP...", file=sys.stderr) + sys.stderr.write(" ") + conf["prefix"] = prefix + "_2" + wait_for(testdir, conf, "Keytab loader for MEMORY:gp-loaded loaded", + signal.SIGHUP) + retval |= run_basic(testdir, env, conf) + + print("Testing failing keytab loader...", file=sys.stderr) + sys.stderr.write(" ") + conf["prefix"] = prefix + "_3" + open(failfile, "w").close() + set_conf(testdir, conf, "gp-retry") + wait_for(testdir, conf, + "Keytab loader for MEMORY:gp-retry exited with status 75", + signal.SIGHUP) + retval |= run_basic(testdir, env, conf, expected_failure=True) + + print("Testing keytab loader retry...", file=sys.stderr) + sys.stderr.write(" ") + conf["prefix"] = prefix + "_4" + os.unlink(failfile) + wait_for(testdir, conf, "Keytab loader for MEMORY:gp-retry loaded") + retval |= run_basic(testdir, env, conf) + + print("Testing failing re-run keeps the loaded keys...", file=sys.stderr) + sys.stderr.write(" ") + conf["prefix"] = prefix + "_5" + open(failfile, "w").close() + wait_for(testdir, conf, + "Keytab loader for MEMORY:gp-retry exited with status 75", + signal.SIGHUP) + retval |= run_basic(testdir, env, conf) + os.unlink(failfile) + + # be a good citizen and clean up after ourselves + update_gssproxy_conf(testdir, conf["keysenv"], GSSPROXY_CONF_TEMPLATE) + wait_for(testdir, conf, "Keytab MEMORY:gp-retry is no longer configured", + signal.SIGHUP) + + print_return(retval, -1, "(%d) Keytab loader" % testlib.cmd_index, False) + testlib.cmd_index += 1 + return retval + +if __name__ == "__main__": + from runtests import runtests_main + runtests_main(["t_keytab_loader.py"]) diff --git a/tests/t_kt_loader.c b/tests/t_kt_loader.c new file mode 100644 index 000000000..5bb02c334 --- /dev/null +++ b/tests/t_kt_loader.c @@ -0,0 +1,71 @@ +/* Copyright (C) 2026 the GSS-PROXY contributors, see COPYING for license */ + +/* A keytab_loader for the tests: "unseal" a keytab and write it to the + * memfd gssproxy passes as file descriptor 3. + * + * t_kt_loader SEALED [FAILFILE] + * + * SEALED is a keytab with every byte XORed with T_KT_SEAL_BYTE, so the file + * itself is not a usable keytab and gssproxy only gets the keys through the + * loader. While FAILFILE exists the loader fails with status 75 (retry + * later), as kuipa-unseal does when no server can unseal. */ + +#include +#include +#include +#include +#include + +#define T_KT_SEAL_BYTE 0x5a +#define LOADER_FD 3 +#define EX_TEMPFAIL 75 + +int main(int argc, const char *argv[]) +{ + unsigned char buf[4096]; + size_t len, off; + ssize_t w; + FILE *f; + + if (argc < 2 || argc > 3) { + fprintf(stderr, "Usage: %s SEALED [FAILFILE]\n", argv[0]); + return 1; + } + + if (argc == 3 && access(argv[2], F_OK) == 0) { + fprintf(stderr, "%s: %s exists, failing\n", argv[0], argv[2]); + return EX_TEMPFAIL; + } + + f = fopen(argv[1], "rb"); + if (!f) { + fprintf(stderr, "%s: %s: %s\n", argv[0], argv[1], strerror(errno)); + return 1; + } + + while ((len = fread(buf, 1, sizeof(buf), f)) > 0) { + for (size_t i = 0; i < len; i++) { + buf[i] ^= T_KT_SEAL_BYTE; + } + for (off = 0; off < len; off += w) { + w = write(LOADER_FD, buf + off, len - off); + if (w == -1) { + if (errno == EINTR) { + w = 0; + continue; + } + fprintf(stderr, "%s: write: %s\n", argv[0], strerror(errno)); + fclose(f); + return 1; + } + } + } + if (ferror(f)) { + fprintf(stderr, "%s: %s: read error\n", argv[0], argv[1]); + fclose(f); + return 1; + } + + fclose(f); + return 0; +}