Commit 72dea256 for guacamole.apache.org

commit 72dea2561cbbd857ca47082f26bec43f51ae13a0
Author: Ciro Iriarte <ciro.iriarte@gmail.com>
Date:   Tue Jul 14 11:16:49 2026 -0300

    GUACAMOLE-2301: Add serial console protocol (local device, ser2net raw TCP, RFC2217)

    Adds a new "serial" terminal protocol plugin for browser access to serial
    consoles (switch/router/PDU administration). Three transports select via the
    serial-type/network-protocol parameters: a local tty device configured with
    termios, raw TCP to a ser2net endpoint, and RFC2217 (telnet COM-PORT-OPTION,
    compiled only when libtelnet is available via ENABLE_SERIAL_RFC2217).

    Supports configurable send-break (break-duration) through a client-agnostic
    serial-control pipe, paste pacing (paste-delay), and a device-path allowlist,
    reusing the shared terminal for recording/typescript/clipboard/color-scheme,
    mirroring the telnet/ipmi plugin structure.

diff --git a/Makefile.am b/Makefile.am
index 6ce3814a..97983363 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -37,6 +37,7 @@ DIST_SUBDIRS =               \
     src/pulse                \
     src/protocols/kubernetes \
     src/protocols/rdp        \
+    src/protocols/serial     \
     src/protocols/ssh        \
     src/protocols/telnet     \
     src/protocols/vnc
@@ -69,6 +70,10 @@ if ENABLE_SSH
 SUBDIRS += src/protocols/ssh
 endif

+if ENABLE_SERIAL
+SUBDIRS += src/protocols/serial
+endif
+
 if ENABLE_TELNET
 SUBDIRS += src/protocols/telnet
 endif
diff --git a/configure.ac b/configure.ac
index 9bd4b8b2..22948941 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1386,6 +1386,19 @@ fi
 AM_CONDITIONAL([ENABLE_TELNET], [test "x${have_libtelnet}"  = "xyes" \
                                    -a "x${have_terminal}" = "xyes"])

+# Serial console support. The core plugin (local devices and raw TCP) requires
+# only the terminal. The RFC2217 network transport additionally requires
+# libtelnet and is compiled in conditionally (see ENABLE_SERIAL_RFC2217).
+AM_CONDITIONAL([ENABLE_SERIAL], [test "x${have_terminal}" = "xyes"])
+
+# RFC2217 (COM-PORT-OPTION) serial transport, built on libtelnet. When
+# libtelnet is unavailable the serial plugin still builds; only the RFC2217
+# network transport is omitted.
+AM_CONDITIONAL([ENABLE_SERIAL_RFC2217], [test "x${have_libtelnet}" = "xyes"])
+AS_IF([test "x${have_libtelnet}" = "xyes"],
+      [AC_DEFINE([ENABLE_SERIAL_RFC2217], [1],
+                 [Define if RFC2217 serial transport (libtelnet) is available])])
+
 AC_SUBST(TELNET_LIBS)

 #
@@ -1571,6 +1584,7 @@ AC_CONFIG_FILES([Makefile
                  src/protocols/kubernetes/tests/Makefile
                  src/protocols/rdp/Makefile
                  src/protocols/rdp/tests/Makefile
+                 src/protocols/serial/Makefile
                  src/protocols/ssh/Makefile
                  src/protocols/telnet/Makefile
                  src/protocols/vnc/Makefile])
@@ -1583,6 +1597,7 @@ AC_OUTPUT
 AM_COND_IF([ENABLE_KUBERNETES], [build_kubernetes=yes], [build_kubernetes=no])
 AM_COND_IF([ENABLE_RDP],        [build_rdp=yes],        [build_rdp=no])
 AM_COND_IF([ENABLE_SSH],        [build_ssh=yes],        [build_ssh=no])
+AM_COND_IF([ENABLE_SERIAL],     [build_serial=yes],     [build_serial=no])
 AM_COND_IF([ENABLE_TELNET],     [build_telnet=yes],     [build_telnet=no])
 AM_COND_IF([ENABLE_VNC],        [build_vnc=yes],        [build_vnc=no])

@@ -1645,6 +1660,8 @@ $PACKAGE_NAME version $PACKAGE_VERSION
       Kubernetes .... ${build_kubernetes}
       RDP ........... ${build_rdp}
       SSH ........... ${build_ssh}
+      Serial ........ ${build_serial}
+      RFC2217 (serial) .... ${have_libtelnet}
       Telnet ........ ${build_telnet}
       VNC ........... ${build_vnc}

