Commit bc1c454e for guacamole.apache.org

commit bc1c454e92bdf51e03518412e39139907879dfa2
Author: Ciro Iriarte <ciro.iriarte@gmail.com>
Date:   Fri Jul 17 12:12:15 2026 -0300

    GUACAMOLE-2301: Add serial reverse (listen) mode for inbound connections

    Add a reverse/listen mode for network serial connections: instead of
    dialing out to a gateway, guacd binds a local TCP port and accepts a
    single inbound connection from the device (e.g. a QEMU/KVM or VMware
    virtual serial port, or a console server configured to dial out).

    Network fd acquisition is factored into guac_serial_net_open_fd(), used
    by both the raw and RFC2217 backends, which either dials out or, when
    reverse-connect is set, binds/listens/accepts. The accepted socket feeds
    the existing raw/RFC2217 framing unchanged, and the auto-reconnect path
    re-listens for the next inbound connection on drop.

    New parameters: reverse-connect, bind-address (default 127.0.0.1),
    listen-timeout (default 60s). In reverse mode hostname is optional.

diff --git a/src/protocols/serial/rfc2217.c b/src/protocols/serial/rfc2217.c
index e3ba18d9..c7dfc333 100644
--- a/src/protocols/serial/rfc2217.c
+++ b/src/protocols/serial/rfc2217.c
@@ -22,6 +22,7 @@
 #include "rfc2217.h"
 #include "settings.h"
 #include "stream.h"
+#include "tcp.h"

 #include <guacamole/client.h>
 #include <guacamole/protocol.h>
@@ -239,40 +240,11 @@ int guac_serial_rfc2217_open(guac_serial_stream* stream, guac_client* client,
         { -1, 0, 0 }
     };

-    /* Connect to the remote serial server */
-    int fd = guac_tcp_connect(settings->hostname, settings->port,
-            settings->timeout);
-    if (fd < 0) {
-
-        int err = errno;
-
-        /* Distinguish an actively-refused or reset endpoint from other
-         * failures. The client is not aborted here so the caller may retry. */
-        if (err == ECONNREFUSED) {
-            guac_client_log(client, GUAC_LOG_ERROR, "ser2net endpoint "
-                    "\"%s:%s\" refused the connection: %s", settings->hostname,
-                    settings->port, strerror(err));
-            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
-        }
-        else if (err == ECONNRESET) {
-            guac_client_log(client, GUAC_LOG_ERROR, "Connection to ser2net "
-                    "endpoint \"%s:%s\" was reset: %s", settings->hostname,
-                    settings->port, strerror(err));
-            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
-        }
-        else {
-            guac_client_log(client, GUAC_LOG_ERROR, "Unable to connect to "
-                    "serial server \"%s\" port \"%s\".", settings->hostname,
-                    settings->port);
-            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND;
-        }
-
+    /* Acquire the network socket (dialing out, or accepting an inbound
+     * connection in reverse mode). On failure open_status is already set. */
+    int fd = guac_serial_net_open_fd(stream, client, settings);
+    if (fd < 0)
         return -1;
-    }
-
-    /* Disable Nagle's algorithm so keystrokes are sent without delay */
-    int flag = 1;
-    setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));

     /* Store the socket before initializing libtelnet, as the event handler
      * writes outbound data to this descriptor */
diff --git a/src/protocols/serial/serial.c b/src/protocols/serial/serial.c
index ff8f1b93..448881fa 100644
--- a/src/protocols/serial/serial.c
+++ b/src/protocols/serial/serial.c
@@ -116,6 +116,8 @@ static void guac_serial_endpoint(guac_serial_settings* settings, char* buffer,
         int size) {
     if (settings->type == GUAC_SERIAL_TYPE_LOCAL)
         snprintf(buffer, size, "%s", settings->device);
+    else if (settings->reverse_connect)
+        snprintf(buffer, size, "listen:%s", settings->port);
     else
         snprintf(buffer, size, "%s:%s", settings->hostname, settings->port);
 }
@@ -439,9 +441,15 @@ void* guac_serial_client_thread(void* data) {
             settings->baud_rate, settings->data_bits,
             guac_serial_parity_letter(settings->parity), settings->stop_bits);

