Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,14 @@ EXTRA_DIST = \
Dockerfile \
LICENSE \
NOTICE \
README-text-output.md \
bin/guacctl \
doc/libguac/Doxyfile.in \
doc/libguac-terminal/Doxyfile.in \
src/guacd-docker \
util/generate-test-runner.pl

util/generate-test-runner.pl \
util/manual-tests/README-text-output-e2e.md \
util/manual-tests/text-output-e2e.sh \
util/manual-tests/text-output-guacd-e2e.py \
util/manual-tests/text-output-k8s-exec-mock.py \
util/manual-tests/text-output-tunnel-smoke.py
188 changes: 188 additions & 0 deletions README-text-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
Terminal text-output mode
=========================

This branch adds an opt-in terminal connection parameter named
`text-output` for SSH, telnet, and Kubernetes connections.

When `text-output` is enabled, guacd tees the raw bytes received from the remote
terminal/PTY to an outbound Guacamole pipe stream named `STDOUT`. Clients can
consume that pipe to implement CLI-style access to terminal sessions without
scraping pixels from the display.

Modes
-----

The parameter accepts two enabled values:

* `text-output=true` — **tee** mode: the raw bytes are teed to the `STDOUT`
pipe *and* the normal graphical terminal display continues to be rendered, so
browser clients still work. Use this when a connection may be viewed both
graphically and by a text/CLI client.

* `text-output=raw` — **headless** mode: the graphical terminal is not rendered
at all. The raw bytes are delivered only via the `STDOUT` pipe, skipping the
terminal emulator and its graphical instruction stream. This eliminates the
per-frame glyph rasterization/encoding and the graphical bytes on the wire, at
the cost of no usable graphical display. Use this for connections consumed
solely by a text/CLI client.

Any other value (including `false` or omission) leaves text-output disabled.

Supported protocols
-------------------

* SSH: `text-output=true` | `text-output=raw`
* Telnet: `text-output=true` | `text-output=raw`
* Kubernetes: `text-output=true` | `text-output=raw`

The parameter is intentionally opt-in. Existing connections continue to behave
normally unless the parameter is explicitly enabled.

Security and clipboard/copy behavior
------------------------------------

The `STDOUT` pipe exposes the raw terminal byte stream to the Guacamole client.
This is effectively a copy/export channel. For that reason, the implementation
honors the existing copy restriction used by terminal protocols:

* If copy/clipboard output is disabled for the connection, `text-output` is not
opened.
* If copy/clipboard output is allowed and `text-output=true`, guacd opens the
`STDOUT` pipe and writes raw terminal bytes to it.

Flow control and backpressure
-----------------------------

Clients must send an `ack` instruction for every `blob` received on the `STDOUT`
pipe, and should do so on receipt rather than after rendering — acking only after
a blocking write to a local terminal lets a slow consumer stall its own ack
stream.

guacd bounds the unacknowledged backlog at 256 KB, and at no more than 256
outstanding blobs. The byte bound is the operative one: in raw mode every PTY
read is flushed as its own blob, so blobs are frequently only a few bytes and a
blob-count bound alone would be reached after a trivial amount of output.

What happens when the window fills depends on the mode, and the difference is
deliberate:

* In tee mode, buffered output is **dropped** and the session continues. The tee
shares the protocol read loop with the graphical display, so blocking on a
stalled text consumer would also stall any co-attached browser user. Delivery
is therefore best-effort, and a dropped chunk is logged as a warning.
* In raw mode, the writer **waits** for the consumer to catch up. Raw mode
renders nothing graphically, so there is no browser user to starve, and
pausing the read loop propagates backpressure to the remote program through
the PTY exactly as a slow local terminal would. Sustained output always
outruns a consumer eventually, so throttling — not dropping, and not
disconnecting — is the only behavior that keeps the byte stream intact.

A consumer that stops acking altogether cannot hold the session open
indefinitely: if the window fails to drain for 15 seconds, the connection is
aborted with `SERVER_ERROR` and the message
`text-output consumer is not keeping up`.

Manual tunnel smoke test
------------------------

A reusable manual smoke test is provided at:

util/manual-tests/text-output-tunnel-smoke.py

