Commit 57d287ff for guacamole.apache.org

commit 57d287ff0513e51c7d6074dba7e3766f8b36a6e1
Author: Bradley Bennett <bbennett@keepersecurity.com>
Date:   Thu May 28 22:32:57 2026 -0400

    GUACAMOLE-2283: Runtime observability: add guacd process & thread naming.

diff --git a/src/guacd/connection.c b/src/guacd/connection.c
index c07d6643..7f5e7e86 100644
--- a/src/guacd/connection.c
+++ b/src/guacd/connection.c
@@ -30,6 +30,7 @@
 #include <guacamole/mem.h>
 #include <guacamole/parser.h>
 #include <guacamole/plugin.h>
+#include <guacamole/proctitle.h>
 #include <guacamole/protocol.h>
 #include <guacamole/socket.h>
 #include <guacamole/user.h>
@@ -106,6 +107,10 @@ static int __write_all(int fd, char* buffer, int length) {
  */
 static void* guacd_connection_write_thread(void* data) {

+    /* Thread name conn-write: forwards data from the connected user to the
+     * connection's child process. */
+    guac_thread_name_set("conn-write");
+
     guacd_connection_io_thread_params* params = (guacd_connection_io_thread_params*) data;
     char buffer[8192];

@@ -132,6 +137,10 @@ static void* guacd_connection_write_thread(void* data) {

 void* guacd_connection_io_thread(void* data) {

+    /* Thread name conn-read: forwards data from the connection's child
+     * process back to the connected user. */
+    guac_thread_name_set("conn-read");
+
     guacd_connection_io_thread_params* params = (guacd_connection_io_thread_params*) data;
     char buffer[8192];

@@ -372,6 +381,10 @@ static int guacd_route_connection(guacd_proc_map* map, guac_socket* socket) {

 void* guacd_connection_thread(void* data) {

+    /* Thread name conn-route: performs the protocol handshake for a new
+     * client connection and routes it to a connection process. */
+    guac_thread_name_set("conn-route");
+
     guacd_connection_thread_params* params = (guacd_connection_thread_params*) data;

     guacd_proc_map* map = params->map;
@@ -409,4 +422,3 @@ void* guacd_connection_thread(void* data) {
     return NULL;

 }
-
diff --git a/src/guacd/daemon.c b/src/guacd/daemon.c
index b374eb9b..61b83e21 100644
--- a/src/guacd/daemon.c
+++ b/src/guacd/daemon.c
@@ -27,6 +27,7 @@
 #include "proc-map.h"

 #include <guacamole/mem.h>
+#include <guacamole/proctitle.h>

 #ifdef ENABLE_SSL
 #include <openssl/ssl.h>
@@ -296,6 +297,8 @@ static void stop_process_callback(guacd_proc* proc, void* data) {

 int main(int argc, char* argv[]) {

+    guac_process_title_init(argc, argv);
+
     /* Server */
     int socket_fd;
     struct addrinfo* addresses;
@@ -614,4 +617,3 @@ int main(int argc, char* argv[]) {
     return 0;

 }
-
diff --git a/src/guacd/proc.c b/src/guacd/proc.c
index 4040a3be..ec717d78 100644
--- a/src/guacd/proc.c
+++ b/src/guacd/proc.c
@@ -29,6 +29,7 @@
 #include <guacamole/mem.h>
 #include <guacamole/parser.h>
 #include <guacamole/plugin.h>
+#include <guacamole/proctitle.h>
 #include <guacamole/protocol.h>
 #include <guacamole/socket.h>
 #include <guacamole/user.h>
@@ -80,6 +81,10 @@ typedef struct guacd_user_thread_params {
  */
 static void* guacd_user_thread(void* data) {

+    /* Thread name user-conn: manages a single user's connection lifecycle
+     * from handshake through disconnect. */
+    guac_thread_name_set("user-conn");
+
     guacd_user_thread_params* params = (guacd_user_thread_params*) data;
     guacd_proc* proc = params->proc;
     guac_client* client = proc->client;
@@ -213,6 +218,10 @@ typedef struct guacd_client_free {
  */
 static void* guacd_client_free_thread(void* data) {

+    /* Thread name client-free: frees a guac_client in the background,
+     * bounded by a timeout in case the free handler hangs. */
+    guac_thread_name_set("client-free");
+
     guacd_client_free* free_operation = (guacd_client_free*) data;

     /* Attempt to free client (this may never return if the client is
@@ -326,6 +335,11 @@ static void guacd_exec_proc(guacd_proc* proc, const char* protocol) {

     int result = 1;

+    /* Label the new child process. guac_process_title_set() also writes the
+     * main thread's comm, which is the current thread here, so a separate
+     * guac_thread_name_set() call would just duplicate the work. */
+    guac_process_title_set(protocol);
+
     /* Set process group ID to match PID */
     if (setpgid(0, 0)) {
         guacd_log(GUAC_LOG_ERROR, "Cannot set PGID for connection process: %s",
diff --git a/src/libguac/Makefile.am b/src/libguac/Makefile.am
index 6e2e3f14..28984445 100644
--- a/src/libguac/Makefile.am
+++ b/src/libguac/Makefile.am
@@ -74,6 +74,7 @@ libguacinc_HEADERS =                  \
     guacamole/plugin.h                \
     guacamole/pool.h                  \
     guacamole/pool-types.h            \
+    guacamole/proctitle.h             \
     guacamole/protocol.h              \
     guacamole/protocol-constants.h    \
     guacamole/protocol-types.h        \
@@ -155,6 +156,7 @@ libguac_la_SOURCES =          \
     palette.c                 \
     parser.c                  \
     pool.c                    \
+    proctitle.c               \
     protocol.c                \
     raw_encoder.c             \
     recording.c               \
@@ -209,4 +211,3 @@ libguac_la_LDFLAGS =     \
     @VORBIS_LIBS@        \
     @WEBP_LIBS@          \
     @WINSOCK_LIBS@
-
diff --git a/src/libguac/client.c b/src/libguac/client.c
index 5926e9ca..e9635c2a 100644
--- a/src/libguac/client.c
+++ b/src/libguac/client.c
@@ -28,6 +28,7 @@
 #include "guacamole/layer.h"
 #include "guacamole/plugin.h"
 #include "guacamole/pool.h"
+#include "guacamole/proctitle.h"
 #include "guacamole/protocol.h"
 #include "guacamole/rwlock.h"
 #include "guacamole/socket.h"
@@ -240,6 +241,10 @@ promotion_complete:
  */
 static void* guac_client_pending_users_thread(void* data) {

+    /* Thread name user-pending: periodically promotes pending users into
+     * the active connection. */
+    guac_thread_name_set("user-pending");
+
     guac_client* client = (guac_client*) data;

     while (client->state == GUAC_CLIENT_RUNNING) {
diff --git a/src/libguac/display-render-thread.c b/src/libguac/display-render-thread.c
index 345ffb44..933442ad 100644
--- a/src/libguac/display-render-thread.c
+++ b/src/libguac/display-render-thread.c
@@ -23,6 +23,7 @@
 #include "guacamole/display.h"
 #include "guacamole/flag.h"
 #include "guacamole/mem.h"
+#include "guacamole/proctitle.h"
 #include "guacamole/timestamp.h"

 /**
@@ -62,6 +63,10 @@
  */
 static void* guac_display_render_loop(void* data) {

+    /* Thread name display-render: drives the display render loop, flushing
+     * completed frames to the client. */
+    guac_thread_name_set("display-render");
+
     guac_display_render_thread* render_thread = (guac_display_render_thread*) data;
     guac_display* display = render_thread->display;
     guac_client* client = display->client;
diff --git a/src/libguac/display-worker.c b/src/libguac/display-worker.c
index eb251263..7f500cf3 100644
--- a/src/libguac/display-worker.c
+++ b/src/libguac/display-worker.c
@@ -23,6 +23,7 @@
 #include "guacamole/display.h"
 #include "guacamole/fifo.h"
 #include "guacamole/layer.h"
+#include "guacamole/proctitle.h"
 #include "guacamole/protocol-types.h"
 #include "guacamole/protocol.h"
 #include "guacamole/rect.h"
@@ -293,6 +294,10 @@ static int LFR_guac_display_layer_should_use_webp(guac_display_layer* layer,

 void* guac_display_worker_thread(void* data) {

+    /* Thread name display-wrk: one worker in the display pool; encodes and
+     * sends graphical updates for dirty layer regions. */
+    guac_thread_name_set("display-wrk");
+
     int framerate;
     int has_outstanding_frames = 0;

diff --git a/src/libguac/guacamole/proctitle.h b/src/libguac/guacamole/proctitle.h
new file mode 100644
index 00000000..30a7c0f0
--- /dev/null
+++ b/src/libguac/guacamole/proctitle.h
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef _GUAC_PROCTITLE_H
+#define _GUAC_PROCTITLE_H
+
+/**
+ * @file proctitle.h
+ *
+ * These functions allow guacd and its per-connection child processes to
+ * show meaningful process and thread names in tools such as `ps`, `top`,
+ * `htop`, and `gdb`. Process titles can be updated at runtime to identify
+ * the active connection (for example, `vnc user@example.com:5900`), while
+ * worker threads can be assigned short descriptive names (for example,
+ * `display-wrk` or `user-input`).
+ *
+ * Process titles are visible through normal process-listing interfaces and
+ * should be treated as local-observable metadata. Callers must not include
+ * passwords, tokens, keys, or other secrets.
+ *
+ * Process title support operates on a single argv/environ memory region
+ * captured once at process startup by guac_process_title_init(). The captured
+ * buffer address and size are stored as process-wide static state and
+ * reused by all subsequent title updates. Writes are serialized with a
+ * static mutex so callers can invoke from any thread.
+ *
+ * Linux exposes argv+environ as a contiguous block whose contents are
+ * returned through `/proc/<pid>/cmdline`. Process title updates work by:
+ *   - Capturing that block's address and length at startup.
+ *   - Moving `environ` to a heap copy so `getenv()` continues to work
+ *     after the original storage is overwritten.
+ *   - Reusing the captured block as a writable title buffer, NUL-padding
+ *     any unused bytes.
+ *
+ * Thread names are exposed through Linux's thread `comm` mechanism:
+ *   - `prctl(PR_SET_NAME)` updates the calling thread's name
+ *     (15 characters plus NUL). Used by guac_thread_name_set().
+ *
+ *   - Writing `/proc/self/task/<tid>/comm` updates the name of any
+ *     thread in the process. guac_process_title_set() uses this to update
+ *     the main thread so process-oriented tools such as `top` and
+ *     `ps -e` display the active connection.
+ *
+ * --- When to call ---
+ *
+ *   guac_process_title_init:
+ *       Call once from main() before creating threads and before any
+ *       code modifies `environ`.
+ *
+ *   guac_process_title_set:
+ *       Call any time after initialization. Typically used once per
+ *       child process after the connection type and target are known.
+ *
+ *   guac_thread_name_set:
+ *       Call at thread startup to assign a descriptive worker name.
+ */
+
+/**
+ * Recommended buffer size for formatting a process title before passing it
+ * to guac_process_title_set(). Fits a protocol name, user, host, and port in
+ * typical deployments.
+ */
+#define GUAC_PROCESS_TITLE_BUFSIZE 256
+
+/**
+ * Initializes process title support by capturing the writable argv/environ
+ * region and moving `environ` to the heap. Must be called from main()
+ * before any other thread starts and before anything else reuses argv or
+ * environ. Safe to call more than once (subsequent calls no-op) and safe
+ * with bad args (no-op).
+ *
+ * If the heap environ copy fails to allocate, leaves environ in place and
+ * disables cmdline updates: later guac_process_title_set() calls still update
+ * the main-thread comm.
+ *
+ * @param argc
+ *     The argument count as passed to main().
+ *
+ * @param argv
+ *     The argument vector as passed to main().
+ */
+void guac_process_title_init(int argc, char** argv);
+
+/**
+ * Updates the process title: both /proc/<pid>/cmdline (the COMMAND column
+ * in `ps`/`htop`) and the *main thread's* short comm (visible in `top`,
+ * `ps -e`). The calling thread's own comm is left alone: use
+ * guac_thread_name_set() for that.
+ *
+ * If guac_process_title_init() was not called successfully, the cmdline part
+ * is skipped and only the comm write is attempted.
+ *
+ * @param title
+ *     The new title. Truncated to fit the captured argv region (cmdline)
+ *     and to 15 chars for the main thread comm. NULL no-ops.
+ */
+void guac_process_title_set(const char* title);
+
+/**
+ * Convenience wrapper around guac_process_title_set() that formats a network
+ * connection title in the form "<protocol> [user@]host[:port]". Falls back to
+ * "unknown-host" when host is NULL or empty, and omits the "user@" or ":port"
+ * portion when the respective argument is NULL or empty. The port is taken as
+ * a string so callers with either a numeric or named port can use it; numeric
+ * ports should be converted with guac_itoa_safe() first.
+ *
+ * The username is partially masked before display (e.g. "bbennett" becomes
+ * "bb****tt") so the full target account name is not exposed in world-readable
+ * process listings. Short or non-printable-ASCII usernames are masked in full.
+ * This is obfuscation to reduce casual disclosure, not an access control.
+ *
+ * @param protocol
+ *     Short protocol label, e.g. "vnc", "rdp", "ssh". A NULL value no-ops.
+ *
+ * @param user
+ *     The connecting username, or NULL/empty to omit the "user@" portion. The
+ *     value is partially masked before it appears in the process title.
+ *
+ * @param host
+ *     The target hostname, or NULL/empty to substitute "unknown-host".
+ *
+ * @param port
+ *     The target port as a string, or NULL/empty to omit the ":port" portion.
+ */
+void guac_process_title_set_endpoint(const char* protocol, const char* user,
+        const char* host, const char* port);
+
+/**
+ * Sets the *calling* thread's short name (visible in
+ * /proc/<pid>/task/<tid>/comm, `top -H`, `ps -L`, ...)
+ * via prctl(PR_SET_NAME).
+ *
+ * Thread-safe: the underlying prctl targets only the calling thread.
+ *
+ * Note: threads spawned later by the calling thread inherit
+ * this name as their initial comm. If you call into a library
+ * that creates its own workers, those workers will appear with
+ * the parent's name until they rename themselves.
+ *
+ * Each call site is preceded by a comment of the form
+ * "Thread name <name>: <description>". To list every named thread
+ * and what it does, search the source. The description may wrap onto
+ * the following line, so include one line of trailing context:
+ *
+ *     grep -rn -A1 "Thread name " src/
+ *
+ * @param name
+ *     The new thread name. Truncated to 15 chars + NULL.
+ */
+void guac_thread_name_set(const char* name);
+
+#endif
diff --git a/src/libguac/proctitle.c b/src/libguac/proctitle.c
new file mode 100644
index 00000000..79ac17fe
--- /dev/null
+++ b/src/libguac/proctitle.c
@@ -0,0 +1,372 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "config.h"
+
+#include "guacamole/proctitle.h"
+
+#include <guacamole/mem.h>
+#include <guacamole/string.h>
+
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#ifdef HAVE_PRCTL
+#include <sys/prctl.h>
+#endif
+
+extern char** environ;
+
+/**
+ * The size, in bytes, of the buffer used to hold a thread "comm" name. The
+ * Linux kernel limits a thread's comm to TASK_COMM_LEN bytes (16), i.e. 15
+ * characters plus a null terminator, and silently truncates anything longer,
+ * so a larger buffer would serve no purpose.
+ */
+#define GUAC_PROCTITLE_COMM_LENGTH 16
+
+/**
+ * The size, in bytes, of the buffer used to hold the /proc path addressing a
+ * thread's comm pseudo-file. This must be large enough for
+ * GUAC_PROCTITLE_COMM_PATH expanded with the widest possible PID. A 64-bit
+ * PID is at most 20 decimal digits, and the surrounding literal text is 21
+ * characters, so 64 bytes leaves comfortable headroom.
+ */
+#define GUAC_PROCTITLE_COMM_PATH_LENGTH 64
+
+/**
+ * Format string for the /proc path of a thread's comm pseudo-file. The single
+ * argument is the target thread's TID; for the thread-group leader (the
+ * process "main" thread) this equals the process PID returned by getpid().
+ */
+#define GUAC_PROCTITLE_COMM_PATH "/proc/self/task/%ld/comm"
+
+/**
+ * The number of leading and trailing characters of a username preserved by
+ * mask_username(); the characters in between are replaced with a fixed run of
+ * asterisks.
+ */
+#define GUAC_PROCTITLE_USER_REVEAL 2
+
+/**
+ * The fixed asterisk run substituted for the masked (middle) portion of a
+ * username by mask_username(). Its width is intentionally constant, rather
+ * than matching the number of characters removed, so the obfuscated form does
+ * not leak the original username's length.
+ */
+#define GUAC_PROCTITLE_USER_MASK "****"
+
+/**
+ * The minimum username length, in bytes, for which mask_username() reveals the
+ * leading and trailing characters. Names shorter than twice
+ * GUAC_PROCTITLE_USER_REVEAL characters would have all (or overlapping)
+ * characters exposed, so they are masked in their entirety instead. The "+ 1"
+ * guarantees at least one character is always masked.
+ */
+#define GUAC_PROCTITLE_USER_MIN_REVEAL (2 * GUAC_PROCTITLE_USER_REVEAL + 1)
+
+/**
+ * The size, in bytes, of a buffer guaranteed to hold any string produced by
+ * mask_username(): GUAC_PROCTITLE_USER_REVEAL characters at each end plus the
+ * GUAC_PROCTITLE_USER_MASK run (whose sizeof() includes its NUL terminator).
+ */
+#define GUAC_PROCTITLE_USER_MASKED_BUFSIZE \
+    (2 * GUAC_PROCTITLE_USER_REVEAL + sizeof(GUAC_PROCTITLE_USER_MASK))
+
+/**
+ * The inclusive bounds of the printable ASCII range (space through tilde).
+ * mask_username() treats any username byte outside this range as
+ * non-printable and masks the username in full.
+ */
+#define ASCII_PRINTABLE_MIN 0x20
+#define ASCII_PRINTABLE_MAX 0x7E
+
+/**
+ * The start of the writable argv/environ area used for process titles.
+ */
+static char* guac_proctitle_buffer = NULL;
+
+/**
+ * The number of bytes available within guac_proctitle_buffer.
+ */
+static size_t guac_proctitle_buffer_size = 0;
+
+/**
+ * Serializes access to the process-global proctitle state: the argv overlay
+ * buffer and the /proc/<pid>/task/<tgid>/comm write. Static-initialized so
+ * the first caller does not race against a missing pthread_mutex_init().
+ */
+static pthread_mutex_t guac_proctitle_lock = PTHREAD_MUTEX_INITIALIZER;
+
+/**
+ * Updates the main thread's short comm name from any thread in the same
+ * process, without changing the calling thread's own comm. Writes through
+ * /proc/<pid>/task/<tid>/comm; the main thread is identified by
+ * TID == TGID == getpid(). This relies on the Linux procfs layout and is a
+ * no-op on platforms that lack it.
+ */
+static void guac_main_thread_name_set(const char* name) {
+
+#ifdef __linux__
+    char comm_path[GUAC_PROCTITLE_COMM_PATH_LENGTH];
+    char short_name[GUAC_PROCTITLE_COMM_LENGTH];
+    FILE* comm_file;
+
+    if (name == NULL)
+        return;
+
+    memset(short_name, '\0', sizeof(short_name));
+    guac_strlcpy(short_name, name, sizeof(short_name));
+
+    snprintf(comm_path, sizeof(comm_path), GUAC_PROCTITLE_COMM_PATH,
+            (long) getpid());
+
+    /* Write the new name to the main thread's comm pseudo-file. The kernel
+     * accepts a short string (no trailing newline required) and truncates
+     * to TASK_COMM_LEN. fopen() may fail in restricted environments where
+     * /proc is unavailable or write access is denied; treat that as a
+     * silent no-op since process naming is best-effort observability. */
+    comm_file = fopen(comm_path, "w");
+    if (comm_file == NULL)
+        return;
+
+    fputs(short_name, comm_file);
+    fclose(comm_file);
+#else
+    (void) name;
+#endif
+
+}
+
+void guac_process_title_init(int argc, char** argv) {
+
+    char* buffer_end;
+    char** copied_environ;
+    int envc = 0;
+    int i;
+
+    if (argc <= 0 || argv == NULL || argv[0] == NULL)
+        return;
+
+    pthread_mutex_lock(&guac_proctitle_lock);
+
+    /* Idempotent: a second init after the buffer is claimed is a no-op. */
+    if (guac_proctitle_buffer != NULL) {
+        pthread_mutex_unlock(&guac_proctitle_lock);
+        return;
+    }
+
+    buffer_end = argv[argc - 1] + strlen(argv[argc - 1]) + 1;
+
+    for (i = 0; environ != NULL && environ[i] != NULL; i++) {
+
+        if (buffer_end == environ[i])
+            buffer_end = environ[i] + strlen(environ[i]) + 1;
+
+        envc++;
+
+    }
+
+    /* Copy environ to heap so it survives later overwrites of the argv area.
+     * If any allocation fails, leave the original environ in place and bail
+     * out without committing partial state. */
+    copied_environ = guac_mem_alloc(sizeof(char*), envc + 1);
+    if (copied_environ == NULL) {
+        pthread_mutex_unlock(&guac_proctitle_lock);
+        return;
+    }
+
+    for (i = 0; i < envc; i++) {
+
+        copied_environ[i] = guac_strdup(environ[i]);
+        if (copied_environ[i] == NULL) {
+            while (i > 0)
+                guac_mem_free(copied_environ[--i]);
+            guac_mem_free(copied_environ);
+            pthread_mutex_unlock(&guac_proctitle_lock);
+            return;
+        }
+
+    }
+    copied_environ[envc] = NULL;
+
+    environ = copied_environ;
+    guac_proctitle_buffer = argv[0];
+    guac_proctitle_buffer_size = buffer_end - argv[0];
+
+    pthread_mutex_unlock(&guac_proctitle_lock);
+
+}
+
+void guac_process_title_set(const char* title) {
+
+    size_t title_length;
+
+    if (title == NULL)
+        return;
+
+    pthread_mutex_lock(&guac_proctitle_lock);
+
+    guac_main_thread_name_set(title);
+
+    if (guac_proctitle_buffer == NULL || guac_proctitle_buffer_size == 0) {
+        pthread_mutex_unlock(&guac_proctitle_lock);
+        return;
+    }
+
+    title_length = strlen(title);
+    if (title_length >= guac_proctitle_buffer_size)
+        title_length = guac_proctitle_buffer_size - 1;
+
+    memcpy(guac_proctitle_buffer, title, title_length);
+    guac_proctitle_buffer[title_length] = '\0';
+
+    if (title_length + 1 < guac_proctitle_buffer_size) {
+        memset(guac_proctitle_buffer + title_length + 1, '\0',
+                guac_proctitle_buffer_size - title_length - 1);
+    }
+
+    pthread_mutex_unlock(&guac_proctitle_lock);
+
+}
+
+/**
+ * Writes a partially obfuscated form of the given username into the provided
+ * buffer for inclusion in a process title. The goal is to retain enough of the
+ * username for an operator to recognize a session at a glance while keeping the
+ * full target account name out of world-readable process listings
+ * (/proc/<pid>/cmdline).
+ *
+ * The algorithm is:
+ *
+ *   1. A NULL or empty username yields an empty string, so the caller omits
+ *      the "user@" portion of the title entirely.
+ *
+ *   2. A username containing any byte outside the printable ASCII range
+ *      (0x20-0x7E) is masked in full (GUAC_PROCTITLE_USER_MASK). This avoids
+ *      splitting a multi-byte UTF-8 codepoint and emitting garbage.
+ *
+ *   3. A username shorter than GUAC_PROCTITLE_USER_MIN_REVEAL bytes is masked
+ *      in full, since revealing GUAC_PROCTITLE_USER_REVEAL characters at each
+ *      end would otherwise expose every (or overlapping) character (e.g.
+ *      "root").
+ *
+ *   4. Otherwise the first and last GUAC_PROCTITLE_USER_REVEAL characters are
+ *      preserved and the middle is replaced with the fixed-width run
+ *      GUAC_PROCTITLE_USER_MASK, e.g. "bbennett" -> "bb****tt". The run width
+ *      is constant regardless of username length so the result does not leak
+ *      the original length.
+ *
+ * @param user
+ *     The username to obfuscate, which may be NULL.
+ *
+ * @param out
+ *     The buffer to receive the NUL-terminated, obfuscated username. Should be
+ *     at least GUAC_PROCTITLE_USER_MASKED_BUFSIZE bytes.
+ *
+ * @param out_size
+ *     The size of the out buffer, in bytes.
+ */
+static void mask_username(const char* user, char* out, size_t out_size) {
+
+    if (user == NULL || *user == '\0') {
+        out[0] = '\0';
+        return;
+    }
+
+    size_t length = strlen(user);
+
+    /* Reveal the prefix & suffix edges only for sufficiently long, plain
+     * printable-ASCII usernames; otherwise mask the entire value. */
+    int reveal = (length >= GUAC_PROCTITLE_USER_MIN_REVEAL);
+    for (size_t i = 0; reveal && i < length; i++) {
+        unsigned char c = (unsigned char) user[i];
+        if (c < ASCII_PRINTABLE_MIN || c > ASCII_PRINTABLE_MAX)
+            reveal = 0;
+    }
+
+    if (!reveal) {
+        guac_strlcpy(out, GUAC_PROCTITLE_USER_MASK, out_size);
+        return;
+    }
+
+    /* Preserve the first and last GUAC_PROCTITLE_USER_REVEAL characters,
+     * replacing everything between them with the fixed mask. */
+    char prefix[GUAC_PROCTITLE_USER_REVEAL + 1];
+    memcpy(prefix, user, GUAC_PROCTITLE_USER_REVEAL);
+    prefix[GUAC_PROCTITLE_USER_REVEAL] = '\0';
+
+    const char* suffix = user + length - GUAC_PROCTITLE_USER_REVEAL;
+
+    snprintf(out, out_size, "%s%s%s", prefix, GUAC_PROCTITLE_USER_MASK, suffix);
+
+}
+
+void guac_process_title_set_endpoint(const char* protocol, const char* user,
+        const char* host, const char* port) {
+
+    char title[GUAC_PROCESS_TITLE_BUFSIZE];
+    char masked_user[GUAC_PROCTITLE_USER_MASKED_BUFSIZE];
+
+    if (protocol == NULL)
+        return;
+
+    /* Fall back to a placeholder host; omit the user and port portions when
+     * not provided. */
+    if (host == NULL || *host == '\0')
+        host = "unknown-host";
+
+    /* The username is partially masked before display: it is target account
+     * metadata exposed in world-readable process listings. */
+    mask_username(user, masked_user, sizeof(masked_user));
+
+    int has_user = (masked_user[0] != '\0');
+    int has_port = (port != NULL && *port != '\0');
+
+    if (has_user && has_port)
+        snprintf(title, sizeof(title), "%s %s@%s:%s", protocol, masked_user, host, port);
+    else if (has_user)
+        snprintf(title, sizeof(title), "%s %s@%s", protocol, masked_user, host);
+    else if (has_port)
+        snprintf(title, sizeof(title), "%s %s:%s", protocol, host, port);
+    else
+        snprintf(title, sizeof(title), "%s %s", protocol, host);
+
+    guac_process_title_set(title);
+
+}
+
+void guac_thread_name_set(const char* name) {
+
+#ifdef HAVE_PRCTL
+    char short_name[GUAC_PROCTITLE_COMM_LENGTH];
+
+    if (name == NULL)
+        return;
+
+    /* guac_strlcpy() properly terminates short_name[]. */
+    guac_strlcpy(short_name, name, sizeof(short_name));
+    prctl(PR_SET_NAME, short_name, 0, 0, 0);
+#else
+    (void) name;
+#endif
+
+}
diff --git a/src/libguac/socket.c b/src/libguac/socket.c
index 2c2cd874..4dfc2567 100644
--- a/src/libguac/socket.c
+++ b/src/libguac/socket.c
@@ -20,6 +20,7 @@
 #include "config.h"
 #include "guacamole/mem.h"
 #include "guacamole/error.h"
+#include "guacamole/proctitle.h"
 #include "guacamole/protocol.h"
 #include "guacamole/socket.h"
 #include "guacamole/timestamp.h"
@@ -45,6 +46,10 @@ char __guac_socket_BASE64_CHARACTERS[64] = {

 static void* __guac_socket_keep_alive_thread(void* data) {

+    /* Thread name keep-alive: periodically sends keep-alive NOPs on an
+     * otherwise idle socket. */
+    guac_thread_name_set("keep-alive");
+
     int old_cancelstate;

     /* Socket keep-alive loop */
diff --git a/src/libguac/tcp.c b/src/libguac/tcp.c
index ba797658..b4b12ba6 100644
--- a/src/libguac/tcp.c
+++ b/src/libguac/tcp.c
@@ -30,6 +30,11 @@
 #include <sys/socket.h>
 #include <unistd.h>

+/* Fallback for platforms that do not define EBADFD. */
+#ifndef EBADFD
+#define EBADFD EBADF
+#endif
+
 int guac_tcp_connect(const char* hostname, const char* port, const int timeout) {

     int retval;
diff --git a/src/libguac/user-handshake.c b/src/libguac/user-handshake.c
index 422c941b..f0aa35ce 100644
--- a/src/libguac/user-handshake.c
+++ b/src/libguac/user-handshake.c
@@ -23,6 +23,7 @@
 #include "guacamole/client.h"
 #include "guacamole/error.h"
 #include "guacamole/parser.h"
+#include "guacamole/proctitle.h"
 #include "guacamole/protocol.h"
 #include "guacamole/socket.h"
 #include "guacamole/user.h"
@@ -129,6 +130,10 @@ static void guac_user_log_handshake_failure(guac_user* user) {
  */
 static void* guac_user_input_thread(void* data) {

+    /* Thread name user-input: reads and parses instructions from a single
+     * user's socket. */
+    guac_thread_name_set("user-input");
+
     guac_user_input_thread_params* params =
         (guac_user_input_thread_params*) data;

diff --git a/src/protocols/kubernetes/kubernetes.c b/src/protocols/kubernetes/kubernetes.c
index e32925d2..af2005f7 100644
--- a/src/protocols/kubernetes/kubernetes.c
+++ b/src/protocols/kubernetes/kubernetes.c
@@ -29,6 +29,7 @@

 #include <guacamole/client.h>
 #include <guacamole/mem.h>
+#include <guacamole/proctitle.h>
 #include <guacamole/protocol.h>
 #include <guacamole/recording.h>
 #include <libwebsockets.h>
@@ -176,6 +177,10 @@ struct lws_protocols guac_kubernetes_lws_protocols[] = {
  */
 static void* guac_kubernetes_input_thread(void* data) {

+    /* Thread name k8s-input: reads user input and forwards it to the
+     * Kubernetes pod. */
+    guac_thread_name_set("k8s-input");
+
     guac_client* client = (guac_client*) data;
     guac_kubernetes_client* kubernetes_client =
         (guac_kubernetes_client*) client->data;
@@ -198,6 +203,10 @@ static void* guac_kubernetes_input_thread(void* data) {

 void* guac_kubernetes_client_thread(void* data) {

+    /* Thread name k8s-worker: main Kubernetes client thread; manages the
+     * websocket connection to the pod. */
+    guac_thread_name_set("k8s-worker");
+
     guac_client* client = (guac_client*) data;
     guac_kubernetes_client* kubernetes_client =
         (guac_kubernetes_client*) client->data;
@@ -214,6 +223,30 @@ void* guac_kubernetes_client_thread(void* data) {
         goto fail;
     }

+    const char* kubernetes_namespace = settings->kubernetes_namespace;
+    char kubernetes_title[GUAC_PROCESS_TITLE_BUFSIZE];
+
+    /* Namespace should already be populated by argument parsing, but
+     * provide fallback. */
+    if (kubernetes_namespace == NULL || *kubernetes_namespace == '\0')
+        kubernetes_namespace = GUAC_KUBERNETES_DEFAULT_NAMESPACE;
+
+    /* Identify the attached Kubernetes target (for example,
+     * "k8s default/mypod" or "k8s default/mypod/container"). Include the
+     * container when specified to distinguish multi-container pods. */
+    if (settings->kubernetes_container != NULL
+            && *settings->kubernetes_container != '\0')
+        snprintf(kubernetes_title, sizeof(kubernetes_title),
+                "%s %s/%s/%s", GUAC_KUBERNETES_PROCESS_TITLE_NAME,
+                kubernetes_namespace, settings->kubernetes_pod,
+                settings->kubernetes_container);
+    else
+        snprintf(kubernetes_title, sizeof(kubernetes_title),
+                "%s %s/%s", GUAC_KUBERNETES_PROCESS_TITLE_NAME,
+                kubernetes_namespace, settings->kubernetes_pod);
+
+    guac_process_title_set(kubernetes_title);
+
     /* Generate endpoint for attachment URL */
     if (guac_kubernetes_endpoint_uri(endpoint_path, sizeof(endpoint_path),
                 settings->kubernetes_namespace,
diff --git a/src/protocols/kubernetes/settings.h b/src/protocols/kubernetes/settings.h
index 193f870c..50e80612 100644
--- a/src/protocols/kubernetes/settings.h
+++ b/src/protocols/kubernetes/settings.h
@@ -30,6 +30,12 @@
  */
 #define GUAC_KUBERNETES_DEFAULT_PORT 8080

+/**
+ * The protocol label included in the process title (the first argument passed
+ * to guac_process_title_set_endpoint()), as seen in `ps`/`top`.
+ */
+#define GUAC_KUBERNETES_PROCESS_TITLE_NAME "k8s"
+
 /**
  * The name of the Kubernetes namespace that should be used by default if no
  * specific Kubernetes namespace is provided.
diff --git a/src/protocols/rdp/channels/audio-input/audio-buffer.c b/src/protocols/rdp/channels/audio-input/audio-buffer.c
index 38d5b7f1..24b3d05f 100644
--- a/src/protocols/rdp/channels/audio-input/audio-buffer.c
+++ b/src/protocols/rdp/channels/audio-input/audio-buffer.c
@@ -22,6 +22,7 @@

 #include <guacamole/client.h>
 #include <guacamole/mem.h>
+#include <guacamole/proctitle.h>
 #include <guacamole/protocol.h>
 #include <guacamole/socket.h>
 #include <guacamole/stream.h>
@@ -217,6 +218,10 @@ static void guac_rdp_audio_buffer_wait(guac_rdp_audio_buffer* audio_buffer) {
  */
 static void* guac_rdp_audio_buffer_flush_thread(void* data) {

+    /* Thread name rdp-audio: flushes buffered audio input to the RDP
+     * server at the negotiated rate. */
+    guac_thread_name_set("rdp-audio");
+
     guac_rdp_audio_buffer* audio_buffer = (guac_rdp_audio_buffer*) data;
     while (!audio_buffer->stopping) {

diff --git a/src/protocols/rdp/print-job.c b/src/protocols/rdp/print-job.c
index eda0275a..d899598b 100644
--- a/src/protocols/rdp/print-job.c
+++ b/src/protocols/rdp/print-job.c
@@ -23,6 +23,7 @@
 #include <guacamole/client.h>
 #include <guacamole/error.h>
 #include <guacamole/mem.h>
+#include <guacamole/proctitle.h>
 #include <guacamole/protocol.h>
 #include <guacamole/socket.h>
 #include <guacamole/stream.h>
@@ -392,6 +393,10 @@ static pid_t guac_rdp_create_filter_process(guac_client* client,
  */
 static void* guac_rdp_print_job_output_thread(void* data) {

+    /* Thread name rdp-print: streams output from the RDP print filter
+     * process to the client as a downloadable file. */
+    guac_thread_name_set("rdp-print");
+
     int length;
     char buffer[6048];

diff --git a/src/protocols/rdp/rdp.c b/src/protocols/rdp/rdp.c
index 3342079d..23ed9bc4 100644
--- a/src/protocols/rdp/rdp.c
+++ b/src/protocols/rdp/rdp.c
@@ -57,6 +57,7 @@
 #include <freerdp/freerdp.h>
 #include <freerdp/gdi/gdi.h>
 #include <freerdp/graphics.h>
+#include <guacamole/proctitle.h>
 #include <freerdp/primary.h>
 #include <freerdp/settings.h>
 #include <freerdp/update.h>
@@ -732,10 +733,20 @@ fail:

 void* guac_rdp_client_thread(void* data) {

+    /* Thread name rdp-worker: main RDP client thread; runs the FreeRDP
+     * connection and event loop. */
+    guac_thread_name_set("rdp-worker");
+
     guac_client* client = (guac_client*) data;
     guac_rdp_client* rdp_client = (guac_rdp_client*) client->data;
     guac_rdp_settings* settings = rdp_client->settings;

+    char rdp_port[GUAC_USHORT_STRING_BUFSIZE];
+    if (guac_itoa_safe(rdp_port, sizeof(rdp_port), settings->port) < 1)
+        rdp_port[0] = '\0';
+    guac_process_title_set_endpoint(GUAC_RDP_PROCESS_TITLE_NAME,
+            settings->username, settings->hostname, rdp_port);
+
     /* If Wake-on-LAN is enabled, attempt to wake. */
     if (settings->wol_send_packet) {

diff --git a/src/protocols/rdp/settings.h b/src/protocols/rdp/settings.h
index f23cefbe..8ce5f030 100644
--- a/src/protocols/rdp/settings.h
+++ b/src/protocols/rdp/settings.h
@@ -42,6 +42,12 @@
  */
 #define RDP_DEFAULT_PORT 3389

+/**
+ * The protocol label included in the process title (the first argument passed
+ * to guac_process_title_set_endpoint()), as seen in `ps`/`top`.
+ */
+#define GUAC_RDP_PROCESS_TITLE_NAME "rdp"
+
 /**
  * The default SFTP connection timeout, in seconds.
  */
diff --git a/src/protocols/ssh/settings.h b/src/protocols/ssh/settings.h
index 606177c5..e3633f12 100644
--- a/src/protocols/ssh/settings.h
+++ b/src/protocols/ssh/settings.h
@@ -30,6 +30,12 @@
  */
 #define GUAC_SSH_DEFAULT_PORT "22"

+/**
+ * The protocol label included in the process title (the first argument passed
+ * to guac_process_title_set_endpoint()), as seen in `ps`/`top`.
+ */
+#define GUAC_SSH_PROCESS_TITLE_NAME "ssh"
+
 /**
  * The default number of seconds to attempt a connection to the SSH/SFTP
  * server before giving up.
diff --git a/src/protocols/ssh/ssh.c b/src/protocols/ssh/ssh.c
index 508492b7..cd91bd9f 100644
--- a/src/protocols/ssh/ssh.c
+++ b/src/protocols/ssh/ssh.c
@@ -37,6 +37,7 @@
 #include <guacamole/client.h>
 #include <guacamole/error.h>
 #include <guacamole/mem.h>
+#include <guacamole/proctitle.h>
 #include <guacamole/recording.h>
 #include <guacamole/socket.h>
 #include <guacamole/timestamp.h>
@@ -201,6 +202,10 @@ static char* guac_ssh_get_credential(guac_client *client, char* cred_name) {

 void* ssh_input_thread(void* data) {

+    /* Thread name ssh-stdin: reads terminal STDIN and forwards it to the
+     * SSH server. */
+    guac_thread_name_set("ssh-stdin");
+
     guac_client* client = (guac_client*) data;
     guac_ssh_client* ssh_client = (guac_ssh_client*) client->data;

@@ -226,6 +231,10 @@ void* ssh_input_thread(void* data) {

 void* ssh_client_thread(void* data) {

+    /* Thread name ssh-worker: main SSH client thread; runs the SSH session
+     * and drives the terminal. */
+    guac_thread_name_set("ssh-worker");
+
     guac_client* client = (guac_client*) data;
     guac_ssh_client* ssh_client = (guac_ssh_client*) client->data;
     guac_ssh_settings* settings = ssh_client->settings;
@@ -343,6 +352,11 @@ void* ssh_client_thread(void* data) {
     /* Ensure connection is kept alive during lengthy connects */
     guac_socket_require_keep_alive(client->socket);

+    const char* ssh_port = settings->port != NULL
+            ? settings->port : GUAC_SSH_DEFAULT_PORT;
+    guac_process_title_set_endpoint(GUAC_SSH_PROCESS_TITLE_NAME,
+            settings->username, settings->hostname, ssh_port);
+
     /* Open SSH session */
     ssh_client->session = guac_common_ssh_create_session(client,
             settings->hostname, settings->port, ssh_client->user,
diff --git a/src/protocols/telnet/settings.h b/src/protocols/telnet/settings.h
index 43432619..f341fc6a 100644
--- a/src/protocols/telnet/settings.h
+++ b/src/protocols/telnet/settings.h
@@ -31,6 +31,12 @@
  */
 #define GUAC_TELNET_DEFAULT_PORT "23"

+/**
+ * The protocol label included in the process title (the first argument passed
+ * to guac_process_title_set_endpoint()), as seen in `ps`/`top`.
+ */
+#define GUAC_TELNET_PROCESS_TITLE_NAME "telnet"
+
 /**
  * The default number of seconds to wait for a successful connection before
  * timing out.
diff --git a/src/protocols/telnet/telnet.c b/src/protocols/telnet/telnet.c
index 71635e6e..948cdc4a 100644
--- a/src/protocols/telnet/telnet.c
+++ b/src/protocols/telnet/telnet.c
@@ -26,6 +26,7 @@
 #include <guacamole/client.h>
 #include <guacamole/error.h>
 #include <guacamole/mem.h>
+#include <guacamole/proctitle.h>
 #include <guacamole/protocol.h>
 #include <guacamole/recording.h>
 #include <guacamole/tcp.h>
@@ -358,6 +359,10 @@ static void __guac_telnet_event_handler(telnet_t* telnet, telnet_event_t* event,
  */
 static void* __guac_telnet_input_thread(void* data) {

+    /* Thread name telnet-stdin: reads terminal STDIN and forwards it to the
+     * telnet server. */
+    guac_thread_name_set("telnet-stdin");
+
     guac_client* client = (guac_client*) data;
     guac_telnet_client* telnet_client = (guac_telnet_client*) client->data;

@@ -491,6 +496,10 @@ static int __guac_telnet_wait(int socket_fd) {

 void* guac_telnet_client_thread(void* data) {

+    /* Thread name telnet-worker: main telnet client thread; runs the
+     * telnet session and event loop. */
+    guac_thread_name_set("telnet-worker");
+
     guac_client* client = (guac_client*) data;
     guac_telnet_client* telnet_client = (guac_telnet_client*) client->data;
     guac_telnet_settings* settings = telnet_client->settings;
@@ -499,6 +508,11 @@ void* guac_telnet_client_thread(void* data) {
     char buffer[8192];
     int wait_result;

+    const char* telnet_port = settings->port != NULL
+            ? settings->port : GUAC_TELNET_DEFAULT_PORT;
+    guac_process_title_set_endpoint(GUAC_TELNET_PROCESS_TITLE_NAME,
+            settings->username, settings->hostname, telnet_port);
+
     /* If Wake-on-LAN is enabled, attempt to wake. */
     if (settings->wol_send_packet) {

diff --git a/src/protocols/vnc/settings.h b/src/protocols/vnc/settings.h
index f63cf807..ac914f66 100644
--- a/src/protocols/vnc/settings.h
+++ b/src/protocols/vnc/settings.h
@@ -22,6 +22,12 @@

 #include <stdbool.h>

+/**
+ * The protocol label included in the process title (the first argument passed
+ * to guac_process_title_set_endpoint()), as seen in `ps`/`top`.
+ */
+#define GUAC_VNC_PROCESS_TITLE_NAME "vnc"
+
 /**
  * The filename to use for the screen recording, if not specified.
  */
diff --git a/src/protocols/vnc/vnc.c b/src/protocols/vnc/vnc.c
index 1d8ab0c7..3a1c2479 100644
--- a/src/protocols/vnc/vnc.c
+++ b/src/protocols/vnc/vnc.c
@@ -42,6 +42,7 @@
 #include <guacamole/client.h>
 #include <guacamole/display.h>
 #include <guacamole/mem.h>
+#include <guacamole/proctitle.h>
 #include <guacamole/protocol.h>
 #include <guacamole/recording.h>
 #include <guacamole/socket.h>
@@ -452,10 +453,24 @@ static rfbBool guac_vnc_handle_messages(guac_client* client) {

 void* guac_vnc_client_thread(void* data) {

+    /* Thread name vnc-worker: main VNC client thread; runs the libvncclient
+     * connection and message loop. */
+    guac_thread_name_set("vnc-worker");
+
     guac_client* client = (guac_client*) data;
     guac_vnc_client* vnc_client = (guac_vnc_client*) client->data;
     guac_vnc_settings* settings = vnc_client->settings;

+    /* VNC has no default port (0 == unspecified), so suppress a misleading
+     * ":0" in the title. */
+    char vnc_port[GUAC_USHORT_STRING_BUFSIZE];
+    if (settings->port == 0
+            || guac_itoa_safe(vnc_port, sizeof(vnc_port),
+                    settings->port) < 1)
+        vnc_port[0] = '\0';
+    guac_process_title_set_endpoint(GUAC_VNC_PROCESS_TITLE_NAME,
+            settings->username, settings->hostname, vnc_port);
+
     /* If Wake-on-LAN is enabled, attempt to wake. */
     if (settings->wol_send_packet) {

diff --git a/src/terminal/terminal.c b/src/terminal/terminal.c
index d6b4004c..a8ab2306 100644
--- a/src/terminal/terminal.c
+++ b/src/terminal/terminal.c
@@ -49,6 +49,7 @@
 #include <guacamole/error.h>
 #include <guacamole/flag.h>
 #include <guacamole/mem.h>
+#include <guacamole/proctitle.h>
 #include <guacamole/protocol.h>
 #include <guacamole/socket.h>
 #include <guacamole/string.h>
@@ -352,6 +353,10 @@ static void guac_terminal_repaint_default_layer(guac_terminal* terminal,
  */
 void* guac_terminal_thread(void* data) {

+    /* Thread name terminal: renders the terminal emulator display and
+     * processes output from the remote. */
+    guac_thread_name_set("term-render");
+
     guac_terminal* terminal = (guac_terminal*) data;
     guac_client* client = terminal->client;