-    /* Set process title to the device or hostname reached by this connection */
-    const char* title_host = settings->type == GUAC_SERIAL_TYPE_LOCAL
-            ? settings->device : settings->hostname;
+    /* Set process title to the device, listen endpoint, or hostname reached by
+     * this connection */
+    const char* title_host;
+    if (settings->type == GUAC_SERIAL_TYPE_LOCAL)
+        title_host = settings->device;
+    else if (settings->reverse_connect)
+        title_host = endpoint;
+    else
+        title_host = settings->hostname;
     guac_process_title_set_endpoint(GUAC_SERIAL_PROCESS_TITLE_NAME,
             NULL, title_host, NULL);

diff --git a/src/protocols/serial/settings.c b/src/protocols/serial/settings.c
index ddb68cc8..1a63d5dd 100644
--- a/src/protocols/serial/settings.c
+++ b/src/protocols/serial/settings.c
@@ -78,6 +78,9 @@ const char* GUAC_SERIAL_CLIENT_ARGS[] = {
     "line-ending",
     "local-echo",
     "hangup-on-close",
+    "reverse-connect",
+    "listen-timeout",
+    "bind-address",
     NULL
 };

@@ -310,6 +313,24 @@ enum SERIAL_ARGS_IDX {
      */
     IDX_HANGUP_ON_CLOSE,

+    /**
+     * Whether guacd should listen for an inbound connection from the device
+     * (reverse mode) instead of dialing out. Defaults to false.
+     */
+    IDX_REVERSE_CONNECT,
+
+    /**
+     * The number of seconds to wait for an inbound connection in reverse mode
+     * before failing. Zero or negative means wait indefinitely.
+     */
+    IDX_LISTEN_TIMEOUT,
+
+    /**
+     * The local address to bind when listening in reverse mode. Defaults to
+     * "127.0.0.1".
+     */
+    IDX_BIND_ADDRESS,
+
     SERIAL_ARGS_COUNT
 };

@@ -530,6 +551,30 @@ guac_serial_settings* guac_serial_parse_args(guac_user* user,
         guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
                 IDX_TIMEOUT, GUAC_SERIAL_DEFAULT_TIMEOUT);