It validates the full client-facing path:

1. authenticate to the Guacamole REST API;
2. open a connection through `/websocket-tunnel`;
3. verify that guacd opens the outbound `STDOUT` pipe;
4. type a harmless `printf` command through Guacamole keyboard instructions;
5. verify that the command output returns through the `STDOUT` pipe.

The script requires the Python `websocket-client` package:

python3 -m pip install websocket-client

Example against the local test deployment used during development:

util/manual-tests/text-output-tunnel-smoke.py \
--url http://10.2.0.186:8080/guacamole \
--username guacadmin \
--password guacadmin \
--data-source postgresql \
--connection-id 1

The same values can also be provided with environment variables:

GUAC_URL=http://10.2.0.186:8080/guacamole \
GUAC_USERNAME=guacadmin \
GUAC_PASSWORD=guacadmin \
GUAC_CONNECTION_ID=1 \
util/manual-tests/text-output-tunnel-smoke.py

The command exits with status 0 and prints:

RESULT: tunnel STDOUT pipe smoke test passed

when the `STDOUT` pipe is present and the marker emitted by the remote shell is
received through that pipe.

For protocol-specific smoke checks where the backend target is intentionally
minimal or unreachable, `--pipe-only` can be used to validate that guacd opens
the protocol's `STDOUT` pipe without requiring an interactive shell command to
complete:

util/manual-tests/text-output-tunnel-smoke.py \
--url http://10.2.0.186:8080/guacamole \
--username guacadmin \
--password guacadmin \
--data-source postgresql \
--connection-id 2 \
--pipe-only

For shell prompts that are not the default `$ `, use `--prompt` to select the
bytes the smoke test should wait for before typing the marker command. For
example, BusyBox `/bin/sh` inside Kubernetes commonly prompts with `# `:

util/manual-tests/text-output-tunnel-smoke.py \
--url http://10.2.0.186:8080/guacamole \
--username guacadmin \
--password guacadmin \
--data-source postgresql \
--connection-id 3 \
--prompt '# '

Development validation snapshot
-------------------------------

The SSH implementation was validated end-to-end on July 4, 2026 against a test
Guacamole stack:

* Guacamole web application 1.6.0 on Tomcat 9
* patched guacd from this branch
* PostgreSQL authentication/connection store
* connection `1`: `SSH text-output (localhost)` with `text-output=true`

Validation performed:

* REST login returned a valid token.
* `GET /api/session/data/postgresql/connections` listed the SSH test
connection.
* `/websocket-tunnel` opened successfully for the connection.
* The tunnel advertised `PIPE stream=1 mimetype=application/octet-stream
name=STDOUT`.
* A harmless `printf` marker command was sent through the Guacamole keyboard
protocol and the marker was received through the `STDOUT` pipe.

Additional protocol validation was performed against real Telnet and
Kubernetes targets on a clean openSUSE Leap 16.0 VM (`10.2.0.190`) provisioned
from the CI template on VLAN 100:

* Telnet: Guacamole connection `2` targeted a `socat` TCP listener backed by a
real PTY shell on port `2323`; the marker
`GUAC_TELNET_OUTPUT_SMOKE_OK_20260704` round-tripped through `STDOUT`.
* Kubernetes: Guacamole connection `3` targeted a single-node k3s cluster and
executed `/bin/sh` in pod `default/guac-smoke`; the marker
`GUAC_K8S_OUTPUT_SMOKE_OK_20260704` round-tripped through `STDOUT`.
* Clean VM build/check: the branch built successfully on openSUSE Leap 16.0
with SSH, Kubernetes, guacd, and CUnit tests enabled. Telnet was not built on
that VM because Leap 16.0 did not provide `libtelnet-devel`; Telnet runtime
validation used the deployed patched guacd.
10 changes: 9 additions & 1 deletion src/protocols/kubernetes/io.c
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,15 @@ void guac_kubernetes_receive_data(guac_client* client,
/* Write STDOUT / STDERR directly to terminal as output */
case GUAC_KUBERNETES_CHANNEL_STDOUT:
case GUAC_KUBERNETES_CHANNEL_STDERR:
guac_terminal_write(kubernetes_client->term, buffer, length);
/* Tee the raw remote byte stream to the text-output pipe, if
* enabled. Has no effect unless text-output mode opened the pipe. */
guac_terminal_text_output_write(kubernetes_client->term, buffer, length);

/* In raw text-output mode the graphical terminal is not rendered:
* the remote bytes are delivered only via the text-output pipe,
* skipping the terminal emulator and its graphical output. */
if (!kubernetes_client->settings->text_output_raw)
guac_terminal_write(kubernetes_client->term, buffer, length);
break;

/* Ignore data on other channels */
Expand Down
14 changes: 14 additions & 0 deletions src/protocols/kubernetes/kubernetes.c
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,20 @@ void* guac_kubernetes_client_thread(void* data) {
settings->typescript_write_existing);
}

