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;
+}