+    /* Parse reverse (listen) mode flag */
+    settings->reverse_connect =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_REVERSE_CONNECT, false);
+
+    /* Parse listen timeout, treating negative values as "wait indefinitely" */
+    settings->listen_timeout =
+        guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_LISTEN_TIMEOUT, GUAC_SERIAL_DEFAULT_LISTEN_TIMEOUT);
+    if (settings->listen_timeout < 0)
+        settings->listen_timeout = 0;
+
+    /* Parse local bind address used when listening in reverse mode */
+    settings->bind_address =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_BIND_ADDRESS, "127.0.0.1");
+
+    /* Reverse mode is meaningless for local connections */
+    if (settings->reverse_connect && settings->type == GUAC_SERIAL_TYPE_LOCAL) {
+        guac_user_log(user, GUAC_LOG_WARNING, "reverse-connect has no effect on "
+                "local serial connections; ignoring.");
+        settings->reverse_connect = false;
+    }
+
     /* Validate transport-specific requirements */
     if (settings->type == GUAC_SERIAL_TYPE_LOCAL) {
         if (settings->device == NULL || settings->device[0] == '\0') {
@@ -540,7 +585,10 @@ guac_serial_settings* guac_serial_parse_args(guac_user* user,
         }
     }
     else {
-        if (settings->hostname == NULL || settings->hostname[0] == '\0') {
+        /* In reverse mode guacd binds and listens rather than dialing out, so
+         * a hostname is only required when dialing out */
+        if (!settings->reverse_connect
+                && (settings->hostname == NULL || settings->hostname[0] == '\0')) {
             guac_user_log(user, GUAC_LOG_ERROR, "A \"hostname\" is required for "
                     "network serial connections.");
             guac_serial_settings_free(settings);
@@ -827,6 +875,7 @@ void guac_serial_settings_free(guac_serial_settings* settings) {
     guac_mem_free(settings->allowed_devices);
     guac_mem_free(settings->hostname);
     guac_mem_free(settings->port);
+    guac_mem_free(settings->bind_address);

     /* Free display preferences */
     guac_mem_free(settings->font_name);
diff --git a/src/protocols/serial/settings.h b/src/protocols/serial/settings.h
index 65dab32e..f221be0a 100644
--- a/src/protocols/serial/settings.h
+++ b/src/protocols/serial/settings.h
@@ -50,6 +50,12 @@
  */
 #define GUAC_SERIAL_DEFAULT_BREAK_DURATION 500

+/**
+ * The default number of seconds to wait for an inbound connection in reverse
+ * mode before failing.
+ */
+#define GUAC_SERIAL_DEFAULT_LISTEN_TIMEOUT 60
+
 /**
  * The interval, in seconds, between automatic reconnection attempts after the
  * serial connection is dropped.
@@ -192,6 +198,24 @@ typedef struct guac_serial_settings {
      */
     char* port;

+    /**
+     * Whether guacd should listen for an inbound connection from the device
+     * (reverse mode) instead of dialing out. Only meaningful for network type.
+     */
+    bool reverse_connect;
+
+    /**
+     * Seconds to wait for an inbound connection in reverse mode before failing.
+     * Zero or negative means wait indefinitely (bounded by client shutdown).
+     */
+    int listen_timeout;
+
+    /**
+     * Local address to bind when listening in reverse mode. NULL/empty =>
+     * 127.0.0.1.
+     */
+    char* bind_address;
+
     /**
      * The number of seconds to wait for a connection before timing out.
      */
diff --git a/src/protocols/serial/tcp.c b/src/protocols/serial/tcp.c
index a45594a0..6b73f973 100644
--- a/src/protocols/serial/tcp.c
+++ b/src/protocols/serial/tcp.c
@@ -24,52 +24,242 @@
 #include "tcp.h"

 #include <guacamole/client.h>
+#include <guacamole/error.h>
 #include <guacamole/protocol.h>
 #include <guacamole/tcp.h>

 #include <errno.h>
+#include <netdb.h>
 #include <netinet/in.h>
 #include <netinet/tcp.h>
+#include <poll.h>
 #include <string.h>
+#include <sys/socket.h>
+#include <unistd.h>

-int guac_serial_tcp_open(guac_serial_stream* stream, guac_client* client,
-        guac_serial_settings* settings) {
+/**
+ * Binds and listens on the address/port described by the given settings and
+ * accepts a single inbound connection from the device (reverse mode). The wait
+ * for the inbound connection honors both the configured listen timeout and
+ * client shutdown. On success the accepted socket is returned; TCP_NODELAY is
+ * left to the caller (guac_serial_net_open_fd).
+ *
+ * @param stream
+ *     The serial stream whose open_status is set on failure.
+ *
+ * @param client
+ *     The client for which the connection is being opened.
+ *
+ * @param settings
+ *     The parsed connection settings describing the address/port to listen on.
+ *
+ * @return
+ *     The accepted file descriptor on success, or -1 on failure with
+ *     stream->open_status set and the reason logged.
+ */
+static int guac_serial_reverse_accept(guac_serial_stream* stream,
+        guac_client* client, guac_serial_settings* settings) {

-    /* Connect to the remote serial server */
-    int fd = guac_tcp_connect(settings->hostname, settings->port,
-            settings->timeout);
-    if (fd < 0) {
+    /* Default to loopback if no bind address was configured */
+    const char* bind_addr = (settings->bind_address && settings->bind_address[0])
+            ? settings->bind_address : "127.0.0.1";

-        int err = errno;
+    /* Resolve the local address to bind and listen on */
+    struct addrinfo hints = {
+        .ai_family   = AF_UNSPEC,
+        .ai_socktype = SOCK_STREAM,
+        .ai_flags    = AI_PASSIVE
+    };

-        /* Distinguish an actively-refused or reset endpoint from other
-         * failures (e.g. name resolution, timeout) */
-        if (err == ECONNREFUSED) {
-            guac_client_log(client, GUAC_LOG_ERROR, "ser2net endpoint "
-                    "\"%s:%s\" refused the connection: %s", settings->hostname,
-                    settings->port, strerror(err));
-            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
-        }
-        else if (err == ECONNRESET) {
-            guac_client_log(client, GUAC_LOG_ERROR, "Connection to ser2net "
-                    "endpoint \"%s:%s\" was reset: %s", settings->hostname,
-                    settings->port, strerror(err));
-            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
+    struct addrinfo* addrs;
+    int err = getaddrinfo(bind_addr, settings->port, &hints, &addrs);
+    if (err != 0) {
+        guac_client_log(client, GUAC_LOG_ERROR, "Unable to resolve bind "
+                "address \"%s\" for reverse serial connection: %s", bind_addr,
+                gai_strerror(err));
+        stream->open_status = GUAC_PROTOCOL_STATUS_SERVER_ERROR;
+        return -1;
+    }
+
+    /* Bind to the first address for which a socket can be created and bound */
+    int listen_fd = -1;
+    for (struct addrinfo* addr = addrs; addr != NULL; addr = addr->ai_next) {
+
+        listen_fd = socket(addr->ai_family,
+                addr->ai_socktype | SOCK_CLOEXEC, addr->ai_protocol);
+        if (listen_fd < 0)
+            continue;
+
+        /* Allow re-listening after a reconnect despite lingering TIME_WAIT */
+        int reuse = 1;
+        setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
+
+        if (bind(listen_fd, addr->ai_addr, addr->ai_addrlen) == 0)
+            break;
+
+        close(listen_fd);
+        listen_fd = -1;
+
+    }
+
+    freeaddrinfo(addrs);
+
+    if (listen_fd < 0) {
+        guac_client_log(client, GUAC_LOG_ERROR, "Unable to bind %s:%s for "
+                "reverse serial connection: %s", bind_addr, settings->port,
+                strerror(errno));
+        stream->open_status = GUAC_PROTOCOL_STATUS_SERVER_ERROR;
+        return -1;
+    }
+
+    /* Listen for a single inbound connection */
+    if (listen(listen_fd, 1) != 0) {
+        guac_client_log(client, GUAC_LOG_ERROR, "Unable to listen on %s:%s for "
+                "reverse serial connection: %s", bind_addr, settings->port,
+                strerror(errno));
+        close(listen_fd);
+        stream->open_status = GUAC_PROTOCOL_STATUS_SERVER_ERROR;
+        return -1;
+    }
+
+    guac_client_log(client, GUAC_LOG_INFO, "Listening on %s:%s for an inbound "
+            "serial connection...", bind_addr, settings->port);
+
+    /* Wait for one inbound connection, honoring both the listen timeout and
+     * client shutdown. Poll in 1000ms slices so teardown stays responsive. */
+    int timeout = settings->listen_timeout;   /* seconds; <= 0 waits forever */
+    int waited_ms = 0;
+    while (client->state == GUAC_CLIENT_RUNNING) {
+
+        struct pollfd pfd = { .fd = listen_fd, .events = POLLIN };
+        int pr = poll(&pfd, 1, 1000);
+        if (pr < 0) {
+            if (errno == EINTR)
+                continue;
+            guac_client_log(client, GUAC_LOG_WARNING, "Error while waiting for "
+                    "an inbound serial connection on %s:%s: %s", bind_addr,
+                    settings->port, strerror(errno));
+            break;
         }
-        else {
-            guac_client_log(client, GUAC_LOG_ERROR, "Unable to connect to "
-                    "serial server \"%s\" port \"%s\".", settings->hostname,
-                    settings->port);
-            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND;
+
+        /* An inbound connection is ready to accept */
+        if (pr > 0 && (pfd.revents & POLLIN))
+            break;
+
+        /* Otherwise a slice elapsed; enforce the listen timeout if configured */
+        if (timeout > 0) {
+            waited_ms += 1000;
+            if (waited_ms >= timeout * 1000) {
+                guac_client_log(client, GUAC_LOG_ERROR, "Timed out after %ds "
+                        "waiting for an inbound serial connection on %s:%s",
+                        timeout, bind_addr, settings->port);
+                close(listen_fd);
+                stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_TIMEOUT;
+                return -1;
+            }
         }

+    }
+
+    /* Abort if the client is shutting down rather than accepting */
+    if (client->state != GUAC_CLIENT_RUNNING) {
+        close(listen_fd);
+        stream->open_status = GUAC_PROTOCOL_STATUS_SERVER_ERROR;
+        return -1;
+    }
+
+    /* Accept the single inbound connection and stop listening */
+    struct sockaddr_storage peer;
+    socklen_t plen = sizeof(peer);
+    int fd;
+    GUAC_RETRY_EINTR(fd, accept(listen_fd, (struct sockaddr*) &peer, &plen));
+    close(listen_fd);
+
+    if (fd < 0) {
+        guac_client_log(client, GUAC_LOG_ERROR, "Failed to accept inbound "
+                "serial connection on %s:%s: %s", bind_addr, settings->port,
+                strerror(errno));
+        stream->open_status = GUAC_PROTOCOL_STATUS_SERVER_ERROR;
         return -1;
     }

+    /* Log the peer which connected */
+    char host[NI_MAXHOST] = "?";
+    char serv[NI_MAXSERV] = "?";
+    getnameinfo((struct sockaddr*) &peer, plen, host, sizeof(host),
+            serv, sizeof(serv), NI_NUMERICHOST | NI_NUMERICSERV);
+    guac_client_log(client, GUAC_LOG_INFO, "Accepted inbound serial connection "
+            "from %s:%s", host, serv);
+
+    /* Keep an idle inbound link alive so a silent device drop is detected */
+    int keepalive = 1;
+    setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
+
+    return fd;
+
+}
+
+int guac_serial_net_open_fd(guac_serial_stream* stream, guac_client* client,
+        guac_serial_settings* settings) {
+
+    int fd;
+
+    /* In reverse mode, accept an inbound connection rather than dialing out.
+     * guac_serial_reverse_accept() already logs and sets open_status. */
+    if (settings->reverse_connect) {
+        fd = guac_serial_reverse_accept(stream, client, settings);
+        if (fd < 0)
+            return -1;
+    }
+
+    /* Otherwise dial out to the remote serial server */
+    else {
+        fd = guac_tcp_connect(settings->hostname, settings->port,
+                settings->timeout);
+        if (fd < 0) {
+
+            int err = errno;
+
+            /* Distinguish an actively-refused or reset endpoint from other
+             * failures (e.g. name resolution, timeout) */
+            if (err == ECONNREFUSED) {
+                guac_client_log(client, GUAC_LOG_ERROR, "ser2net endpoint "
+                        "\"%s:%s\" refused the connection: %s",
+                        settings->hostname, settings->port, strerror(err));
+                stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
+            }
+            else if (err == ECONNRESET) {
+                guac_client_log(client, GUAC_LOG_ERROR, "Connection to ser2net "
+                        "endpoint \"%s:%s\" was reset: %s", settings->hostname,
+                        settings->port, strerror(err));
+                stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
+            }
+            else {
+                guac_client_log(client, GUAC_LOG_ERROR, "Unable to connect to "
+                        "serial server \"%s\" port \"%s\".", settings->hostname,
+                        settings->port);
+                stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND;
+            }
+
+            return -1;
+        }
+    }
+
     /* Disable Nagle's algorithm so keystrokes are sent without delay */
     int flag = 1;
     setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));

+    return fd;
+
+}
+
+int guac_serial_tcp_open(guac_serial_stream* stream, guac_client* client,
+        guac_serial_settings* settings) {
+
+    int fd = guac_serial_net_open_fd(stream, client, settings);
+    if (fd < 0)
+        return -1;
+
     stream->backend = GUAC_SERIAL_BACKEND_TCP;
     stream->fd = fd;
     return 0;
diff --git a/src/protocols/serial/tcp.h b/src/protocols/serial/tcp.h
index 638dcefa..c1e182a9 100644
--- a/src/protocols/serial/tcp.h
+++ b/src/protocols/serial/tcp.h
@@ -45,4 +45,29 @@
 int guac_serial_tcp_open(guac_serial_stream* stream, guac_client* client,
         guac_serial_settings* settings);

+/**
+ * Acquires the network file descriptor for a serial connection. In reverse mode
+ * (settings->reverse_connect) guacd binds, listens, and accepts a single
+ * inbound connection from the device; otherwise it dials out via
+ * guac_tcp_connect(). TCP_NODELAY is set on the resulting socket on success.
+ * This is shared by both the raw TCP and RFC2217 backends so that reverse mode
+ * need only be implemented once.
+ *
+ * @param stream
+ *     The serial stream whose open_status is set on failure.
+ *
+ * @param client
+ *     The client for which the connection is being opened.
+ *
+ * @param settings
+ *     The parsed connection settings describing the server to connect to or the
+ *     address/port to listen on.
+ *
+ * @return
+ *     The acquired file descriptor on success, or -1 on failure with
+ *     stream->open_status set and the reason logged.
+ */
+int guac_serial_net_open_fd(guac_serial_stream* stream, guac_client* client,
+        guac_serial_settings* settings);
+
 #endif