/* Enable raw text-output mode, if requested. This tees the raw terminal
* (PTY) byte stream to an outbound "STDOUT" pipe for native/CLI clients,
* in addition to the normal graphical display. As text-output is
* effectively a copy/exfiltration channel, it is gated behind
* disable-copy. */
if (guac_terminal_text_output_should_open(settings->text_output,
settings->disable_copy))
guac_terminal_text_output_open(kubernetes_client->term, "STDOUT",
settings->text_output_raw);
else if (settings->text_output)
guac_client_log(client, GUAC_LOG_WARNING, "\"text-output\" was "
"requested but is being ignored because copying from the "
"terminal is disabled (\"disable-copy\").");

/* Init libwebsockets context creation parameters */
struct lws_context_creation_info context_info = {
.port = CONTEXT_PORT_NO_LISTEN, /* We are not a WebSocket server */
Expand Down
23 changes: 23 additions & 0 deletions src/protocols/kubernetes/settings.c
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
#include <guacamole/user.h>

#include <stdlib.h>
#include <string.h>

/* Client plugin arguments */
const char* GUAC_KUBERNETES_CLIENT_ARGS[] = {
Expand Down Expand Up @@ -63,6 +64,7 @@ const char* GUAC_KUBERNETES_CLIENT_ARGS[] = {
"disable-copy",
"disable-paste",
"terminal-type",
"text-output",
NULL
};

Expand Down Expand Up @@ -287,6 +289,16 @@ enum KUBERNETES_ARGS_IDX {
*/
IDX_TERMINAL_TYPE,

/**
* Whether the raw terminal (PTY) byte stream should be teed, verbatim, to
* an outbound "STDOUT" pipe stream in addition to the normal graphical
* display. This enables a native/CLI Guacamole client to render the
* session as true in-terminal text. If set to "true", text-output mode is
* enabled; by default it is disabled. Honored only when copying from the
* terminal is not disabled (see IDX_DISABLE_COPY).
*/
IDX_TEXT_OUTPUT,

KUBERNETES_ARGS_COUNT
};

Expand Down Expand Up @@ -495,6 +507,17 @@ guac_kubernetes_settings* guac_kubernetes_parse_args(guac_user* user,
settings->terminal_type =
guac_user_parse_args_string(user, GUAC_KUBERNETES_CLIENT_ARGS, argv,
IDX_TERMINAL_TYPE, "linux");
/* Parse text-output mode. Accepts "true" (tee mode: the graphical display
* is preserved for browser clients) or "raw" (headless: the graphical
* terminal is not rendered, eliminating the graphical instruction stream
* and its rendering cost). Any other value disables text-output. */
char* text_output_mode =
guac_user_parse_args_string(user, GUAC_KUBERNETES_CLIENT_ARGS, argv,
IDX_TEXT_OUTPUT, "false");
settings->text_output_raw = (strcmp(text_output_mode, "raw") == 0);
settings->text_output = settings->text_output_raw
|| (strcmp(text_output_mode, "true") == 0);
guac_mem_free(text_output_mode);

/* Parsing was successful */
return settings;
Expand Down
19 changes: 19 additions & 0 deletions src/protocols/kubernetes/settings.h
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,25 @@ typedef struct guac_kubernetes_settings {
*/
bool disable_paste;

/**
* Whether raw text-output mode is enabled. If set, the raw terminal (PTY)
* byte stream is teed, verbatim, to an outbound "STDOUT" pipe stream in
* addition to the normal graphical display, enabling a native/CLI client
* to render the session as true in-terminal text. Honored only when
* disable_copy is not set, as text-output is effectively a copy channel.
*/
bool text_output;

/**
* Whether text-output should run in "raw" (headless) mode. When set, the
* graphical terminal is not rendered from remote output: the raw bytes are
* delivered only via the text-output pipe, skipping the terminal emulator
* and its (CPU- and bandwidth-heavy) graphical instruction stream. Implies
* text_output. Intended for connections consumed solely by a text/CLI
* client, where nothing renders the graphical display.
*/
bool text_output_raw;

/**
* The path in which the typescript should be saved, if enabled. If no
* typescript should be saved, this will be NULL.
Expand Down
27 changes: 25 additions & 2 deletions src/protocols/ssh/settings.c
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const char* GUAC_SSH_CLIENT_ARGS[] = {
"clipboard-buffer-size",
"disable-copy",
"disable-paste",
"text-output",
"wol-send-packet",
"wol-mac-addr",
"wol-broadcast-addr",
Expand Down Expand Up @@ -348,7 +349,17 @@ enum SSH_ARGS_IDX {
* the clipboard. By default, clipboard access is not blocked.
*/
IDX_DISABLE_PASTE,


/**
* Whether the raw terminal (PTY) byte stream should be teed, verbatim, to
* an outbound "STDOUT" pipe stream in addition to the normal graphical
* display. This enables a native/CLI Guacamole client to render the
* session as true in-terminal text. If set to "true", text-output mode is
* enabled; by default it is disabled. Honored only when copying from the
* terminal is not disabled (see IDX_DISABLE_COPY).
*/
IDX_TEXT_OUTPUT,

/**
* Whether the magic WoL packet should be sent prior to starting the
* connection. If set to "true", the system will attempt to send the WoL
Expand Down Expand Up @@ -620,7 +631,19 @@ guac_ssh_settings* guac_ssh_parse_args(guac_user* user,
settings->disable_paste =
guac_user_parse_args_boolean(user, GUAC_SSH_CLIENT_ARGS, argv,
IDX_DISABLE_PASTE, false);


/* Parse text-output mode. Accepts "true" (tee mode: the graphical display
* is preserved for browser clients) or "raw" (headless: the graphical
* terminal is not rendered, eliminating the graphical instruction stream
* and its rendering cost). Any other value disables text-output. */
char* text_output_mode =
guac_user_parse_args_string(user, GUAC_SSH_CLIENT_ARGS, argv,
IDX_TEXT_OUTPUT, "false");
settings->text_output_raw = (strcmp(text_output_mode, "raw") == 0);
settings->text_output = settings->text_output_raw
|| (strcmp(text_output_mode, "true") == 0);
guac_mem_free(text_output_mode);

/* Parse Wake-on-LAN (WoL) parameters. */
settings->wol_send_packet =
guac_user_parse_args_boolean(user, GUAC_SSH_CLIENT_ARGS, argv,
Expand Down
19 changes: 19 additions & 0 deletions src/protocols/ssh/settings.h
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,25 @@ typedef struct guac_ssh_settings {
*/
bool disable_paste;

/**
* Whether raw text-output mode is enabled. If set, the raw terminal (PTY)
* byte stream is teed, verbatim, to an outbound "STDOUT" pipe stream in
* addition to the normal graphical display, enabling a native/CLI client
* to render the session as true in-terminal text. Honored only when
* disable_copy is not set, as text-output is effectively a copy channel.
*/
bool text_output;

/**
* Whether text-output should run in "raw" (headless) mode. When set, the
* graphical terminal is not rendered from remote output: the raw bytes are
* delivered only via the text-output pipe, skipping the terminal emulator
* and its (CPU- and bandwidth-heavy) graphical instruction stream. Implies
* text_output. Intended for connections consumed solely by a text/CLI
* client, where nothing renders the graphical display.
*/
bool text_output_raw;

/**
* Whether SFTP is enabled.
*/
Expand Down
Loading