diff --git a/src/protocols/serial/Makefile.am b/src/protocols/serial/Makefile.am
new file mode 100644
index 00000000..f9cbcc46
--- /dev/null
+++ b/src/protocols/serial/Makefile.am
@@ -0,0 +1,72 @@
+#
+# 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.
+#
+# NOTE: Parts of this file (Makefile.am) are automatically transcluded verbatim
+# into Makefile.in. Though the build system (GNU Autotools) automatically adds
+# its own license boilerplate to the generated Makefile.in, that boilerplate
+# does not apply to the transcluded portions of Makefile.am which are licensed
+# to you by the ASF under the Apache License, Version 2.0, as described above.
+#
+
+AUTOMAKE_OPTIONS = foreign
+ACLOCAL_AMFLAGS = -I m4
+
+lib_LTLIBRARIES = libguac-client-serial.la
+
+libguac_client_serial_la_SOURCES = \
+    argv.c                          \
+    client.c                        \
+    clipboard.c                     \
+    input.c                         \
+    local.c                         \
+    pipe.c                          \
+    rfc2217.c                       \
+    serial.c                        \
+    settings.c                      \
+    stream.c                        \
+    tcp.c                           \
+    user.c
+
+noinst_HEADERS = \
+    argv.h       \
+    client.h     \
+    clipboard.h  \
+    input.h      \
+    local.h      \
+    pipe.h       \
+    rfc2217.h    \
+    serial.h     \
+    settings.h   \
+    stream.h     \
+    tcp.h        \
+    user.h
+
+libguac_client_serial_la_CFLAGS = \
+    -Werror -Wall -Iinclude        \
+    @LIBGUAC_INCLUDE@             \
+    @TERMINAL_INCLUDE@
+
+libguac_client_serial_la_LIBADD = \
+    @COMMON_LTLIB@                \
+    @LIBGUAC_LTLIB@               \
+    @TERMINAL_LTLIB@
+
+libguac_client_serial_la_LDFLAGS = \
+    -version-info 0:0:0            \
+    @PTHREAD_LIBS@                 \
+    @TELNET_LIBS@
diff --git a/src/protocols/serial/argv.c b/src/protocols/serial/argv.c
new file mode 100644
index 00000000..94a101bf
--- /dev/null
+++ b/src/protocols/serial/argv.c
@@ -0,0 +1,91 @@
+/*
+ * 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 "argv.h"
+#include "serial.h"
+#include "terminal/terminal.h"
+
+#include <guacamole/protocol.h>
+#include <guacamole/socket.h>
+#include <guacamole/user.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+int guac_serial_argv_callback(guac_user* user, const char* mimetype,
+        const char* name, const char* value, void* data) {
+
+    guac_client* client = user->client;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+    guac_terminal* terminal = serial_client->term;
+
+    /* Update color scheme */
+    if (strcmp(name, GUAC_SERIAL_ARGV_COLOR_SCHEME) == 0)
+        guac_terminal_apply_color_scheme(terminal, value);
+
+    /* Update font name */
+    else if (strcmp(name, GUAC_SERIAL_ARGV_FONT_NAME) == 0)
+        guac_terminal_apply_font(terminal, value, -1, 0);
+
+    /* Update only if font size is sane */
+    else if (strcmp(name, GUAC_SERIAL_ARGV_FONT_SIZE) == 0) {
+        int size = atoi(value);
+        if (size > 0)
+            guac_terminal_apply_font(terminal, NULL, size,
+                    serial_client->settings->resolution);
+    }
+
+    return 0;
+
+}
+
+void* guac_serial_send_current_argv(guac_user* user, void* data) {
+
+    /* Defer to the batch handler, using the user's socket to send the data */
+    guac_serial_send_current_argv_batch(user->client, user->socket);
+
+    return NULL;
+
+}
+
+void guac_serial_send_current_argv_batch(
+        guac_client* client, guac_socket* socket) {
+
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+    guac_terminal* terminal = serial_client->term;
+
+    /* Send current color scheme */
+    guac_client_stream_argv(client, socket, "text/plain",
+            GUAC_SERIAL_ARGV_COLOR_SCHEME,
+            guac_terminal_get_color_scheme(terminal));
+
+    /* Send current font name */
+    guac_client_stream_argv(client, socket, "text/plain",
+            GUAC_SERIAL_ARGV_FONT_NAME,
+            guac_terminal_get_font_name(terminal));
+
+    /* Send current font size */
+    char font_size[64];
+    sprintf(font_size, "%i", guac_terminal_get_font_size(terminal));
+    guac_client_stream_argv(client, socket, "text/plain",
+            GUAC_SERIAL_ARGV_FONT_SIZE, font_size);
+
+}
diff --git a/src/protocols/serial/argv.h b/src/protocols/serial/argv.h
new file mode 100644
index 00000000..80af66bb
--- /dev/null
+++ b/src/protocols/serial/argv.h
@@ -0,0 +1,89 @@
+/*
+ * 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_SERIAL_ARGV_H
+#define GUAC_SERIAL_ARGV_H
+
+#include <guacamole/argv.h>
+#include <guacamole/user.h>
+
+/**
+ * The name of the parameter that specifies/updates the color scheme used by
+ * the terminal emulator.
+ */
+#define GUAC_SERIAL_ARGV_COLOR_SCHEME "color-scheme"
+
+/**
+ * The name of the parameter that specifies/updates the name of the font used
+ * by the terminal emulator.
+ */
+#define GUAC_SERIAL_ARGV_FONT_NAME "font-name"
+
+/**
+ * The name of the parameter that specifies/updates the font size used by the
+ * terminal emulator.
+ */
+#define GUAC_SERIAL_ARGV_FONT_SIZE "font-size"
+
+/**
+ * Handles a received argument value from a Guacamole "argv" instruction,
+ * updating the given connection parameter.
+ */
+guac_argv_callback guac_serial_argv_callback;
+
+/**
+ * Sends the current values of all non-sensitive parameters which may be set
+ * while the connection is running to the given user. Note that the user
+ * receiving these values will not necessarily be able to set new values
+ * themselves if their connection is read-only. This function can be used as
+ * the callback for guac_client_foreach_user() and guac_client_for_owner()
+ *
+ * @param user
+ *     The user that should receive the values of all non-sensitive parameters
+ *     which may be set while the connection is running.
+ *
+ * @param data
+ *     The guac_serial_client instance associated with the current connection.
+ *
+ * @return
+ *     Always NULL.
+ */
+void* guac_serial_send_current_argv(guac_user* user, void* data);
+
+/**
+ * Sends the current values of all non-sensitive parameters which may be set
+ * while the connection is running to the users associated with the provided
+ * socket. Note that the users receiving these values will not necessarily be
+ * able to set new values themselves if their connection is read-only.
+ *
+ * @param client
+ *     The client associated with the users that should receive the values of
+ *     all non-sensitive parameters which may be set while the connection is
+ *     running.
+ *
+ * @param socket
+ *     The socket to send the arguments to the batch of users along.
+ *
+ * @return
+ *     Always NULL.
+ */
+void guac_serial_send_current_argv_batch(
+        guac_client* client, guac_socket* socket);
+
+#endif
diff --git a/src/protocols/serial/client.c b/src/protocols/serial/client.c
new file mode 100644
index 00000000..4969bb89
--- /dev/null
+++ b/src/protocols/serial/client.c
@@ -0,0 +1,146 @@
+/*
+ * 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 "argv.h"
+#include "client.h"
+#include "serial.h"
+#include "settings.h"
+#include "stream.h"
+#include "user.h"
+
+#include <langinfo.h>
+#include <locale.h>
+#include <pthread.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <guacamole/argv.h>
+#include <guacamole/client.h>
+#include <guacamole/mem.h>
+#include <guacamole/recording.h>
+#include <guacamole/socket.h>
+
+/**
+ * A pending join handler implementation that will synchronize the connection
+ * state for all pending users prior to them being promoted to full user.
+ *
+ * @param client
+ *     The client whose pending users are about to be promoted.
+ *
+ * @return
+ *     Always zero.
+ */
+static int guac_serial_join_pending_handler(guac_client* client) {
+
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+
+    /* Synchronize the terminal state to all pending users */
+    if (serial_client->term != NULL) {
+        guac_socket* broadcast_socket = client->pending_socket;
+        guac_terminal_sync_users(serial_client->term, client, broadcast_socket);
+        guac_serial_send_current_argv_batch(client, broadcast_socket);
+        guac_socket_flush(broadcast_socket);
+    }
+
+    return 0;
+
+}
+
+int guac_client_init(guac_client* client) {
+
+    /* Set client args */
+    client->args = GUAC_SERIAL_CLIENT_ARGS;
+
+    /* Allocate client instance data */
+    guac_serial_client* serial_client = guac_mem_zalloc(sizeof(guac_serial_client));
+    client->data = serial_client;
+
+    /* Set handlers */
+    client->join_handler = guac_serial_user_join_handler;
+    client->join_pending_handler = guac_serial_join_pending_handler;
+    client->free_handler = guac_serial_client_free_handler;
+    client->leave_handler = guac_serial_user_leave_handler;
+
+    /* Register handlers for argument values that may be sent after the handshake */
+    guac_argv_register(GUAC_SERIAL_ARGV_COLOR_SCHEME, guac_serial_argv_callback, NULL, GUAC_ARGV_OPTION_ECHO);
+    guac_argv_register(GUAC_SERIAL_ARGV_FONT_NAME, guac_serial_argv_callback, NULL, GUAC_ARGV_OPTION_ECHO);
+    guac_argv_register(GUAC_SERIAL_ARGV_FONT_SIZE, guac_serial_argv_callback, NULL, GUAC_ARGV_OPTION_ECHO);
+
+    /* Set locale and warn if not UTF-8 */
+    setlocale(LC_CTYPE, "");
+    if (strcmp(nl_langinfo(CODESET), "UTF-8") != 0) {
+        guac_client_log(client, GUAC_LOG_INFO,
+                "Current locale does not use UTF-8. Some characters may "
+                "not render correctly.");
+    }
+
+    /* Success */
+    return 0;
+
+}
+
+int guac_serial_client_free_handler(guac_client* client) {
+
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+
+    /* Close the serial stream's file descriptor, unblocking the client
+     * thread's read loop. The descriptor is cleared so it is not closed a
+     * second time when the stream is later freed. */
+    if (serial_client->stream != NULL && serial_client->stream->fd != -1) {
+        close(serial_client->stream->fd);
+        serial_client->stream->fd = -1;
+    }
+
+    /* Stop the terminal — closing its input pipe to unblock the input thread —
+     * WITHOUT freeing it yet. The client thread may still be writing serial
+     * output to the terminal until it has fully exited, so the terminal must
+     * outlive the join below to avoid a use-after-free. */
+    if (serial_client->term != NULL)
+        guac_terminal_stop(serial_client->term);
+
+    /* Wait for the client thread to fully exit before touching anything it
+     * uses. Joined only when it was successfully created, so early-failure
+     * setup paths neither leak the joinable thread nor join an invalid one. */
+    if (serial_client->client_thread_valid) {
+        pthread_join(serial_client->client_thread, NULL);
+        serial_client->client_thread_valid = 0;
+    }
+
+    /* Clean up recording, if in progress */
+    if (serial_client->recording != NULL)
+        guac_recording_free(serial_client->recording);
+
+    /* With no thread left to touch it, the terminal can now be freed safely */
+    if (serial_client->term != NULL)
+        guac_terminal_free(serial_client->term);
+
+    /* Free the serial stream (frees any libtelnet session) */
+    if (serial_client->stream != NULL)
+        guac_serial_stream_close(serial_client->stream);
+
+    /* Free settings */
+    if (serial_client->settings != NULL)
+        guac_serial_settings_free(serial_client->settings);
+
+    guac_mem_free(serial_client);
+    return 0;
+
+}
diff --git a/src/protocols/serial/client.h b/src/protocols/serial/client.h
new file mode 100644
index 00000000..b31ac89b
--- /dev/null
+++ b/src/protocols/serial/client.h
@@ -0,0 +1,34 @@
+/*
+ * 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_SERIAL_CLIENT_H
+#define GUAC_SERIAL_CLIENT_H
+
+#include "terminal/terminal.h"
+
+#include <pthread.h>
+#include <sys/types.h>
+
+/**
+ * Free handler. Required by libguac and called when the guac_client is
+ * disconnected and must be cleaned up.
+ */
+guac_client_free_handler guac_serial_client_free_handler;
+
+#endif
diff --git a/src/protocols/serial/clipboard.c b/src/protocols/serial/clipboard.c
new file mode 100644
index 00000000..53a59456
--- /dev/null
+++ b/src/protocols/serial/clipboard.c
@@ -0,0 +1,78 @@
+/*
+ * 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 "clipboard.h"
+#include "serial.h"
+#include "terminal/terminal.h"
+
+#include <guacamole/client.h>
+#include <guacamole/stream.h>
+#include <guacamole/user.h>
+
+int guac_serial_clipboard_handler(guac_user* user, guac_stream* stream,
+        char* mimetype) {
+
+    /* Clear clipboard and prepare for new data */
+    guac_client* client = user->client;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+    guac_terminal_clipboard_reset(serial_client->term, mimetype);
+
+    /* Set handlers for clipboard stream */
+    stream->blob_handler = guac_serial_clipboard_blob_handler;
+    stream->end_handler = guac_serial_clipboard_end_handler;
+
+    /* Report clipboard within recording */
+    if (serial_client->recording != NULL)
+        guac_recording_report_clipboard_begin(serial_client->recording, stream,
+                mimetype);
+
+    return 0;
+}
+
+int guac_serial_clipboard_blob_handler(guac_user* user, guac_stream* stream,
+        void* data, int length) {
+
+    guac_client* client = user->client;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+
+    /* Report clipboard blob within recording */
+    if (serial_client->recording != NULL)
+        guac_recording_report_clipboard_blob(serial_client->recording, stream,
+                data, length);
+
+    /* Append new data */
+    guac_terminal_clipboard_append(serial_client->term, data, length);
+
+    return 0;
+}
+
+int guac_serial_clipboard_end_handler(guac_user* user, guac_stream* stream) {
+
+    guac_client* client = user->client;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+
+    /* Report clipboard stream end within recording */
+    if (serial_client->recording != NULL)
+        guac_recording_report_clipboard_end(serial_client->recording, stream);
+
+    /* Nothing to do - clipboard is implemented within client */
+
+    return 0;
+}
diff --git a/src/protocols/serial/clipboard.h b/src/protocols/serial/clipboard.h
new file mode 100644
index 00000000..63520cae
--- /dev/null
+++ b/src/protocols/serial/clipboard.h
@@ -0,0 +1,40 @@
+/*
+ * 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_SERIAL_CLIPBOARD_H
+#define GUAC_SERIAL_CLIPBOARD_H
+
+#include <guacamole/user.h>
+
+/**
+ * Handler for inbound clipboard streams.
+ */
+guac_user_clipboard_handler guac_serial_clipboard_handler;
+
+/**
+ * Handler for data received along clipboard streams.
+ */
+guac_user_blob_handler guac_serial_clipboard_blob_handler;
+
+/**
+ * Handler for end-of-stream related to clipboard.
+ */
+guac_user_end_handler guac_serial_clipboard_end_handler;
+
+#endif
diff --git a/src/protocols/serial/input.c b/src/protocols/serial/input.c
new file mode 100644
index 00000000..1147e242
--- /dev/null
+++ b/src/protocols/serial/input.c
@@ -0,0 +1,108 @@
+/*
+ * 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 "input.h"
+#include "serial.h"
+#include "stream.h"
+#include "terminal/terminal.h"
+
+#include <guacamole/client.h>
+#include <guacamole/recording.h>
+#include <guacamole/user.h>
+
+#include <stdlib.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+int guac_serial_user_mouse_handler(guac_user* user, int x, int y, int mask) {
+
+    guac_client* client = user->client;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+    guac_terminal* term = serial_client->term;
+
+    /* Skip if terminal not yet ready */
+    if (term == NULL)
+        return 0;
+
+    /* Report mouse position within recording */
+    if (serial_client->recording != NULL)
+        guac_recording_report_mouse(serial_client->recording, x, y, mask);
+
+    /* Send mouse event to terminal */
+    guac_terminal_send_mouse(term, user, x, y, mask);
+
+    return 0;
+
+}
+
+int guac_serial_user_key_handler(guac_user* user, int keysym, int pressed) {
+
+    guac_client* client = user->client;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+    guac_terminal* term = serial_client->term;
+
+    /* Report key state within recording */
+    if (serial_client->recording != NULL)
+        guac_recording_report_key(serial_client->recording, keysym, pressed);
+
+    /* Skip if terminal not yet ready */
+    if (term == NULL)
+        return 0;
+
+    /* Intercept and handle Pause / Break / Ctrl+0 as a serial break */
+    if (pressed && (
+                keysym == 0xFF13                  /* Pause */
+             || keysym == 0xFF6B                  /* Break */
+             || (
+                    guac_terminal_get_mod_ctrl(term)
+                    && keysym == '0'
+                )                                 /* Ctrl + 0 */
+       )) {
+
+        /* Send a serial break if the connection is established */
+        if (serial_client->stream != NULL)
+            guac_serial_stream_send_break(serial_client->stream);
+
+        return 0;
+    }
+
+    /* Send key */
+    guac_terminal_send_key(term, keysym, pressed);
+
+    return 0;
+
+}
+
+int guac_serial_user_size_handler(guac_user* user, int width, int height) {
+
+    /* Get terminal */
+    guac_client* client = user->client;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+    guac_terminal* terminal = serial_client->term;
+
+    /* Skip if terminal not yet ready */
+    if (terminal == NULL)
+        return 0;
+
+    /* Resize terminal */
+    guac_terminal_resize(terminal, width, height);
+
+    return 0;
+}
diff --git a/src/protocols/serial/input.h b/src/protocols/serial/input.h
new file mode 100644
index 00000000..8237ac79
--- /dev/null
+++ b/src/protocols/serial/input.h
@@ -0,0 +1,43 @@
+/*
+ * 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_SERIAL_INPUT_H
+#define GUAC_SERIAL_INPUT_H
+
+#include <guacamole/user.h>
+
+/**
+ * Handler for key events. Required by libguac and called whenever key events
+ * are received.
+ */
+guac_user_key_handler guac_serial_user_key_handler;
+
+/**
+ * Handler for mouse events. Required by libguac and called whenever mouse
+ * events are received.
+ */
+guac_user_mouse_handler guac_serial_user_mouse_handler;
+
+/**
+ * Handler for size events. Required by libguac and called whenever the remote
+ * display (window) is resized.
+ */
+guac_user_size_handler guac_serial_user_size_handler;
+
+#endif
diff --git a/src/protocols/serial/local.c b/src/protocols/serial/local.c
new file mode 100644
index 00000000..d4b01a9b
--- /dev/null
+++ b/src/protocols/serial/local.c
@@ -0,0 +1,180 @@
+/*
+ * 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 "local.h"
+#include "settings.h"
+#include "stream.h"
+
+#include <guacamole/client.h>
+#include <guacamole/protocol.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <termios.h>
+#include <unistd.h>
+
+int guac_serial_local_open(guac_serial_stream* stream, guac_client* client,
+        guac_serial_settings* settings) {
+
+    /* Open device non-blocking so open() does not wait for carrier detect */
+    int fd = open(settings->device, O_RDWR | O_NOCTTY | O_NONBLOCK);
+    if (fd < 0) {
+        int status = (errno == ENOENT)
+                ? GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND
+                : GUAC_PROTOCOL_STATUS_SERVER_ERROR;
+        guac_client_abort(client, status,
+                "Unable to open serial device \"%s\": %s",
+                settings->device, strerror(errno));
+        return -1;
+    }
+
+    /* Request exclusive access; warn but continue if unsupported */
+    if (ioctl(fd, TIOCEXCL) != 0)
+        guac_client_log(client, GUAC_LOG_WARNING, "Unable to obtain exclusive "
+                "access to serial device \"%s\": %s", settings->device,
+                strerror(errno));
+
+    /* Read current line settings */
+    struct termios tio;
+    if (tcgetattr(fd, &tio) != 0) {
+        guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
+                "Unable to read attributes of serial device \"%s\": %s",
+                settings->device, strerror(errno));
+        close(fd);
+        return -1;
+    }
+
+    /* Start from a fully raw configuration */
+    cfmakeraw(&tio);
+
+    /* Apply line speed */
+    speed_t speed = guac_serial_baud_to_speed(settings->baud_rate);
+    cfsetispeed(&tio, speed);
+    cfsetospeed(&tio, speed);
+
+    /* Ignore modem control lines and enable the receiver */
+    tio.c_cflag |= (CLOCAL | CREAD);
+
+    /* Apply data bits */
+    tio.c_cflag &= ~CSIZE;
+    switch (settings->data_bits) {
+        case 5: tio.c_cflag |= CS5; break;
+        case 6: tio.c_cflag |= CS6; break;
+        case 7: tio.c_cflag |= CS7; break;
+        default: tio.c_cflag |= CS8; break;
+    }
+
+    /* Apply parity */
+    switch (settings->parity) {
+
+        case GUAC_SERIAL_PARITY_NONE:
+            tio.c_cflag &= ~PARENB;
+            break;
+
+        case GUAC_SERIAL_PARITY_EVEN:
+            tio.c_cflag |= PARENB;
+            tio.c_cflag &= ~PARODD;
+            break;
+
+        case GUAC_SERIAL_PARITY_ODD:
+            tio.c_cflag |= (PARENB | PARODD);
+            break;
+
+        case GUAC_SERIAL_PARITY_MARK:
+#ifdef CMSPAR
+            tio.c_cflag |= (PARENB | CMSPAR | PARODD);
+#else
+            guac_client_log(client, GUAC_LOG_WARNING, "Mark parity is not "
+                    "supported on this platform; using no parity.");
+            tio.c_cflag &= ~PARENB;
+#endif
+            break;
+
+        case GUAC_SERIAL_PARITY_SPACE:
+#ifdef CMSPAR
+            tio.c_cflag |= (PARENB | CMSPAR);
+            tio.c_cflag &= ~PARODD;
+#else
+            guac_client_log(client, GUAC_LOG_WARNING, "Space parity is not "
+                    "supported on this platform; using no parity.");
+            tio.c_cflag &= ~PARENB;
+#endif
+            break;
+
+    }
+
+    /* Apply stop bits */
+    if (settings->stop_bits == 2)
+        tio.c_cflag |= CSTOPB;
+    else
+        tio.c_cflag &= ~CSTOPB;
+
+    /* Apply flow control */
+    switch (settings->flow_control) {
+
+        case GUAC_SERIAL_FLOW_NONE:
+            tio.c_iflag &= ~(IXON | IXOFF | IXANY);
+#ifdef CRTSCTS
+            tio.c_cflag &= ~CRTSCTS;
+#endif
+            break;
+
+        case GUAC_SERIAL_FLOW_RTS_CTS:
+            tio.c_iflag &= ~(IXON | IXOFF | IXANY);
+#ifdef CRTSCTS
+            tio.c_cflag |= CRTSCTS;
+#else
+            guac_client_log(client, GUAC_LOG_WARNING, "Hardware (RTS/CTS) flow "
+                    "control is not supported on this platform; using no flow "
+                    "control.");
+#endif
+            break;
+
+        case GUAC_SERIAL_FLOW_XON_XOFF:
+#ifdef CRTSCTS
+            tio.c_cflag &= ~CRTSCTS;
+#endif
+            tio.c_iflag |= (IXON | IXOFF);
+            break;
+
+    }
+
+    /* Block for at least one byte per read, with no inter-byte timeout */
+    tio.c_cc[VMIN] = 1;
+    tio.c_cc[VTIME] = 0;
+
+    /* Apply configuration immediately and discard any pending I/O */
+    if (tcsetattr(fd, TCSANOW, &tio) != 0) {
+        guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
+                "Unable to configure serial device \"%s\": %s",
+                settings->device, strerror(errno));
+        close(fd);
+        return -1;
+    }
+    tcflush(fd, TCIOFLUSH);
+
+    stream->backend = GUAC_SERIAL_BACKEND_LOCAL;
+    stream->fd = fd;
+    return 0;
+
+}
diff --git a/src/protocols/serial/local.h b/src/protocols/serial/local.h
new file mode 100644
index 00000000..40eccc7a
--- /dev/null
+++ b/src/protocols/serial/local.h
@@ -0,0 +1,49 @@
+/*
+ * 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_SERIAL_LOCAL_H
+#define GUAC_SERIAL_LOCAL_H
+
+#include "settings.h"
+#include "stream.h"
+
+#include <guacamole/client.h>
+
+/**
+ * Opens the local serial device described by the given settings, configuring
+ * its termios line discipline (baud rate, data/stop bits, parity, and flow
+ * control) for raw I/O, and stores the resulting file descriptor in the given
+ * stream. On failure, the client is aborted.
+ *
+ * @param stream
+ *     The serial stream to populate with the opened device.
+ *
+ * @param client
+ *     The client for which the device is being opened.
+ *
+ * @param settings
+ *     The parsed connection settings describing the device to open.
+ *
+ * @return
+ *     Zero on success, non-zero on failure.
+ */
+int guac_serial_local_open(guac_serial_stream* stream, guac_client* client,
+        guac_serial_settings* settings);
+
+#endif
diff --git a/src/protocols/serial/pipe.c b/src/protocols/serial/pipe.c
new file mode 100644
index 00000000..8d8171d7
--- /dev/null
+++ b/src/protocols/serial/pipe.c
@@ -0,0 +1,197 @@
+/*
+ * 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 "pipe.h"
+#include "serial.h"
+#include "stream.h"
+#include "terminal/terminal.h"
+
+#include <guacamole/mem.h>
+#include <guacamole/protocol.h>
+#include <guacamole/socket.h>
+#include <guacamole/user.h>
+
+#include <ctype.h>
+#include <string.h>
+
+/**
+ * The maximum length, in bytes, of a single control command token.
+ */
+#define GUAC_SERIAL_CONTROL_TOKEN_LENGTH 64
+
+/**
+ * Per-inbound-stream reassembly state for the control pipe. Control commands
+ * are whitespace-delimited plain-text tokens which may span multiple blobs.
+ */
+typedef struct guac_serial_control_stream {
+
+    /**
+     * Buffer accumulating the current (possibly partial) command token.
+     */
+    char token[GUAC_SERIAL_CONTROL_TOKEN_LENGTH];
+
+    /**
+     * The number of bytes currently held in the token buffer.
+     */
+    int length;
+
+} guac_serial_control_stream;
+
+/**
+ * Executes a single, complete control command token.
+ *
+ * @param user
+ *     The user which sent the command.
+ *
+ * @param token
+ *     The null-terminated command token.
+ */
+static void guac_serial_control_dispatch(guac_user* user, const char* token) {
+
+    guac_client* client = user->client;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+
+    /* Send a serial break */
+    if (strcmp(token, "break") == 0) {
+        if (serial_client->stream != NULL)
+            guac_serial_stream_send_break(serial_client->stream);
+    }
+
+    /* Ignore unrecognized commands */
+    else
+        guac_client_log(client, GUAC_LOG_DEBUG, "Ignoring unknown serial "
+                "control command: \"%s\".", token);
+
+}
+
+/**
+ * Blob handler for the inbound control pipe: reassembles whitespace-delimited
+ * plain-text tokens and dispatches each complete token.
+ */
+static int guac_serial_control_blob_handler(guac_user* user,
+        guac_stream* stream, void* data, int length) {
+
+    guac_serial_control_stream* cs =
+        (guac_serial_control_stream*) stream->data;
+    const char* in = (const char*) data;
+
+    for (int i = 0; i < length; i++) {
+
+        char c = in[i];
+
+        /* A whitespace character completes the current token */
+        if (isspace((unsigned char) c)) {
+            if (cs->length > 0) {
+                cs->token[cs->length] = '\0';
+                guac_serial_control_dispatch(user, cs->token);
+                cs->length = 0;
+            }
+        }
+
+        /* Accumulate non-whitespace characters into the current token */
+        else if (cs->length < GUAC_SERIAL_CONTROL_TOKEN_LENGTH - 1)
+            cs->token[cs->length++] = c;
+
+        /* Oversized token; drop it to resynchronize on the next whitespace */
+        else
+            cs->length = 0;
+
+    }
+
+    guac_protocol_send_ack(user->socket, stream, "OK",
+            GUAC_PROTOCOL_STATUS_SUCCESS);
+    guac_socket_flush(user->socket);
+    return 0;
+
+}
+
+/**
+ * End handler for the inbound control pipe. Dispatches any final unterminated
+ * token and releases the reassembly state.
+ */
+static int guac_serial_control_end_handler(guac_user* user,
+        guac_stream* stream) {
+
+    guac_serial_control_stream* cs =
+        (guac_serial_control_stream*) stream->data;
+
+    /* Dispatch any trailing token not followed by whitespace */
+    if (cs != NULL && cs->length > 0) {
+        cs->token[cs->length] = '\0';
+        guac_serial_control_dispatch(user, cs->token);
+    }
+
+    guac_mem_free(stream->data);
+    stream->data = NULL;
+    return 0;
+
+}
+
+/**
+ * Sets up an inbound "serial-control" pipe stream just opened by the given
+ * user, installing the blob/end handlers that parse and dispatch control
+ * commands.
+ *
+ * @param user
+ *     The user which opened the control pipe.
+ *
+ * @param stream
+ *     The inbound pipe stream.
+ */
+static void guac_serial_control_open(guac_user* user, guac_stream* stream) {
+
+    guac_serial_control_stream* cs =
+        guac_mem_zalloc(sizeof(guac_serial_control_stream));
+
+    stream->data = cs;
+    stream->blob_handler = guac_serial_control_blob_handler;
+    stream->end_handler = guac_serial_control_end_handler;
+
+    guac_protocol_send_ack(user->socket, stream, "OK",
+            GUAC_PROTOCOL_STATUS_SUCCESS);
+    guac_socket_flush(user->socket);
+
+}
+
+int guac_serial_pipe_handler(guac_user* user, guac_stream* stream,
+        char* mimetype, char* name) {
+
+    guac_client* client = user->client;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+
+    /* Redirect STDIN if pipe has required name */
+    if (strcmp(name, GUAC_SERIAL_STDIN_PIPE_NAME) == 0) {
+        guac_terminal_send_stream(serial_client->term, user, stream);
+        return 0;
+    }
+
+    /* Out-of-band control channel */
+    if (strcmp(name, GUAC_SERIAL_CONTROL_PIPE_NAME) == 0) {
+        guac_serial_control_open(user, stream);
+        return 0;
+    }
+
+    /* No other inbound pipe streams are supported */
+    guac_protocol_send_ack(user->socket, stream, "No such input stream.",
+            GUAC_PROTOCOL_STATUS_RESOURCE_NOT_FOUND);
+    guac_socket_flush(user->socket);
+    return 0;
+
+}
diff --git a/src/protocols/serial/pipe.h b/src/protocols/serial/pipe.h
new file mode 100644
index 00000000..c6f02cbe
--- /dev/null
+++ b/src/protocols/serial/pipe.h
@@ -0,0 +1,47 @@
+/*
+ * 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_SERIAL_PIPE_H
+#define GUAC_SERIAL_PIPE_H
+
+#include <guacamole/user.h>
+
+/**
+ * The name reserved for the inbound pipe stream which forces the terminal
+ * emulator's STDIN to be received from the pipe.
+ */
+#define GUAC_SERIAL_STDIN_PIPE_NAME "STDIN"
+
+/**
+ * The name reserved for the inbound out-of-band control pipe stream. A client
+ * opens an inbound pipe stream with this name to send plain-text, whitespace-
+ * delimited control commands to the serial connection. The only command
+ * currently supported is "break", which sends a serial break.
+ */
+#define GUAC_SERIAL_CONTROL_PIPE_NAME "serial-control"
+
+/**
+ * Handles an incoming stream from a Guacamole "pipe" instruction. If the pipe
+ * is named "STDIN", the contents of the pipe stream are redirected to STDIN of
+ * the terminal emulator for as long as the pipe is open. If the pipe is named
+ * "serial-control", the contents are parsed as plain-text control commands.
+ */
+guac_user_pipe_handler guac_serial_pipe_handler;
+
+#endif
diff --git a/src/protocols/serial/rfc2217.c b/src/protocols/serial/rfc2217.c
new file mode 100644
index 00000000..48502df5
--- /dev/null
+++ b/src/protocols/serial/rfc2217.c
@@ -0,0 +1,361 @@
+/*
+ * 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 "rfc2217.h"
+#include "settings.h"
+#include "stream.h"
+
+#include <guacamole/client.h>
+#include <guacamole/protocol.h>
+
+#ifdef ENABLE_SERIAL_RFC2217
+
+#include "serial.h"
+#include "terminal/terminal.h"
+
+#include <guacamole/error.h>
+#include <guacamole/tcp.h>
+#include <libtelnet.h>
+
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <stdint.h>
+#include <unistd.h>
+
+/**
+ * The telnet option number of the RFC2217 COM-PORT-OPTION. libtelnet does not
+ * define this option, so it is defined here.
+ */
+#ifndef TELNET_TELOPT_COMPORT
+#define TELNET_TELOPT_COMPORT 44
+#endif
+
+/**
+ * COM-PORT-OPTION client-to-server command: set the serial line speed. The
+ * data is a 4-byte big-endian unsigned integer giving the speed in bits per
+ * second.
+ */
+#define GUAC_SERIAL_RFC2217_SET_BAUDRATE 1
+
+/**
+ * COM-PORT-OPTION client-to-server command: set the number of data bits. The
+ * data is a single byte (5 through 8).
+ */
+#define GUAC_SERIAL_RFC2217_SET_DATASIZE 2
+
+/**
+ * COM-PORT-OPTION client-to-server command: set the parity. The data is a
+ * single byte (1=none, 2=odd, 3=even, 4=mark, 5=space).
+ */
+#define GUAC_SERIAL_RFC2217_SET_PARITY 3
+
+/**
+ * COM-PORT-OPTION client-to-server command: set the number of stop bits. The
+ * data is a single byte (1=1 stop bit, 2=2 stop bits).
+ */
+#define GUAC_SERIAL_RFC2217_SET_STOPSIZE 4
+
+/**
+ * COM-PORT-OPTION client-to-server command: set line/flow control and break
+ * state. The data is a single byte. For flow control: 1=none, 2=xon/xoff,
+ * 3=rts/cts. For the break signal: 5=break on, 6=break off.
+ */
+#define GUAC_SERIAL_RFC2217_SET_CONTROL 5
+
+/**
+ * Writes the entire buffer given to the specified file descriptor, retrying
+ * the write automatically if necessary.
+ *
+ * @param fd
+ *     The file descriptor to write to.
+ *
+ * @param buffer
+ *     The buffer to write.
+ *
+ * @param size
+ *     The number of bytes from the buffer to write.
+ *
+ * @return
+ *     The number of bytes written, or a value not equal to size if an error
+ *     prevents all future writes.
+ */
+static int guac_serial_rfc2217_write_all(int fd, const char* buffer, int size) {
+
+    int remaining = size;
+    while (remaining > 0) {
+
+        int ret_val;
+        GUAC_RETRY_EINTR(ret_val, write(fd, buffer, remaining));
+        if (ret_val <= 0)
+            return -1;
+
+        remaining -= ret_val;
+        buffer += ret_val;
+
+    }
+
+    return size;
+
+}
+
+/**
+ * Sends a single-byte COM-PORT-OPTION subnegotiation.
+ */
+static void guac_serial_rfc2217_send_byte(telnet_t* telnet,
+        unsigned char command, unsigned char value) {
+    telnet_begin_sb(telnet, TELNET_TELOPT_COMPORT);
+    telnet_send(telnet, (char*) &command, 1);
+    telnet_send(telnet, (char*) &value, 1);
+    telnet_finish_sb(telnet);
+}
+
+/**
+ * Sends the serial line parameters described by the given settings using
+ * client-to-server COM-PORT-OPTION subnegotiations.
+ */
+static void guac_serial_rfc2217_set_line(telnet_t* telnet,
+        guac_serial_settings* settings) {
+
+    /* SET-BAUDRATE: 4-byte big-endian speed in bits per second */
+    uint32_t baud = (uint32_t) settings->baud_rate;
+    unsigned char rate[5] = {
+        GUAC_SERIAL_RFC2217_SET_BAUDRATE,
+        (unsigned char) ((baud >> 24) & 0xFF),
+        (unsigned char) ((baud >> 16) & 0xFF),
+        (unsigned char) ((baud >>  8) & 0xFF),
+        (unsigned char) ( baud        & 0xFF)
+    };
+    telnet_begin_sb(telnet, TELNET_TELOPT_COMPORT);
+    telnet_send(telnet, (char*) rate, sizeof(rate));
+    telnet_finish_sb(telnet);
+
+    /* SET-DATASIZE */
+    guac_serial_rfc2217_send_byte(telnet, GUAC_SERIAL_RFC2217_SET_DATASIZE,
+            (unsigned char) settings->data_bits);
+
+    /* SET-PARITY (1=none, 2=odd, 3=even, 4=mark, 5=space) */
+    unsigned char parity;
+    switch (settings->parity) {
+        case GUAC_SERIAL_PARITY_ODD:   parity = 2; break;
+        case GUAC_SERIAL_PARITY_EVEN:  parity = 3; break;
+        case GUAC_SERIAL_PARITY_MARK:  parity = 4; break;
+        case GUAC_SERIAL_PARITY_SPACE: parity = 5; break;
+        default:                       parity = 1; break;
+    }
+    guac_serial_rfc2217_send_byte(telnet, GUAC_SERIAL_RFC2217_SET_PARITY,
+            parity);
+
+    /* SET-STOPSIZE (1=1 stop bit, 2=2 stop bits) */
+    guac_serial_rfc2217_send_byte(telnet, GUAC_SERIAL_RFC2217_SET_STOPSIZE,
+            (unsigned char) settings->stop_bits);
+
+    /* SET-CONTROL flow control (1=none, 2=xon/xoff, 3=rts/cts) */
+    unsigned char flow;
+    switch (settings->flow_control) {
+        case GUAC_SERIAL_FLOW_XON_XOFF: flow = 2; break;
+        case GUAC_SERIAL_FLOW_RTS_CTS:  flow = 3; break;
+        default:                        flow = 1; break;
+    }
+    guac_serial_rfc2217_send_byte(telnet, GUAC_SERIAL_RFC2217_SET_CONTROL,
+            flow);
+
+}
+
+/**
+ * Event handler, as defined by libtelnet. Invoked for every event fired by the
+ * RFC2217 libtelnet session. The user data is the guac_serial_stream.
+ */
+static void guac_serial_rfc2217_event_handler(telnet_t* telnet,
+        telnet_event_t* event, void* data) {
+
+    guac_serial_stream* stream = (guac_serial_stream*) data;
+    guac_client* client = stream->client;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+
+    switch (event->type) {
+
+        /* Terminal output received */
+        case TELNET_EV_DATA:
+            guac_terminal_write(serial_client->term, event->data.buffer,
+                    event->data.size);
+            break;
+
+        /* Data destined for remote end */
+        case TELNET_EV_SEND:
+            if (guac_serial_rfc2217_write_all(stream->fd, event->data.buffer,
+                    event->data.size) != (int) event->data.size)
+                guac_client_stop(client);
+            break;
+
+        /* Connection warnings */
+        case TELNET_EV_WARNING:
+            guac_client_log(client, GUAC_LOG_WARNING, "%s", event->error.msg);
+            break;
+
+        /* Connection errors */
+        case TELNET_EV_ERROR:
+            guac_client_abort(client, GUAC_PROTOCOL_STATUS_UPSTREAM_ERROR,
+                    "RFC2217 connection closing with error: %s",
+                    event->error.msg);
+            break;
+
+        /* Ignore other events */
+        default:
+            break;
+
+    }
+
+}
+
+int guac_serial_rfc2217_open(guac_serial_stream* stream, guac_client* client,
+        guac_serial_settings* settings) {
+
+    /* Support levels for the telnet options used by the RFC2217 session */
+    static const telnet_telopt_t rfc2217_options[] = {
+        { TELNET_TELOPT_COMPORT, TELNET_WILL, TELNET_DONT },
+        { TELNET_TELOPT_BINARY,  TELNET_WILL, TELNET_DO   },
+        { TELNET_TELOPT_SGA,     TELNET_WILL, TELNET_DO   },
+        { TELNET_TELOPT_ECHO,    TELNET_WONT, TELNET_DO   },
+        { -1, 0, 0 }
+    };
+
+    /* Connect to the remote serial server */
+    int fd = guac_tcp_connect(settings->hostname, settings->port,
+            settings->timeout);
+    if (fd < 0) {
+        guac_client_abort(client, GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND,
+                "Unable to connect to serial server \"%s\" port \"%s\".",
+                settings->hostname, settings->port);
+        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 */
+    stream->backend = GUAC_SERIAL_BACKEND_RFC2217;
+    stream->fd = fd;
+
+    /* Initialize the libtelnet session, passing the stream as user data */
+    telnet_t* telnet = telnet_init(rfc2217_options,
+            guac_serial_rfc2217_event_handler, 0, stream);
+    if (telnet == NULL) {
+        guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
+                "RFC2217 client allocation failed.");
+        close(fd);
+        stream->fd = -1;
+        return -1;
+    }
+
+    stream->telnet = telnet;
+
+    /* Advertise our willingness to use COM-PORT-OPTION and binary mode */
+    telnet_negotiate(telnet, TELNET_WILL, TELNET_TELOPT_COMPORT);
+    telnet_negotiate(telnet, TELNET_WILL, TELNET_TELOPT_BINARY);
+    telnet_negotiate(telnet, TELNET_DO, TELNET_TELOPT_BINARY);
+
+    /* Push the configured serial line parameters to the remote end */
+    guac_serial_rfc2217_set_line(telnet, settings);
+
+    return 0;
+
+}
+
+void guac_serial_rfc2217_send(guac_serial_stream* stream, const char* buffer,
+        int length) {
+    telnet_send((telnet_t*) stream->telnet, buffer, length);
+}
+
+void guac_serial_rfc2217_recv(guac_serial_stream* stream, const char* buffer,
+        int length) {
+    telnet_recv((telnet_t*) stream->telnet, buffer, length);
+}
+
+int guac_serial_rfc2217_send_break(guac_serial_stream* stream) {
+
+    telnet_t* telnet = (telnet_t*) stream->telnet;
+
+    /* SET-CONTROL: break on */
+    guac_serial_rfc2217_send_byte(telnet, GUAC_SERIAL_RFC2217_SET_CONTROL, 5);
+
+    /* Hold the line low for the configured duration */
+    if (stream->break_duration > 0)
+        usleep((useconds_t) stream->break_duration * 1000);
+
+    /* SET-CONTROL: break off */
+    guac_serial_rfc2217_send_byte(telnet, GUAC_SERIAL_RFC2217_SET_CONTROL, 6);
+
+    return 0;
+
+}
+
+void guac_serial_rfc2217_free(guac_serial_stream* stream) {
+    if (stream->telnet != NULL) {
+        telnet_free((telnet_t*) stream->telnet);
+        stream->telnet = NULL;
+    }
+}
+
+#else /* ENABLE_SERIAL_RFC2217 */
+
+int guac_serial_rfc2217_open(guac_serial_stream* stream, guac_client* client,
+        guac_serial_settings* settings) {
+
+    (void) stream;
+    (void) settings;
+
+    guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
+            "RFC2217 support was not compiled in "
+            "(rebuild guacamole-server with libtelnet).");
+    return -1;
+
+}
+
+void guac_serial_rfc2217_send(guac_serial_stream* stream, const char* buffer,
+        int length) {
+    /* RFC2217 unavailable; a stream with this backend is never created */
+    (void) stream;
+    (void) buffer;
+    (void) length;
+}
+
+void guac_serial_rfc2217_recv(guac_serial_stream* stream, const char* buffer,
+        int length) {
+    /* RFC2217 unavailable; a stream with this backend is never created */
+    (void) stream;
+    (void) buffer;
+    (void) length;
+}
+
+int guac_serial_rfc2217_send_break(guac_serial_stream* stream) {
+    /* RFC2217 unavailable; a stream with this backend is never created */
+    (void) stream;
+    return 0;
+}
+
+void guac_serial_rfc2217_free(guac_serial_stream* stream) {
+    /* No libtelnet session can exist when RFC2217 is not compiled in */
+    (void) stream;
+}
+
+#endif /* ENABLE_SERIAL_RFC2217 */
diff --git a/src/protocols/serial/rfc2217.h b/src/protocols/serial/rfc2217.h
new file mode 100644
index 00000000..971f6a22
--- /dev/null
+++ b/src/protocols/serial/rfc2217.h
@@ -0,0 +1,119 @@
+/*
+ * 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_SERIAL_RFC2217_H
+#define GUAC_SERIAL_RFC2217_H
+
+#include "settings.h"
+#include "stream.h"
+
+#include <guacamole/client.h>
+
+/*
+ * This header intentionally does not depend on libtelnet. All libtelnet usage
+ * is confined to rfc2217.c and compiled only when ENABLE_SERIAL_RFC2217 is
+ * defined (i.e. libtelnet is available). When it is not, the functions below
+ * are still defined, but fail or become no-ops, so the rest of the plugin
+ * links and runs (with the RFC2217 transport unavailable).
+ */
+
+/**
+ * Opens an RFC2217 (telnet COM-PORT-OPTION) connection to the network serial
+ * server described by the given settings, negotiates the configured serial line
+ * parameters, and stores the resulting socket and libtelnet session in the
+ * given stream. On failure, the client is aborted.
+ *
+ * If RFC2217 support was not compiled in, the client is aborted and -1 is
+ * returned.
+ *
+ * @param stream
+ *     The serial stream to populate with the opened socket and session.
+ *
+ * @param client
+ *     The client for which the connection is being opened.
+ *
+ * @param settings
+ *     The parsed connection settings describing the server to connect to and
+ *     the serial line parameters to negotiate.
+ *
+ * @return
+ *     Zero on success, non-zero on failure.
+ */
+int guac_serial_rfc2217_open(guac_serial_stream* stream, guac_client* client,
+        guac_serial_settings* settings);
+
+/**
+ * Sends the given buffer to the remote end of an RFC2217 connection, applying
+ * telnet IAC framing. This must only be called on a stream whose backend is
+ * GUAC_SERIAL_BACKEND_RFC2217.
+ *
+ * @param stream
+ *     The RFC2217 serial stream to write to.
+ *
+ * @param buffer
+ *     The buffer of data to send.
+ *
+ * @param length
+ *     The number of bytes from the buffer to send.
+ */
+void guac_serial_rfc2217_send(guac_serial_stream* stream, const char* buffer,
+        int length);
+
+/**
+ * Feeds the given buffer of bytes received from the remote end into the
+ * RFC2217 libtelnet parser, which will de-frame the data and write terminal
+ * output. This must only be called on a stream whose backend is
+ * GUAC_SERIAL_BACKEND_RFC2217.
+ *
+ * @param stream
+ *     The RFC2217 serial stream that received the data.
+ *
+ * @param buffer
+ *     The buffer of received data.
+ *
+ * @param length
+ *     The number of bytes within the buffer.
+ */
+void guac_serial_rfc2217_recv(guac_serial_stream* stream, const char* buffer,
+        int length);
+
+/**
+ * Sends a serial break to the remote end of an RFC2217 connection by holding
+ * the transmit line low for the stream's configured break duration using
+ * COM-PORT-OPTION SET-CONTROL subnegotiations. This must only be called on a
+ * stream whose backend is GUAC_SERIAL_BACKEND_RFC2217.
+ *
+ * @param stream
+ *     The RFC2217 serial stream on which to send the break.
+ *
+ * @return
+ *     Zero on success, non-zero on error.
+ */
+int guac_serial_rfc2217_send_break(guac_serial_stream* stream);
+
+/**
+ * Frees the libtelnet session associated with the given stream, if any. Safe
+ * to call regardless of backend; has no effect if no session is present.
+ *
+ * @param stream
+ *     The serial stream whose libtelnet session should be freed.
+ */
+void guac_serial_rfc2217_free(guac_serial_stream* stream);
+
+#endif
diff --git a/src/protocols/serial/serial.c b/src/protocols/serial/serial.c
new file mode 100644
index 00000000..93c9c38c
--- /dev/null
+++ b/src/protocols/serial/serial.c
@@ -0,0 +1,228 @@
+/*
+ * 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 "argv.h"
+#include "rfc2217.h"
+#include "serial.h"
+#include "settings.h"
+#include "stream.h"
+#include "terminal/terminal.h"
+
+#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 <poll.h>
+#include <pthread.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+/**
+ * Input thread, started by the main serial client thread. This thread
+ * continuously reads from the terminal's STDIN and transfers all read data to
+ * the serial line.
+ *
+ * @param data
+ *     The current guac_client instance.
+ *
+ * @return
+ *     Always NULL.
+ */
+static void* guac_serial_input_thread(void* data) {
+
+    /* Thread name serial-stdin: reads terminal STDIN and forwards it to the
+     * serial line. */
+    guac_thread_name_set("serial-stdin");
+
+    guac_client* client = (guac_client*) data;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+
+    char buffer[8192];
+    int bytes_read;
+
+    /* Write all data read from STDIN to the serial stream */
+    while ((bytes_read = guac_terminal_read_stdin(serial_client->term, buffer,
+            sizeof(buffer))) > 0) {
+        if (guac_serial_stream_write(serial_client->stream, buffer, bytes_read) < 0)
+            break;
+    }
+
+    return NULL;
+
+}
+
+/**
+ * Waits for data on the given file descriptor for up to one second. The return
+ * value is identical to that of poll(): 0 on timeout, < 0 on error, and > 0
+ * when the descriptor is readable or has hung up.
+ *
+ * @param fd
+ *     The file descriptor to wait for.
+ *
+ * @return
+ *     A value greater than zero when data is available or the descriptor has
+ *     hung up, zero on timeout, and less than zero on error.
+ */
+static int guac_serial_wait(int fd) {
+
+    /* Watch for readable data as well as hangup/error conditions */
+    struct pollfd fds[] = {{
+        .fd      = fd,
+        .events  = POLLIN | POLLHUP | POLLERR,
+        .revents = 0,
+    }};
+
+    int wait_result;
+    GUAC_RETRY_EINTR(wait_result, poll(fds, 1, 1000));
+
+    return wait_result;
+
+}
+
+void* guac_serial_client_thread(void* data) {
+
+    /* Thread name serial-worker: main serial client thread; runs the serial
+     * session and event loop. */
+    guac_thread_name_set("serial-worker");
+
+    guac_client* client = (guac_client*) data;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+    guac_serial_settings* settings = serial_client->settings;
+
+    pthread_t input_thread;
+    char buffer[8192];
+    int wait_result;
+
+    /* Set process title to the device or hostname reached by this connection */
+    const char* endpoint = settings->type == GUAC_SERIAL_TYPE_LOCAL
+            ? settings->device : settings->hostname;
+    guac_process_title_set_endpoint(GUAC_SERIAL_PROCESS_TITLE_NAME,
+            NULL, endpoint, NULL);
+
+    /* Set up screen recording, if requested */
+    if (settings->recording_path != NULL) {
+        serial_client->recording = guac_recording_create(client,
+                settings->recording_path,
+                settings->recording_name,
+                settings->create_recording_path,
+                !settings->recording_exclude_output,
+                !settings->recording_exclude_mouse,
+                0, /* Touch events not supported */
+                settings->recording_include_keys,
+                settings->recording_write_existing,
+                settings->recording_include_clipboard);
+    }
+
+    /* Create terminal options with required parameters */
+    guac_terminal_options* options = guac_terminal_options_create(
+            settings->width, settings->height, settings->resolution);
+
+    /* Set optional parameters */
+    options->clipboard_buffer_size = settings->clipboard_buffer_size;
+    options->disable_copy = settings->disable_copy;
+    options->max_scrollback = settings->max_scrollback;
+    options->font_name = settings->font_name;
+    options->font_size = settings->font_size;
+    options->color_scheme = settings->color_scheme;
+    options->backspace = settings->backspace;
+    options->linux_console_keys = (strcmp(settings->terminal_type, "linux") == 0);
+
+    /* Create terminal */
+    serial_client->term = guac_terminal_create(client, options);
+
+    /* Free options struct now that it's been used */
+    guac_mem_free(options);
+
+    /* Fail if terminal init failed */
+    if (serial_client->term == NULL) {
+        guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
+                "Terminal initialization failed");
+        return NULL;
+    }
+
+    /* Set up typescript, if requested */
+    if (settings->typescript_path != NULL) {
+        guac_terminal_create_typescript(serial_client->term,
+                settings->typescript_path,
+                settings->typescript_name,
+                settings->create_typescript_path,
+                settings->typescript_write_existing);
+    }
+
+    /* Open the serial stream via the configured transport */
+    serial_client->stream = guac_serial_stream_open(client, settings);
+    if (serial_client->stream == NULL) {
+        /* Already aborted within guac_serial_stream_open() */
+        return NULL;
+    }
+
+    /* Connection established */
+    guac_client_log(client, GUAC_LOG_INFO, "Serial connection established.");
+
+    /* Allow the terminal to render; there is no login detection */
+    guac_terminal_start(serial_client->term);
+
+    /* Send current values of exposed arguments to owner only */
+    guac_client_for_owner(client, guac_serial_send_current_argv, serial_client);
+
+    /* Start input thread */
+    if (pthread_create(&input_thread, NULL, guac_serial_input_thread,
+            (void*) client)) {
+        guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
+                "Unable to start input thread");
+        return NULL;
+    }
+
+    /* While data available, write to terminal */
+    while ((wait_result = guac_serial_wait(serial_client->stream->fd)) >= 0) {
+
+        /* Resume waiting if no data available */
+        if (wait_result == 0)
+            continue;
+
+        int bytes_read;
+        GUAC_RETRY_EINTR(bytes_read,
+                read(serial_client->stream->fd, buffer, sizeof(buffer)));
+
+        /* EOF, EIO, or hangup indicates the serial line has closed */
+        if (bytes_read <= 0)
+            break;
+
+        /* RFC2217 output must be de-framed; all other transports are raw */
+        if (serial_client->stream->backend == GUAC_SERIAL_BACKEND_RFC2217)
+            guac_serial_rfc2217_recv(serial_client->stream, buffer, bytes_read);
+        else
+            guac_terminal_write(serial_client->term, buffer, bytes_read);
+
+    }
+
+    /* Kill client and wait for input thread to die */
+    guac_client_stop(client);
+    pthread_join(input_thread, NULL);
+
+    guac_client_log(client, GUAC_LOG_INFO, "Serial connection ended.");
+    return NULL;
+
+}
diff --git a/src/protocols/serial/serial.h b/src/protocols/serial/serial.h
new file mode 100644
index 00000000..2963daa3
--- /dev/null
+++ b/src/protocols/serial/serial.h
@@ -0,0 +1,83 @@
+/*
+ * 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_SERIAL_H
+#define GUAC_SERIAL_H
+
+#include "settings.h"
+#include "stream.h"
+#include "terminal/terminal.h"
+
+#include <guacamole/recording.h>
+
+#include <pthread.h>
+
+/**
+ * Serial-specific client data.
+ */
+typedef struct guac_serial_client {
+
+    /**
+     * Serial connection settings.
+     */
+    guac_serial_settings* settings;
+
+    /**
+     * The serial client thread.
+     */
+    pthread_t client_thread;
+
+    /**
+     * Whether client_thread has been successfully created and must therefore
+     * be joined during cleanup.
+     */
+    int client_thread_valid;
+
+    /**
+     * The serial byte stream connected to the serial line, or NULL if no
+     * connection has been established.
+     */
+    guac_serial_stream* stream;
+
+    /**
+     * The terminal which will render all output from the serial line.
+     */
+    guac_terminal* term;
+
+    /**
+     * The in-progress session recording, or NULL if no recording is in
+     * progress.
+     */
+    guac_recording* recording;
+
+} guac_serial_client;
+
+/**
+ * Main serial client thread, handling transfer of serial output to the
+ * terminal and user input to the serial line.
+ *
+ * @param data
+ *     The guac_client instance associated with the serial connection.
+ *
+ * @return
+ *     Always NULL.
+ */
+void* guac_serial_client_thread(void* data);
+
+#endif
diff --git a/src/protocols/serial/settings.c b/src/protocols/serial/settings.c
new file mode 100644
index 00000000..770ff906
--- /dev/null
+++ b/src/protocols/serial/settings.c
@@ -0,0 +1,795 @@
+/*
+ * 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 "argv.h"
+#include "common/defaults.h"
+#include "common/clipboard.h"
+#include "settings.h"
+#include "terminal/terminal.h"
+
+#include <guacamole/mem.h>
+#include <guacamole/string.h>
+#include <guacamole/user.h>
+
+#include <sys/types.h>
+#include <termios.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* Client plugin arguments */
+const char* GUAC_SERIAL_CLIENT_ARGS[] = {
+    "serial-type",
+    "network-protocol",
+    "device",
+    "hostname",
+    "port",
+    "timeout",
+    "baud-rate",
+    "data-bits",
+    "stop-bits",
+    "parity",
+    "flow-control",
+    "break-duration",
+    "paste-delay",
+    "allowed-devices",
+    "read-only",
+    GUAC_SERIAL_ARGV_FONT_NAME,
+    GUAC_SERIAL_ARGV_FONT_SIZE,
+    GUAC_SERIAL_ARGV_COLOR_SCHEME,
+    "scrollback",
+    "backspace",
+    "terminal-type",
+    "clipboard-buffer-size",
+    "disable-copy",
+    "disable-paste",
+    "typescript-path",
+    "typescript-name",
+    "create-typescript-path",
+    "typescript-write-existing",
+    "recording-path",
+    "recording-name",
+    "recording-exclude-output",
+    "recording-exclude-mouse",
+    "recording-include-keys",
+    "recording-include-clipboard",
+    "create-recording-path",
+    "recording-write-existing",
+    NULL
+};
+
+enum SERIAL_ARGS_IDX {
+
+    /**
+     * The transport used to reach the serial line ("local" or "network").
+     */
+    IDX_SERIAL_TYPE,
+
+    /**
+     * The network protocol used when the transport type is "network" ("raw" or
+     * "rfc2217").
+     */
+    IDX_NETWORK_PROTOCOL,
+
+    /**
+     * The absolute path of the local serial device to open (e.g.
+     * "/dev/ttyUSB0"). Required for "local" connections.
+     */
+    IDX_DEVICE,
+
+    /**
+     * The hostname of the network serial server to connect to. Required for
+     * "network" connections.
+     */
+    IDX_HOSTNAME,
+
+    /**
+     * The port of the network serial server to connect to. Required for
+     * "network" connections.
+     */
+    IDX_PORT,
+
+    /**
+     * The number of seconds to wait for the remote server to respond. Optional.
+     */
+    IDX_TIMEOUT,
+
+    /**
+     * The serial line speed, in bits per second (e.g. 9600, 115200).
+     */
+    IDX_BAUD_RATE,
+
+    /**
+     * The number of data bits per character (5, 6, 7, or 8).
+     */
+    IDX_DATA_BITS,
+
+    /**
+     * The number of stop bits per character (1 or 2).
+     */
+    IDX_STOP_BITS,
+
+    /**
+     * The parity scheme ("none", "odd", "even", "mark", or "space").
+     */
+    IDX_PARITY,
+
+    /**
+     * The flow control scheme ("none", "rts-cts", or "xon-xoff").
+     */
+    IDX_FLOW_CONTROL,
+
+    /**
+     * The duration, in milliseconds, that the transmit line is held low when
+     * sending a serial break.
+     */
+    IDX_BREAK_DURATION,
+
+    /**
+     * The delay, in milliseconds, inserted between each byte written to the
+     * serial line. Zero disables pacing.
+     */
+    IDX_PASTE_DELAY,
+
+    /**
+     * An optional ":"-separated allowlist of local device paths or path
+     * prefixes. If set, a "local" connection whose canonicalized device does
+     * not equal or fall under one of the entries is rejected.
+     */
+    IDX_ALLOWED_DEVICES,
+
+    /**
+     * "true" if this connection should be read-only (user input should be
+     * dropped), "false" or blank otherwise.
+     */
+    IDX_READ_ONLY,
+
+    /**
+     * The name of the font to use within the terminal.
+     */
+    IDX_FONT_NAME,
+
+    /**
+     * The size of the font to use within the terminal, in points.
+     */
+    IDX_FONT_SIZE,
+
+    /**
+     * The color scheme to use, as a series of semicolon-separated color-value
+     * pairs, or one of the special color scheme names.
+     */
+    IDX_COLOR_SCHEME,
+
+    /**
+     * The maximum size of the scrollback buffer in rows.
+     */
+    IDX_SCROLLBACK,
+
+    /**
+     * ASCII code, as an integer to use for the backspace key, or
+     * GUAC_TERMINAL_DEFAULT_BACKSPACE if not specified.
+     */
+    IDX_BACKSPACE,
+
+    /**
+     * The terminal emulator type that is used for rendering (e.g. "xterm" or
+     * "xterm-256color"). "linux" is used if unspecified.
+     */
+    IDX_TERMINAL_TYPE,
+
+    /**
+     * The maximum number of bytes to allow within the clipboard.
+     */
+    IDX_CLIPBOARD_BUFFER_SIZE,
+
+    /**
+     * Whether outbound clipboard access should be blocked.
+     */
+    IDX_DISABLE_COPY,
+
+    /**
+     * Whether inbound clipboard access should be blocked.
+     */
+    IDX_DISABLE_PASTE,
+
+    /**
+     * The full absolute path to the directory in which typescripts should be
+     * written.
+     */
+    IDX_TYPESCRIPT_PATH,
+
+    /**
+     * The name that should be given to typescripts which are written in the
+     * given path.
+     */
+    IDX_TYPESCRIPT_NAME,
+
+    /**
+     * Whether the specified typescript path should automatically be created if
+     * it does not yet exist.
+     */
+    IDX_CREATE_TYPESCRIPT_PATH,
+
+    /**
+     * Whether existing files should be appended to when creating a new
+     * typescript. Disabled by default.
+     */
+    IDX_TYPESCRIPT_WRITE_EXISTING,
+
+    /**
+     * The full absolute path to the directory in which screen recordings
+     * should be written.
+     */
+    IDX_RECORDING_PATH,
+
+    /**
+     * The name that should be given to screen recordings which are written in
+     * the given path.
+     */
+    IDX_RECORDING_NAME,
+
+    /**
+     * Whether output which is broadcast to each connected client should NOT be
+     * included in the session recording.
+     */
+    IDX_RECORDING_EXCLUDE_OUTPUT,
+
+    /**
+     * Whether changes to mouse state should NOT be included in the session
+     * recording.
+     */
+    IDX_RECORDING_EXCLUDE_MOUSE,
+
+    /**
+     * Whether keys pressed and released should be included in the session
+     * recording.
+     */
+    IDX_RECORDING_INCLUDE_KEYS,
+
+    /**
+     * Whether clipboard data should be included in the session recording.
+     */
+    IDX_RECORDING_INCLUDE_CLIPBOARD,
+
+    /**
+     * Whether the specified screen recording path should automatically be
+     * created if it does not yet exist.
+     */
+    IDX_CREATE_RECORDING_PATH,
+
+    /**
+     * Whether existing files should be appended to when creating a new
+     * recording. Disabled by default.
+     */
+    IDX_RECORDING_WRITE_EXISTING,
+
+    SERIAL_ARGS_COUNT
+};
+
+speed_t guac_serial_baud_to_speed(int baud_rate) {
+
+    switch (baud_rate) {
+        case 300:    return B300;
+        case 1200:   return B1200;
+        case 2400:   return B2400;
+        case 4800:   return B4800;
+        case 9600:   return B9600;
+        case 19200:  return B19200;
+        case 38400:  return B38400;
+        case 57600:  return B57600;
+        case 115200: return B115200;
+        case 230400: return B230400;
+        default:     return (speed_t) -1;
+    }
+
+}
+
+/**
+ * Parses the given transport type string into a guac_serial_type value.
+ *
+ * @param str
+ *     The transport type string ("local" or "network").
+ *
+ * @param type
+ *     A pointer to the guac_serial_type value that should receive the parsed
+ *     result.
+ *
+ * @return
+ *     Zero if the string was successfully parsed, non-zero otherwise.
+ */
+static int guac_serial_parse_type(const char* str, guac_serial_type* type) {
+    if (strcmp(str, "local") == 0)   { *type = GUAC_SERIAL_TYPE_LOCAL;   return 0; }
+    if (strcmp(str, "network") == 0) { *type = GUAC_SERIAL_TYPE_NETWORK; return 0; }
+    return 1;
+}
+
+/**
+ * Parses the given network protocol string into a
+ * guac_serial_network_protocol value.
+ *
+ * @param str
+ *     The network protocol string ("raw" or "rfc2217").
+ *
+ * @param protocol
+ *     A pointer to the value that should receive the parsed result.
+ *
+ * @return
+ *     Zero if the string was successfully parsed, non-zero otherwise.
+ */
+static int guac_serial_parse_network_protocol(const char* str,
+        guac_serial_network_protocol* protocol) {
+    if (strcmp(str, "raw") == 0)     { *protocol = GUAC_SERIAL_NETWORK_PROTOCOL_RAW;     return 0; }
+    if (strcmp(str, "rfc2217") == 0) { *protocol = GUAC_SERIAL_NETWORK_PROTOCOL_RFC2217; return 0; }
+    return 1;
+}
+
+/**
+ * Parses the given parity string into a guac_serial_parity value.
+ *
+ * @param str
+ *     The parity string ("none", "odd", "even", "mark", or "space").
+ *
+ * @param parity
+ *     A pointer to the value that should receive the parsed result.
+ *
+ * @return
+ *     Zero if the string was successfully parsed, non-zero otherwise.
+ */
+static int guac_serial_parse_parity(const char* str, guac_serial_parity* parity) {
+    if (strcmp(str, "none") == 0)  { *parity = GUAC_SERIAL_PARITY_NONE;  return 0; }
+    if (strcmp(str, "odd") == 0)   { *parity = GUAC_SERIAL_PARITY_ODD;   return 0; }
+    if (strcmp(str, "even") == 0)  { *parity = GUAC_SERIAL_PARITY_EVEN;  return 0; }
+    if (strcmp(str, "mark") == 0)  { *parity = GUAC_SERIAL_PARITY_MARK;  return 0; }
+    if (strcmp(str, "space") == 0) { *parity = GUAC_SERIAL_PARITY_SPACE; return 0; }
+    return 1;
+}
+
+/**
+ * Parses the given flow control string into a guac_serial_flow_control value.
+ *
+ * @param str
+ *     The flow control string ("none", "rts-cts", or "xon-xoff").
+ *
+ * @param flow
+ *     A pointer to the value that should receive the parsed result.
+ *
+ * @return
+ *     Zero if the string was successfully parsed, non-zero otherwise.
+ */
+static int guac_serial_parse_flow(const char* str, guac_serial_flow_control* flow) {
+    if (strcmp(str, "none") == 0)     { *flow = GUAC_SERIAL_FLOW_NONE;     return 0; }
+    if (strcmp(str, "rts-cts") == 0)  { *flow = GUAC_SERIAL_FLOW_RTS_CTS;  return 0; }
+    if (strcmp(str, "xon-xoff") == 0) { *flow = GUAC_SERIAL_FLOW_XON_XOFF; return 0; }
+    return 1;
+}
+
+/**
+ * Returns whether the given local device is permitted by the given ":"-
+ * separated allowlist of device paths or path prefixes. Both the device and
+ * each allowlist entry are canonicalized with realpath() before comparison; a
+ * device is permitted if its canonical path equals a canonicalized entry or
+ * falls beneath one of the canonicalized entries.
+ *
+ * @param user
+ *     The user on whose behalf error messages should be logged.
+ *
+ * @param device
+ *     The local device path to test.
+ *
+ * @param allowed
+ *     The ":"-separated allowlist of permitted device paths or prefixes.
+ *
+ * @return
+ *     true if the device is permitted, false otherwise.
+ */
+static bool guac_serial_device_allowed(guac_user* user, const char* device,
+        const char* allowed) {
+
+    char real_device[PATH_MAX];
+    if (realpath(device, real_device) == NULL) {
+        guac_user_log(user, GUAC_LOG_ERROR, "Unable to resolve serial device "
+                "\"%s\": %s", device, strerror(errno));
+        return false;
+    }
+
+    /* Duplicate the list so it may be tokenized in place */
+    char* list = guac_strdup(allowed);
+    char* saveptr = NULL;
+    bool permitted = false;
+
+    for (char* token = strtok_r(list, ":", &saveptr); token != NULL;
+            token = strtok_r(NULL, ":", &saveptr)) {
+
+        /* Skip empty entries */
+        if (token[0] == '\0')
+            continue;
+
+        /* Canonicalize the allowlist entry; skip entries that do not resolve */
+        char real_entry[PATH_MAX];
+        if (realpath(token, real_entry) == NULL)
+            continue;
+
+        size_t entry_len = strlen(real_entry);
+
+        /* Permit an exact match */
+        if (strcmp(real_device, real_entry) == 0) {
+            permitted = true;
+            break;
+        }
+
+        /* Permit a device that falls beneath a permitted prefix */
+        if (strncmp(real_device, real_entry, entry_len) == 0
+                && real_device[entry_len] == '/') {
+            permitted = true;
+            break;
+        }
+
+    }
+
+    guac_mem_free(list);
+
+    if (!permitted)
+        guac_user_log(user, GUAC_LOG_ERROR, "Serial device \"%s\" is not "
+                "permitted by the configured allowed-devices list.", device);
+
+    return permitted;
+
+}
+
+guac_serial_settings* guac_serial_parse_args(guac_user* user,
+        int argc, const char** argv) {
+
+    /* Validate arg count */
+    if (argc != SERIAL_ARGS_COUNT) {
+        guac_user_log(user, GUAC_LOG_WARNING, "Incorrect number of connection "
+                "parameters provided: expected %i, got %i.",
+                SERIAL_ARGS_COUNT, argc);
+        return NULL;
+    }
+
+    guac_serial_settings* settings = guac_mem_zalloc(sizeof(guac_serial_settings));
+
+    /* Parse transport type */
+    char* type_str = guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS,
+            argv, IDX_SERIAL_TYPE, "local");
+    if (guac_serial_parse_type(type_str, &settings->type)) {
+        guac_user_log(user, GUAC_LOG_ERROR, "Invalid serial-type \"%s\": must "
+                "be \"local\" or \"network\".", type_str);
+        guac_mem_free(type_str);
+        guac_mem_free(settings);
+        return NULL;
+    }
+    guac_mem_free(type_str);
+
+    /* Parse network protocol */
+    char* proto_str = guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS,
+            argv, IDX_NETWORK_PROTOCOL, "raw");
+    if (guac_serial_parse_network_protocol(proto_str, &settings->network_protocol)) {
+        guac_user_log(user, GUAC_LOG_ERROR, "Invalid network-protocol \"%s\": "
+                "must be \"raw\" or \"rfc2217\".", proto_str);
+        guac_mem_free(proto_str);
+        guac_mem_free(settings);
+        return NULL;
+    }
+    guac_mem_free(proto_str);
+
+    /* Parse local device path */
+    settings->device =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_DEVICE, NULL);
+
+    /* Parse network connection information */
+    settings->hostname =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_HOSTNAME, NULL);
+
+    settings->port =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_PORT, NULL);
+
+    settings->timeout =
+        guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_TIMEOUT, GUAC_SERIAL_DEFAULT_TIMEOUT);
+
+    /* Validate transport-specific requirements */
+    if (settings->type == GUAC_SERIAL_TYPE_LOCAL) {
+        if (settings->device == NULL || settings->device[0] == '\0') {
+            guac_user_log(user, GUAC_LOG_ERROR, "A \"device\" is required for "
+                    "local serial connections.");
+            guac_serial_settings_free(settings);
+            return NULL;
+        }
+    }
+    else {
+        if (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);
+            return NULL;
+        }
+        if (settings->port == NULL || settings->port[0] == '\0') {
+            guac_user_log(user, GUAC_LOG_ERROR, "A \"port\" is required for "
+                    "network serial connections.");
+            guac_serial_settings_free(settings);
+            return NULL;
+        }
+    }
+
+#ifndef ENABLE_SERIAL_RFC2217
+    /* Reject RFC2217 if libtelnet support was not compiled in */
+    if (settings->type == GUAC_SERIAL_TYPE_NETWORK
+            && settings->network_protocol == GUAC_SERIAL_NETWORK_PROTOCOL_RFC2217) {
+        guac_user_log(user, GUAC_LOG_ERROR, "RFC2217 transport requested but "
+                "not compiled in; rebuild with libtelnet, or use "
+                "network-protocol=raw.");
+        guac_serial_settings_free(settings);
+        return NULL;
+    }
+#endif
+
+    /* Parse serial line speed */
+    settings->baud_rate =
+        guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_BAUD_RATE, 9600);
+    if (guac_serial_baud_to_speed(settings->baud_rate) == (speed_t) -1) {
+        guac_user_log(user, GUAC_LOG_ERROR, "Unsupported baud-rate: %i.",
+                settings->baud_rate);
+        guac_serial_settings_free(settings);
+        return NULL;
+    }
+
+    /* Parse data bits */
+    settings->data_bits =
+        guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_DATA_BITS, 8);
+    if (settings->data_bits < 5 || settings->data_bits > 8) {
+        guac_user_log(user, GUAC_LOG_ERROR, "Invalid data-bits: %i (must be "
+                "5, 6, 7, or 8).", settings->data_bits);
+        guac_serial_settings_free(settings);
+        return NULL;
+    }
+
+    /* Parse stop bits */
+    settings->stop_bits =
+        guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_STOP_BITS, 1);
+    if (settings->stop_bits != 1 && settings->stop_bits != 2) {
+        guac_user_log(user, GUAC_LOG_ERROR, "Invalid stop-bits: %i (must be "
+                "1 or 2).", settings->stop_bits);
+        guac_serial_settings_free(settings);
+        return NULL;
+    }
+
+    /* Parse parity */
+    char* parity_str = guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS,
+            argv, IDX_PARITY, "none");
+    if (guac_serial_parse_parity(parity_str, &settings->parity)) {
+        guac_user_log(user, GUAC_LOG_ERROR, "Invalid parity \"%s\": must be "
+                "\"none\", \"odd\", \"even\", \"mark\", or \"space\".",
+                parity_str);
+        guac_mem_free(parity_str);
+        guac_serial_settings_free(settings);
+        return NULL;
+    }
+    guac_mem_free(parity_str);
+
+    /* Parse flow control */
+    char* flow_str = guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS,
+            argv, IDX_FLOW_CONTROL, "none");
+    if (guac_serial_parse_flow(flow_str, &settings->flow_control)) {
+        guac_user_log(user, GUAC_LOG_ERROR, "Invalid flow-control \"%s\": must "
+                "be \"none\", \"rts-cts\", or \"xon-xoff\".", flow_str);
+        guac_mem_free(flow_str);
+        guac_serial_settings_free(settings);
+        return NULL;
+    }
+    guac_mem_free(flow_str);
+
+    /* Parse break duration */
+    settings->break_duration =
+        guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_BREAK_DURATION, GUAC_SERIAL_DEFAULT_BREAK_DURATION);
+    if (settings->break_duration < 0)
+        settings->break_duration = GUAC_SERIAL_DEFAULT_BREAK_DURATION;
+
+    /* Parse paste delay */
+    settings->paste_delay =
+        guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_PASTE_DELAY, 0);
+    if (settings->paste_delay < 0)
+        settings->paste_delay = 0;
+
+    /* Enforce local device allowlist, if configured */
+    char* allowed = guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS,
+            argv, IDX_ALLOWED_DEVICES, NULL);
+    if (settings->type == GUAC_SERIAL_TYPE_LOCAL
+            && allowed != NULL && allowed[0] != '\0') {
+        if (!guac_serial_device_allowed(user, settings->device, allowed)) {
+            guac_mem_free(allowed);
+            guac_serial_settings_free(settings);
+            return NULL;
+        }
+    }
+    guac_mem_free(allowed);
+
+    /* Read-only mode */
+    settings->read_only =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_READ_ONLY, false);
+
+    /* Read maximum scrollback size */
+    settings->max_scrollback =
+        guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_SCROLLBACK, GUAC_TERMINAL_DEFAULT_MAX_SCROLLBACK);
+
+    /* Read font name */
+    settings->font_name =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_FONT_NAME, GUAC_TERMINAL_DEFAULT_FONT_NAME);
+
+    /* Read font size */
+    settings->font_size =
+        guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_FONT_SIZE, GUAC_TERMINAL_DEFAULT_FONT_SIZE);
+
+    /* Copy requested color scheme */
+    settings->color_scheme =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_COLOR_SCHEME, GUAC_TERMINAL_DEFAULT_COLOR_SCHEME);
+
+    /* Pull width/height/resolution directly from user */
+    settings->width      = user->info.optimal_width;
+    settings->height     = user->info.optimal_height;
+    settings->resolution = user->info.optimal_resolution;
+
+    /* Parse backspace key code */
+    settings->backspace =
+        guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_BACKSPACE, GUAC_TERMINAL_DEFAULT_BACKSPACE);
+
+    /* Read terminal emulator type. */
+    settings->terminal_type =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_TERMINAL_TYPE, "linux");
+
+    /* Set the maximum number of bytes to allow within the clipboard. */
+    settings->clipboard_buffer_size =
+        guac_user_parse_args_int(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_CLIPBOARD_BUFFER_SIZE, 0);
+
+    /* Use default clipboard buffer size if given one is invalid. */
+    if (settings->clipboard_buffer_size < GUAC_COMMON_CLIPBOARD_MIN_LENGTH) {
+        settings->clipboard_buffer_size = GUAC_COMMON_CLIPBOARD_MIN_LENGTH;
+        guac_user_log(user, GUAC_LOG_INFO, "Unspecified or invalid clipboard buffer "
+                "size: \"%s\". Using the default minimum size: %i.",
+                argv[IDX_CLIPBOARD_BUFFER_SIZE],
+                settings->clipboard_buffer_size);
+    }
+    else if (settings->clipboard_buffer_size > GUAC_COMMON_CLIPBOARD_MAX_LENGTH) {
+        settings->clipboard_buffer_size = GUAC_COMMON_CLIPBOARD_MAX_LENGTH;
+        guac_user_log(user, GUAC_LOG_WARNING, "Invalid clipboard buffer "
+                "size: \"%s\". Using the default maximum size: %i.",
+                argv[IDX_CLIPBOARD_BUFFER_SIZE],
+                settings->clipboard_buffer_size);
+    }
+
+    /* Parse clipboard copy disable flag */
+    settings->disable_copy =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_DISABLE_COPY, false);
+
+    /* Parse clipboard paste disable flag */
+    settings->disable_paste =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_DISABLE_PASTE, false);
+
+    /* Read typescript path */
+    settings->typescript_path =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_TYPESCRIPT_PATH, NULL);
+
+    /* Read typescript name */
+    settings->typescript_name =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_TYPESCRIPT_NAME, GUAC_SERIAL_DEFAULT_TYPESCRIPT_NAME);
+
+    /* Parse path creation flag */
+    settings->create_typescript_path =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_CREATE_TYPESCRIPT_PATH, false);
+
+    /* Parse allow write existing file flag */
+    settings->typescript_write_existing =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_TYPESCRIPT_WRITE_EXISTING, false);
+
+    /* Read recording path */
+    settings->recording_path =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_RECORDING_PATH, NULL);
+
+    /* Read recording name */
+    settings->recording_name =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_RECORDING_NAME, GUAC_SERIAL_DEFAULT_RECORDING_NAME);
+
+    /* Parse output exclusion flag */
+    settings->recording_exclude_output =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_RECORDING_EXCLUDE_OUTPUT, false);
+
+    /* Parse mouse exclusion flag */
+    settings->recording_exclude_mouse =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_RECORDING_EXCLUDE_MOUSE, false);
+
+    /* Parse key event inclusion flag */
+    settings->recording_include_keys =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_RECORDING_INCLUDE_KEYS, false);
+
+    /* Parse clipboard inclusion flag */
+    settings->recording_include_clipboard =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_RECORDING_INCLUDE_CLIPBOARD, false);
+
+    /* Parse path creation flag */
+    settings->create_recording_path =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_CREATE_RECORDING_PATH, false);
+
+    /* Parse allow write existing file flag */
+    settings->recording_write_existing =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_RECORDING_WRITE_EXISTING, false);
+
+    /* Parsing was successful */
+    return settings;
+
+}
+
+void guac_serial_settings_free(guac_serial_settings* settings) {
+
+    /* Free transport connection information */
+    guac_mem_free(settings->device);
+    guac_mem_free(settings->hostname);
+    guac_mem_free(settings->port);
+
+    /* Free display preferences */
+    guac_mem_free(settings->font_name);
+    guac_mem_free(settings->color_scheme);
+
+    /* Free typescript settings */
+    guac_mem_free(settings->typescript_name);
+    guac_mem_free(settings->typescript_path);
+
+    /* Free screen recording settings */
+    guac_mem_free(settings->recording_name);
+    guac_mem_free(settings->recording_path);
+
+    /* Free terminal emulator type. */
+    guac_mem_free(settings->terminal_type);
+
+    /* Free overall structure */
+    guac_mem_free(settings);
+
+}
diff --git a/src/protocols/serial/settings.h b/src/protocols/serial/settings.h
new file mode 100644
index 00000000..728c8196
--- /dev/null
+++ b/src/protocols/serial/settings.h
@@ -0,0 +1,404 @@
+/*
+ * 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_SERIAL_SETTINGS_H
+#define GUAC_SERIAL_SETTINGS_H
+
+#include <guacamole/user.h>
+
+#include <termios.h>
+#include <sys/types.h>
+#include <stdbool.h>
+
+/**
+ * The port to connect to when initiating any network serial connection, if no
+ * other port is specified.
+ */
+#define GUAC_SERIAL_DEFAULT_PORT "2000"
+
+/**
+ * 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_SERIAL_PROCESS_TITLE_NAME "serial"
+
+/**
+ * The default number of seconds to wait for a successful connection before
+ * timing out.
+ */
+#define GUAC_SERIAL_DEFAULT_TIMEOUT 10
+
+/**
+ * The default duration, in milliseconds, that the transmit line is held low
+ * when sending a serial break.
+ */
+#define GUAC_SERIAL_DEFAULT_BREAK_DURATION 500
+
+/**
+ * The filename to use for the typescript, if not specified.
+ */
+#define GUAC_SERIAL_DEFAULT_TYPESCRIPT_NAME "typescript"
+
+/**
+ * The filename to use for the screen recording, if not specified.
+ */
+#define GUAC_SERIAL_DEFAULT_RECORDING_NAME "recording"
+
+/**
+ * The transport used to reach the serial line.
+ */
+typedef enum guac_serial_type {
+
+    /**
+     * A local serial device accessed directly via a device node (e.g.
+     * "/dev/ttyUSB0").
+     */
+    GUAC_SERIAL_TYPE_LOCAL,
+
+    /**
+     * A remote serial line reached over the network (raw TCP or RFC2217).
+     */
+    GUAC_SERIAL_TYPE_NETWORK
+
+} guac_serial_type;
+
+/**
+ * The network protocol used when the transport type is
+ * GUAC_SERIAL_TYPE_NETWORK.
+ */
+typedef enum guac_serial_network_protocol {
+
+    /**
+     * A raw byte stream, as exposed by ser2net in "raw" mode. Serial line
+     * parameters cannot be negotiated and are purely informational.
+     */
+    GUAC_SERIAL_NETWORK_PROTOCOL_RAW,
+
+    /**
+     * The RFC2217 (telnet COM-PORT-OPTION) protocol, allowing serial line
+     * parameters and break signalling to be negotiated with the remote end.
+     */
+    GUAC_SERIAL_NETWORK_PROTOCOL_RFC2217
+
+} guac_serial_network_protocol;
+
+/**
+ * The parity scheme applied to the serial line.
+ */
+typedef enum guac_serial_parity {
+    GUAC_SERIAL_PARITY_NONE,
+    GUAC_SERIAL_PARITY_ODD,
+    GUAC_SERIAL_PARITY_EVEN,
+    GUAC_SERIAL_PARITY_MARK,
+    GUAC_SERIAL_PARITY_SPACE
+} guac_serial_parity;
+
+/**
+ * The flow control scheme applied to the serial line.
+ */
+typedef enum guac_serial_flow_control {
+    GUAC_SERIAL_FLOW_NONE,
+    GUAC_SERIAL_FLOW_RTS_CTS,
+    GUAC_SERIAL_FLOW_XON_XOFF
+} guac_serial_flow_control;
+
+/**
+ * Settings for the serial connection. The values for this structure are parsed
+ * from the arguments given during the Guacamole protocol handshake using the
+ * guac_serial_parse_args() function.
+ */
+typedef struct guac_serial_settings {
+
+    /**
+     * The transport used to reach the serial line.
+     */
+    guac_serial_type type;
+
+    /**
+     * The network protocol used when type is GUAC_SERIAL_TYPE_NETWORK.
+     */
+    guac_serial_network_protocol network_protocol;
+
+    /**
+     * The absolute path of the local serial device to open when type is
+     * GUAC_SERIAL_TYPE_LOCAL, e.g. "/dev/ttyUSB0". NULL if not a local
+     * connection.
+     */
+    char* device;
+
+    /**
+     * The hostname of the network serial server to connect to when type is
+     * GUAC_SERIAL_TYPE_NETWORK. NULL if not a network connection.
+     */
+    char* hostname;
+
+    /**
+     * The port of the network serial server to connect to when type is
+     * GUAC_SERIAL_TYPE_NETWORK.
+     */
+    char* port;
+
+    /**
+     * The number of seconds to wait for a connection before timing out.
+     */
+    int timeout;
+
+    /**
+     * The serial line speed in bits per second (e.g. 9600, 115200).
+     */
+    int baud_rate;
+
+    /**
+     * The number of data bits per character (5, 6, 7, or 8).
+     */
+    int data_bits;
+
+    /**
+     * The number of stop bits per character (1 or 2).
+     */
+    int stop_bits;
+
+    /**
+     * The parity scheme applied to the serial line.
+     */
+    guac_serial_parity parity;
+
+    /**
+     * The flow control scheme applied to the serial line.
+     */
+    guac_serial_flow_control flow_control;
+
+    /**
+     * The duration, in milliseconds, that the transmit line is held low when
+     * sending a serial break.
+     */
+    int break_duration;
+
+    /**
+     * The delay, in milliseconds, inserted between each byte written to the
+     * serial line. Zero disables pacing entirely. Used to avoid FIFO overrun
+     * when pasting to a low-baud line with no flow control.
+     */
+    int paste_delay;
+
+    /**
+     * Whether this connection is read-only, and user input should be dropped.
+     */
+    bool read_only;
+
+    /**
+     * The maximum size of the scrollback buffer in rows.
+     */
+    int max_scrollback;
+
+    /**
+     * The name of the font to use for display rendering.
+     */
+    char* font_name;
+
+    /**
+     * The size of the font to use, in points.
+     */
+    int font_size;
+
+    /**
+     * The name of the color scheme to use.
+     */
+    char* color_scheme;
+
+    /**
+     * The desired width of the terminal display, in pixels.
+     */
+    int width;
+
+    /**
+     * The desired height of the terminal display, in pixels.
+     */
+    int height;
+
+    /**
+     * The desired screen resolution, in DPI.
+     */
+    int resolution;
+
+    /**
+     * The maximum number of bytes to allow within the clipboard.
+     */
+    int clipboard_buffer_size;
+
+    /**
+     * Whether outbound clipboard access should be blocked. If set, it will not
+     * be possible to copy data from the terminal to the client using the
+     * clipboard.
+     */
+    bool disable_copy;
+
+    /**
+     * Whether inbound clipboard access should be blocked. If set, it will not
+     * be possible to paste data from the client to the terminal using the
+     * clipboard.
+     */
+    bool disable_paste;
+
+    /**
+     * The path in which the typescript should be saved, if enabled. If no
+     * typescript should be saved, this will be NULL.
+     */
+    char* typescript_path;
+
+    /**
+     * The filename to use for the typescript, if enabled.
+     */
+    char* typescript_name;
+
+    /**
+     * Whether the typescript path should be automatically created if it does
+     * not already exist.
+     */
+    bool create_typescript_path;
+
+    /**
+     * Whether existing files should be appended to when creating a new
+     * typescript. Disabled by default.
+     */
+    bool typescript_write_existing;
+
+    /**
+     * The path in which the screen recording should be saved, if enabled. If
+     * no screen recording should be saved, this will be NULL.
+     */
+    char* recording_path;
+
+    /**
+     * The filename to use for the screen recording, if enabled.
+     */
+    char* recording_name;
+
+    /**
+     * Whether the screen recording path should be automatically created if it
+     * does not already exist.
+     */
+    bool create_recording_path;
+
+    /**
+     * Whether output which is broadcast to each connected client (graphics,
+     * streams, etc.) should NOT be included in the session recording. Output
+     * is included by default, as it is necessary for any recording which must
+     * later be viewable as video.
+     */
+    bool recording_exclude_output;
+
+    /**
+     * Whether changes to mouse state, such as position and buttons pressed or
+     * released, should NOT be included in the session recording. Mouse state
+     * is included by default, as it is necessary for the mouse cursor to be
+     * rendered in any resulting video.
+     */
+    bool recording_exclude_mouse;
+
+    /**
+     * Whether keys pressed and released should be included in the session
+     * recording. Key events are NOT included by default within the recording,
+     * as doing so has privacy and security implications.  Including key events
+     * may be necessary in certain auditing contexts, but should only be done
+     * with caution. Key events can easily contain sensitive information, such
+     * as passwords, credit card numbers, etc.
+     */
+    bool recording_include_keys;
+
+    /**
+     * Whether clipboard data should be included in the session recording.
+     * Clipboard data is NOT included by default within the recording,
+     * as doing so has privacy and security implications. Including clipboard
+     * data may be necessary in certain auditing contexts, but should only be
+     * done with caution. Clipboard data can easily contain sensitive
+     * information, such as passwords, credit card numbers, etc.
+     */
+    bool recording_include_clipboard;
+
+    /**
+     * Whether existing files should be appended to when creating a new
+     * recording. Disabled by default.
+     */
+    bool recording_write_existing;
+
+    /**
+     * The ASCII code, as an integer, that the terminal will use when the
+     * backspace key is pressed.  By default, this is 127, ASCII delete, if
+     * not specified in the client settings.
+     */
+    int backspace;
+
+    /**
+     * The terminal emulator type that is used for rendering.
+     */
+    char* terminal_type;
+
+} guac_serial_settings;
+
+/**
+ * Parses all given args, storing them in a newly-allocated settings object. If
+ * the args fail to parse, NULL is returned.
+ *
+ * @param user
+ *     The user who submitted the given arguments while joining the
+ *     connection.
+ *
+ * @param argc
+ *     The number of arguments within the argv array.
+ *
+ * @param argv
+ *     The values of all arguments provided by the user.
+ *
+ * @return
+ *     A newly-allocated settings object which must be freed with
+ *     guac_serial_settings_free() when no longer needed. If the arguments fail
+ *     to parse, NULL is returned.
+ */
+guac_serial_settings* guac_serial_parse_args(guac_user* user,
+        int argc, const char** argv);
+
+/**
+ * Maps the given serial line speed, in bits per second, to the corresponding
+ * termios speed_t constant (e.g. 9600 -> B9600).
+ *
+ * @param baud_rate
+ *     The serial line speed, in bits per second.
+ *
+ * @return
+ *     The termios speed_t constant corresponding to the given speed, or
+ *     (speed_t) -1 if the given speed is not supported.
+ */
+speed_t guac_serial_baud_to_speed(int baud_rate);
+
+/**
+ * Frees the given guac_serial_settings object, having been previously
+ * allocated via guac_serial_parse_args().
+ *
+ * @param settings
+ *     The settings object to free.
+ */
+void guac_serial_settings_free(guac_serial_settings* settings);
+
+/**
+ * NULL-terminated array of accepted client args.
+ */
+extern const char* GUAC_SERIAL_CLIENT_ARGS[];
+
+#endif
diff --git a/src/protocols/serial/stream.c b/src/protocols/serial/stream.c
new file mode 100644
index 00000000..1d189413
--- /dev/null
+++ b/src/protocols/serial/stream.c
@@ -0,0 +1,205 @@
+/*
+ * 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 "local.h"
+#include "rfc2217.h"
+#include "settings.h"
+#include "stream.h"
+#include "tcp.h"
+
+#include <guacamole/client.h>
+#include <guacamole/error.h>
+#include <guacamole/mem.h>
+
+#include <errno.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <termios.h>
+#include <unistd.h>
+
+/**
+ * Writes the entire buffer given to the specified file descriptor, retrying
+ * the write automatically if necessary.
+ *
+ * @param fd
+ *     The file descriptor to write to.
+ *
+ * @param buffer
+ *     The buffer to write.
+ *
+ * @param size
+ *     The number of bytes from the buffer to write.
+ *
+ * @return
+ *     The number of bytes written, or a value not equal to size if an error
+ *     prevents all future writes.
+ */
+static int guac_serial_stream_write_all(int fd, const char* buffer, int size) {
+
+    int remaining = size;
+    while (remaining > 0) {
+
+        int ret_val;
+        GUAC_RETRY_EINTR(ret_val, write(fd, buffer, remaining));
+        if (ret_val <= 0)
+            return -1;
+
+        remaining -= ret_val;
+        buffer += ret_val;
+
+    }
+
+    return size;
+
+}
+
+guac_serial_stream* guac_serial_stream_open(guac_client* client,
+        guac_serial_settings* settings) {
+
+    guac_serial_stream* stream = guac_mem_zalloc(sizeof(guac_serial_stream));
+    stream->client = client;
+    stream->fd = -1;
+    stream->telnet = NULL;
+    stream->paste_delay = settings->paste_delay;
+    stream->break_duration = settings->break_duration;
+    pthread_mutex_init(&stream->write_lock, NULL);
+
+    /* Dispatch to the appropriate backend */
+    int result;
+    if (settings->type == GUAC_SERIAL_TYPE_LOCAL)
+        result = guac_serial_local_open(stream, client, settings);
+    else if (settings->network_protocol == GUAC_SERIAL_NETWORK_PROTOCOL_RFC2217)
+        result = guac_serial_rfc2217_open(stream, client, settings);
+    else
+        result = guac_serial_tcp_open(stream, client, settings);
+
+    /* Clean up and return NULL on failure (backend has already aborted) */
+    if (result != 0) {
+        pthread_mutex_destroy(&stream->write_lock);
+        guac_mem_free(stream);
+        return NULL;
+    }
+
+    return stream;
+
+}
+
+int guac_serial_stream_write(guac_serial_stream* stream, const char* buffer,
+        int length) {
+
+    int result = length;
+
+    pthread_mutex_lock(&stream->write_lock);
+
+    /* Pace output byte-by-byte when a paste delay is configured */
+    if (stream->paste_delay > 0) {
+
+        for (int i = 0; i < length; i++) {
+
+            if (stream->backend == GUAC_SERIAL_BACKEND_RFC2217)
+                guac_serial_rfc2217_send(stream, buffer + i, 1);
+            else if (guac_serial_stream_write_all(stream->fd, buffer + i, 1) != 1) {
+                result = -1;
+                break;
+            }
+
+            usleep((useconds_t) stream->paste_delay * 1000);
+
+        }
+
+    }
+
+    /* Otherwise, write the entire buffer at once */
+    else {
+
+        if (stream->backend == GUAC_SERIAL_BACKEND_RFC2217)
+            guac_serial_rfc2217_send(stream, buffer, length);
+        else if (guac_serial_stream_write_all(stream->fd, buffer, length) != length)
+            result = -1;
+
+    }
+
+    pthread_mutex_unlock(&stream->write_lock);
+    return result;
+
+}
+
+int guac_serial_stream_send_break(guac_serial_stream* stream) {
+
+    int result = 0;
+
+    pthread_mutex_lock(&stream->write_lock);
+
+    switch (stream->backend) {
+
+        case GUAC_SERIAL_BACKEND_LOCAL:
+#if defined(TIOCSBRK) && defined(TIOCCBRK)
+            /* Hold the transmit line low for the configured duration */
+            if (ioctl(stream->fd, TIOCSBRK) == 0) {
+                if (stream->break_duration > 0)
+                    usleep((useconds_t) stream->break_duration * 1000);
+                ioctl(stream->fd, TIOCCBRK);
+            }
+
+            /* Fall back to tcsendbreak() if TIOCSBRK is unsupported */
+            else
+                tcsendbreak(stream->fd, 0);
+#else
+            tcsendbreak(stream->fd, 0);
+#endif
+            break;
+
+        case GUAC_SERIAL_BACKEND_TCP:
+            guac_client_log(stream->client, GUAC_LOG_WARNING, "Sending a serial "
+                    "break is not supported on the raw TCP transport; use "
+                    "rfc2217 instead.");
+            break;
+
+        case GUAC_SERIAL_BACKEND_RFC2217:
+            guac_serial_rfc2217_send_break(stream);
+            break;
+
+    }
+
+    pthread_mutex_unlock(&stream->write_lock);
+    return result;
+
+}
+
+void guac_serial_stream_close(guac_serial_stream* stream) {
+
+    /* Nothing to do if never opened */
+    if (stream == NULL)
+        return;
+
+    /* Free the libtelnet session, if any */
+    guac_serial_rfc2217_free(stream);
+
+    /* Close the underlying file descriptor, if open */
+    if (stream->fd != -1) {
+        close(stream->fd);
+        stream->fd = -1;
+    }
+
+    pthread_mutex_destroy(&stream->write_lock);
+    guac_mem_free(stream);
+
+}
diff --git a/src/protocols/serial/stream.h b/src/protocols/serial/stream.h
new file mode 100644
index 00000000..b3c7c542
--- /dev/null
+++ b/src/protocols/serial/stream.h
@@ -0,0 +1,168 @@
+/*
+ * 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_SERIAL_STREAM_H
+#define GUAC_SERIAL_STREAM_H
+
+#include "settings.h"
+
+#include <guacamole/client.h>
+
+#include <pthread.h>
+
+/**
+ * The transport backend providing the serial byte stream.
+ */
+typedef enum guac_serial_backend {
+
+    /**
+     * A local serial device opened directly via a device node.
+     */
+    GUAC_SERIAL_BACKEND_LOCAL,
+
+    /**
+     * A raw TCP connection to a remote serial server (e.g. ser2net in raw
+     * mode).
+     */
+    GUAC_SERIAL_BACKEND_TCP,
+
+    /**
+     * An RFC2217 (telnet COM-PORT-OPTION) connection to a remote serial server.
+     */
+    GUAC_SERIAL_BACKEND_RFC2217
+
+} guac_serial_backend;
+
+/**
+ * A transport-neutral, bidirectional serial byte stream. All reads are
+ * performed directly on the file descriptor by the main worker thread; writes
+ * and break signalling are dispatched to the appropriate backend and
+ * serialized with an internal lock.
+ */
+typedef struct guac_serial_stream {
+
+    /**
+     * The transport backend providing this stream.
+     */
+    guac_serial_backend backend;
+
+    /**
+     * The device file descriptor (local) or socket file descriptor (tcp,
+     * rfc2217), or -1 if not open.
+     */
+    int fd;
+
+    /**
+     * The libtelnet session (telnet_t*) used for IAC framing when the backend
+     * is GUAC_SERIAL_BACKEND_RFC2217, or NULL otherwise. Declared as an opaque
+     * pointer so this header does not depend on libtelnet, which is required
+     * only by the RFC2217 backend (see rfc2217.c). Only the RFC2217 backend
+     * dereferences this value.
+     */
+    void* telnet;
+
+    /**
+     * Lock serializing writes and break signalling on this stream.
+     */
+    pthread_mutex_t write_lock;
+
+    /**
+     * The delay, in milliseconds, inserted between each byte written to the
+     * serial line. Zero disables pacing. Copied from settings.
+     */
+    int paste_delay;
+
+    /**
+     * The duration, in milliseconds, that the transmit line is held low when
+     * sending a serial break. Copied from settings.
+     */
+    int break_duration;
+
+    /**
+     * The client associated with this stream, used for logging.
+     */
+    guac_client* client;
+
+} guac_serial_stream;
+
+/**
+ * Opens a serial byte stream using the transport described by the given
+ * settings, dispatching to the local, TCP, or RFC2217 backend as appropriate.
+ * On failure, the client is aborted and NULL is returned.
+ *
+ * @param client
+ *     The client for which the stream is being opened.
+ *
+ * @param settings
+ *     The parsed connection settings describing the transport to use.
+ *
+ * @return
+ *     A newly-allocated serial stream which must be freed with
+ *     guac_serial_stream_close(), or NULL if the connection could not be
+ *     established.
+ */
+guac_serial_stream* guac_serial_stream_open(guac_client* client,
+        guac_serial_settings* settings);
+
+/**
+ * Writes the given buffer to the serial stream, blocking until the entire
+ * buffer has been written or an unrecoverable error occurs. If a non-zero
+ * paste delay is configured, bytes are written one at a time with the
+ * configured delay inserted between each.
+ *
+ * @param stream
+ *     The serial stream to write to.
+ *
+ * @param buffer
+ *     The buffer of data to write.
+ *
+ * @param length
+ *     The number of bytes from the buffer to write.
+ *
+ * @return
+ *     The number of bytes written on success, or a negative value if an error
+ *     prevents all future writes.
+ */
+int guac_serial_stream_write(guac_serial_stream* stream, const char* buffer,
+        int length);
+
+/**
+ * Sends a serial break on the serial stream. For local and RFC2217 transports,
+ * the transmit line is held low for the configured break duration. Raw TCP
+ * transports do not support break signalling; a warning is logged and the call
+ * is a no-op.
+ *
+ * @param stream
+ *     The serial stream on which to send the break.
+ *
+ * @return
+ *     Zero on success, non-zero on error.
+ */
+int guac_serial_stream_send_break(guac_serial_stream* stream);
+
+/**
+ * Closes the given serial stream, releasing all associated resources. This
+ * function is idempotent and safe to call with a NULL stream.
+ *
+ * @param stream
+ *     The serial stream to close.
+ */
+void guac_serial_stream_close(guac_serial_stream* stream);
+
+#endif
diff --git a/src/protocols/serial/tcp.c b/src/protocols/serial/tcp.c
new file mode 100644
index 00000000..9f825456
--- /dev/null
+++ b/src/protocols/serial/tcp.c
@@ -0,0 +1,54 @@
+/*
+ * 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 "settings.h"
+#include "stream.h"
+#include "tcp.h"
+
+#include <guacamole/client.h>
+#include <guacamole/protocol.h>
+#include <guacamole/tcp.h>
+
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+
+int guac_serial_tcp_open(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) {
+        guac_client_abort(client, GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND,
+                "Unable to connect to serial server \"%s\" port \"%s\".",
+                settings->hostname, settings->port);
+        return -1;
+    }
+
+    /* Disable Nagle's algorithm so keystrokes are sent without delay */
+    int flag = 1;
+    setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));
+
+    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
new file mode 100644
index 00000000..638dcefa
--- /dev/null
+++ b/src/protocols/serial/tcp.h
@@ -0,0 +1,48 @@
+/*
+ * 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_SERIAL_TCP_H
+#define GUAC_SERIAL_TCP_H
+
+#include "settings.h"
+#include "stream.h"
+
+#include <guacamole/client.h>
+
+/**
+ * Opens a raw TCP connection to the network serial server described by the
+ * given settings (e.g. ser2net operating in raw mode) and stores the resulting
+ * socket in the given stream. On failure, the client is aborted.
+ *
+ * @param stream
+ *     The serial stream to populate with the opened socket.
+ *
+ * @param client
+ *     The client for which the connection is being opened.
+ *
+ * @param settings
+ *     The parsed connection settings describing the server to connect to.
+ *
+ * @return
+ *     Zero on success, non-zero on failure.
+ */
+int guac_serial_tcp_open(guac_serial_stream* stream, guac_client* client,
+        guac_serial_settings* settings);
+
+#endif
diff --git a/src/protocols/serial/user.c b/src/protocols/serial/user.c
new file mode 100644
index 00000000..2dce7dc4
--- /dev/null
+++ b/src/protocols/serial/user.c
@@ -0,0 +1,118 @@
+/*
+ * 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 "argv.h"
+#include "clipboard.h"
+#include "input.h"
+#include "pipe.h"
+#include "serial.h"
+#include "settings.h"
+#include "terminal/terminal.h"
+#include "user.h"
+
+#include <guacamole/argv.h>
+#include <guacamole/client.h>
+#include <guacamole/socket.h>
+#include <guacamole/user.h>
+
+#include <pthread.h>
+#include <string.h>
+
+int guac_serial_user_join_handler(guac_user* user, int argc, char** argv) {
+
+    guac_client* client = user->client;
+    guac_serial_client* serial_client = (guac_serial_client*) client->data;
+
+    /* Parse provided arguments */
+    guac_serial_settings* settings = guac_serial_parse_args(user,
+            argc, (const char**) argv);
+
+    /* Fail if settings cannot be parsed */
+    if (settings == NULL) {
+        guac_user_log(user, GUAC_LOG_INFO,
+                "Badly formatted client arguments.");
+        return 1;
+    }
+
+    /* Store settings at user level */
+    user->data = settings;
+
+    /* Connect via serial if owner */
+    if (user->owner) {
+
+        /* Store owner's settings at client level */
+        serial_client->settings = settings;
+
+        /* Start client thread */
+        if (pthread_create(&(serial_client->client_thread), NULL,
+                    guac_serial_client_thread, (void*) client)) {
+            guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
+                    "Unable to start serial client thread");
+            return 1;
+        }
+
+        /* Thread now owns cleanup responsibilities and must be joined */
+        serial_client->client_thread_valid = 1;
+
+    }
+
+    /* Only handle events if not read-only */
+    if (!settings->read_only) {
+
+        /* General mouse/keyboard events */
+        user->key_handler = guac_serial_user_key_handler;
+        user->mouse_handler = guac_serial_user_mouse_handler;
+
+        /* Inbound (client to server) clipboard transfer */
+        if (!settings->disable_paste)
+            user->clipboard_handler = guac_serial_clipboard_handler;
+
+        /* STDIN redirection and out-of-band control channel */
+        user->pipe_handler = guac_serial_pipe_handler;
+
+        /* Updates to connection parameters */
+        user->argv_handler = guac_argv_handler;
+
+        /* Display size change events */
+        user->size_handler = guac_serial_user_size_handler;
+
+    }
+
+    return 0;
+
+}
+
+int guac_serial_user_leave_handler(guac_user* user) {
+
+    guac_serial_client* serial_client =
+        (guac_serial_client*) user->client->data;
+
+    /* Remove the user from the terminal */
+    guac_terminal_remove_user(serial_client->term, user);
+
+    /* Free settings if not owner (owner settings will be freed with client) */
+    if (!user->owner) {
+        guac_serial_settings* settings = (guac_serial_settings*) user->data;
+        guac_serial_settings_free(settings);
+    }
+
+    return 0;
+}
diff --git a/src/protocols/serial/user.h b/src/protocols/serial/user.h
new file mode 100644
index 00000000..2864af7f
--- /dev/null
+++ b/src/protocols/serial/user.h
@@ -0,0 +1,35 @@
+/*
+ * 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_SERIAL_USER_H
+#define GUAC_SERIAL_USER_H
+
+#include <guacamole/user.h>
+
+/**
+ * Handler for joining users.
+ */
+guac_user_join_handler guac_serial_user_join_handler;
+
+/**
+ * Handler for leaving users.
+ */
+guac_user_leave_handler guac_serial_user_leave_handler;
+
+#endif