From 8a32f72789f6573cebe290d2a2b35c3f17413627 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Mon, 29 Jun 2026 14:26:43 -0300 Subject: [PATCH 01/36] GUACAMOLE-261: Add native SPICE protocol support (libguac-client-spice) Introduces a new in-tree protocol module under src/protocols/spice/ which allows guacd to proxy SPICE servers (as used by QEMU/KVM/libvirt), built as libguac-client-spice.so with the standard guac_client_init entry point. The module is built against spice-gtk (spice-client-glib-2.0) and mirrors the structure of the existing VNC module. Because spice-gtk is event-driven, the client thread runs a private GMainContext/GMainLoop and dispatches channels via the SpiceSession "channel-new" signal. Implemented: * Build integration: --with-spice, PKG_CHECK_MODULES for spice-client-glib-2.0 (and optional libphodav for folder sharing), ENABLE_SPICE/ENABLE_SPICE_WEBDAV conditionals, wired into the root configure.ac and Makefile.am with a configure-summary entry. * Display channel -> guac_display (primary surface + damage rects). * Keyboard and absolute/relative mouse via SpiceInputsChannel, with an X11-keysym to PC-scancode keymap. * Cursor channel -> guac cursor layer. * Clipboard (SPICE guest agent) <-> guac_common_clipboard. * Audio playback -> guac_audio_stream. * Plaintext and TLS transport (CA file, cert-subject, ignore-cert). * Folder sharing via the SPICE WebDAV channel (shared-dir), gated on libphodav. * SFTP via common-ssh, screen recording, Wake-on-LAN, and runtime argv password/username updates. USB redirection and smartcard passthrough are intentionally left unconnected (no physical devices on a headless proxy); multi-monitor renders the primary monitor only. --- Makefile.am | 7 +- configure.ac | 46 ++- src/protocols/spice/Makefile.am | 94 +++++ src/protocols/spice/argv.c | 52 +++ src/protocols/spice/argv.h | 43 +++ src/protocols/spice/audio.c | 123 +++++++ src/protocols/spice/audio.h | 40 +++ src/protocols/spice/auth.c | 107 ++++++ src/protocols/spice/auth.h | 40 +++ src/protocols/spice/channels.c | 180 ++++++++++ src/protocols/spice/channels.h | 59 ++++ src/protocols/spice/client.c | 191 ++++++++++ src/protocols/spice/client.h | 35 ++ src/protocols/spice/clipboard.c | 219 ++++++++++++ src/protocols/spice/clipboard.h | 56 +++ src/protocols/spice/cursor.c | 143 ++++++++ src/protocols/spice/cursor.h | 40 +++ src/protocols/spice/display.c | 180 ++++++++++ src/protocols/spice/display.h | 40 +++ src/protocols/spice/input.c | 192 ++++++++++ src/protocols/spice/input.h | 37 ++ src/protocols/spice/keymap.c | 208 +++++++++++ src/protocols/spice/keymap.h | 39 ++ src/protocols/spice/log.c | 99 ++++++ src/protocols/spice/log.h | 37 ++ src/protocols/spice/settings.c | 606 ++++++++++++++++++++++++++++++++ src/protocols/spice/settings.h | 367 +++++++++++++++++++ src/protocols/spice/sftp.c | 41 +++ src/protocols/spice/sftp.h | 31 ++ src/protocols/spice/spice.c | 301 ++++++++++++++++ src/protocols/spice/spice.h | 234 ++++++++++++ src/protocols/spice/user.c | 121 +++++++ src/protocols/spice/user.h | 36 ++ 33 files changed, 4042 insertions(+), 2 deletions(-) create mode 100644 src/protocols/spice/Makefile.am create mode 100644 src/protocols/spice/argv.c create mode 100644 src/protocols/spice/argv.h create mode 100644 src/protocols/spice/audio.c create mode 100644 src/protocols/spice/audio.h create mode 100644 src/protocols/spice/auth.c create mode 100644 src/protocols/spice/auth.h create mode 100644 src/protocols/spice/channels.c create mode 100644 src/protocols/spice/channels.h create mode 100644 src/protocols/spice/client.c create mode 100644 src/protocols/spice/client.h create mode 100644 src/protocols/spice/clipboard.c create mode 100644 src/protocols/spice/clipboard.h create mode 100644 src/protocols/spice/cursor.c create mode 100644 src/protocols/spice/cursor.h create mode 100644 src/protocols/spice/display.c create mode 100644 src/protocols/spice/display.h create mode 100644 src/protocols/spice/input.c create mode 100644 src/protocols/spice/input.h create mode 100644 src/protocols/spice/keymap.c create mode 100644 src/protocols/spice/keymap.h create mode 100644 src/protocols/spice/log.c create mode 100644 src/protocols/spice/log.h create mode 100644 src/protocols/spice/settings.c create mode 100644 src/protocols/spice/settings.h create mode 100644 src/protocols/spice/sftp.c create mode 100644 src/protocols/spice/sftp.h create mode 100644 src/protocols/spice/spice.c create mode 100644 src/protocols/spice/spice.h create mode 100644 src/protocols/spice/user.c create mode 100644 src/protocols/spice/user.h diff --git a/Makefile.am b/Makefile.am index 6ce3814a28..dcb5a5db4e 100644 --- a/Makefile.am +++ b/Makefile.am @@ -39,7 +39,8 @@ DIST_SUBDIRS = \ src/protocols/rdp \ src/protocols/ssh \ src/protocols/telnet \ - src/protocols/vnc + src/protocols/vnc \ + src/protocols/spice SUBDIRS = \ src/libguac \ @@ -77,6 +78,10 @@ if ENABLE_VNC SUBDIRS += src/protocols/vnc endif +if ENABLE_SPICE +SUBDIRS += src/protocols/spice +endif + if ENABLE_GUACD SUBDIRS += src/guacd endif diff --git a/configure.ac b/configure.ac index 2f2560dd8d..28708430be 100644 --- a/configure.ac +++ b/configure.ac @@ -558,6 +558,45 @@ fi AM_CONDITIONAL([ENABLE_VNC], [test "x${have_libvncserver}" = "xyes"]) AC_SUBST(VNC_LIBS) +# +# spice-gtk (SPICE protocol support) +# + +have_spice=disabled +AC_ARG_WITH([spice], + [AS_HELP_STRING([--with-spice], + [support SPICE @<:@default=check@:>@])], + [], + [with_spice=check]) + +if test "x$with_spice" != "xno" +then + have_spice=yes + + # libguac-client-spice is built against the GObject-based SPICE client + # library provided by spice-gtk. PKG_CHECK_MODULES automatically defines + # and AC_SUBSTs SPICE_CFLAGS and SPICE_LIBS. + PKG_CHECK_MODULES([SPICE], [spice-client-glib-2.0 >= 0.38],, + [have_spice=no]) +fi + +# +# Optional shared-folder (WebDAV) support for SPICE via libphodav +# + +have_phodav=no +if test "x${have_spice}" = "xyes" +then + PKG_CHECK_MODULES([PHODAV], [libphodav-3.0], + [have_phodav=yes + AC_DEFINE([ENABLE_SPICE_WEBDAV],, + [Whether SPICE shared-folder (WebDAV) support via libphodav is enabled.])], + [have_phodav=no]) +fi + +AM_CONDITIONAL([ENABLE_SPICE], [test "x${have_spice}" = "xyes"]) +AM_CONDITIONAL([ENABLE_SPICE_WEBDAV], [test "x${have_phodav}" = "xyes"]) + # # Repeater support within libVNCServer # @@ -1489,7 +1528,8 @@ AC_CONFIG_FILES([Makefile src/protocols/rdp/tests/Makefile src/protocols/ssh/Makefile src/protocols/telnet/Makefile - src/protocols/vnc/Makefile]) + src/protocols/vnc/Makefile + src/protocols/spice/Makefile]) AC_OUTPUT # @@ -1501,6 +1541,7 @@ 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_TELNET], [build_telnet=yes], [build_telnet=no]) AM_COND_IF([ENABLE_VNC], [build_vnc=yes], [build_vnc=no]) +AM_COND_IF([ENABLE_SPICE], [build_spice=yes], [build_spice=no]) # # Service / tool build status @@ -1550,6 +1591,8 @@ $PACKAGE_NAME version $PACKAGE_VERSION libswscale .......... ${have_libswscale} libtelnet ........... ${have_libtelnet} libVNCServer ........ ${have_libvncserver} + spice-gtk ........... ${have_spice} + libphodav ........... ${have_phodav} libvorbis ........... ${have_vorbis} libpulse ............ ${have_pulse} libwebsockets ....... ${have_libwebsockets} @@ -1563,6 +1606,7 @@ $PACKAGE_NAME version $PACKAGE_VERSION SSH ........... ${build_ssh} Telnet ........ ${build_telnet} VNC ........... ${build_vnc} + SPICE ......... ${build_spice} Services / tools: diff --git a/src/protocols/spice/Makefile.am b/src/protocols/spice/Makefile.am new file mode 100644 index 0000000000..766c844f54 --- /dev/null +++ b/src/protocols/spice/Makefile.am @@ -0,0 +1,94 @@ +# +# 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-spice.la + +libguac_client_spice_la_SOURCES = \ + argv.c \ + auth.c \ + audio.c \ + channels.c \ + client.c \ + clipboard.c \ + cursor.c \ + display.c \ + input.c \ + keymap.c \ + log.c \ + settings.c \ + spice.c \ + user.c + +noinst_HEADERS = \ + argv.h \ + auth.h \ + audio.h \ + channels.h \ + client.h \ + clipboard.h \ + cursor.h \ + display.h \ + input.h \ + keymap.h \ + log.h \ + settings.h \ + spice.h \ + user.h + +# Note: -pedantic is intentionally omitted here (unlike the other protocol +# modules). The third-party SPICE headers (spice-protocol) use zero-length +# arrays for variable-length protocol messages, which ISO C forbids and which +# -pedantic would reject within those system headers. +libguac_client_spice_la_CFLAGS = \ + -Werror -Wall \ + @COMMON_INCLUDE@ \ + @COMMON_SSH_INCLUDE@ \ + @LIBGUAC_INCLUDE@ \ + @SPICE_CFLAGS@ + +libguac_client_spice_la_LDFLAGS = \ + -version-info 0:0:0 \ + @CAIRO_LIBS@ \ + @SPICE_LIBS@ + +libguac_client_spice_la_LIBADD = \ + @COMMON_LTLIB@ \ + @LIBGUAC_LTLIB@ + +# Optional SFTP support +if ENABLE_COMMON_SSH +libguac_client_spice_la_SOURCES += sftp.c +noinst_HEADERS += sftp.h +libguac_client_spice_la_LIBADD += @COMMON_SSH_LTLIB@ +endif + +# Optional shared-folder (WebDAV) support via libphodav. Folder sharing is +# driven entirely through spice-gtk (by setting the session "shared-dir" +# property); libphodav is a runtime dependency pulled in by spice-gtk, so no +# additional sources or linkage are required here. The PHODAV headers are only +# referenced to confirm availability at configure time (ENABLE_SPICE_WEBDAV). diff --git a/src/protocols/spice/argv.c b/src/protocols/spice/argv.c new file mode 100644 index 0000000000..7b1e4f4cd9 --- /dev/null +++ b/src/protocols/spice/argv.c @@ -0,0 +1,52 @@ +/* + * 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 "spice.h" + +#include +#include +#include + +#include + +int guac_spice_argv_callback(guac_user* user, const char* mimetype, + const char* name, const char* value, void* data) { + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_spice_settings* settings = spice_client->settings; + + /* Update username */ + if (strcmp(name, GUAC_SPICE_ARGV_USERNAME) == 0) { + guac_mem_free(settings->username); + settings->username = guac_strdup(value); + } + + /* Update password */ + else if (strcmp(name, GUAC_SPICE_ARGV_PASSWORD) == 0) { + guac_mem_free(settings->password); + settings->password = guac_strdup(value); + } + + return 0; + +} diff --git a/src/protocols/spice/argv.h b/src/protocols/spice/argv.h new file mode 100644 index 0000000000..561a309d1d --- /dev/null +++ b/src/protocols/spice/argv.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_SPICE_ARGV_H +#define GUAC_SPICE_ARGV_H + +#include +#include + +/** + * The name of the parameter Guacamole will use to specify/update the SPICE + * password (ticket). + */ +#define GUAC_SPICE_ARGV_PASSWORD "password" + +/** + * The name of the parameter Guacamole will use to specify/update the username. + */ +#define GUAC_SPICE_ARGV_USERNAME "username" + +/** + * Handler which is invoked when the value of a connection parameter is being + * updated at runtime via an inbound "argv" stream. + */ +guac_argv_callback guac_spice_argv_callback; + +#endif diff --git a/src/protocols/spice/audio.c b/src/protocols/spice/audio.c new file mode 100644 index 0000000000..acdf7d760b --- /dev/null +++ b/src/protocols/spice/audio.c @@ -0,0 +1,123 @@ +/* + * 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 "audio.h" +#include "spice.h" + +#include +#include +#include + +/** + * The number of bits per audio sample. The SPICE playback channel always + * delivers signed 16-bit PCM (SPICE_AUDIO_FMT_S16). + */ +#define GUAC_SPICE_AUDIO_BPS 16 + +/** + * Signal handler for the SPICE playback channel "playback-start" signal. + * Allocates (or reconfigures) the Guacamole audio stream to match the format + * reported by the SPICE server. + */ +static void guac_spice_playback_start(SpicePlaybackChannel* channel, + gint format, gint channels, gint frequency, gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* SPICE only ever delivers signed 16-bit PCM */ + if (format != SPICE_AUDIO_FMT_S16) { + guac_client_log(client, GUAC_LOG_WARNING, + "Unsupported SPICE audio format (%d). Audio will be dropped.", + format); + return; + } + + spice_client->audio_rate = frequency; + spice_client->audio_channels = channels; + + /* Allocate the audio stream on first use, or reconfigure it if the format + * has changed since it was allocated */ + if (spice_client->audio == NULL) + spice_client->audio = guac_audio_stream_alloc(client, NULL, + frequency, channels, GUAC_SPICE_AUDIO_BPS); + else + guac_audio_stream_reset(spice_client->audio, NULL, + frequency, channels, GUAC_SPICE_AUDIO_BPS); + + if (spice_client->audio == NULL) + guac_client_log(client, GUAC_LOG_INFO, + "No audio support detected for connected client(s). Audio " + "will be disabled."); + +} + +/** + * Signal handler for the SPICE playback channel "playback-data" signal. Writes + * the received PCM data to the Guacamole audio stream. + */ +static void guac_spice_playback_data(SpicePlaybackChannel* channel, + gpointer data, gint size, gpointer user_data) { + + guac_client* client = (guac_client*) user_data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + if (spice_client->audio == NULL) + return; + + guac_audio_stream_write_pcm(spice_client->audio, + (const unsigned char*) data, size); + guac_audio_stream_flush(spice_client->audio); + +} + +/** + * Signal handler for the SPICE playback channel "playback-stop" signal. + * Flushes any buffered audio. + */ +static void guac_spice_playback_stop(SpicePlaybackChannel* channel, + gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + if (spice_client->audio != NULL) + guac_audio_stream_flush(spice_client->audio); + +} + +void guac_spice_playback_channel_connect(guac_client* client, + SpiceChannel* channel) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Do nothing if audio has not been enabled */ + if (!spice_client->settings->audio_enabled) + return; + + g_signal_connect(channel, "playback-start", + G_CALLBACK(guac_spice_playback_start), client); + g_signal_connect(channel, "playback-data", + G_CALLBACK(guac_spice_playback_data), client); + g_signal_connect(channel, "playback-stop", + G_CALLBACK(guac_spice_playback_stop), client); + +} diff --git a/src/protocols/spice/audio.h b/src/protocols/spice/audio.h new file mode 100644 index 0000000000..8c4e801f9e --- /dev/null +++ b/src/protocols/spice/audio.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_SPICE_AUDIO_H +#define GUAC_SPICE_AUDIO_H + +#include +#include + +/** + * Connects the necessary signal handlers to the given SPICE playback channel + * so that audio received from the SPICE server is streamed to connected + * Guacamole users. + * + * @param client + * The guac_client associated with the SPICE connection. + * + * @param channel + * The SPICE playback channel to handle. + */ +void guac_spice_playback_channel_connect(guac_client* client, + SpiceChannel* channel); + +#endif diff --git a/src/protocols/spice/auth.c b/src/protocols/spice/auth.c new file mode 100644 index 0000000000..532a82a290 --- /dev/null +++ b/src/protocols/spice/auth.c @@ -0,0 +1,107 @@ +/* + * 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 "auth.h" +#include "spice.h" + +#include +#include + +void guac_spice_session_configure(guac_client* client, SpiceSession* session) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_spice_settings* settings = spice_client->settings; + + /* Always configure the target host */ + g_object_set(session, "host", settings->hostname, NULL); + + /* Configure plaintext port, if provided */ + if (settings->port != NULL) + g_object_set(session, "port", settings->port, NULL); + + /* Configure TLS port and verification policy, if TLS is in use */ + if (settings->tls && settings->tls_port != NULL) { + + g_object_set(session, "tls-port", settings->tls_port, NULL); + + /* Provide CA certificate for verification, if given */ + if (settings->ca_file != NULL) + g_object_set(session, "ca-file", settings->ca_file, NULL); + + /* Provide expected certificate subject, if given */ + if (settings->cert_subject != NULL) + g_object_set(session, "cert-subject", settings->cert_subject, NULL); + + /* Determine certificate verification policy. Self-signed certificates + * (as used by default by QEMU/libvirt) require verification to be + * disabled entirely. */ + SpiceSessionVerify verify; + if (settings->ignore_cert) + verify = 0; + else if (settings->cert_subject != NULL) + verify = SPICE_SESSION_VERIFY_SUBJECT; + else + verify = SPICE_SESSION_VERIFY_HOSTNAME | SPICE_SESSION_VERIFY_PUBKEY; + + g_object_set(session, "verify", verify, NULL); + + } + + /* Configure authentication ticket (password), if provided */ + if (settings->password != NULL) + g_object_set(session, "password", settings->password, NULL); + + /* Configure username, if provided */ + if (settings->username != NULL) + g_object_set(session, "username", settings->username, NULL); + + /* Reflect read-only state to the session */ + g_object_set(session, "read-only", settings->read_only, NULL); + + /* Enable or disable audio playback negotiation */ + g_object_set(session, "enable-audio", settings->audio_enabled, NULL); + + /* USB redirection and smartcard passthrough are not meaningful for a + * headless proxy such as guacd, which has no local physical devices to + * redirect. Disable both to avoid advertising unsupported channels. */ + g_object_set(session, "enable-usbredir", FALSE, NULL); + g_object_set(session, "enable-smartcard", FALSE, NULL); + +#ifdef ENABLE_SPICE_WEBDAV + /* Expose shared folder via the SPICE WebDAV channel, if requested. The + * SPICE client library (spice-gtk) drives the underlying phodav-based + * WebDAV server automatically once "shared-dir" is set. */ + if (settings->enable_drive && settings->drive_path != NULL) { + g_object_set(session, "shared-dir", settings->drive_path, NULL); + guac_client_log(client, GUAC_LOG_INFO, + "Sharing directory \"%s\" with the SPICE server%s.", + settings->drive_path, + settings->drive_read_only ? " (read-only)" : ""); + } +#else + if (settings->enable_drive) + guac_client_log(client, GUAC_LOG_WARNING, "Folder sharing was " + "requested but support for the SPICE WebDAV channel " + "(libphodav) was not available at build time. Folder sharing " + "will be disabled."); +#endif + +} diff --git a/src/protocols/spice/auth.h b/src/protocols/spice/auth.h new file mode 100644 index 0000000000..58e37a1871 --- /dev/null +++ b/src/protocols/spice/auth.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_SPICE_AUTH_H +#define GUAC_SPICE_AUTH_H + +#include +#include + +/** + * Configures the given SPICE session according to the connection settings + * stored within the given client, setting connection parameters (host, ports, + * password) and any TLS-related parameters (CA file, certificate subject, + * peer verification policy). + * + * @param client + * The guac_client associated with the SPICE connection. + * + * @param session + * The SPICE session to configure. + */ +void guac_spice_session_configure(guac_client* client, SpiceSession* session); + +#endif diff --git a/src/protocols/spice/channels.c b/src/protocols/spice/channels.c new file mode 100644 index 0000000000..7f94f0231b --- /dev/null +++ b/src/protocols/spice/channels.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 "audio.h" +#include "channels.h" +#include "clipboard.h" +#include "cursor.h" +#include "display.h" +#include "spice.h" + +#include +#include +#include + +/** + * Signal handler for the "channel-event" signal of any SPICE channel. Handles + * connection-level errors and closure by aborting or stopping the Guacamole + * connection as appropriate. + */ +static void guac_spice_channel_event(SpiceChannel* channel, + SpiceChannelEvent event, gpointer data) { + + guac_client* client = (guac_client*) data; + + switch (event) { + + case SPICE_CHANNEL_OPENED: + guac_client_log(client, GUAC_LOG_DEBUG, "SPICE channel opened."); + break; + + case SPICE_CHANNEL_ERROR_AUTH: + guac_client_abort(client, GUAC_PROTOCOL_STATUS_CLIENT_UNAUTHORIZED, + "SPICE authentication failed. Verify the configured " + "password (ticket)."); + break; + + case SPICE_CHANNEL_ERROR_TLS: + guac_client_abort(client, GUAC_PROTOCOL_STATUS_UPSTREAM_ERROR, + "TLS error while connecting to SPICE server."); + break; + + case SPICE_CHANNEL_ERROR_CONNECT: + guac_client_abort(client, GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND, + "Unable to connect to SPICE server."); + break; + + case SPICE_CHANNEL_ERROR_LINK: + case SPICE_CHANNEL_ERROR_IO: + guac_client_abort(client, GUAC_PROTOCOL_STATUS_UPSTREAM_ERROR, + "Connection to SPICE server failed or was lost."); + break; + + default: + /* All other events (including normal closure) are not treated as + * errors here; connection teardown is driven by the client state */ + break; + + } + +} + +/** + * Signal handler for the SPICE main channel "main-mouse-update" signal. + * Records the currently negotiated mouse mode so that pointer events can be + * sent using the correct (absolute or relative) coordinate scheme. + */ +static void guac_spice_main_mouse_update(SpiceMainChannel* channel, + gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + gint mouse_mode = SPICE_MOUSE_MODE_CLIENT; + g_object_get(channel, "mouse-mode", &mouse_mode, NULL); + spice_client->mouse_mode = mouse_mode; + + guac_client_log(client, GUAC_LOG_DEBUG, "SPICE mouse mode is now %s.", + mouse_mode == SPICE_MOUSE_MODE_SERVER ? "server" : "client"); + +} + +void guac_spice_channel_new(SpiceSession* session, SpiceChannel* channel, + gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* All channels report connection-level errors the same way */ + g_signal_connect(channel, "channel-event", + G_CALLBACK(guac_spice_channel_event), client); + + /* Main channel: session-wide state, mouse mode, and clipboard */ + if (SPICE_IS_MAIN_CHANNEL(channel)) { + guac_client_log(client, GUAC_LOG_DEBUG, "Connecting SPICE main channel."); + spice_client->main_channel = SPICE_MAIN_CHANNEL(channel); + g_signal_connect(channel, "main-mouse-update", + G_CALLBACK(guac_spice_main_mouse_update), client); + guac_spice_clipboard_connect(client, SPICE_MAIN_CHANNEL(channel)); + } + + /* Display channel: remote framebuffer */ + else if (SPICE_IS_DISPLAY_CHANNEL(channel)) { + guac_client_log(client, GUAC_LOG_DEBUG, "Connecting SPICE display channel."); + guac_spice_display_channel_connect(client, channel); + } + + /* Inputs channel: keyboard and mouse */ + else if (SPICE_IS_INPUTS_CHANNEL(channel)) { + guac_client_log(client, GUAC_LOG_DEBUG, "Connecting SPICE inputs channel."); + spice_client->inputs_channel = SPICE_INPUTS_CHANNEL(channel); + } + + /* Cursor channel: remote cursor shape */ + else if (SPICE_IS_CURSOR_CHANNEL(channel)) { + guac_client_log(client, GUAC_LOG_DEBUG, "Connecting SPICE cursor channel."); + guac_spice_cursor_channel_connect(client, channel); + } + + /* Playback channel: audio output */ + else if (SPICE_IS_PLAYBACK_CHANNEL(channel)) { + guac_client_log(client, GUAC_LOG_DEBUG, "Connecting SPICE playback channel."); + guac_spice_playback_channel_connect(client, channel); + } + +#ifdef ENABLE_SPICE_WEBDAV + /* WebDAV channel: folder sharing (driven internally by spice-gtk) */ + else if (SPICE_IS_WEBDAV_CHANNEL(channel)) { + guac_client_log(client, GUAC_LOG_DEBUG, "Connecting SPICE WebDAV channel."); + } +#endif + + /* USB redirection, smartcard, and microphone (record) channels are not + * supported by the headless guacd proxy and are intentionally left + * unconnected. */ + else { + guac_client_log(client, GUAC_LOG_DEBUG, + "Ignoring unsupported SPICE channel type."); + return; + } + + /* Open the channel now that handlers are registered */ + spice_channel_connect(channel); + +} + +void guac_spice_channel_destroy(SpiceSession* session, SpiceChannel* channel, + gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Release references to channels being destroyed */ + if (SPICE_IS_MAIN_CHANNEL(channel)) + spice_client->main_channel = NULL; + else if (SPICE_IS_DISPLAY_CHANNEL(channel)) + spice_client->display_channel = NULL; + else if (SPICE_IS_INPUTS_CHANNEL(channel)) + spice_client->inputs_channel = NULL; + else if (SPICE_IS_CURSOR_CHANNEL(channel)) + spice_client->cursor_channel = NULL; + +} diff --git a/src/protocols/spice/channels.h b/src/protocols/spice/channels.h new file mode 100644 index 0000000000..93563972e0 --- /dev/null +++ b/src/protocols/spice/channels.h @@ -0,0 +1,59 @@ +/* + * 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_SPICE_CHANNELS_H +#define GUAC_SPICE_CHANNELS_H + +#include +#include + +/** + * Signal handler for the SpiceSession "channel-new" signal. Inspects the type + * of each newly-created channel, wires up the appropriate Guacamole-specific + * signal handlers, and opens the channel. + * + * @param session + * The SPICE session which created the channel. + * + * @param channel + * The newly-created SPICE channel. + * + * @param data + * The guac_client associated with the SPICE connection. + */ +void guac_spice_channel_new(SpiceSession* session, SpiceChannel* channel, + gpointer data); + +/** + * Signal handler for the SpiceSession "channel-destroy" signal. Releases any + * references held to the destroyed channel. + * + * @param session + * The SPICE session which destroyed the channel. + * + * @param channel + * The SPICE channel being destroyed. + * + * @param data + * The guac_client associated with the SPICE connection. + */ +void guac_spice_channel_destroy(SpiceSession* session, SpiceChannel* channel, + gpointer data); + +#endif diff --git a/src/protocols/spice/client.c b/src/protocols/spice/client.c new file mode 100644 index 0000000000..011e8f4345 --- /dev/null +++ b/src/protocols/spice/client.c @@ -0,0 +1,191 @@ +/* + * 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 "client.h" +#include "common/clipboard.h" +#include "settings.h" +#include "spice.h" +#include "user.h" + +#ifdef ENABLE_COMMON_SSH +#include "common-ssh/sftp.h" +#include "common-ssh/ssh.h" +#include "common-ssh/user.h" +#endif + +#include +#include +#include +#include +#include +#include + +#include + +/** + * Adds the given pending user to the SPICE audio stream, ensuring the user can + * receive audio. + * + * @param user + * The pending user who should be added to the audio stream. + * + * @param data + * The guac_audio_stream that the user should be added to. + * + * @return + * Always NULL. + */ +static void* guac_spice_sync_pending_user_audio(guac_user* user, void* data) { + guac_audio_stream* audio = (guac_audio_stream*) data; + guac_audio_stream_add_user(audio, user); + return NULL; +} + +/** + * A pending join handler which synchronizes the connection state for all + * pending users prior to them being promoted to full users. + * + * @param client + * The client whose pending users are about to be promoted. + * + * @return + * Always zero. + */ +static int guac_spice_join_pending_handler(guac_client* client) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_socket* broadcast_socket = client->pending_socket; + + /* Synchronize any audio stream for each pending user */ + if (spice_client->audio) + guac_client_foreach_pending_user(client, + guac_spice_sync_pending_user_audio, spice_client->audio); + + /* Synchronize with current display */ + if (spice_client->display != NULL) { + guac_display_dup(spice_client->display, broadcast_socket); + guac_socket_flush(broadcast_socket); + } + + return 0; + +} + +int guac_client_init(guac_client* client) { + + /* Set client args */ + client->args = GUAC_SPICE_CLIENT_ARGS; + + /* Alloc client data */ + guac_spice_client* spice_client = guac_mem_zalloc(sizeof(guac_spice_client)); + client->data = spice_client; + + /* Init surface metadata lock */ + pthread_mutex_init(&spice_client->surface_lock, NULL); + + /* Default to client (absolute) mouse mode until told otherwise */ + spice_client->mouse_mode = SPICE_MOUSE_MODE_CLIENT; + + /* Set handlers */ + client->join_handler = guac_spice_user_join_handler; + client->join_pending_handler = guac_spice_join_pending_handler; + client->leave_handler = guac_spice_user_leave_handler; + client->free_handler = guac_spice_client_free_handler; + + return 0; + +} + +int guac_spice_client_free_handler(guac_client* client) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_spice_settings* settings = spice_client->settings; + + /* Ensure all background rendering processes are stopped before freeing + * underlying memory */ + if (spice_client->display != NULL) + guac_display_stop(spice_client->display); + + /* Signal the SPICE event loop to stop, then wait for the client thread to + * finish */ + if (spice_client->main_loop != NULL) + g_main_loop_quit(spice_client->main_loop); + + pthread_join(spice_client->client_thread, NULL); + + /* Clean up the SPICE session */ + if (spice_client->spice_session != NULL) { + spice_session_disconnect(spice_client->spice_session); + g_object_unref(spice_client->spice_session); + } + + /* Free the GLib main loop and context */ + if (spice_client->main_loop != NULL) + g_main_loop_unref(spice_client->main_loop); + + if (spice_client->main_context != NULL) + g_main_context_unref(spice_client->main_context); + +#ifdef ENABLE_COMMON_SSH + /* Free SFTP filesystem, if loaded */ + if (spice_client->sftp_filesystem) + guac_common_ssh_destroy_sftp_filesystem(spice_client->sftp_filesystem); + + /* Free SFTP session */ + if (spice_client->sftp_session) + guac_common_ssh_destroy_session(spice_client->sftp_session); + + /* Free SFTP user */ + if (spice_client->sftp_user) + guac_common_ssh_destroy_user(spice_client->sftp_user); + + guac_common_ssh_uninit(); +#endif + + /* Clean up recording, if in progress */ + if (spice_client->recording != NULL) + guac_recording_free(spice_client->recording); + + /* Free clipboard */ + if (spice_client->clipboard != NULL) + guac_common_clipboard_free(spice_client->clipboard); + + /* Free display */ + if (spice_client->display != NULL) + guac_display_free(spice_client->display); + + /* If audio enabled, stop streaming */ + if (spice_client->audio != NULL) + guac_audio_stream_free(spice_client->audio); + + /* Free parsed settings */ + if (settings != NULL) + guac_spice_settings_free(settings); + + /* Clean up surface lock */ + pthread_mutex_destroy(&spice_client->surface_lock); + + /* Free generic data struct */ + guac_mem_free(client->data); + + return 0; + +} diff --git a/src/protocols/spice/client.h b/src/protocols/spice/client.h new file mode 100644 index 0000000000..9cf4a34f57 --- /dev/null +++ b/src/protocols/spice/client.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_SPICE_CLIENT_H +#define GUAC_SPICE_CLIENT_H + +#include + +/** + * The number of milliseconds to wait between connection attempts. + */ +#define GUAC_SPICE_CONNECT_INTERVAL 1000 + +/** + * Handler which frees all data associated with the guac_client. + */ +guac_client_free_handler guac_spice_client_free_handler; + +#endif diff --git a/src/protocols/spice/clipboard.c b/src/protocols/spice/clipboard.c new file mode 100644 index 0000000000..edeb529ac8 --- /dev/null +++ b/src/protocols/spice/clipboard.c @@ -0,0 +1,219 @@ +/* + * 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 "common/clipboard.h" +#include "spice.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +/** + * The SPICE clipboard selection used for all clipboard exchange. The standard + * "CLIPBOARD" selection (as opposed to the X11 "PRIMARY" selection) is used, + * matching the behavior expected by typical desktop environments. + */ +#define GUAC_SPICE_CLIPBOARD_SELECTION VD_AGENT_CLIPBOARD_SELECTION_CLIPBOARD + +/** + * Signal handler for the SPICE main channel "main-clipboard-selection-grab" + * signal, invoked when the remote guest takes ownership of the clipboard. If + * the guest is offering UTF-8 text, the data is requested from the guest. + */ +static void guac_spice_clipboard_grab(SpiceMainChannel* channel, + guint selection, gpointer types, guint ntypes, gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Ignore inbound clipboard if outbound (remote-to-client) transfer is + * disabled */ + if (spice_client->settings->disable_copy) + return; + + /* Request UTF-8 text from the guest, if offered */ + guint32* offered = (guint32*) types; + for (guint i = 0; i < ntypes; i++) { + if (offered[i] == VD_AGENT_CLIPBOARD_UTF8_TEXT) { + spice_main_channel_clipboard_selection_request(channel, selection, + VD_AGENT_CLIPBOARD_UTF8_TEXT); + return; + } + } + +} + +/** + * Signal handler for the SPICE main channel "main-clipboard-selection" signal, + * invoked when the remote guest sends clipboard data (in response to an earlier + * request). The received data is pushed to all connected Guacamole users. + */ +static void guac_spice_clipboard_selection(SpiceMainChannel* channel, + guint selection, guint type, gpointer data, guint size, + gpointer user_data) { + + guac_client* client = (guac_client*) user_data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Ignore inbound clipboard if outbound transfer is disabled or unsupported + * data type is received */ + if (spice_client->settings->disable_copy + || type != VD_AGENT_CLIPBOARD_UTF8_TEXT + || spice_client->clipboard == NULL) + return; + + /* Replace clipboard contents with received text and broadcast to users */ + guac_common_clipboard_reset(spice_client->clipboard, "text/plain"); + guac_common_clipboard_append(spice_client->clipboard, (char*) data, size); + guac_common_clipboard_send(spice_client->clipboard, client); + +} + +/** + * Signal handler for the SPICE main channel "main-clipboard-selection-request" + * signal, invoked when the remote guest requests the current clipboard + * contents. The most recently received Guacamole clipboard data is sent to the + * guest. + * + * @return + * TRUE if the request was handled, FALSE otherwise. + */ +static gboolean guac_spice_clipboard_request(SpiceMainChannel* channel, + guint selection, guint type, gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Ignore requests if inbound (client-to-remote) transfer is disabled, the + * requested type is unsupported, or no clipboard data is available */ + if (spice_client->settings->disable_paste + || type != VD_AGENT_CLIPBOARD_UTF8_TEXT + || spice_client->clipboard == NULL) + return FALSE; + + /* Provide the current clipboard contents to the guest */ + spice_main_channel_clipboard_selection_notify(channel, selection, + VD_AGENT_CLIPBOARD_UTF8_TEXT, + (const guchar*) spice_client->clipboard->buffer, + spice_client->clipboard->length); + + return TRUE; + +} + +void guac_spice_clipboard_connect(guac_client* client, + SpiceMainChannel* channel) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Clipboard integration is unavailable if disabled entirely */ + if (spice_client->settings->disable_clipboard) + return; + + g_signal_connect(channel, "main-clipboard-selection-grab", + G_CALLBACK(guac_spice_clipboard_grab), client); + g_signal_connect(channel, "main-clipboard-selection", + G_CALLBACK(guac_spice_clipboard_selection), client); + g_signal_connect(channel, "main-clipboard-selection-request", + G_CALLBACK(guac_spice_clipboard_request), client); + +} + +int guac_spice_clipboard_handler(guac_user* user, guac_stream* stream, + char* mimetype) { + + guac_spice_client* spice_client = (guac_spice_client*) user->client->data; + + /* Ignore stream creation if no clipboard structure is available */ + guac_common_clipboard* clipboard = spice_client->clipboard; + if (clipboard == NULL) + return 0; + + /* Clear clipboard and prepare for new data */ + guac_common_clipboard_reset(clipboard, mimetype); + + /* Set handlers for clipboard stream */ + stream->blob_handler = guac_spice_clipboard_blob_handler; + stream->end_handler = guac_spice_clipboard_end_handler; + + /* Report clipboard within recording */ + if (spice_client->recording != NULL) + guac_recording_report_clipboard_begin(spice_client->recording, stream, + mimetype); + + return 0; + +} + +int guac_spice_clipboard_blob_handler(guac_user* user, guac_stream* stream, + void* data, int length) { + + guac_spice_client* spice_client = (guac_spice_client*) user->client->data; + + guac_common_clipboard* clipboard = spice_client->clipboard; + if (clipboard == NULL) + return 0; + + /* Report clipboard blob within recording */ + if (spice_client->recording != NULL) + guac_recording_report_clipboard_blob(spice_client->recording, stream, + data, length); + + /* Append new data */ + guac_common_clipboard_append(clipboard, (char*) data, length); + + return 0; + +} + +int guac_spice_clipboard_end_handler(guac_user* user, guac_stream* stream) { + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + guac_common_clipboard* clipboard = spice_client->clipboard; + if (clipboard == NULL) + return 0; + + /* Report clipboard stream end within recording */ + if (spice_client->recording != NULL) + guac_recording_report_clipboard_end(spice_client->recording, stream); + + /* Take ownership of the SPICE clipboard on behalf of the client, offering + * the newly-received text to the remote guest. The guest will subsequently + * request the data via "main-clipboard-selection-request". */ + if (spice_client->main_channel != NULL + && !spice_client->settings->disable_paste) { + guint32 types[] = { VD_AGENT_CLIPBOARD_UTF8_TEXT }; + spice_main_channel_clipboard_selection_grab(spice_client->main_channel, + GUAC_SPICE_CLIPBOARD_SELECTION, types, 1); + } + + return 0; + +} diff --git a/src/protocols/spice/clipboard.h b/src/protocols/spice/clipboard.h new file mode 100644 index 0000000000..bdda3ee47e --- /dev/null +++ b/src/protocols/spice/clipboard.h @@ -0,0 +1,56 @@ +/* + * 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_SPICE_CLIPBOARD_H +#define GUAC_SPICE_CLIPBOARD_H + +#include +#include +#include + +/** + * Connects the clipboard-related signal handlers of the given SPICE main + * channel, enabling clipboard exchange (via the SPICE guest agent) between the + * Guacamole client and the remote desktop. + * + * @param client + * The guac_client associated with the SPICE connection. + * + * @param channel + * The SPICE main channel to handle. + */ +void guac_spice_clipboard_connect(guac_client* client, + SpiceMainChannel* channel); + +/** + * Handler for inbound (client-to-server) clipboard streams. + */ +guac_user_clipboard_handler guac_spice_clipboard_handler; + +/** + * Handler for blobs of inbound clipboard data. + */ +guac_user_blob_handler guac_spice_clipboard_blob_handler; + +/** + * Handler for the end of an inbound clipboard stream. + */ +guac_user_end_handler guac_spice_clipboard_end_handler; + +#endif diff --git a/src/protocols/spice/cursor.c b/src/protocols/spice/cursor.c new file mode 100644 index 0000000000..29df8c14ae --- /dev/null +++ b/src/protocols/spice/cursor.c @@ -0,0 +1,143 @@ +/* + * 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 "cursor.h" +#include "spice.h" + +#include +#include +#include +#include + +#include + +/** + * Signal handler for the SPICE cursor channel "cursor-set" signal. Renders the + * given cursor shape into the cursor layer of the Guacamole display. + * + * The SPICE cursor image data is provided as 32 bits per pixel in R, G, B, A + * byte order (as consumed by GdkPixbuf within spice-gtk), which is converted + * here to the native-endian 0xAARRGGBB layout used by the Guacamole display. + */ +static void guac_spice_cursor_set(SpiceCursorChannel* channel, + gint width, gint height, gint hot_x, gint hot_y, gpointer rgba, + gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + if (spice_client->display == NULL || rgba == NULL) + return; + + /* Begin drawing operation directly to the cursor layer */ + guac_display_layer* cursor_layer = guac_display_cursor(spice_client->display); + guac_display_layer_resize(cursor_layer, width, height); + guac_display_set_cursor_hotspot(spice_client->display, hot_x, hot_y); + + guac_display_layer_raw_context* context = + guac_display_layer_open_raw(cursor_layer); + + guac_rect op_bounds; + guac_rect_init(&op_bounds, 0, 0, width, height); + guac_rect_constrain(&op_bounds, &context->bounds); + + const unsigned char* src_row = (const unsigned char*) rgba; + unsigned char* dst_row = GUAC_RECT_MUTABLE_BUFFER(op_bounds, + context->buffer, context->stride, GUAC_DISPLAY_LAYER_RAW_BPP); + + for (int dy = 0; dy < height; dy++) { + + const unsigned char* src_pixel = src_row; + uint32_t* dst_pixel = (uint32_t*) dst_row; + + for (int dx = 0; dx < width; dx++) { + + uint8_t red = src_pixel[0]; + uint8_t green = src_pixel[1]; + uint8_t blue = src_pixel[2]; + uint8_t alpha = src_pixel[3]; + + *(dst_pixel++) = ((uint32_t) alpha << 24) + | ((uint32_t) red << 16) + | ((uint32_t) green << 8) + | (uint32_t) blue; + + src_pixel += 4; + + } + + src_row += (size_t) width * 4; + dst_row += context->stride; + + } + + /* Mark entire cursor as modified */ + guac_rect_extend(&context->dirty, &op_bounds); + + guac_display_layer_close_raw(cursor_layer, context); + guac_display_render_thread_notify_modified(spice_client->render_thread); + +} + +/** + * Signal handler for the SPICE cursor channel "cursor-hide" signal. Hides the + * Guacamole cursor by switching to an empty (fully transparent) cursor. + */ +static void guac_spice_cursor_hide(SpiceCursorChannel* channel, + gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + if (spice_client->display != NULL) + guac_display_set_cursor(spice_client->display, GUAC_DISPLAY_CURSOR_NONE); + +} + +/** + * Signal handler for the SPICE cursor channel "cursor-reset" signal. Restores + * the default Guacamole cursor. + */ +static void guac_spice_cursor_reset(SpiceCursorChannel* channel, + gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + if (spice_client->display != NULL) + guac_display_set_cursor(spice_client->display, GUAC_DISPLAY_CURSOR_POINTER); + +} + +void guac_spice_cursor_channel_connect(guac_client* client, + SpiceChannel* channel) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + spice_client->cursor_channel = channel; + + g_signal_connect(channel, "cursor-set", + G_CALLBACK(guac_spice_cursor_set), client); + g_signal_connect(channel, "cursor-hide", + G_CALLBACK(guac_spice_cursor_hide), client); + g_signal_connect(channel, "cursor-reset", + G_CALLBACK(guac_spice_cursor_reset), client); + +} diff --git a/src/protocols/spice/cursor.h b/src/protocols/spice/cursor.h new file mode 100644 index 0000000000..55626122f1 --- /dev/null +++ b/src/protocols/spice/cursor.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_SPICE_CURSOR_H +#define GUAC_SPICE_CURSOR_H + +#include +#include + +/** + * Connects the necessary signal handlers to the given SPICE cursor channel so + * that the remote cursor shape is mirrored into the cursor layer of the + * guac_display of the given client. + * + * @param client + * The guac_client associated with the SPICE connection. + * + * @param channel + * The SPICE cursor channel to handle. + */ +void guac_spice_cursor_channel_connect(guac_client* client, + SpiceChannel* channel); + +#endif diff --git a/src/protocols/spice/display.c b/src/protocols/spice/display.c new file mode 100644 index 0000000000..8f36ce7ba4 --- /dev/null +++ b/src/protocols/spice/display.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 "display.h" +#include "spice.h" + +#include +#include +#include +#include + +#include +#include + +/** + * Signal handler for the SPICE display channel "display-primary-create" + * signal. Records the location and dimensions of the new primary surface and + * resizes the Guacamole display accordingly. + */ +static void guac_spice_display_primary_create(SpiceChannel* channel, + gint format, gint width, gint height, gint stride, gint shmid, + gpointer imgdata, gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + pthread_mutex_lock(&spice_client->surface_lock); + + /* Record details of the new primary surface */ + spice_client->surface_data = imgdata; + spice_client->surface_format = format; + spice_client->surface_width = width; + spice_client->surface_height = height; + spice_client->surface_stride = stride; + + pthread_mutex_unlock(&spice_client->surface_lock); + + guac_client_log(client, GUAC_LOG_DEBUG, + "SPICE primary surface created: %dx%d (stride %d, format %d).", + width, height, stride, format); + + /* Resize the Guacamole display to match the new primary surface */ + if (spice_client->display != NULL) + guac_display_layer_resize( + guac_display_default_layer(spice_client->display), + width, height); + +} + +/** + * Signal handler for the SPICE display channel "display-primary-destroy" + * signal. Clears the recorded primary surface, preventing any further reads + * from the (now invalid) surface buffer. + */ +static void guac_spice_display_primary_destroy(SpiceChannel* channel, + gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + pthread_mutex_lock(&spice_client->surface_lock); + spice_client->surface_data = NULL; + pthread_mutex_unlock(&spice_client->surface_lock); + +} + +/** + * Signal handler for the SPICE display channel "display-invalidate" signal. + * Copies the damaged region of the primary surface into the Guacamole display. + */ +static void guac_spice_display_invalidate(SpiceChannel* channel, + gint x, gint y, gint w, gint h, gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + if (spice_client->display == NULL) + return; + + pthread_mutex_lock(&spice_client->surface_lock); + + /* Ignore updates received while no primary surface exists */ + const unsigned char* surface = (const unsigned char*) spice_client->surface_data; + if (surface == NULL) { + pthread_mutex_unlock(&spice_client->surface_lock); + return; + } + + int surface_stride = spice_client->surface_stride; + int surface_width = spice_client->surface_width; + int surface_height = spice_client->surface_height; + + guac_display_layer* default_layer = + guac_display_default_layer(spice_client->display); + + /* Acquire exclusive access to the layer for drawing */ + guac_display_layer_raw_context* context = + guac_display_layer_open_raw(default_layer); + + /* Constrain the damaged region to the bounds of both the SPICE surface + * and the current pending frame */ + guac_rect op_bounds; + guac_rect_init(&op_bounds, x, y, w, h); + + guac_rect surface_bounds; + guac_rect_init(&surface_bounds, 0, 0, surface_width, surface_height); + guac_rect_constrain(&op_bounds, &surface_bounds); + guac_rect_constrain(&op_bounds, &context->bounds); + + int dst_x = guac_rect_width(&op_bounds) > 0 ? op_bounds.left : 0; + int dst_y = op_bounds.top; + int width = guac_rect_width(&op_bounds); + int height = guac_rect_height(&op_bounds); + + /* The SPICE primary surface (SPICE_SURFACE_FMT_32_xRGB) shares the same + * little-endian 32-bit memory layout as the Guacamole raw layer buffer, so + * each damaged row can be copied directly. */ + if (width > 0 && height > 0) { + + unsigned char* dst_row = GUAC_RECT_MUTABLE_BUFFER(op_bounds, + context->buffer, context->stride, GUAC_DISPLAY_LAYER_RAW_BPP); + + const unsigned char* src_row = surface + + (size_t) dst_y * surface_stride + + (size_t) dst_x * GUAC_DISPLAY_LAYER_RAW_BPP; + + size_t row_length = (size_t) width * GUAC_DISPLAY_LAYER_RAW_BPP; + + for (int row = 0; row < height; row++) { + memcpy(dst_row, src_row, row_length); + dst_row += context->stride; + src_row += surface_stride; + } + + /* Mark the modified region as dirty */ + guac_rect_extend(&context->dirty, &op_bounds); + + } + + pthread_mutex_unlock(&spice_client->surface_lock); + + /* Drawing is complete */ + guac_display_layer_close_raw(default_layer, context); + guac_display_render_thread_notify_modified(spice_client->render_thread); + +} + +void guac_spice_display_channel_connect(guac_client* client, + SpiceChannel* channel) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + spice_client->display_channel = channel; + + /* Mirror the remote framebuffer into the Guacamole display */ + g_signal_connect(channel, "display-primary-create", + G_CALLBACK(guac_spice_display_primary_create), client); + g_signal_connect(channel, "display-primary-destroy", + G_CALLBACK(guac_spice_display_primary_destroy), client); + g_signal_connect(channel, "display-invalidate", + G_CALLBACK(guac_spice_display_invalidate), client); + +} diff --git a/src/protocols/spice/display.h b/src/protocols/spice/display.h new file mode 100644 index 0000000000..167bbdab8e --- /dev/null +++ b/src/protocols/spice/display.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_SPICE_DISPLAY_H +#define GUAC_SPICE_DISPLAY_H + +#include +#include + +/** + * Connects the necessary signal handlers to the given SPICE display channel so + * that the remote framebuffer is mirrored into the guac_display of the given + * client. + * + * @param client + * The guac_client associated with the SPICE connection. + * + * @param channel + * The SPICE display channel to handle. + */ +void guac_spice_display_channel_connect(guac_client* client, + SpiceChannel* channel); + +#endif diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c new file mode 100644 index 0000000000..6ca701b050 --- /dev/null +++ b/src/protocols/spice/input.c @@ -0,0 +1,192 @@ +/* + * 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 "keymap.h" +#include "spice.h" + +#include +#include +#include +#include +#include +#include + +/** + * Translates a Guacamole mouse button mask into the equivalent SPICE button + * state mask (a bitwise OR of SPICE_MOUSE_BUTTON_MASK_* values). Only the + * primary three buttons are represented within a SPICE button state mask; + * scroll events are delivered separately as momentary button presses. + * + * @param mask + * The Guacamole mouse button mask to translate. + * + * @return + * The equivalent SPICE button state mask. + */ +static int guac_spice_button_mask(int mask) { + + int spice_mask = 0; + + if (mask & GUAC_CLIENT_MOUSE_LEFT) + spice_mask |= SPICE_MOUSE_BUTTON_MASK_LEFT; + + if (mask & GUAC_CLIENT_MOUSE_MIDDLE) + spice_mask |= SPICE_MOUSE_BUTTON_MASK_MIDDLE; + + if (mask & GUAC_CLIENT_MOUSE_RIGHT) + spice_mask |= SPICE_MOUSE_BUTTON_MASK_RIGHT; + + return spice_mask; + +} + +/** + * Sends a momentary button press and release for the given SPICE button, + * relative to the given current button state mask. This is used for scroll + * wheel events, which Guacamole delivers as transient button presses. + * + * @param inputs + * The SPICE inputs channel through which the events should be sent. + * + * @param button + * The SPICE button to press and release (one of SPICE_MOUSE_BUTTON_*). + * + * @param button_state + * The current SPICE button state mask. + */ +static void guac_spice_scroll(SpiceInputsChannel* inputs, int button, + int button_state) { + spice_inputs_channel_button_press(inputs, button, button_state); + spice_inputs_channel_button_release(inputs, button, button_state); +} + +int guac_spice_user_mouse_handler(guac_user* user, int x, int y, int mask) { + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + SpiceInputsChannel* inputs = spice_client->inputs_channel; + + /* Track current mouse location/state for all connected users */ + guac_display_render_thread_notify_user_moved_mouse( + spice_client->render_thread, user, x, y, mask); + + /* Report mouse position within recording */ + if (spice_client->recording != NULL) + guac_recording_report_mouse(spice_client->recording, x, y, mask); + + /* Send SPICE events only once the inputs channel is ready */ + if (inputs == NULL) + return 0; + + int spice_mask = guac_spice_button_mask(mask); + int prev_mask = guac_spice_button_mask(spice_client->last_mouse_mask); + + /* Update pointer position. SPICE client mouse mode accepts absolute + * coordinates directly; server mode requires relative motion deltas. */ + if (spice_client->mouse_mode == SPICE_MOUSE_MODE_SERVER) + spice_inputs_channel_motion(inputs, + x - spice_client->last_mouse_x, + y - spice_client->last_mouse_y, spice_mask); + else + spice_inputs_channel_position(inputs, x, y, 0, spice_mask); + + /* Send press/release events for any of the primary buttons that changed + * state since the previous mouse event */ + if (spice_mask != prev_mask) { + + if ((spice_mask & SPICE_MOUSE_BUTTON_MASK_LEFT) + != (prev_mask & SPICE_MOUSE_BUTTON_MASK_LEFT)) { + if (spice_mask & SPICE_MOUSE_BUTTON_MASK_LEFT) + spice_inputs_channel_button_press(inputs, SPICE_MOUSE_BUTTON_LEFT, spice_mask); + else + spice_inputs_channel_button_release(inputs, SPICE_MOUSE_BUTTON_LEFT, spice_mask); + } + + if ((spice_mask & SPICE_MOUSE_BUTTON_MASK_MIDDLE) + != (prev_mask & SPICE_MOUSE_BUTTON_MASK_MIDDLE)) { + if (spice_mask & SPICE_MOUSE_BUTTON_MASK_MIDDLE) + spice_inputs_channel_button_press(inputs, SPICE_MOUSE_BUTTON_MIDDLE, spice_mask); + else + spice_inputs_channel_button_release(inputs, SPICE_MOUSE_BUTTON_MIDDLE, spice_mask); + } + + if ((spice_mask & SPICE_MOUSE_BUTTON_MASK_RIGHT) + != (prev_mask & SPICE_MOUSE_BUTTON_MASK_RIGHT)) { + if (spice_mask & SPICE_MOUSE_BUTTON_MASK_RIGHT) + spice_inputs_channel_button_press(inputs, SPICE_MOUSE_BUTTON_RIGHT, spice_mask); + else + spice_inputs_channel_button_release(inputs, SPICE_MOUSE_BUTTON_RIGHT, spice_mask); + } + + } + + /* Handle scroll wheel as momentary button presses, triggered on the + * leading edge of the corresponding Guacamole scroll bit */ + if ((mask & GUAC_CLIENT_MOUSE_SCROLL_UP) + && !(spice_client->last_mouse_mask & GUAC_CLIENT_MOUSE_SCROLL_UP)) + guac_spice_scroll(inputs, SPICE_MOUSE_BUTTON_UP, spice_mask); + + if ((mask & GUAC_CLIENT_MOUSE_SCROLL_DOWN) + && !(spice_client->last_mouse_mask & GUAC_CLIENT_MOUSE_SCROLL_DOWN)) + guac_spice_scroll(inputs, SPICE_MOUSE_BUTTON_DOWN, spice_mask); + + /* Remember state for the next event */ + spice_client->last_mouse_mask = mask; + spice_client->last_mouse_x = x; + spice_client->last_mouse_y = y; + + return 0; + +} + +int guac_spice_user_key_handler(guac_user* user, int keysym, int pressed) { + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + SpiceInputsChannel* inputs = spice_client->inputs_channel; + + /* Report key state within recording */ + if (spice_client->recording != NULL) + guac_recording_report_key(spice_client->recording, keysym, pressed); + + /* Send SPICE events only once the inputs channel is ready */ + if (inputs == NULL) + return 0; + + /* Translate keysym to a PC scancode */ + unsigned int scancode = guac_spice_keysym_to_scancode(keysym); + if (scancode == 0) { + guac_client_log(client, GUAC_LOG_DEBUG, + "Ignoring keysym 0x%04X with no known SPICE scancode mapping.", + keysym); + return 0; + } + + /* Send key press or release */ + if (pressed) + spice_inputs_channel_key_press(inputs, scancode); + else + spice_inputs_channel_key_release(inputs, scancode); + + return 0; + +} diff --git a/src/protocols/spice/input.h b/src/protocols/spice/input.h new file mode 100644 index 0000000000..b2d60c1b82 --- /dev/null +++ b/src/protocols/spice/input.h @@ -0,0 +1,37 @@ +/* + * 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_SPICE_INPUT_H +#define GUAC_SPICE_INPUT_H + +#include + +/** + * Handler for Guacamole mouse events, translating them into SPICE pointer + * motion and button events. + */ +guac_user_mouse_handler guac_spice_user_mouse_handler; + +/** + * Handler for Guacamole key events, translating X11 keysyms into PC scancodes + * and sending them via the SPICE inputs channel. + */ +guac_user_key_handler guac_spice_user_key_handler; + +#endif diff --git a/src/protocols/spice/keymap.c b/src/protocols/spice/keymap.c new file mode 100644 index 0000000000..44d04bfa78 --- /dev/null +++ b/src/protocols/spice/keymap.c @@ -0,0 +1,208 @@ +/* + * 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 "keymap.h" + +#include + +/** + * Bit which, when set within a scancode value, indicates that the scancode is + * an "extended" scancode, transmitted on the wire with a leading 0xE0 byte. + * This matches the encoding expected by spice-gtk's + * spice_inputs_channel_key_press()/key_release() functions. + */ +#define GUAC_SPICE_SCANCODE_EXTENDED 0x100 + +/** + * A single mapping from an X11 keysym to its corresponding PC (AT set 1) + * scancode. + */ +typedef struct guac_spice_keysym_mapping { + + /** + * The X11 keysym being mapped. + */ + int keysym; + + /** + * The corresponding PC scancode, with GUAC_SPICE_SCANCODE_EXTENDED set if + * the scancode is an extended scancode. + */ + unsigned int scancode; + +} guac_spice_keysym_mapping; + +/** + * Static mapping of X11 keysyms to PC scancodes for the US keyboard layout. + * Guacamole transmits the keysym of the resulting character along with + * separate press/release events for modifier keys (Shift, Control, etc.), so + * both the unshifted and shifted keysyms produced by a given physical key map + * to that key's scancode. + */ +static const guac_spice_keysym_mapping guac_spice_keysym_scancodes[] = { + + /* Number row (digits and their shifted symbols) */ + { 0x0031, 0x02 }, { 0x0021, 0x02 }, /* 1 ! */ + { 0x0032, 0x03 }, { 0x0040, 0x03 }, /* 2 @ */ + { 0x0033, 0x04 }, { 0x0023, 0x04 }, /* 3 # */ + { 0x0034, 0x05 }, { 0x0024, 0x05 }, /* 4 $ */ + { 0x0035, 0x06 }, { 0x0025, 0x06 }, /* 5 % */ + { 0x0036, 0x07 }, { 0x005e, 0x07 }, /* 6 ^ */ + { 0x0037, 0x08 }, { 0x0026, 0x08 }, /* 7 & */ + { 0x0038, 0x09 }, { 0x002a, 0x09 }, /* 8 * */ + { 0x0039, 0x0a }, { 0x0028, 0x0a }, /* 9 ( */ + { 0x0030, 0x0b }, { 0x0029, 0x0b }, /* 0 ) */ + { 0x002d, 0x0c }, { 0x005f, 0x0c }, /* - _ */ + { 0x003d, 0x0d }, { 0x002b, 0x0d }, /* = + */ + + /* QWERTY row */ + { 0x0071, 0x10 }, { 0x0051, 0x10 }, /* q Q */ + { 0x0077, 0x11 }, { 0x0057, 0x11 }, /* w W */ + { 0x0065, 0x12 }, { 0x0045, 0x12 }, /* e E */ + { 0x0072, 0x13 }, { 0x0052, 0x13 }, /* r R */ + { 0x0074, 0x14 }, { 0x0054, 0x14 }, /* t T */ + { 0x0079, 0x15 }, { 0x0059, 0x15 }, /* y Y */ + { 0x0075, 0x16 }, { 0x0055, 0x16 }, /* u U */ + { 0x0069, 0x17 }, { 0x0049, 0x17 }, /* i I */ + { 0x006f, 0x18 }, { 0x004f, 0x18 }, /* o O */ + { 0x0070, 0x19 }, { 0x0050, 0x19 }, /* p P */ + { 0x005b, 0x1a }, { 0x007b, 0x1a }, /* [ { */ + { 0x005d, 0x1b }, { 0x007d, 0x1b }, /* ] } */ + + /* ASDF row */ + { 0x0061, 0x1e }, { 0x0041, 0x1e }, /* a A */ + { 0x0073, 0x1f }, { 0x0053, 0x1f }, /* s S */ + { 0x0064, 0x20 }, { 0x0044, 0x20 }, /* d D */ + { 0x0066, 0x21 }, { 0x0046, 0x21 }, /* f F */ + { 0x0067, 0x22 }, { 0x0047, 0x22 }, /* g G */ + { 0x0068, 0x23 }, { 0x0048, 0x23 }, /* h H */ + { 0x006a, 0x24 }, { 0x004a, 0x24 }, /* j J */ + { 0x006b, 0x25 }, { 0x004b, 0x25 }, /* k K */ + { 0x006c, 0x26 }, { 0x004c, 0x26 }, /* l L */ + { 0x003b, 0x27 }, { 0x003a, 0x27 }, /* ; : */ + { 0x0027, 0x28 }, { 0x0022, 0x28 }, /* ' " */ + { 0x0060, 0x29 }, { 0x007e, 0x29 }, /* ` ~ */ + { 0x005c, 0x2b }, { 0x007c, 0x2b }, /* \ | */ + + /* ZXCV row */ + { 0x007a, 0x2c }, { 0x005a, 0x2c }, /* z Z */ + { 0x0078, 0x2d }, { 0x0058, 0x2d }, /* x X */ + { 0x0063, 0x2e }, { 0x0043, 0x2e }, /* c C */ + { 0x0076, 0x2f }, { 0x0056, 0x2f }, /* v V */ + { 0x0062, 0x30 }, { 0x0042, 0x30 }, /* b B */ + { 0x006e, 0x31 }, { 0x004e, 0x31 }, /* n N */ + { 0x006d, 0x32 }, { 0x004d, 0x32 }, /* m M */ + { 0x002c, 0x33 }, { 0x003c, 0x33 }, /* , < */ + { 0x002e, 0x34 }, { 0x003e, 0x34 }, /* . > */ + { 0x002f, 0x35 }, { 0x003f, 0x35 }, /* / ? */ + + { 0x0020, 0x39 }, /* Space */ + + /* Editing / control keys */ + { 0xff1b, 0x01 }, /* Escape */ + { 0xff08, 0x0e }, /* BackSpace */ + { 0xff09, 0x0f }, /* Tab */ + { 0xff0d, 0x1c }, /* Return */ + { 0xffe5, 0x3a }, /* Caps_Lock */ + { 0xff7f, 0x45 }, /* Num_Lock */ + { 0xff14, 0x46 }, /* Scroll_Lock */ + + /* Modifier keys */ + { 0xffe1, 0x2a }, /* Shift_L */ + { 0xffe2, 0x36 }, /* Shift_R */ + { 0xffe3, 0x1d }, /* Control_L */ + { 0xffe4, 0x1d | GUAC_SPICE_SCANCODE_EXTENDED }, /* Control_R */ + { 0xffe9, 0x38 }, /* Alt_L */ + { 0xffea, 0x38 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Alt_R */ + { 0xffe7, 0x38 }, /* Meta_L (treat as Alt) */ + { 0xffe8, 0x38 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Meta_R */ + { 0xffeb, 0x5b | GUAC_SPICE_SCANCODE_EXTENDED }, /* Super_L (left "Windows") */ + { 0xffec, 0x5c | GUAC_SPICE_SCANCODE_EXTENDED }, /* Super_R (right "Windows") */ + { 0xff67, 0x5d | GUAC_SPICE_SCANCODE_EXTENDED }, /* Menu */ + + /* Function keys */ + { 0xffbe, 0x3b }, /* F1 */ + { 0xffbf, 0x3c }, /* F2 */ + { 0xffc0, 0x3d }, /* F3 */ + { 0xffc1, 0x3e }, /* F4 */ + { 0xffc2, 0x3f }, /* F5 */ + { 0xffc3, 0x40 }, /* F6 */ + { 0xffc4, 0x41 }, /* F7 */ + { 0xffc5, 0x42 }, /* F8 */ + { 0xffc6, 0x43 }, /* F9 */ + { 0xffc7, 0x44 }, /* F10 */ + { 0xffc8, 0x57 }, /* F11 */ + { 0xffc9, 0x58 }, /* F12 */ + + /* Navigation cluster (extended) */ + { 0xff63, 0x52 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Insert */ + { 0xffff, 0x53 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Delete */ + { 0xff50, 0x47 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Home */ + { 0xff57, 0x4f | GUAC_SPICE_SCANCODE_EXTENDED }, /* End */ + { 0xff55, 0x49 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Page_Up (Prior) */ + { 0xff56, 0x51 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Page_Down (Next) */ + { 0xff52, 0x48 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Up */ + { 0xff54, 0x50 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Down */ + { 0xff51, 0x4b | GUAC_SPICE_SCANCODE_EXTENDED }, /* Left */ + { 0xff53, 0x4d | GUAC_SPICE_SCANCODE_EXTENDED }, /* Right */ + + { 0xff61, 0x37 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Print */ + + /* Keypad */ + { 0xff8d, 0x1c | GUAC_SPICE_SCANCODE_EXTENDED }, /* KP_Enter */ + { 0xffaf, 0x35 | GUAC_SPICE_SCANCODE_EXTENDED }, /* KP_Divide */ + { 0xffaa, 0x37 }, /* KP_Multiply */ + { 0xffad, 0x4a }, /* KP_Subtract */ + { 0xffab, 0x4e }, /* KP_Add */ + { 0xffae, 0x53 }, /* KP_Decimal */ + { 0xffb0, 0x52 }, /* KP_0 */ + { 0xffb1, 0x4f }, /* KP_1 */ + { 0xffb2, 0x50 }, /* KP_2 */ + { 0xffb3, 0x51 }, /* KP_3 */ + { 0xffb4, 0x4b }, /* KP_4 */ + { 0xffb5, 0x4c }, /* KP_5 */ + { 0xffb6, 0x4d }, /* KP_6 */ + { 0xffb7, 0x47 }, /* KP_7 */ + { 0xffb8, 0x48 }, /* KP_8 */ + { 0xffb9, 0x49 }, /* KP_9 */ + + /* End of table */ + { 0, 0 } + +}; + +unsigned int guac_spice_keysym_to_scancode(int keysym) { + + /* Search the static mapping table for the given keysym */ + const guac_spice_keysym_mapping* current = guac_spice_keysym_scancodes; + while (current->keysym != 0) { + + if (current->keysym == keysym) + return current->scancode; + + current++; + + } + + /* No known mapping */ + return 0; + +} diff --git a/src/protocols/spice/keymap.h b/src/protocols/spice/keymap.h new file mode 100644 index 0000000000..b08398f9e8 --- /dev/null +++ b/src/protocols/spice/keymap.h @@ -0,0 +1,39 @@ +/* + * 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_SPICE_KEYMAP_H +#define GUAC_SPICE_KEYMAP_H + +/** + * Translates the given X11 keysym into the corresponding PC (AT set 1 / "XT") + * scancode expected by the SPICE inputs channel. Extended scancodes (those + * which are transmitted on the wire with a leading 0xE0 byte) are returned + * with bit 8 (0x100) set, matching the encoding expected by + * spice_inputs_channel_key_press() / spice_inputs_channel_key_release(). + * + * @param keysym + * The X11 keysym to translate. + * + * @return + * The corresponding PC scancode (with bit 0x100 set for extended keys), or + * 0 if the keysym has no known scancode mapping. + */ +unsigned int guac_spice_keysym_to_scancode(int keysym); + +#endif diff --git a/src/protocols/spice/log.c b/src/protocols/spice/log.c new file mode 100644 index 0000000000..a0a992c9ae --- /dev/null +++ b/src/protocols/spice/log.c @@ -0,0 +1,99 @@ +/* + * 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 "log.h" + +#include + +#include + +/** + * Translates a GLib log level into the closest-matching Guacamole log level. + * + * @param level + * The GLib log level flags associated with a log message. + * + * @return + * The Guacamole log level which most closely corresponds to the given + * GLib log level. + */ +static guac_client_log_level guac_spice_log_level(GLogLevelFlags level) { + + /* Mask off the log level bits, ignoring any additional flags */ + switch (level & G_LOG_LEVEL_MASK) { + + case G_LOG_LEVEL_ERROR: + case G_LOG_LEVEL_CRITICAL: + return GUAC_LOG_ERROR; + + case G_LOG_LEVEL_WARNING: + return GUAC_LOG_WARNING; + + case G_LOG_LEVEL_MESSAGE: + case G_LOG_LEVEL_INFO: + return GUAC_LOG_INFO; + + default: + return GUAC_LOG_DEBUG; + + } + +} + +/** + * GLib log handler which routes all received log messages to the logging + * facilities of the guac_client provided as user data. + * + * @param domain + * The log domain of the message, or NULL if not specified. + * + * @param level + * The GLib log level flags of the message. + * + * @param message + * The text of the log message. + * + * @param data + * The guac_client to which the message should be logged. + */ +static void guac_spice_log_handler(const gchar* domain, GLogLevelFlags level, + const gchar* message, gpointer data) { + + guac_client* client = (guac_client*) data; + + guac_client_log(client, guac_spice_log_level(level), "%s: %s", + domain != NULL ? domain : "spice", message); + +} + +void guac_spice_client_log_init(guac_client* client) { + + /* Route all log messages produced by spice-gtk and the underlying + * GLib/GObject stack to the logging facilities of the given client */ + g_log_set_handler("GSpice", + G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, + guac_spice_log_handler, client); + + g_log_set_handler(NULL, + G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, + guac_spice_log_handler, client); + +} diff --git a/src/protocols/spice/log.h b/src/protocols/spice/log.h new file mode 100644 index 0000000000..5a32e8ff69 --- /dev/null +++ b/src/protocols/spice/log.h @@ -0,0 +1,37 @@ +/* + * 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_SPICE_LOG_H +#define GUAC_SPICE_LOG_H + +#include + +#include + +/** + * Installs a GLib log handler which routes log messages produced by spice-gtk + * (and the GLib/GObject stack it depends upon) to the logging facilities of + * the given guac_client. + * + * @param client + * The guac_client to which spice-gtk log messages should be routed. + */ +void guac_spice_client_log_init(guac_client* client); + +#endif diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c new file mode 100644 index 0000000000..07ef82963e --- /dev/null +++ b/src/protocols/spice/settings.c @@ -0,0 +1,606 @@ +/* + * 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 "settings.h" + +#include +#include +#include +#include + +#include +#include +#include + +/* Client plugin arguments */ +const char* GUAC_SPICE_CLIENT_ARGS[] = { + "hostname", + "port", + "tls-port", + GUAC_SPICE_ARGV_USERNAME, + GUAC_SPICE_ARGV_PASSWORD, + "tls", + "ca-cert", + "ignore-cert", + "cert-subject", + "color-depth", + "read-only", + "disable-display-resize", + "disable-copy", + "disable-paste", + "disable-clipboard", + "clipboard-buffer-size", + "enable-audio", + "enable-drive", + "drive-path", + "drive-read-only", + +#ifdef ENABLE_COMMON_SSH + "enable-sftp", + "sftp-hostname", + "sftp-host-key", + "sftp-port", + "sftp-timeout", + "sftp-username", + "sftp-password", + "sftp-private-key", + "sftp-passphrase", + "sftp-public-key", + "sftp-directory", + "sftp-root-directory", + "sftp-server-alive-interval", + "sftp-disable-download", + "sftp-disable-upload", +#endif + + "recording-path", + "recording-name", + "recording-exclude-output", + "recording-exclude-mouse", + "recording-include-keys", + "create-recording-path", + "recording-write-existing", + "wol-send-packet", + "wol-mac-addr", + "wol-broadcast-addr", + "wol-udp-port", + "wol-wait-time", + + NULL +}; + +enum SPICE_ARGS_IDX { + + /** + * The hostname of the SPICE server to connect to. + */ + IDX_HOSTNAME, + + /** + * The port of the SPICE server to connect to (plaintext). + */ + IDX_PORT, + + /** + * The TLS port of the SPICE server to connect to. + */ + IDX_TLS_PORT, + + /** + * The username to provide, retained for logging purposes. + */ + IDX_USERNAME, + + /** + * The password (ticket) to use when authenticating. + */ + IDX_PASSWORD, + + /** + * "true" if TLS should be used to secure the connection, "false" or blank + * otherwise. + */ + IDX_TLS, + + /** + * The path to a PEM-encoded certificate authority file used to verify the + * SPICE server's TLS certificate. + */ + IDX_CA_CERT, + + /** + * "true" if verification of the SPICE server's TLS certificate should be + * disabled, "false" or blank otherwise. + */ + IDX_IGNORE_CERT, + + /** + * The expected subject of the SPICE server's TLS certificate. + */ + IDX_CERT_SUBJECT, + + /** + * The color depth to request, in bits. + */ + IDX_COLOR_DEPTH, + + /** + * "true" if this connection should be read-only, "false" or blank + * otherwise. + */ + IDX_READ_ONLY, + + /** + * "true" if remote display resize should be disabled, "false" or blank + * otherwise. + */ + IDX_DISABLE_DISPLAY_RESIZE, + + /** + * "true" if outbound (remote-to-client) clipboard access should be + * blocked, "false" or blank otherwise. + */ + IDX_DISABLE_COPY, + + /** + * "true" if inbound (client-to-remote) clipboard access should be blocked, + * "false" or blank otherwise. + */ + IDX_DISABLE_PASTE, + + /** + * "true" if clipboard integration should be disabled entirely, "false" or + * blank otherwise. + */ + IDX_DISABLE_CLIPBOARD, + + /** + * The maximum number of bytes to allow within the clipboard. + */ + IDX_CLIPBOARD_BUFFER_SIZE, + + /** + * "true" if audio playback should be enabled, "false" or blank otherwise. + */ + IDX_ENABLE_AUDIO, + + /** + * "true" if folder sharing should be enabled, "false" or blank otherwise. + */ + IDX_ENABLE_DRIVE, + + /** + * The local directory to expose as a shared folder. + */ + IDX_DRIVE_PATH, + + /** + * "true" if the shared folder should be read-only, "false" or blank + * otherwise. + */ + IDX_DRIVE_READ_ONLY, + +#ifdef ENABLE_COMMON_SSH + /** + * "true" if SFTP should be enabled, "false" or blank otherwise. + */ + IDX_ENABLE_SFTP, + + /** + * The hostname of the SSH server to connect to for SFTP. + */ + IDX_SFTP_HOSTNAME, + + /** + * The public SSH host key of the SFTP server. + */ + IDX_SFTP_HOST_KEY, + + /** + * The port of the SSH server to connect to for SFTP. + */ + IDX_SFTP_PORT, + + /** + * The number of seconds to attempt to connect to the SSH server before + * giving up. + */ + IDX_SFTP_TIMEOUT, + + /** + * The username to provide when authenticating with the SSH server for + * SFTP. + */ + IDX_SFTP_USERNAME, + + /** + * The password to provide when authenticating with the SSH server for + * SFTP. + */ + IDX_SFTP_PASSWORD, + + /** + * The base64-encoded private key to use for SFTP authentication. + */ + IDX_SFTP_PRIVATE_KEY, + + /** + * The passphrase to use to decrypt the private key. + */ + IDX_SFTP_PASSPHRASE, + + /** + * The base64-encoded public key to use for SFTP authentication. + */ + IDX_SFTP_PUBLIC_KEY, + + /** + * The default upload directory within the SSH server. + */ + IDX_SFTP_DIRECTORY, + + /** + * The directory to expose as a filesystem guac_object. + */ + IDX_SFTP_ROOT_DIRECTORY, + + /** + * The SSH keepalive interval, in seconds. + */ + IDX_SFTP_SERVER_ALIVE_INTERVAL, + + /** + * "true" if SFTP downloads should be blocked, "false" or blank otherwise. + */ + IDX_SFTP_DISABLE_DOWNLOAD, + + /** + * "true" if SFTP uploads should be blocked, "false" or blank otherwise. + */ + IDX_SFTP_DISABLE_UPLOAD, +#endif + + /** + * The full absolute path to the directory in which screen recordings + * should be written. + */ + IDX_RECORDING_PATH, + + /** + * The name of the recording which should be written. + */ + IDX_RECORDING_NAME, + + /** + * Whether output should NOT be included in the screen recording. + */ + IDX_RECORDING_EXCLUDE_OUTPUT, + + /** + * Whether mouse state should NOT be included in the screen recording. + */ + IDX_RECORDING_EXCLUDE_MOUSE, + + /** + * Whether keys pressed and released should be included in the screen + * recording. + */ + IDX_RECORDING_INCLUDE_KEYS, + + /** + * 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. + */ + IDX_RECORDING_WRITE_EXISTING, + + /** + * Whether to send the magic Wake-on-LAN (WoL) packet prior to connecting. + */ + IDX_WOL_SEND_PACKET, + + /** + * The MAC address to place in the magic WoL packet. + */ + IDX_WOL_MAC_ADDR, + + /** + * The broadcast address to which to send the magic WoL packet. + */ + IDX_WOL_BROADCAST_ADDR, + + /** + * The UDP port to use when sending the WoL packet. + */ + IDX_WOL_UDP_PORT, + + /** + * The number of seconds to wait after sending the WoL packet before + * attempting to connect. + */ + IDX_WOL_WAIT_TIME, + + SPICE_ARGS_COUNT +}; + +guac_spice_settings* guac_spice_parse_args(guac_user* user, + int argc, const char** argv) { + + /* Validate arg count */ + if (argc != SPICE_ARGS_COUNT) { + guac_user_log(user, GUAC_LOG_WARNING, "Incorrect number of connection " + "parameters provided: expected %i, got %i.", + SPICE_ARGS_COUNT, argc); + return NULL; + } + + guac_spice_settings* settings = guac_mem_zalloc(sizeof(guac_spice_settings)); + + settings->hostname = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_HOSTNAME, ""); + + settings->port = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_PORT, ""); + + settings->tls_port = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_TLS_PORT, ""); + + settings->username = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_USERNAME, NULL); + + settings->password = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_PASSWORD, NULL); + + settings->tls = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_TLS, false); + + settings->ca_file = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_CA_CERT, NULL); + + settings->ignore_cert = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_IGNORE_CERT, false); + + settings->cert_subject = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_CERT_SUBJECT, NULL); + + settings->color_depth = + guac_user_parse_args_int(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_COLOR_DEPTH, 0); + + settings->read_only = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_READ_ONLY, false); + + settings->disable_display_resize = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_DISABLE_DISPLAY_RESIZE, false); + + settings->disable_copy = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_DISABLE_COPY, false); + + settings->disable_paste = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_DISABLE_PASTE, false); + + settings->disable_clipboard = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_DISABLE_CLIPBOARD, false); + + settings->clipboard_buffer_size = + guac_user_parse_args_int(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_CLIPBOARD_BUFFER_SIZE, + GUAC_SPICE_CLIPBOARD_DEFAULT_BUFFER_SIZE); + + settings->audio_enabled = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_ENABLE_AUDIO, false); + + settings->enable_drive = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_ENABLE_DRIVE, false); + + settings->drive_path = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_DRIVE_PATH, NULL); + + settings->drive_read_only = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_DRIVE_READ_ONLY, false); + + /* If neither a plaintext nor a TLS port was provided, fall back to the + * default SPICE port for the appropriate transport */ + if (strcmp(settings->port, "") == 0 && strcmp(settings->tls_port, "") == 0) { + if (settings->tls) { + guac_mem_free(settings->tls_port); + settings->tls_port = guac_strdup("5900"); + } + else { + guac_mem_free(settings->port); + settings->port = guac_strdup("5900"); + } + } + +#ifdef ENABLE_COMMON_SSH + settings->enable_sftp = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_ENABLE_SFTP, false); + + settings->sftp_hostname = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_HOSTNAME, settings->hostname); + + settings->sftp_host_key = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_HOST_KEY, NULL); + + settings->sftp_port = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_PORT, "22"); + + settings->sftp_timeout = + guac_user_parse_args_int(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_TIMEOUT, GUAC_SPICE_DEFAULT_SFTP_TIMEOUT); + + settings->sftp_username = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_USERNAME, ""); + + settings->sftp_password = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_PASSWORD, ""); + + settings->sftp_private_key = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_PRIVATE_KEY, NULL); + + settings->sftp_passphrase = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_PASSPHRASE, ""); + + settings->sftp_public_key = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_PUBLIC_KEY, NULL); + + settings->sftp_directory = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_DIRECTORY, NULL); + + settings->sftp_root_directory = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_ROOT_DIRECTORY, "/"); + + settings->sftp_server_alive_interval = + guac_user_parse_args_int(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_SERVER_ALIVE_INTERVAL, 0); + + settings->sftp_disable_download = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_DISABLE_DOWNLOAD, false); + + settings->sftp_disable_upload = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SFTP_DISABLE_UPLOAD, false); +#endif + + settings->recording_path = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_RECORDING_PATH, NULL); + + settings->recording_name = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_RECORDING_NAME, GUAC_SPICE_DEFAULT_RECORDING_NAME); + + settings->recording_exclude_output = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_RECORDING_EXCLUDE_OUTPUT, false); + + settings->recording_exclude_mouse = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_RECORDING_EXCLUDE_MOUSE, false); + + settings->recording_include_keys = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_RECORDING_INCLUDE_KEYS, false); + + settings->create_recording_path = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_CREATE_RECORDING_PATH, false); + + settings->recording_write_existing = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_RECORDING_WRITE_EXISTING, false); + + settings->wol_send_packet = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_WOL_SEND_PACKET, false); + + settings->wol_mac_addr = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_WOL_MAC_ADDR, NULL); + + settings->wol_broadcast_addr = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_WOL_BROADCAST_ADDR, GUAC_WOL_LOCAL_IPV4_BROADCAST); + + settings->wol_udp_port = (unsigned short) + guac_user_parse_args_int(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_WOL_UDP_PORT, GUAC_WOL_PORT); + + settings->wol_wait_time = + guac_user_parse_args_int(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_WOL_WAIT_TIME, 0); + + return settings; + +} + +void guac_spice_settings_free(guac_spice_settings* settings) { + + /* Free settings strings */ + guac_mem_free(settings->hostname); + guac_mem_free(settings->port); + guac_mem_free(settings->tls_port); + guac_mem_free(settings->username); + guac_mem_free(settings->password); + guac_mem_free(settings->ca_file); + guac_mem_free(settings->cert_subject); + guac_mem_free(settings->drive_path); + guac_mem_free(settings->recording_name); + guac_mem_free(settings->recording_path); + +#ifdef ENABLE_COMMON_SSH + /* Free SFTP settings */ + guac_mem_free(settings->sftp_directory); + guac_mem_free(settings->sftp_root_directory); + guac_mem_free(settings->sftp_host_key); + guac_mem_free(settings->sftp_hostname); + guac_mem_free(settings->sftp_passphrase); + guac_mem_free(settings->sftp_password); + guac_mem_free(settings->sftp_port); + guac_mem_free(settings->sftp_private_key); + guac_mem_free(settings->sftp_public_key); + guac_mem_free(settings->sftp_username); +#endif + + /* Free Wake-on-LAN strings */ + guac_mem_free(settings->wol_mac_addr); + guac_mem_free(settings->wol_broadcast_addr); + + /* Free settings structure */ + guac_mem_free(settings); + +} diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h new file mode 100644 index 0000000000..6325bb59a2 --- /dev/null +++ b/src/protocols/spice/settings.h @@ -0,0 +1,367 @@ +/* + * 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_SPICE_SETTINGS_H +#define GUAC_SPICE_SETTINGS_H + +#include + +#include + +/** + * 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_SPICE_PROCESS_TITLE_NAME "spice" + +/** + * The filename to use for the screen recording, if not specified. + */ +#define GUAC_SPICE_DEFAULT_RECORDING_NAME "recording" + +/** + * The default SPICE server port to connect to if no port is specified. + */ +#define GUAC_SPICE_DEFAULT_PORT 5900 + +/** + * The default number of seconds to attempt to connect to the SFTP server. + */ +#define GUAC_SPICE_DEFAULT_SFTP_TIMEOUT 10 + +/** + * The default maximum number of bytes to allow within the clipboard. + */ +#define GUAC_SPICE_CLIPBOARD_DEFAULT_BUFFER_SIZE 262144 + +/** + * SPICE-specific connection settings, parsed from the arguments provided when + * a user joins the connection. + */ +typedef struct guac_spice_settings { + + /** + * The hostname of the SPICE server to connect to. + */ + char* hostname; + + /** + * The port of the SPICE server to connect to, used for plaintext + * connections. + */ + char* port; + + /** + * The port of the SPICE server to connect to using TLS, or NULL if TLS is + * not in use. + */ + char* tls_port; + + /** + * The password to use when authenticating with the SPICE server (the SPICE + * "ticket"), or NULL if no password should be sent. + */ + char* password; + + /** + * The username given in the arguments, retained for process-title and + * logging purposes (SPICE itself authenticates using a ticket/password). + */ + char* username; + + /** + * Whether TLS should be used to encrypt the connection. If set, tls_port + * (or port) is used to establish an encrypted SPICE connection. + */ + bool tls; + + /** + * The path to a PEM-encoded certificate authority file used to verify the + * SPICE server's TLS certificate, or NULL if no CA file was provided. + */ + char* ca_file; + + /** + * The expected subject of the SPICE server's TLS certificate, or NULL if + * the subject should not be explicitly verified. + */ + char* cert_subject; + + /** + * Whether verification of the SPICE server's TLS certificate should be + * disabled. This is typically required for the self-signed certificates + * used by default by QEMU/libvirt. + */ + bool ignore_cert; + + /** + * The color depth to request, in bits. SPICE negotiates its own surface + * format; this value is retained for informational/compatibility purposes. + */ + int color_depth; + + /** + * Whether this connection is read-only, and user input should be dropped. + */ + bool read_only; + + /** + * Whether the SPICE client should request that the remote display resize + * to match the client resolution should be disabled. + */ + bool disable_display_resize; + + /** + * 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 remote desktop 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 remote desktop using + * the clipboard. + */ + bool disable_paste; + + /** + * Whether the clipboard (guest agent) integration should be disabled + * entirely. + */ + bool disable_clipboard; + + /** + * Whether audio playback from the SPICE server should be enabled. + */ + bool audio_enabled; + + /** + * Whether folder sharing (shared directory via the SPICE WebDAV channel) + * should be enabled. + */ + bool enable_drive; + + /** + * The local directory to expose to the SPICE server as a shared folder, or + * NULL if folder sharing is disabled. + */ + char* drive_path; + + /** + * Whether the shared folder should be exposed read-only. + */ + bool drive_read_only; + +#ifdef ENABLE_COMMON_SSH + /** + * Whether SFTP should be enabled for the SPICE connection. + */ + bool enable_sftp; + + /** + * The hostname of the SSH server to connect to for SFTP. + */ + char* sftp_hostname; + + /** + * The public SSH host key. + */ + char* sftp_host_key; + + /** + * The port of the SSH server to connect to for SFTP. + */ + char* sftp_port; + + /** + * The number of seconds to attempt to connect to the SFTP server. + */ + int sftp_timeout; + + /** + * The username to provide when authenticating with the SSH server for + * SFTP. + */ + char* sftp_username; + + /** + * The password to provide when authenticating with the SSH server for + * SFTP (if not using a private key). + */ + char* sftp_password; + + /** + * The base64-encoded private key to use when authenticating with the SSH + * server for SFTP (if not using a password). + */ + char* sftp_private_key; + + /** + * The passphrase to use to decrypt the provided base64-encoded private + * key. + */ + char* sftp_passphrase; + + /** + * The base64-encoded public key to use when authenticating with the SSH + * server for SFTP using key-based authentication. + */ + char* sftp_public_key; + + /** + * The default location for file uploads within the SSH server. + */ + char* sftp_directory; + + /** + * The path of the directory within the SSH server to expose as a + * filesystem guac_object. + */ + char* sftp_root_directory; + + /** + * The interval at which SSH keepalive messages are sent to the server for + * SFTP connections. + */ + int sftp_server_alive_interval; + + /** + * Whether file downloads over SFTP should be blocked. + */ + bool sftp_disable_download; + + /** + * Whether file uploads over SFTP should be blocked. + */ + bool sftp_disable_upload; +#endif + + /** + * 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 should NOT be + * included in the session recording. + */ + bool recording_exclude_output; + + /** + * Whether changes to mouse state should NOT be included in the session + * recording. + */ + bool recording_exclude_mouse; + + /** + * Whether keys pressed and released should be included in the session + * recording. + */ + bool recording_include_keys; + + /** + * Whether existing files should be appended to when creating a new + * recording. + */ + bool recording_write_existing; + + /** + * Whether or not to send the magic Wake-on-LAN (WoL) packet prior to + * trying to connect to the remote host. + */ + bool wol_send_packet; + + /** + * The MAC address to place in the magic WoL packet to wake the remote + * host. + */ + char* wol_mac_addr; + + /** + * The broadcast address to which to send the magic WoL packet. + */ + char* wol_broadcast_addr; + + /** + * The UDP port to use when sending the WoL packet. + */ + unsigned short wol_udp_port; + + /** + * The number of seconds after sending the magic WoL packet to wait before + * attempting to connect to the remote host. + */ + int wol_wait_time; + +} guac_spice_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_spice_settings_free() when no longer needed. If the arguments fail + * to parse, NULL is returned. + */ +guac_spice_settings* guac_spice_parse_args(guac_user* user, + int argc, const char** argv); + +/** + * Frees the given guac_spice_settings object, having been previously allocated + * via guac_spice_parse_args(). + * + * @param settings + * The settings object to free. + */ +void guac_spice_settings_free(guac_spice_settings* settings); + +/** + * NULL-terminated array of accepted client args. + */ +extern const char* GUAC_SPICE_CLIENT_ARGS[]; + +#endif diff --git a/src/protocols/spice/sftp.c b/src/protocols/spice/sftp.c new file mode 100644 index 0000000000..be8a280707 --- /dev/null +++ b/src/protocols/spice/sftp.c @@ -0,0 +1,41 @@ +/* + * 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 "common-ssh/sftp.h" +#include "sftp.h" +#include "spice.h" + +#include +#include +#include + +int guac_spice_sftp_file_handler(guac_user* user, guac_stream* stream, + char* mimetype, char* filename) { + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_common_ssh_sftp_filesystem* filesystem = spice_client->sftp_filesystem; + + /* Handle file upload */ + return guac_common_ssh_sftp_handle_file_stream(filesystem, user, stream, + mimetype, filename); + +} diff --git a/src/protocols/spice/sftp.h b/src/protocols/spice/sftp.h new file mode 100644 index 0000000000..e7fad9f101 --- /dev/null +++ b/src/protocols/spice/sftp.h @@ -0,0 +1,31 @@ +/* + * 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_SPICE_SFTP_H +#define GUAC_SPICE_SFTP_H + +#include + +/** + * Handles an incoming stream from a Guacamole "file" instruction, saving the + * contents of that stream to the file having the given name. + */ +guac_user_file_handler guac_spice_sftp_file_handler; + +#endif diff --git a/src/protocols/spice/spice.c b/src/protocols/spice/spice.c new file mode 100644 index 0000000000..8d8076386f --- /dev/null +++ b/src/protocols/spice/spice.c @@ -0,0 +1,301 @@ +/* + * 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 "auth.h" +#include "channels.h" +#include "client.h" +#include "log.h" +#include "settings.h" +#include "spice.h" + +#ifdef ENABLE_COMMON_SSH +#include "common-ssh/sftp.h" +#include "common-ssh/ssh.h" +#include "common-ssh/user.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/** + * GSource callback which periodically checks whether the Guacamole connection + * is still running, terminating the SPICE event loop if it is not. + * + * @param data + * The guac_client associated with the SPICE connection. + * + * @return + * G_SOURCE_CONTINUE if the connection is still running and the loop should + * continue, or G_SOURCE_REMOVE if the loop should terminate. + */ +static gboolean guac_spice_state_check(gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Continue running while the client is running */ + if (client->state == GUAC_CLIENT_RUNNING) + return G_SOURCE_CONTINUE; + + /* Otherwise, stop the SPICE event loop */ + g_main_loop_quit(spice_client->main_loop); + return G_SOURCE_REMOVE; + +} + +#ifdef ENABLE_COMMON_SSH +/** + * Establishes the SSH/SFTP connection requested by the connection settings, + * exposing the resulting filesystem to the connection owner. Any failure + * aborts the Guacamole connection. + * + * @param client + * The guac_client associated with the SPICE connection. + * + * @return + * Zero on success, non-zero if the SFTP connection could not be + * established. + */ +static int guac_spice_start_sftp(guac_client* client) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_spice_settings* settings = spice_client->settings; + + guac_common_ssh_init(client); + + /* Abort if username is missing */ + if (settings->sftp_username == NULL) { + guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR, + "SFTP username is required if SFTP is enabled."); + return 1; + } + + guac_client_log(client, GUAC_LOG_DEBUG, + "Connecting via SSH for SFTP filesystem access."); + + spice_client->sftp_user = + guac_common_ssh_create_user(settings->sftp_username); + + /* Authenticate with private key, if given */ + if (settings->sftp_private_key != NULL) { + + if (guac_common_ssh_user_import_key(spice_client->sftp_user, + settings->sftp_private_key, settings->sftp_passphrase)) { + guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR, + "Private key unreadable."); + return 1; + } + + /* Import the public key, if specified */ + if (settings->sftp_public_key != NULL + && guac_common_ssh_user_import_public_key( + spice_client->sftp_user, settings->sftp_public_key)) { + guac_client_abort(client, GUAC_PROTOCOL_STATUS_CLIENT_UNAUTHORIZED, + "Failed to import public key: %s", + guac_common_ssh_key_error()); + return 1; + } + + } + + /* Otherwise, authenticate with password */ + else + guac_common_ssh_user_set_password(spice_client->sftp_user, + settings->sftp_password); + + /* Attempt SSH connection */ + spice_client->sftp_session = + guac_common_ssh_create_session(client, settings->sftp_hostname, + settings->sftp_port, spice_client->sftp_user, + settings->sftp_timeout, settings->sftp_server_alive_interval, + settings->sftp_host_key, NULL); + + if (spice_client->sftp_session == NULL) + /* Already aborted within guac_common_ssh_create_session() */ + return 1; + + /* Load filesystem */ + spice_client->sftp_filesystem = + guac_common_ssh_create_sftp_filesystem(spice_client->sftp_session, + settings->sftp_root_directory, NULL, + settings->sftp_disable_download, + settings->sftp_disable_upload); + + /* Expose filesystem to connection owner */ + guac_client_for_owner(client, + guac_common_ssh_expose_sftp_filesystem, + spice_client->sftp_filesystem); + + /* Configure destination for basic uploads, if specified */ + if (settings->sftp_directory != NULL) + guac_common_ssh_sftp_set_upload_path(spice_client->sftp_filesystem, + settings->sftp_directory); + + guac_client_log(client, GUAC_LOG_DEBUG, "SFTP connection succeeded."); + return 0; + +} +#endif + +void* guac_spice_client_thread(void* data) { + + /* Thread name spice-worker: main SPICE client thread; runs the spice-gtk + * session and GLib event loop. */ + guac_thread_name_set("spice-worker"); + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_spice_settings* settings = spice_client->settings; + + /* Set the process title to reflect the connection endpoint */ + const char* title_port = settings->port[0] != '\0' + ? settings->port : settings->tls_port; + guac_process_title_set_endpoint(GUAC_SPICE_PROCESS_TITLE_NAME, + settings->username, settings->hostname, title_port); + + /* Route spice-gtk/GLib log output to the Guacamole client */ + guac_spice_client_log_init(client); + + /* Register password/username arguments for runtime updates */ + guac_argv_register(GUAC_SPICE_ARGV_PASSWORD, + guac_spice_argv_callback, NULL, 0); + guac_argv_register(GUAC_SPICE_ARGV_USERNAME, + guac_spice_argv_callback, NULL, 0); + + /* If Wake-on-LAN is enabled, attempt to wake the remote host */ + if (settings->wol_send_packet) { + guac_client_log(client, GUAC_LOG_DEBUG, "Sending Wake-on-LAN packet."); + if (guac_wol_wake(settings->wol_mac_addr, + settings->wol_broadcast_addr, settings->wol_udp_port)) { + guac_client_log(client, GUAC_LOG_ERROR, + "Failed to send Wake-on-LAN packet."); + } + else if (settings->wol_wait_time > 0) { + guac_client_log(client, GUAC_LOG_DEBUG, "Waiting %d seconds for " + "remote host to wake.", settings->wol_wait_time); + guac_timestamp_msleep(settings->wol_wait_time * 1000); + } + } + + /* Create a private GLib main context for this connection and make it the + * thread-default so that all SPICE channels created on this thread + * dispatch their signals within our event loop */ + spice_client->main_context = g_main_context_new(); + g_main_context_push_thread_default(spice_client->main_context); + spice_client->main_loop = g_main_loop_new(spice_client->main_context, FALSE); + + /* Allocate and configure the SPICE session */ + spice_client->spice_session = spice_session_new(); + guac_spice_session_configure(client, spice_client->spice_session); + + /* Dispatch newly-created and destroyed channels */ + g_signal_connect(spice_client->spice_session, "channel-new", + G_CALLBACK(guac_spice_channel_new), client); + g_signal_connect(spice_client->spice_session, "channel-destroy", + G_CALLBACK(guac_spice_channel_destroy), client); + + /* Create the Guacamole display and render thread. The display is resized + * once the SPICE display channel reports the dimensions of its primary + * surface. */ + spice_client->display = guac_display_alloc(client); + + if (!settings->read_only) + guac_display_set_cursor(spice_client->display, + GUAC_DISPLAY_CURSOR_POINTER); + + guac_display_end_frame(spice_client->display); + spice_client->render_thread = + guac_display_render_thread_create(spice_client->display); + + /* Set up screen recording, if requested */ + if (settings->recording_path != NULL) { + spice_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, + 0); /* Clipboard recording not yet supported for SPICE */ + } + +#ifdef ENABLE_COMMON_SSH + /* Connect via SSH for SFTP, if enabled */ + if (settings->enable_sftp && guac_spice_start_sftp(client)) + return NULL; +#endif + + /* Begin connecting to the SPICE server */ + guac_client_log(client, GUAC_LOG_INFO, "Connecting to SPICE server %s.", + settings->hostname); + + if (!spice_session_connect(spice_client->spice_session)) { + guac_client_abort(client, GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND, + "Unable to initiate connection to SPICE server."); + } + + else { + + /* Periodically check whether the connection should continue running */ + GSource* state_source = + g_timeout_source_new(GUAC_SPICE_STATE_CHECK_INTERVAL); + g_source_set_callback(state_source, guac_spice_state_check, client, NULL); + g_source_attach(state_source, spice_client->main_context); + + /* Run the SPICE event loop until the connection ends */ + g_main_loop_run(spice_client->main_loop); + + g_source_destroy(state_source); + g_source_unref(state_source); + + } + + /* Disconnect the SPICE session */ + spice_session_disconnect(spice_client->spice_session); + + /* Stop the render loop */ + if (spice_client->render_thread != NULL) { + guac_display_render_thread_destroy(spice_client->render_thread); + spice_client->render_thread = NULL; + } + + /* Relinquish the thread-default main context */ + g_main_context_pop_thread_default(spice_client->main_context); + + /* Kill client and finish connection */ + guac_client_stop(client); + guac_client_log(client, GUAC_LOG_INFO, "Internal SPICE client disconnected"); + return NULL; + +} diff --git a/src/protocols/spice/spice.h b/src/protocols/spice/spice.h new file mode 100644 index 0000000000..b7f5d02a6d --- /dev/null +++ b/src/protocols/spice/spice.h @@ -0,0 +1,234 @@ +/* + * 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_SPICE_SPICE_H +#define GUAC_SPICE_SPICE_H + +#include "common/clipboard.h" +#include "settings.h" + +#include +#include +#include +#include +#include + +#ifdef ENABLE_COMMON_SSH +#include "common-ssh/sftp.h" +#include "common-ssh/ssh.h" +#include "common-ssh/user.h" +#endif + +#include + +/** + * The maximum number of milliseconds to wait between iterations of the + * SPICE client event loop while checking whether the connection should + * continue running. + */ +#define GUAC_SPICE_STATE_CHECK_INTERVAL 250 + +/** + * SPICE-specific client data. + */ +typedef struct guac_spice_client { + + /** + * The SPICE client thread. + */ + pthread_t client_thread; + + /** + * The underlying SPICE session, which manages all individual channel + * connections to the SPICE server. + */ + SpiceSession* spice_session; + + /** + * The main SPICE channel, used for session-wide state such as mouse mode, + * agent connectivity, and clipboard exchange. NULL until the channel has + * been opened. + */ + SpiceMainChannel* main_channel; + + /** + * The SPICE display channel, providing the remote framebuffer. NULL until + * the channel has been opened. + */ + SpiceChannel* display_channel; + + /** + * The SPICE inputs channel, used for sending keyboard and mouse events. + * NULL until the channel has been opened. + */ + SpiceInputsChannel* inputs_channel; + + /** + * The SPICE cursor channel, providing the remote cursor shape. NULL until + * the channel has been opened. + */ + SpiceChannel* cursor_channel; + + /** + * The GLib main context within which all SPICE event processing occurs. + */ + GMainContext* main_context; + + /** + * The GLib main loop driving SPICE event processing. spice-gtk is an + * event-driven, signal-based library, so all channel I/O is dispatched by + * running this loop within the client thread. + */ + GMainLoop* main_loop; + + /** + * Client settings, parsed from args. + */ + guac_spice_settings* settings; + + /** + * The current display state. + */ + guac_display* display; + + /** + * The current instance of the guac_display render thread. If the thread + * has not yet been started, this will be NULL. + */ + guac_display_render_thread* render_thread; + + /** + * Lock which synchronizes access to the primary surface metadata below, + * which is updated from SPICE display channel signal handlers and read + * while copying damaged regions into the guac_display. + */ + pthread_mutex_t surface_lock; + + /** + * Pointer to the primary surface buffer provided by the SPICE display + * channel, or NULL if no primary surface currently exists. + */ + void* surface_data; + + /** + * The width of the primary surface, in pixels. + */ + int surface_width; + + /** + * The height of the primary surface, in pixels. + */ + int surface_height; + + /** + * The stride (number of bytes per row) of the primary surface. + */ + int surface_stride; + + /** + * The SPICE surface format (one of the SPICE_SURFACE_FMT_* constants) of + * the primary surface. + */ + int surface_format; + + /** + * The currently negotiated mouse mode, one of SPICE_MOUSE_MODE_CLIENT or + * SPICE_MOUSE_MODE_SERVER. + */ + int mouse_mode; + + /** + * The Guacamole button mask (bitwise OR of GUAC_CLIENT_MOUSE_*) as of the + * most recently handled mouse event. Used to detect button press/release + * transitions and, in SPICE server (relative) mouse mode, to compute + * motion deltas. + */ + int last_mouse_mask; + + /** + * The X coordinate of the most recently handled mouse event. + */ + int last_mouse_x; + + /** + * The Y coordinate of the most recently handled mouse event. + */ + int last_mouse_y; + + /** + * Internal clipboard. + */ + guac_common_clipboard* clipboard; + + /** + * Audio output stream, or NULL if audio is not enabled. + */ + guac_audio_stream* audio; + + /** + * The number of audio channels last reported by the SPICE playback + * channel. + */ + int audio_channels; + + /** + * The sample rate (in Hz) last reported by the SPICE playback channel. + */ + int audio_rate; + +#ifdef ENABLE_COMMON_SSH + /** + * The user and credentials used to authenticate for SFTP. + */ + guac_common_ssh_user* sftp_user; + + /** + * The SSH session used for SFTP. + */ + guac_common_ssh_session* sftp_session; + + /** + * An SFTP-based filesystem. + */ + guac_common_ssh_sftp_filesystem* sftp_filesystem; +#endif + + /** + * The in-progress session recording, or NULL if no recording is in + * progress. + */ + guac_recording* recording; + +} guac_spice_client; + +/** + * SPICE client thread. This thread establishes the SPICE session and runs the + * GLib main loop which drives all SPICE event processing for the duration of + * the connection. It exists as a single instance, shared by all users. + * + * @param data + * The guac_client instance associated with the requested SPICE + * connection. + * + * @return + * Always NULL. + */ +void* guac_spice_client_thread(void* data); + +#endif diff --git a/src/protocols/spice/user.c b/src/protocols/spice/user.c new file mode 100644 index 0000000000..68ae508d79 --- /dev/null +++ b/src/protocols/spice/user.c @@ -0,0 +1,121 @@ +/* + * 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 "common/clipboard.h" +#include "input.h" +#include "settings.h" +#include "spice.h" +#include "user.h" + +#ifdef ENABLE_COMMON_SSH +#include "sftp.h" +#endif + +#include +#include +#include +#include + +#include + +int guac_spice_user_join_handler(guac_user* user, int argc, char** argv) { + + guac_spice_client* spice_client = (guac_spice_client*) user->client->data; + + /* Parse provided arguments */ + guac_spice_settings* settings = guac_spice_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 to SPICE server if owner */ + if (user->owner) { + + /* Store owner's settings at client level */ + spice_client->settings = settings; + + /* Init clipboard */ + spice_client->clipboard = + guac_common_clipboard_alloc(settings->clipboard_buffer_size); + + /* Start client thread */ + if (pthread_create(&spice_client->client_thread, NULL, + guac_spice_client_thread, user->client)) { + guac_user_log(user, GUAC_LOG_ERROR, + "Unable to start SPICE client thread."); + return 1; + } + + } + + /* Only handle events if not read-only */ + if (!settings->read_only) { + + /* General mouse/keyboard events */ + user->mouse_handler = guac_spice_user_mouse_handler; + user->key_handler = guac_spice_user_key_handler; + + /* Inbound (client-to-server) clipboard transfer */ + if (!settings->disable_paste && !settings->disable_clipboard) + user->clipboard_handler = guac_spice_clipboard_handler; + +#ifdef ENABLE_COMMON_SSH + /* Set generic (non-filesystem) file upload handler */ + if (settings->enable_sftp && !settings->sftp_disable_upload) + user->file_handler = guac_spice_sftp_file_handler; +#endif + + } + + /* Update connection parameters (e.g. password) if we own the connection */ + if (user->owner) + user->argv_handler = guac_argv_handler; + + return 0; + +} + +int guac_spice_user_leave_handler(guac_user* user) { + + guac_spice_client* spice_client = (guac_spice_client*) user->client->data; + + if (spice_client->display) + guac_display_notify_user_left(spice_client->display, user); + + /* Free settings if not owner (owner settings are freed with the client) */ + if (!user->owner) { + guac_spice_settings* settings = (guac_spice_settings*) user->data; + guac_spice_settings_free(settings); + } + + return 0; + +} diff --git a/src/protocols/spice/user.h b/src/protocols/spice/user.h new file mode 100644 index 0000000000..26eb51ef32 --- /dev/null +++ b/src/protocols/spice/user.h @@ -0,0 +1,36 @@ +/* + * 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_SPICE_USER_H +#define GUAC_SPICE_USER_H + +#include + +/** + * Handler for joining users. Parses the user's arguments, registers input + * handlers, and (for the connection owner) starts the SPICE client thread. + */ +guac_user_join_handler guac_spice_user_join_handler; + +/** + * Handler for leaving users. + */ +guac_user_leave_handler guac_spice_user_leave_handler; + +#endif From a5110dad661b0b7812305697109174d3147f01a5 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Tue, 30 Jun 2026 12:17:34 -0300 Subject: [PATCH 02/36] GUACAMOLE-261: Fix botched-merge build break in __guac_terminal_send_key The merge of the patch branch left src/terminal/terminal.c uncompilable (broke main as well, independent of the SPICE work): * A duplicate, truncated modifier-tracking block (using the GUAC_TERMINAL_KEY_* aliases) preceded the canonical GUAC_KEYSYM_* block; its final "else if (... SHIFT ...)" had no body, triggering -Werror=dangling-else. * A stray '}' inside the Ctrl+letter handling closed the function early, cascading into "expected identifier before 'return'" at end of function. * Four helper functions (__guac_terminal_is_function_keysym, __guac_terminal_is_editing_keysym, __guac_terminal_send_modified_editing, __guac_terminal_send_modified_function) were defined but never wired in, failing -Werror=unused-function. Removes the duplicate block and the stray brace, and wires the editing/ function modified-key helpers in alongside the existing arrow-key handling (mirroring __guac_terminal_send_modified_arrow), so modified editing and function keys emit their xterm-style CSI sequences. --- src/terminal/terminal.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/terminal/terminal.c b/src/terminal/terminal.c index e822e8589b..2f449bd243 100644 --- a/src/terminal/terminal.c +++ b/src/terminal/terminal.c @@ -1819,6 +1819,7 @@ static int __guac_terminal_send_key(guac_terminal* term, int keysym, int pressed guac_terminal_notify(term); } + /* Track modifiers */ /* * Super (Windows/Command) and Hyper are treated as Meta since terminals don't * have separate modifier bits for them. @@ -1959,7 +1960,6 @@ static int __guac_terminal_send_key(guac_terminal* term, int keysym, int pressed if (__guac_terminal_is_editing_keysym(keysym)) return __guac_terminal_send_modified_editing(term, keysym); return 0; - } return guac_terminal_send_data(term, &data, 1); @@ -2063,6 +2063,14 @@ static int __guac_terminal_send_key(guac_terminal* term, int keysym, int pressed if (__guac_terminal_any_modifier(term) && __guac_terminal_is_arrow_keysym(keysym)) return __guac_terminal_send_modified_arrow(term, keysym); + /* Cursor editing keys w/ modifiers */ + if (__guac_terminal_any_modifier(term) && __guac_terminal_is_editing_keysym(keysym)) + return __guac_terminal_send_modified_editing(term, keysym); + + /* Function keys w/ modifiers */ + if (__guac_terminal_any_modifier(term) && __guac_terminal_is_function_keysym(keysym)) + return __guac_terminal_send_modified_function(term, keysym); + /* Application cursor mode (DECCKM) affects only arrow keys. * Home and End use their own sequences above and are unaffected. */ if (term->application_cursor_keys) { From c0582400e428d679b6fc07f87d401bedd07978e4 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Tue, 30 Jun 2026 12:18:11 -0300 Subject: [PATCH 03/36] GUACAMOLE-261: fix channel connection and add keyboard layout support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the SPICE module: 1. Fix the SPICE connection never fully establishing. The client thread ran its GLib event loop on a private GMainContext (created with g_main_context_new() and pushed as thread-default). spice-gtk, however, schedules each channel's connection coroutine on the *default* main context, so that loop never drove the link: only the main channel was ever created, the display/inputs/cursor/playback channels never connected, and the connection rendered a blank frame and eventually timed out ("User is not responding"). Drive the event loop from the thread's default GMainContext instead (matching how spicy/virt-viewer use spice-gtk). guacd forks a dedicated process per connection, so the default context is private to the connection and safe to use. With this, all channels open, the remote primary surface is received, and display + input work end to end. 2. Add keyboard-layout-aware keysym translation. Replaces the single static US keysym->scancode table with the keymap subsystem used by the RDP module: per-layout .keymap files compiled to _generated_keymaps.c by keymaps/generate.pl, a guac_spice_keyboard that tracks modifier/lock state and picks the lowest-cost scancode sequence for each keysym, and dead-key decomposition (decompose.c) so precomposed accented characters can be typed on layouts that produce them via a dead key. The layout is selected with the new "server-layout" parameter (default en-us-qwerty); 20 layouts are included. generate.pl additionally encodes the "+ext" flag into the 0x100 bit of the scancode so extended keys (arrows, navigation cluster, right-hand modifiers, keypad Enter/Divide) are transmitted correctly -- the RDP generator this was adapted from drops that flag. Verified end to end against a QEMU/SPICE desktop through the Guacamole web client: real desktop renders, and "ñ á é í ó ú" type correctly via es-latam-qwerty (ñ direct, accented vowels via dead-acute decomposition). Ported from the draft by Nick Couchman in apache/guacamole-server#394 (GUACAMOLE-261). Co-authored-by: Virtually Nick --- src/protocols/spice/.gitignore | 3 + src/protocols/spice/Makefile.am | 52 ++ src/protocols/spice/channels.c | 5 + src/protocols/spice/client.c | 13 +- src/protocols/spice/decompose.c | 176 +++++ src/protocols/spice/decompose.h | 50 ++ src/protocols/spice/input.c | 31 +- src/protocols/spice/keyboard.c | 662 ++++++++++++++++++ src/protocols/spice/keyboard.h | 323 +++++++++ src/protocols/spice/keymap.c | 189 +---- src/protocols/spice/keymap.h | 183 ++++- src/protocols/spice/keymaps/base.keymap | 93 +++ .../spice/keymaps/da_dk_qwerty.keymap | 73 ++ .../spice/keymaps/de_ch_qwertz.keymap | 67 ++ .../spice/keymaps/de_de_qwertz.keymap | 74 ++ .../spice/keymaps/en_gb_qwerty.keymap | 79 +++ .../spice/keymaps/en_us_qwerty.keymap | 42 ++ .../spice/keymaps/es_es_qwerty.keymap | 63 ++ .../spice/keymaps/es_latam_qwerty.keymap | 63 ++ src/protocols/spice/keymaps/failsafe.keymap | 22 + .../spice/keymaps/fr_be_azerty.keymap | 76 ++ .../spice/keymaps/fr_ca_qwerty.keymap | 54 ++ .../spice/keymaps/fr_ch_qwertz.keymap | 68 ++ .../spice/keymaps/fr_fr_azerty.keymap | 65 ++ src/protocols/spice/keymaps/generate.pl | 259 +++++++ .../spice/keymaps/hu_hu_qwertz.keymap | 108 +++ .../spice/keymaps/it_it_qwerty.keymap | 59 ++ .../spice/keymaps/ja_jp_qwerty.keymap | 35 + .../spice/keymaps/no_no_qwerty.keymap | 75 ++ .../spice/keymaps/pl_pl_qwerty.keymap | 63 ++ .../spice/keymaps/pt_br_qwerty.keymap | 66 ++ .../spice/keymaps/sv_se_qwerty.keymap | 74 ++ .../spice/keymaps/tr_tr_qwerty.keymap | 92 +++ src/protocols/spice/settings.c | 13 + src/protocols/spice/settings.h | 6 + src/protocols/spice/spice.c | 34 +- src/protocols/spice/spice.h | 22 + 37 files changed, 3214 insertions(+), 218 deletions(-) create mode 100644 src/protocols/spice/.gitignore create mode 100644 src/protocols/spice/decompose.c create mode 100644 src/protocols/spice/decompose.h create mode 100644 src/protocols/spice/keyboard.c create mode 100644 src/protocols/spice/keyboard.h create mode 100644 src/protocols/spice/keymaps/base.keymap create mode 100644 src/protocols/spice/keymaps/da_dk_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/de_ch_qwertz.keymap create mode 100644 src/protocols/spice/keymaps/de_de_qwertz.keymap create mode 100644 src/protocols/spice/keymaps/en_gb_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/en_us_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/es_es_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/es_latam_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/failsafe.keymap create mode 100644 src/protocols/spice/keymaps/fr_be_azerty.keymap create mode 100644 src/protocols/spice/keymaps/fr_ca_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/fr_ch_qwertz.keymap create mode 100644 src/protocols/spice/keymaps/fr_fr_azerty.keymap create mode 100755 src/protocols/spice/keymaps/generate.pl create mode 100644 src/protocols/spice/keymaps/hu_hu_qwertz.keymap create mode 100644 src/protocols/spice/keymaps/it_it_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/ja_jp_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/no_no_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/pl_pl_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/pt_br_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/sv_se_qwerty.keymap create mode 100644 src/protocols/spice/keymaps/tr_tr_qwerty.keymap diff --git a/src/protocols/spice/.gitignore b/src/protocols/spice/.gitignore new file mode 100644 index 0000000000..88e105bb63 --- /dev/null +++ b/src/protocols/spice/.gitignore @@ -0,0 +1,3 @@ + +# Autogenerated sources +_generated_keymaps.c diff --git a/src/protocols/spice/Makefile.am b/src/protocols/spice/Makefile.am index 766c844f54..3fb05ea4ae 100644 --- a/src/protocols/spice/Makefile.am +++ b/src/protocols/spice/Makefile.am @@ -28,6 +28,9 @@ ACLOCAL_AMFLAGS = -I m4 lib_LTLIBRARIES = libguac-client-spice.la +nodist_libguac_client_spice_la_SOURCES = \ + _generated_keymaps.c + libguac_client_spice_la_SOURCES = \ argv.c \ auth.c \ @@ -36,8 +39,10 @@ libguac_client_spice_la_SOURCES = \ client.c \ clipboard.c \ cursor.c \ + decompose.c \ display.c \ input.c \ + keyboard.c \ keymap.c \ log.c \ settings.c \ @@ -52,8 +57,10 @@ noinst_HEADERS = \ client.h \ clipboard.h \ cursor.h \ + decompose.h \ display.h \ input.h \ + keyboard.h \ keymap.h \ log.h \ settings.h \ @@ -92,3 +99,48 @@ endif # property); libphodav is a runtime dependency pulled in by spice-gtk, so no # additional sources or linkage are required here. The PHODAV headers are only # referenced to confirm availability at configure time (ENABLE_SPICE_WEBDAV). + +# +# Autogenerated keyboard layouts (keymaps) +# +# _generated_keymaps.c is produced from the .keymap files below by generate.pl. +# The "base" keymap MUST be listed first, and any keymap which inherits from +# another MUST be listed after its parent, so that the parent's C symbol is +# defined before it is referenced. +# + +CLEANFILES = \ + _generated_keymaps.c + +BUILT_SOURCES = \ + _generated_keymaps.c + +spice_keymaps = \ + $(srcdir)/keymaps/base.keymap \ + $(srcdir)/keymaps/failsafe.keymap \ + $(srcdir)/keymaps/da_dk_qwerty.keymap \ + $(srcdir)/keymaps/de_ch_qwertz.keymap \ + $(srcdir)/keymaps/de_de_qwertz.keymap \ + $(srcdir)/keymaps/en_gb_qwerty.keymap \ + $(srcdir)/keymaps/en_us_qwerty.keymap \ + $(srcdir)/keymaps/es_es_qwerty.keymap \ + $(srcdir)/keymaps/es_latam_qwerty.keymap \ + $(srcdir)/keymaps/fr_be_azerty.keymap \ + $(srcdir)/keymaps/fr_ca_qwerty.keymap \ + $(srcdir)/keymaps/fr_ch_qwertz.keymap \ + $(srcdir)/keymaps/fr_fr_azerty.keymap \ + $(srcdir)/keymaps/hu_hu_qwertz.keymap \ + $(srcdir)/keymaps/it_it_qwerty.keymap \ + $(srcdir)/keymaps/ja_jp_qwerty.keymap \ + $(srcdir)/keymaps/no_no_qwerty.keymap \ + $(srcdir)/keymaps/pl_pl_qwerty.keymap \ + $(srcdir)/keymaps/pt_br_qwerty.keymap \ + $(srcdir)/keymaps/sv_se_qwerty.keymap \ + $(srcdir)/keymaps/tr_tr_qwerty.keymap + +_generated_keymaps.c: $(spice_keymaps) $(srcdir)/keymaps/generate.pl + $(AM_V_GEN) $(srcdir)/keymaps/generate.pl $(spice_keymaps) + +EXTRA_DIST = \ + $(spice_keymaps) \ + keymaps/generate.pl diff --git a/src/protocols/spice/channels.c b/src/protocols/spice/channels.c index 7f94f0231b..f320435024 100644 --- a/src/protocols/spice/channels.c +++ b/src/protocols/spice/channels.c @@ -126,6 +126,11 @@ void guac_spice_channel_new(SpiceSession* session, SpiceChannel* channel, else if (SPICE_IS_INPUTS_CHANNEL(channel)) { guac_client_log(client, GUAC_LOG_DEBUG, "Connecting SPICE inputs channel."); spice_client->inputs_channel = SPICE_INPUTS_CHANNEL(channel); + + /* Track remote keyboard lock state (Caps Lock, Num Lock, etc.) so that + * keysym translation can keep local and remote lock states in sync */ + g_signal_connect(channel, "inputs-modifiers", + G_CALLBACK(guac_spice_keyboard_set_indicators), client); } /* Cursor channel: remote cursor shape */ diff --git a/src/protocols/spice/client.c b/src/protocols/spice/client.c index 011e8f4345..98cbb15ee8 100644 --- a/src/protocols/spice/client.c +++ b/src/protocols/spice/client.c @@ -101,6 +101,10 @@ int guac_client_init(guac_client* client) { /* Init surface metadata lock */ pthread_mutex_init(&spice_client->surface_lock, NULL); + /* Init outbound message lock and keyboard state lock */ + pthread_mutex_init(&spice_client->message_lock, NULL); + pthread_rwlock_init(&spice_client->lock, NULL); + /* Default to client (absolute) mouse mode until told otherwise */ spice_client->mouse_mode = SPICE_MOUSE_MODE_CLIENT; @@ -176,12 +180,19 @@ int guac_spice_client_free_handler(guac_client* client) { if (spice_client->audio != NULL) guac_audio_stream_free(spice_client->audio); + /* Free keyboard (client thread has already been joined, so no concurrent + * access remains) */ + if (spice_client->keyboard != NULL) + guac_spice_keyboard_free(spice_client->keyboard); + /* Free parsed settings */ if (settings != NULL) guac_spice_settings_free(settings); - /* Clean up surface lock */ + /* Clean up locks */ pthread_mutex_destroy(&spice_client->surface_lock); + pthread_mutex_destroy(&spice_client->message_lock); + pthread_rwlock_destroy(&spice_client->lock); /* Free generic data struct */ guac_mem_free(client->data); diff --git a/src/protocols/spice/decompose.c b/src/protocols/spice/decompose.c new file mode 100644 index 0000000000..5f4cd65bb2 --- /dev/null +++ b/src/protocols/spice/decompose.c @@ -0,0 +1,176 @@ +/* + * 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 "keyboard.h" + +/** + * The X11 keysym for the dead key which types a grave (`). + */ +#define DEAD_GRAVE 0xFE50 + +/** + * The X11 keysym for the dead key which types an acute (´). Note that this is + * NOT equivalent to an apostrophe or single quote. + */ +#define DEAD_ACUTE 0xFE51 + +/** + * The X11 keysym for the dead key which types a circumflex/caret (^). + */ +#define DEAD_CIRCUMFLEX 0xFE52 + +/** + * The X11 keysym for the dead key which types a tilde (~). + */ +#define DEAD_TILDE 0xFE53 + +/** + * The X11 keysym for the dead key which types a dieresis/umlaut (¨). + */ +#define DEAD_DIERESIS 0xFE57 + +/** + * The X11 keysym for the dead key which types an abovering (˚). Note that this + * is NOT equivalent to the degree symbol. + */ +#define DEAD_ABOVERING 0xFE58 + +/** + * The decomposed form of a key that can be typed using two keypresses: a dead + * key followed by a base key. For example, on a keyboard which lacks a single + * dedicated key for doing the same, "ó" would be typed using the dead acute + * key followed by the "o" key. The dead key and base key are pressed and + * released in sequence; they are not held down. + */ +typedef struct guac_spice_decomposed_key { + + /** + * The keysym of the dead key which must first be pressed and released to + * begin typing the desired character. The dead key defines the diacritic + * which will be applied to the character typed by the base key. + */ + int dead_keysym; + + /** + * The keysym of the base key which must be pressed and released to finish + * typing the desired character. The base key defines the normal form of + * the character (the form which lacks any diacritic) to which the + * diacritic defined by the previously-pressed dead key will be applied. + */ + int base_keysym; + +} guac_spice_decomposed_key; + +/** + * A lookup table of all known decomposed forms of various keysyms. Keysyms map + * directly to entries within this table. A keysym which has no entry within + * this table does not have a defined decomposed form (or at least does not + * have a decomposed form relevant to SPICE). + */ +guac_spice_decomposed_key guac_spice_decomposed_keys[256] = { + + /* ^ */ [0x005E] = { DEAD_CIRCUMFLEX, ' ' }, + /* ` */ [0x0060] = { DEAD_GRAVE, ' ' }, + /* ~ */ [0x007E] = { DEAD_TILDE, ' ' }, + /* ¨ */ [0x00A8] = { DEAD_DIERESIS, ' ' }, + /* ´ */ [0x00B4] = { DEAD_ACUTE, ' ' }, + /* À */ [0x00C0] = { DEAD_GRAVE, 'A' }, + /* Á */ [0x00C1] = { DEAD_ACUTE, 'A' }, + /*  */ [0x00C2] = { DEAD_CIRCUMFLEX, 'A' }, + /* à */ [0x00C3] = { DEAD_TILDE, 'A' }, + /* Ä */ [0x00C4] = { DEAD_DIERESIS, 'A' }, + /* Å */ [0x00C5] = { DEAD_ABOVERING, 'A' }, + /* È */ [0x00C8] = { DEAD_GRAVE, 'E' }, + /* É */ [0x00C9] = { DEAD_ACUTE, 'E' }, + /* Ê */ [0x00CA] = { DEAD_CIRCUMFLEX, 'E' }, + /* Ë */ [0x00CB] = { DEAD_DIERESIS, 'E' }, + /* Ì */ [0x00CC] = { DEAD_GRAVE, 'I' }, + /* Í */ [0x00CD] = { DEAD_ACUTE, 'I' }, + /* Î */ [0x00CE] = { DEAD_CIRCUMFLEX, 'I' }, + /* Ï */ [0x00CF] = { DEAD_DIERESIS, 'I' }, + /* Ñ */ [0x00D1] = { DEAD_TILDE, 'N' }, + /* Ò */ [0x00D2] = { DEAD_GRAVE, 'O' }, + /* Ó */ [0x00D3] = { DEAD_ACUTE, 'O' }, + /* Ô */ [0x00D4] = { DEAD_CIRCUMFLEX, 'O' }, + /* Õ */ [0x00D5] = { DEAD_TILDE, 'O' }, + /* Ö */ [0x00D6] = { DEAD_DIERESIS, 'O' }, + /* Ù */ [0x00D9] = { DEAD_GRAVE, 'U' }, + /* Ú */ [0x00DA] = { DEAD_ACUTE, 'U' }, + /* Û */ [0x00DB] = { DEAD_CIRCUMFLEX, 'U' }, + /* Ü */ [0x00DC] = { DEAD_DIERESIS, 'U' }, + /* Ý */ [0x00DD] = { DEAD_ACUTE, 'Y' }, + /* à */ [0x00E0] = { DEAD_GRAVE, 'a' }, + /* á */ [0x00E1] = { DEAD_ACUTE, 'a' }, + /* â */ [0x00E2] = { DEAD_CIRCUMFLEX, 'a' }, + /* ã */ [0x00E3] = { DEAD_TILDE, 'a' }, + /* ä */ [0x00E4] = { DEAD_DIERESIS, 'a' }, + /* å */ [0x00E5] = { DEAD_ABOVERING, 'a' }, + /* è */ [0x00E8] = { DEAD_GRAVE, 'e' }, + /* é */ [0x00E9] = { DEAD_ACUTE, 'e' }, + /* ê */ [0x00EA] = { DEAD_CIRCUMFLEX, 'e' }, + /* ë */ [0x00EB] = { DEAD_DIERESIS, 'e' }, + /* ì */ [0x00EC] = { DEAD_GRAVE, 'i' }, + /* í */ [0x00ED] = { DEAD_ACUTE, 'i' }, + /* î */ [0x00EE] = { DEAD_CIRCUMFLEX, 'i' }, + /* ï */ [0x00EF] = { DEAD_DIERESIS, 'i' }, + /* ñ */ [0x00F1] = { DEAD_TILDE, 'n' }, + /* ò */ [0x00F2] = { DEAD_GRAVE, 'o' }, + /* ó */ [0x00F3] = { DEAD_ACUTE, 'o' }, + /* ô */ [0x00F4] = { DEAD_CIRCUMFLEX, 'o' }, + /* õ */ [0x00F5] = { DEAD_TILDE, 'o' }, + /* ö */ [0x00F6] = { DEAD_DIERESIS, 'o' }, + /* ù */ [0x00F9] = { DEAD_GRAVE, 'u' }, + /* ú */ [0x00FA] = { DEAD_ACUTE, 'u' }, + /* û */ [0x00FB] = { DEAD_CIRCUMFLEX, 'u' }, + /* ü */ [0x00FC] = { DEAD_DIERESIS, 'u' }, + /* ý */ [0x00FD] = { DEAD_ACUTE, 'y' }, + /* ÿ */ [0x00FF] = { DEAD_DIERESIS, 'y' } + +}; + +int guac_spice_decompose_keysym(guac_spice_keyboard* keyboard, int keysym) { + + /* Verify keysym is within range of lookup table */ + if (keysym < 0x00 || keysym > 0xFF) + return 1; + + /* Verify keysym is actually defined within lookup table */ + guac_spice_decomposed_key* key = &guac_spice_decomposed_keys[keysym]; + if (!key->dead_keysym) + return 1; + + /* Cannot type using decomposed keys if those keys are not defined within + * the current layout */ + if (!guac_spice_keyboard_is_defined(keyboard, key->dead_keysym) + || !guac_spice_keyboard_is_defined(keyboard, key->base_keysym)) + return 1; + + /* Press dead key */ + guac_spice_keyboard_update_keysym(keyboard, key->dead_keysym, 1, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + guac_spice_keyboard_update_keysym(keyboard, key->dead_keysym, 0, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + + /* Press base key */ + guac_spice_keyboard_update_keysym(keyboard, key->base_keysym, 1, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + guac_spice_keyboard_update_keysym(keyboard, key->base_keysym, 0, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + + /* Decomposed key successfully typed */ + return 0; + +} + diff --git a/src/protocols/spice/decompose.h b/src/protocols/spice/decompose.h new file mode 100644 index 0000000000..f6638394a5 --- /dev/null +++ b/src/protocols/spice/decompose.h @@ -0,0 +1,50 @@ +/* + * 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_SPICE_DECOMPOSE_H +#define GUAC_SPICE_DECOMPOSE_H + +#include "config.h" +#include "keyboard.h" + +/** + * Attempts to type the given keysym by decomposing the associated character + * into the dead key and base key pair which would be used to type that + * character on a keyboard which lacks the necessary dedicated key. The key + * events for the dead key and base key are sent only if the keyboard layout of + * the given keyboard defines those keys. + * + * For example, the keysym for "ò" (0x00F2) would decompose into a dead grave + * (`) and the base key "o". May it rest in peace. + * + * @param keyboard + * The guac_spice_keyboard associated with the current SPICE session. + * + * @param keysym + * The keysym being pressed. + * + * @return + * Zero if the keysym was successfully decomposed and sent to the Spice + * server as a pair of key events (the dead key and base key), non-zero + * otherwise. + */ +int guac_spice_decompose_keysym(guac_spice_keyboard* keyboard, int keysym); + +#endif + diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index 6ca701b050..8850c87d65 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -20,7 +20,7 @@ #include "config.h" #include "input.h" -#include "keymap.h" +#include "keyboard.h" #include "spice.h" #include @@ -162,31 +162,24 @@ int guac_spice_user_key_handler(guac_user* user, int keysym, int pressed) { guac_client* client = user->client; guac_spice_client* spice_client = (guac_spice_client*) client->data; - SpiceInputsChannel* inputs = spice_client->inputs_channel; + int retval = 0; /* Report key state within recording */ if (spice_client->recording != NULL) guac_recording_report_key(spice_client->recording, keysym, pressed); - /* Send SPICE events only once the inputs channel is ready */ - if (inputs == NULL) - return 0; + pthread_rwlock_rdlock(&(spice_client->lock)); - /* Translate keysym to a PC scancode */ - unsigned int scancode = guac_spice_keysym_to_scancode(keysym); - if (scancode == 0) { - guac_client_log(client, GUAC_LOG_DEBUG, - "Ignoring keysym 0x%04X with no known SPICE scancode mapping.", - keysym); - return 0; - } + /* Translate and send the key event only once the inputs channel and + * keyboard are ready. The keyboard handles mapping the keysym to the + * appropriate scancode(s) for the negotiated keyboard layout, including + * any required modifier and lock key synchronization. */ + if (spice_client->inputs_channel != NULL && spice_client->keyboard != NULL) + retval = guac_spice_keyboard_update_keysym(spice_client->keyboard, + keysym, pressed, GUAC_SPICE_KEY_SOURCE_CLIENT); - /* Send key press or release */ - if (pressed) - spice_inputs_channel_key_press(inputs, scancode); - else - spice_inputs_channel_key_release(inputs, scancode); + pthread_rwlock_unlock(&(spice_client->lock)); - return 0; + return retval; } diff --git a/src/protocols/spice/keyboard.c b/src/protocols/spice/keyboard.c new file mode 100644 index 0000000000..b0a9660e67 --- /dev/null +++ b/src/protocols/spice/keyboard.c @@ -0,0 +1,662 @@ +/* + * 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 "decompose.h" +#include "keyboard.h" +#include "keymap.h" +#include "spice.h" + +#include +#include + +#include + +/** + * The name of the spice-gtk SpiceInputsChannel GObject property which reports + * the current state of the remote keyboard lock keys (Caps Lock, Num Lock, + * etc.) as a bitwise OR of SPICE_INPUTS_* flags. + */ +#define SPICE_PROPERTY_KEY_MODIFIERS "key-modifiers" + +/** + * Translates the given keysym into the corresponding lock flag, as would be + * required by the Spice synchronize event. If the given keysym does not + * represent a lock key, zero is returned. + * + * @param keysym + * The keysym to translate into a Spice lock flag. + * + * @return + * The Spice lock flag which corresponds to the given keysym, or zero if the + * given keysym does not represent a lock key. + */ +static int guac_spice_keyboard_lock_flag(int keysym) { + + /* Translate keysym into corresponding lock flag */ + switch (keysym) { + + /* Scroll lock */ + case GUAC_SPICE_KEYSYM_SCROLL_LOCK: + return SPICE_INPUTS_SCROLL_LOCK; + + /* Num lock */ + case GUAC_SPICE_KEYSYM_NUM_LOCK: + return SPICE_INPUTS_NUM_LOCK; + + /* Caps lock */ + case GUAC_SPICE_KEYSYM_CAPS_LOCK: + return SPICE_INPUTS_CAPS_LOCK; + + } + + /* Not a lock key */ + return 0; + +} + +/** + * Immediately sends an Spice key event having the given scancode and flags. + * + * @param spice_client + * The Spice client instance associated with the SPICE session along which the + * key event should be sent. + * + * @param scancode + * The scancode of the key to press or release via the Spice key event. + * + * @param flags + * Any Spice-specific flags required for the provided scancode to have the + * intended meaning, such as KBD_FLAGS_EXTENDED. The possible flags and + * their meanings are dictated by SPICE. KBD_FLAGS_DOWN and KBD_FLAGS_UP + * need not be specified here - they will automatically be added depending + * on the value specified for the pressed parameter. + * + * @param pressed + * Non-zero if the key is being pressed, zero if the key is being released. + */ +static void guac_spice_send_key_event(guac_spice_client* spice_client, + int scancode, int flags, int pressed) { + + /* Send actual key press or release */ + pthread_mutex_lock(&(spice_client->message_lock)); + if (pressed) + spice_inputs_channel_key_press(spice_client->inputs_channel, scancode); + else + spice_inputs_channel_key_release(spice_client->inputs_channel, scancode); + pthread_mutex_unlock(&(spice_client->message_lock)); + +} + +/** + * Immediately sends an Spice synchonize event having the given flags. A Spice + * synchronize event sets the state of remote lock keys absolutely, where a + * lock key will be active only if its corresponding flag is set in the event. + * + * @param spice_client + * The Spice client instance associated with the Spice session along which the + * synchronize event should be sent. + * + * @param modifiers + * Bitwise OR of the flags representing the lock keys which should be set, + * if any, as dictated by the Spice protocol. If no flags are set, then no + * lock keys will be active. + */ +static void guac_spice_send_synchronize_event(guac_spice_client* spice_client, + unsigned int modifiers) { + + /* Skip if inputs channel is not connected */ + if (spice_client->inputs_channel == NULL) + return; + + /* Synchronize lock key states */ + pthread_mutex_lock(&(spice_client->message_lock)); + spice_inputs_channel_set_key_locks(spice_client->inputs_channel, modifiers); + pthread_mutex_unlock(&(spice_client->message_lock)); + +} + +/** + * Given a keyboard instance and X11 keysym, returns a pointer to the + * keys_by_keysym entry that represents the key having that keysym within the + * keyboard, regardless of whether the key is currently defined. If no such key + * can exist (the keysym cannot be mapped or is out of range), NULL is + * returned. + * + * @param keyboard + * The guac_spice_keyboard associated with the current Spice session. + * + * @param keysym + * The keysym of the key to lookup within the given keyboard. + * + * @return + * A pointer to the keys_by_keysym entry which represents or can represent + * the key having the given keysym, or NULL if no such keysym can be + * defined within a guac_spice_keyboard structure. + */ +static guac_spice_key** guac_spice_keyboard_map_key(guac_spice_keyboard* keyboard, + int keysym) { + + int index; + + /* Map keysyms between 0x0000 and 0xFFFF directly */ + if (keysym >= 0x0000 && keysym <= 0xFFFF) + index = keysym; + + /* Map all Unicode keysyms from U+0000 to U+FFFF */ + else if (keysym >= 0x1000000 && keysym <= 0x100FFFF) + index = 0x10000 + (keysym & 0xFFFF); + + /* All other keysyms are unmapped */ + else + return NULL; + + /* Corresponding key mapping (defined or not) has been located */ + return &(keyboard->keys_by_keysym[index]); + +} + +/** + * Returns the number of bits that are set within the given integer (the number + * of 1s in the binary expansion of the given integer). + * + * @param value + * The integer to read. + * + * @return + * The number of bits that are set within the given integer. + */ +static int guac_spice_count_bits(unsigned int value) { + + int bits = 0; + + while (value) { + bits += value & 1; + value >>= 1; + } + + return bits; + +} + +/** + * Returns an estimated cost for sending the necessary Spice events to type the + * key described by the given guac_spice_keysym_desc, given the current lock and + * modifier state of the keyboard. A higher cost value indicates that a greater + * number of events are expected to be required. + * + * Lower-cost approaches should be preferred when multiple alternatives exist + * for typing a particular key, as the lower cost implies fewer additional key + * events required to produce the expected behavior. For example, if Caps Lock + * is enabled, typing an uppercase "A" by pressing the "A" key has a lower cost + * than disabling Caps Lock and pressing Shift+A. + * + * @param keyboard + * The guac_spice_keyboard associated with the current SPICE session. + * + * @param def + * The guac_spice_keysym_desc that describes the key being pressed, as well + * as any requirements that must be satisfied for the key to be interpreted + * as expected. + * + * @return + * An arbitrary integer value which indicates the overall estimated + * complexity of typing the given key. + */ +static int guac_spice_keyboard_get_cost(guac_spice_keyboard* keyboard, + const guac_spice_keysym_desc* def) { + + unsigned int modifiers = guac_spice_keyboard_get_modifier_flags(keyboard); + + /* Each change to any key requires one event, by definition */ + int cost = 1; + + /* Each change to a lock requires roughly two key events */ + unsigned int update_locks = (def->set_modifiers & ~keyboard->modifiers) | (def->clear_modifiers & keyboard->modifiers); + cost += guac_spice_count_bits(update_locks) * 2; + + /* Each change to a modifier requires one key event */ + unsigned int update_modifiers = (def->clear_modifiers & modifiers) | (def->set_modifiers & ~modifiers); + cost += guac_spice_count_bits(update_modifiers); + + return cost; + +} + +/** + * Returns a pointer to the guac_spice_key structure representing the + * definition(s) and state of the key having the given keysym. If no such key + * is defined within the keyboard layout of the Spice server, NULL is returned. + * + * @param keyboard + * The guac_spice_keyboard associated with the current Spice session. + * + * @param keysym + * The keysym of the key to lookup within the given keyboard. + * + * @return + * A pointer to the guac_spice_key structure representing the definition(s) + * and state of the key having the given keysym, or NULL if no such key is + * defined within the keyboard layout of the Spice server. + */ +static guac_spice_key* guac_spice_keyboard_get_key(guac_spice_keyboard* keyboard, + int keysym) { + + /* Verify that the key is actually defined */ + guac_spice_key** key_by_keysym = guac_spice_keyboard_map_key(keyboard, keysym); + if (key_by_keysym == NULL) + return NULL; + + return *key_by_keysym; + +} + +/** + * Given a key which may have multiple possible definitions, returns the + * definition that currently has the lowest cost, taking into account the + * current keyboard lock and modifier states. + * + * @param keyboard + * The guac_spice_keyboard associated with the current Spice session. + * + * @param key + * The key whose lowest-cost possible definition should be retrieved. + * + * @return + * A pointer to the guac_spice_keysym_desc which defines the current + * lowest-cost method of typing the given key. + */ +static const guac_spice_keysym_desc* guac_spice_keyboard_get_definition(guac_spice_keyboard* keyboard, + guac_spice_key* key) { + + /* Consistently map the same entry so long as the key is held */ + if (key->pressed != NULL) + return key->pressed; + + /* Calculate cost of first definition of key (there must always be at least + * one definition) */ + const guac_spice_keysym_desc* best_def = key->definitions[0]; + int best_cost = guac_spice_keyboard_get_cost(keyboard, best_def); + + /* If further definitions exist, choose the definition with the lowest + * overall cost */ + for (int i = 1; i < key->num_definitions; i++) { + + const guac_spice_keysym_desc* def = key->definitions[i]; + int cost = guac_spice_keyboard_get_cost(keyboard, def); + + if (cost < best_cost) { + best_def = def; + best_cost = cost; + } + + } + + return best_def; + +} + +/** + * Adds the keysym/scancode mapping described by the given guac_spice_keysym_desc + * to the internal mapping of the keyboard. If insufficient space remains for + * additional keysyms, or the given keysym has already reached the maximum + * number of possible definitions, the mapping is ignored and the failure is + * logged. + * + * @param keyboard + * The guac_spice_keyboard associated with the current Spice session. + * + * @param mapping + * The keysym/scancode mapping that should be added to the given keyboard. + */ +static void guac_spice_keyboard_add_mapping(guac_spice_keyboard* keyboard, + const guac_spice_keysym_desc* mapping) { + + /* Locate corresponding keysym-to-key translation entry within keyboard + * structure */ + guac_spice_key** key_by_keysym = guac_spice_keyboard_map_key(keyboard, mapping->keysym); + if (key_by_keysym == NULL) { + guac_client_log(keyboard->client, GUAC_LOG_DEBUG, "Ignoring unmappable keysym 0x%X", mapping->keysym); + return; + } + + /* If not yet pointing to a key, point keysym-to-key translation entry at + * next available storage */ + if (*key_by_keysym == NULL) { + + if (keyboard->num_keys == GUAC_SPICE_KEYBOARD_MAX_KEYSYMS) { + guac_client_log(keyboard->client, GUAC_LOG_DEBUG, "Key definition " + "for keysym 0x%X dropped: Keymap exceeds maximum " + "supported number of keysyms", + mapping->keysym); + return; + } + + *key_by_keysym = &keyboard->keys[keyboard->num_keys++]; + + } + + guac_spice_key* key = *key_by_keysym; + + /* Add new definition only if sufficient space remains */ + if (key->num_definitions == GUAC_SPICE_KEY_MAX_DEFINITIONS) { + guac_client_log(keyboard->client, GUAC_LOG_DEBUG, "Key definition " + "for keysym 0x%X dropped: Maximum number of possible " + "definitions has been reached for this keysym", + mapping->keysym); + return; + } + + /* Store new possible definition of key */ + key->definitions[key->num_definitions++] = mapping; + +} + +/** + * Loads all keysym/scancode mappings declared within the given keymap and its + * parent keymap, if any. These mappings are stored within the given + * guac_spice_keyboard structure for future use in translating keysyms to the + * scancodes required by Spice key events. + * + * @param keyboard + * The guac_spice_keyboard which should be initialized with the + * keysym/scancode mapping defined in the given keymap. + * + * @param keymap + * The keymap to use to populate the given client's keysym/scancode + * mapping. + */ +static void guac_spice_keyboard_load_keymap(guac_spice_keyboard* keyboard, + const guac_spice_keymap* keymap) { + + /* If parent exists, load parent first */ + if (keymap->parent != NULL) + guac_spice_keyboard_load_keymap(keyboard, keymap->parent); + + /* Log load */ + guac_client_log(keyboard->client, GUAC_LOG_INFO, + "Loading keymap \"%s\"", keymap->name); + + /* Copy mapping into keymap */ + const guac_spice_keysym_desc* mapping = keymap->mapping; + while (mapping->keysym != 0) { + guac_spice_keyboard_add_mapping(keyboard, mapping++); + } + +} + +guac_spice_keyboard* guac_spice_keyboard_alloc(guac_client* client, + const guac_spice_keymap* keymap) { + + guac_spice_keyboard* keyboard = guac_mem_zalloc(sizeof(guac_spice_keyboard)); + keyboard->client = client; + + /* Load keymap into keyboard */ + guac_spice_keyboard_load_keymap(keyboard, keymap); + + return keyboard; + +} + +void guac_spice_keyboard_free(guac_spice_keyboard* keyboard) { + free(keyboard); +} + +int guac_spice_keyboard_is_defined(guac_spice_keyboard* keyboard, int keysym) { + + /* Return whether the mapping actually exists */ + return guac_spice_keyboard_get_key(keyboard, keysym) != NULL; + +} + +int guac_spice_keyboard_is_pressed(guac_spice_keyboard* keyboard, int keysym) { + + guac_spice_key* key = guac_spice_keyboard_get_key(keyboard, keysym); + return key != NULL && key->pressed != NULL; + +} + +unsigned int guac_spice_keyboard_get_modifier_flags(guac_spice_keyboard* keyboard) { + + unsigned int modifier_flags = 0; + + /* Shift */ + if (guac_spice_keyboard_is_pressed(keyboard, GUAC_SPICE_KEYSYM_LSHIFT) + || guac_spice_keyboard_is_pressed(keyboard, GUAC_SPICE_KEYSYM_RSHIFT)) + modifier_flags |= GUAC_SPICE_KEYMAP_MODIFIER_SHIFT; + + /* Dedicated AltGr key */ + if (guac_spice_keyboard_is_pressed(keyboard, GUAC_SPICE_KEYSYM_RALT) + || guac_spice_keyboard_is_pressed(keyboard, GUAC_SPICE_KEYSYM_ALTGR)) + modifier_flags |= GUAC_SPICE_KEYMAP_MODIFIER_ALTGR; + + /* AltGr via Ctrl+Alt */ + if (guac_spice_keyboard_is_pressed(keyboard, GUAC_SPICE_KEYSYM_LALT) + && (guac_spice_keyboard_is_pressed(keyboard, GUAC_SPICE_KEYSYM_RCTRL) + || guac_spice_keyboard_is_pressed(keyboard, GUAC_SPICE_KEYSYM_LCTRL))) + modifier_flags |= GUAC_SPICE_KEYMAP_MODIFIER_ALTGR; + + return modifier_flags; + +} + +/** + * Presses/releases the requested key by sending one or more Spice key events, as + * defined within the keymap defining that key. + * + * @param keyboard + * The guac_spice_keyboard associated with the current Spice session. + * + * @param key + * The guac_spice_keysym_desc of the key being pressed or released, as + * retrieved from the relevant keymap. + * + * @param pressed + * Zero if the key is being released, non-zero otherwise. + * + * @return + * Zero if the key was successfully pressed/released, non-zero if the key + * cannot be sent using Spice key events. + */ +static const guac_spice_keysym_desc* guac_spice_keyboard_send_defined_key(guac_spice_keyboard* keyboard, + guac_spice_key* key, int pressed) { + + guac_client* client = keyboard->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + const guac_spice_keysym_desc* keysym_desc = guac_spice_keyboard_get_definition(keyboard, key); + if (keysym_desc->scancode == 0) + return NULL; + + /* Update state of required locks and modifiers only when key is just + * now being pressed */ + if (pressed) { + guac_spice_keyboard_update_locks(keyboard, + keysym_desc->set_locks, + keysym_desc->clear_locks); + + guac_spice_keyboard_update_modifiers(keyboard, + keysym_desc->set_modifiers, + keysym_desc->clear_modifiers); + } + + /* Fire actual key event for target key */ + guac_client_log(client, GUAC_LOG_TRACE, "Firing scancode event: %08x", keysym_desc->scancode); + guac_spice_send_key_event(spice_client, keysym_desc->scancode, + keysym_desc->flags, pressed); + + return keysym_desc; + +} + +void guac_spice_keyboard_update_locks(guac_spice_keyboard* keyboard, + unsigned int set_modifiers, unsigned int clear_modifiers) { + + guac_client* client = keyboard->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Calculate updated lock flags */ + unsigned int modifiers = (keyboard->modifiers | set_modifiers) & ~clear_modifiers; + + /* Synchronize remote side only if lock flags have changed */ + if (modifiers != keyboard->modifiers) { + guac_spice_send_synchronize_event(spice_client, modifiers); + keyboard->modifiers = modifiers; + } + +} + +void guac_spice_keyboard_update_modifiers(guac_spice_keyboard* keyboard, + unsigned int set_flags, unsigned int clear_flags) { + + unsigned int modifier_flags = guac_spice_keyboard_get_modifier_flags(keyboard); + + /* Only clear modifiers that are set */ + clear_flags &= modifier_flags; + + /* Only set modifiers that are currently cleared */ + set_flags &= ~modifier_flags; + + /* Press/release Shift as needed */ + if (set_flags & GUAC_SPICE_KEYMAP_MODIFIER_SHIFT) { + guac_spice_keyboard_update_keysym(keyboard, GUAC_SPICE_KEYSYM_LSHIFT, 1, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + } + else if (clear_flags & GUAC_SPICE_KEYMAP_MODIFIER_SHIFT) { + guac_spice_keyboard_update_keysym(keyboard, GUAC_SPICE_KEYSYM_LSHIFT, 0, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + guac_spice_keyboard_update_keysym(keyboard, GUAC_SPICE_KEYSYM_RSHIFT, 0, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + } + + /* Press/release AltGr as needed */ + if (set_flags & GUAC_SPICE_KEYMAP_MODIFIER_ALTGR) { + guac_spice_keyboard_update_keysym(keyboard, GUAC_SPICE_KEYSYM_ALTGR, 1, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + } + else if (clear_flags & GUAC_SPICE_KEYMAP_MODIFIER_ALTGR) { + guac_spice_keyboard_update_keysym(keyboard, GUAC_SPICE_KEYSYM_ALTGR, 0, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + guac_spice_keyboard_update_keysym(keyboard, GUAC_SPICE_KEYSYM_LALT, 0, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + guac_spice_keyboard_update_keysym(keyboard, GUAC_SPICE_KEYSYM_RALT, 0, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + guac_spice_keyboard_update_keysym(keyboard, GUAC_SPICE_KEYSYM_LCTRL, 0, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + guac_spice_keyboard_update_keysym(keyboard, GUAC_SPICE_KEYSYM_RCTRL, 0, GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + } + +} + +int guac_spice_keyboard_update_keysym(guac_spice_keyboard* keyboard, + int keysym, int pressed, guac_spice_key_source source) { + + /* Synchronize lock keys states, if this has not yet been done */ + if (!keyboard->synchronized) { + + guac_client* client = keyboard->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Synchronize remote lock key states with local state */ + guac_spice_send_synchronize_event(spice_client, keyboard->modifiers); + keyboard->synchronized = 1; + + } + + guac_spice_key* key = guac_spice_keyboard_get_key(keyboard, keysym); + + /* Update tracking of client-side keyboard state but only for keys which + * are tracked server-side, as well (to ensure that the key count remains + * correct, even if a user sends extra unbalanced or excessive press and + * release events) */ + if (source == GUAC_SPICE_KEY_SOURCE_CLIENT && key != NULL) { + if (pressed && !key->user_pressed) { + keyboard->user_pressed_keys++; + key->user_pressed = 1; + } + else if (!pressed && key->user_pressed) { + keyboard->user_pressed_keys--; + key->user_pressed = 0; + } + } + + /* Send events and update server-side lock state only if server-side key + * state is changing (or if server-side state of this key is untracked) */ + if (key == NULL || (pressed && key->pressed == NULL) || (!pressed && key->pressed != NULL)) { + + /* Toggle locks on keydown */ + if (pressed) + keyboard->modifiers ^= guac_spice_keyboard_lock_flag(keysym); + + /* If key is known, update state and attempt to send using normal SPICE key + * events */ + const guac_spice_keysym_desc* definition = NULL; + if (key != NULL) { + definition = guac_spice_keyboard_send_defined_key(keyboard, key, pressed); + key->pressed = pressed ? definition : NULL; + } + + /* Fall back to dead keys if otherwise undefined inside the current + * keymap (note that we only handle "pressed" here, as dead keys cannot + * have a pressed/released state). This allows precomposed accented + * characters (such as "á" or "ñ") to be typed on layouts which produce + * them via a dead key followed by a base key. */ + if (definition == NULL && pressed) { + if (guac_spice_decompose_keysym(keyboard, keysym)) + guac_client_log(keyboard->client, GUAC_LOG_DEBUG, + "Undefined key will not be sent: 0x%X", keysym); + } + + } + + /* Reset SPICE server keyboard state (releasing any automatically + * pressed keys) once all keys have been released on the client + * side */ + if (source == GUAC_SPICE_KEY_SOURCE_CLIENT && keyboard->user_pressed_keys == 0) + guac_spice_keyboard_reset(keyboard); + + return 0; + +} + +void guac_spice_keyboard_reset(guac_spice_keyboard* keyboard) { + + /* Release all pressed keys */ + for (int i = 0; i < keyboard->num_keys; i++) { + guac_spice_key* key = &keyboard->keys[i]; + if (key->pressed != NULL) + guac_spice_keyboard_update_keysym(keyboard, key->pressed->keysym, 0, + GUAC_SPICE_KEY_SOURCE_SYNTHETIC); + } + +} + +void guac_spice_keyboard_set_indicators(SpiceChannel* channel, guac_client* client) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + pthread_rwlock_rdlock(&(spice_client->lock)); + + /* Skip if keyboard not yet ready */ + guac_spice_keyboard* keyboard = spice_client->keyboard; + if (keyboard == NULL) + goto complete; + + unsigned int modifiers; + g_object_get(channel, SPICE_PROPERTY_KEY_MODIFIERS, &modifiers, NULL); + + /* Update with received locks */ + guac_client_log(client, GUAC_LOG_DEBUG, "Received updated keyboard lock flags from Spice server: 0x%X", modifiers); + keyboard->modifiers = modifiers; + +complete: + pthread_rwlock_unlock(&(spice_client->lock)); + +} diff --git a/src/protocols/spice/keyboard.h b/src/protocols/spice/keyboard.h new file mode 100644 index 0000000000..c8d1d327d1 --- /dev/null +++ b/src/protocols/spice/keyboard.h @@ -0,0 +1,323 @@ +/* + * 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_SPICE_KEYBOARD_H +#define GUAC_SPICE_KEYBOARD_H + +#include "keymap.h" + +#include +#include + +/** + * The maximum number of distinct keysyms that any particular keyboard may support. + */ +#define GUAC_SPICE_KEYBOARD_MAX_KEYSYMS 1024 + +/** + * The maximum number of unique modifier variations that any particular keysym + * may define. For example, on a US English keyboard, an uppercase "A" may be + * typed by pressing Shift+A with Caps Lock unset, or by pressing A with Caps + * Lock set (two variations). + */ +#define GUAC_SPICE_KEY_MAX_DEFINITIONS 4 + +/** + * All possible sources of Spice key events tracked by guac_spice_keyboard. + */ +typedef enum guac_spice_key_source { + + /** + * The key event was received directly from the Guacamole client via a + * "key" instruction. + */ + GUAC_SPICE_KEY_SOURCE_CLIENT = 0, + + /** + * The key event is being synthesized internally within the Spice support. + */ + GUAC_SPICE_KEY_SOURCE_SYNTHETIC = 1 + +} guac_spice_key_source; + +/** + * A representation of a single key within the overall local keyboard, + * including the definition of that key within the Spice server's keymap and + * whether the key is currently pressed locally. + */ +typedef struct guac_spice_key { + + /** + * All definitions of this key within the Spice server's keymap (keyboard + * layout). Each definition describes which scancode corresponds to this + * key from the perspective of the Spice server, as well as which other + * scancodes must be pressed/released for this key to have the desired + * meaning. + */ + const guac_spice_keysym_desc* definitions[GUAC_SPICE_KEY_MAX_DEFINITIONS]; + + /** + * The number of definitions within the definitions array. If this key does + * not exist within the Spice server's keymap, this will be 0. + */ + int num_definitions; + + /** + * The definition of this key that is currently pressed. If this key is not + * currently pressed, this will be NULL. + */ + const guac_spice_keysym_desc* pressed; + + /** + * Whether this key is currently pressed by the user, and is included among + * the total tracked by user_pressed_keys within guac_spice_keyboard. + */ + int user_pressed; + +} guac_spice_key; + +/** + * The current keyboard state of an Spice session. + */ +typedef struct guac_spice_keyboard { + + /** + * The guac_client associated with the Spice session whose keyboard state is + * being managed by this guac_spice_keyboard. + */ + guac_client* client; + + /** + * The local state of all known lock keys, as a bitwise OR of all Spice lock + * key flags. Legal flags are KBD_SYNC_SCROLL_LOCK, KBD_SYNC_NUM_LOCK, + * KBD_SYNC_CAPS_LOCK, and KBD_SYNC_KANA_LOCK. + */ + int modifiers; + + /** + * Whether the states of remote lock keys (Caps lock, Num lock, etc.) have + * been synchronized with local lock key states. + */ + int synchronized; + + /** + * The number of keys stored within the keys array. + */ + unsigned int num_keys; + + /** + * The local state of all keys, as well as the necessary information to + * translate received keysyms into scancodes or sequences of scancodes for + * Spice. The state of each key is updated based on received Guacamole key + * events, while the information describing the behavior and scancode + * mapping of each key is populated based on an associated keymap. + * + * Keys within this array are in arbitrary order. + */ + guac_spice_key keys[GUAC_SPICE_KEYBOARD_MAX_KEYSYMS]; + + /** + * Lookup table into the overall keys array, locating the guac_spice_key + * associated with any particular keysym. If a keysym has no corresponding + * guac_spice_key within the keys array, its entry within this lookuptable + * will be NULL. + * + * The index of the key for a given keysym is determined based on a + * simple transformation of the keysym itself. Keysyms between 0x0000 and + * 0xFFFF inclusive are mapped to 0x00000 through 0x0FFFF, while keysyms + * between 0x1000000 and 0x100FFFF inclusive (keysyms which are derived + * from Unicode) are mapped to 0x10000 through 0x1FFFF. + */ + guac_spice_key* keys_by_keysym[0x20000]; + + /** + * The total number of keys that the user of the connection is currently + * holding down. This value indicates only the client-side keyboard state. + * It DOES NOT indicate the number of keys currently pressed within the + * Spice server. + */ + int user_pressed_keys; + +} guac_spice_keyboard; + +/** + * Allocates a new guac_spice_keyboard which manages the keyboard state of the + * SPICE session associated with the given guac_client. Keyboard events will be + * dynamically translated from keysym to Spice scancode according to the + * provided keymap. The returned guac_spice_keyboard must eventually be freed + * with guac_spice_keyboard_free(). + * + * @param client + * The guac_client associated with the Spice session whose keyboard state is + * to be managed by the newly-allocated guac_spice_keyboard. + * + * @param keymap + * The keymap which should be used to translate keyboard events. + * + * @return + * A newly-allocated guac_spice_keyboard which manages the keyboard state + * for the Spice session associated given guac_client. + */ +guac_spice_keyboard* guac_spice_keyboard_alloc(guac_client* client, + const guac_spice_keymap* keymap); + +/** + * Frees all memory allocated for the given guac_spice_keyboard. The + * guac_spice_keyboard must have been previously allocated via + * guac_spice_keyboard_alloc(). + * + * @param keyboard + * The guac_spice_keyboard instance which should be freed. + */ +void guac_spice_keyboard_free(guac_spice_keyboard* keyboard); + +/** + * Returns whether the given keysym is defined for the keyboard layout + * associated with the given keyboard. + * + * @param keyboard + * The guac_spice_keyboard instance to check. + * + * @param keysym + * The keysym of the key being checked against the keyboard layout of the + * given keyboard. + * + * @return + * Non-zero if the key is explicitly defined within the keyboard layout of + * the given keyboard, zero otherwise. + */ +int guac_spice_keyboard_is_defined(guac_spice_keyboard* keyboard, int keysym); + +/** + * Returns whether the key having the given keysym is currently pressed. + * + * @param keyboard + * The guac_spice_keyboard instance to check. + * + * @param keysym + * The keysym of the key being checked. + * + * @return + * Non-zero if the key is currently pressed, zero otherwise. + */ +int guac_spice_keyboard_is_pressed(guac_spice_keyboard* keyboard, int keysym); + +/** + * Returns the local state of all known modifier keys, as a bitwise OR of the + * modifier flags used by the keymaps. Alternative methods of producing the + * effect of certain modifiers, such as holding Ctrl+Alt for AltGr when a + * dedicated AltGr key is unavailable, are taken into account. + * + * @see GUAC_SPICE_KEYMAP_MODIFIER_SHIFT + * @see GUAC_SPICE_KEYMAP_MODIFIER_ALTGR + * + * @param keyboard + * The guac_spice_keyboard associated with the current Spice session. + * + * @return + * The local state of all known modifier keys. + */ +unsigned int guac_spice_keyboard_get_modifier_flags(guac_spice_keyboard* keyboard); + +/** + * Updates the local state of the lock keys (such as Caps lock or Num lock), + * synchronizing the remote state of those keys if it is expected to differ. + * + * @param keyboard + * The guac_spice_keyboard associated with the current Spice session. + * + * @param set_modifiers + * The lock key flags which should be set. Legal flags are + * KBD_SYNC_SCROLL_LOCK, KBD_SYNC_NUM_LOCK, KBD_SYNC_CAPS_LOCK, and + * KBD_SYNC_KANA_LOCK. + * + * @param clear_modifiers + * The lock key flags which should be cleared. Legal flags are + * KBD_SYNC_SCROLL_LOCK, KBD_SYNC_NUM_LOCK, KBD_SYNC_CAPS_LOCK, and + * KBD_SYNC_KANA_LOCK. + */ +void guac_spice_keyboard_update_locks(guac_spice_keyboard* keyboard, + unsigned int set_modifiers, unsigned int clear_modifiers); + +/** + * Updates the local state of the modifier keys (such as Shift or AltGr), + * synchronizing the remote state of those keys if it is expected to differ. + * Valid modifier flags are defined by keymap.h. + * + * @see GUAC_SPICE_KEYMAP_MODIFIER_SHIFT + * @see GUAC_SPICE_KEYMAP_MODIFIER_ALTGR + * + * @param keyboard + * The guac_spice_keyboard associated with the current Spice session. + * + * @param set_modifiers + * The modifier key flags which should be set. + * + * @param clear_modifiers + * The modifier key flags which should be cleared. + */ +void guac_spice_keyboard_update_modifiers(guac_spice_keyboard* keyboard, + unsigned int set_modifiers, unsigned int clear_modifiers); + +/** + * Updates the local state of the given keysym, sending the key events required + * to replicate that state remotely (on the Spice server). The key events sent + * will depend on the current keymap. + * + * @param keyboard + * The guac_spice_keyboard associated with the current SPICE session. + * + * @param keysym + * The keysym being pressed or released. + * + * @param pressed + * Zero if the keysym is being released, non-zero otherwise. + * + * @param source + * The source of the key event represented by this call to + * guac_spice_keyboard_update_keysym(). + * + * @return + * Zero if the keys were successfully sent, non-zero otherwise. + */ +int guac_spice_keyboard_update_keysym(guac_spice_keyboard* keyboard, + int keysym, int pressed, guac_spice_key_source source); + +/** + * Releases all currently pressed keys, sending key release events to the Spice + * server as necessary. Lock states (Caps Lock, etc.) are not affected. + * + * @param keyboard + * The guac_spice_keyboard associated with the current Spice session. + */ +void guac_spice_keyboard_reset(guac_spice_keyboard* keyboard); + +/** + * Callback which is invoked when the Spice server reports changes to keyboard + * lock status using a Server Set Keyboard Indicators PDU. + * + * @param channel + * The spiceContext associated with the current Spice session. + * + * @param client + * The guac_client object associated with the callback. + */ +void guac_spice_keyboard_set_indicators(SpiceChannel* channel, guac_client* client); + +#endif \ No newline at end of file diff --git a/src/protocols/spice/keymap.c b/src/protocols/spice/keymap.c index 44d04bfa78..51b75a780f 100644 --- a/src/protocols/spice/keymap.c +++ b/src/protocols/spice/keymap.c @@ -17,192 +17,25 @@ * under the License. */ -#include "config.h" - #include "keymap.h" -#include - -/** - * Bit which, when set within a scancode value, indicates that the scancode is - * an "extended" scancode, transmitted on the wire with a leading 0xE0 byte. - * This matches the encoding expected by spice-gtk's - * spice_inputs_channel_key_press()/key_release() functions. - */ -#define GUAC_SPICE_SCANCODE_EXTENDED 0x100 - -/** - * A single mapping from an X11 keysym to its corresponding PC (AT set 1) - * scancode. - */ -typedef struct guac_spice_keysym_mapping { - - /** - * The X11 keysym being mapped. - */ - int keysym; - - /** - * The corresponding PC scancode, with GUAC_SPICE_SCANCODE_EXTENDED set if - * the scancode is an extended scancode. - */ - unsigned int scancode; - -} guac_spice_keysym_mapping; - -/** - * Static mapping of X11 keysyms to PC scancodes for the US keyboard layout. - * Guacamole transmits the keysym of the resulting character along with - * separate press/release events for modifier keys (Shift, Control, etc.), so - * both the unshifted and shifted keysyms produced by a given physical key map - * to that key's scancode. - */ -static const guac_spice_keysym_mapping guac_spice_keysym_scancodes[] = { - - /* Number row (digits and their shifted symbols) */ - { 0x0031, 0x02 }, { 0x0021, 0x02 }, /* 1 ! */ - { 0x0032, 0x03 }, { 0x0040, 0x03 }, /* 2 @ */ - { 0x0033, 0x04 }, { 0x0023, 0x04 }, /* 3 # */ - { 0x0034, 0x05 }, { 0x0024, 0x05 }, /* 4 $ */ - { 0x0035, 0x06 }, { 0x0025, 0x06 }, /* 5 % */ - { 0x0036, 0x07 }, { 0x005e, 0x07 }, /* 6 ^ */ - { 0x0037, 0x08 }, { 0x0026, 0x08 }, /* 7 & */ - { 0x0038, 0x09 }, { 0x002a, 0x09 }, /* 8 * */ - { 0x0039, 0x0a }, { 0x0028, 0x0a }, /* 9 ( */ - { 0x0030, 0x0b }, { 0x0029, 0x0b }, /* 0 ) */ - { 0x002d, 0x0c }, { 0x005f, 0x0c }, /* - _ */ - { 0x003d, 0x0d }, { 0x002b, 0x0d }, /* = + */ - - /* QWERTY row */ - { 0x0071, 0x10 }, { 0x0051, 0x10 }, /* q Q */ - { 0x0077, 0x11 }, { 0x0057, 0x11 }, /* w W */ - { 0x0065, 0x12 }, { 0x0045, 0x12 }, /* e E */ - { 0x0072, 0x13 }, { 0x0052, 0x13 }, /* r R */ - { 0x0074, 0x14 }, { 0x0054, 0x14 }, /* t T */ - { 0x0079, 0x15 }, { 0x0059, 0x15 }, /* y Y */ - { 0x0075, 0x16 }, { 0x0055, 0x16 }, /* u U */ - { 0x0069, 0x17 }, { 0x0049, 0x17 }, /* i I */ - { 0x006f, 0x18 }, { 0x004f, 0x18 }, /* o O */ - { 0x0070, 0x19 }, { 0x0050, 0x19 }, /* p P */ - { 0x005b, 0x1a }, { 0x007b, 0x1a }, /* [ { */ - { 0x005d, 0x1b }, { 0x007d, 0x1b }, /* ] } */ - - /* ASDF row */ - { 0x0061, 0x1e }, { 0x0041, 0x1e }, /* a A */ - { 0x0073, 0x1f }, { 0x0053, 0x1f }, /* s S */ - { 0x0064, 0x20 }, { 0x0044, 0x20 }, /* d D */ - { 0x0066, 0x21 }, { 0x0046, 0x21 }, /* f F */ - { 0x0067, 0x22 }, { 0x0047, 0x22 }, /* g G */ - { 0x0068, 0x23 }, { 0x0048, 0x23 }, /* h H */ - { 0x006a, 0x24 }, { 0x004a, 0x24 }, /* j J */ - { 0x006b, 0x25 }, { 0x004b, 0x25 }, /* k K */ - { 0x006c, 0x26 }, { 0x004c, 0x26 }, /* l L */ - { 0x003b, 0x27 }, { 0x003a, 0x27 }, /* ; : */ - { 0x0027, 0x28 }, { 0x0022, 0x28 }, /* ' " */ - { 0x0060, 0x29 }, { 0x007e, 0x29 }, /* ` ~ */ - { 0x005c, 0x2b }, { 0x007c, 0x2b }, /* \ | */ +#include - /* ZXCV row */ - { 0x007a, 0x2c }, { 0x005a, 0x2c }, /* z Z */ - { 0x0078, 0x2d }, { 0x0058, 0x2d }, /* x X */ - { 0x0063, 0x2e }, { 0x0043, 0x2e }, /* c C */ - { 0x0076, 0x2f }, { 0x0056, 0x2f }, /* v V */ - { 0x0062, 0x30 }, { 0x0042, 0x30 }, /* b B */ - { 0x006e, 0x31 }, { 0x004e, 0x31 }, /* n N */ - { 0x006d, 0x32 }, { 0x004d, 0x32 }, /* m M */ - { 0x002c, 0x33 }, { 0x003c, 0x33 }, /* , < */ - { 0x002e, 0x34 }, { 0x003e, 0x34 }, /* . > */ - { 0x002f, 0x35 }, { 0x003f, 0x35 }, /* / ? */ +const guac_spice_keymap* guac_spice_keymap_find(const char* name) { - { 0x0020, 0x39 }, /* Space */ + /* For each keymap */ + const guac_spice_keymap** current = GUAC_SPICE_KEYMAPS; + while (*current != NULL) { - /* Editing / control keys */ - { 0xff1b, 0x01 }, /* Escape */ - { 0xff08, 0x0e }, /* BackSpace */ - { 0xff09, 0x0f }, /* Tab */ - { 0xff0d, 0x1c }, /* Return */ - { 0xffe5, 0x3a }, /* Caps_Lock */ - { 0xff7f, 0x45 }, /* Num_Lock */ - { 0xff14, 0x46 }, /* Scroll_Lock */ - - /* Modifier keys */ - { 0xffe1, 0x2a }, /* Shift_L */ - { 0xffe2, 0x36 }, /* Shift_R */ - { 0xffe3, 0x1d }, /* Control_L */ - { 0xffe4, 0x1d | GUAC_SPICE_SCANCODE_EXTENDED }, /* Control_R */ - { 0xffe9, 0x38 }, /* Alt_L */ - { 0xffea, 0x38 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Alt_R */ - { 0xffe7, 0x38 }, /* Meta_L (treat as Alt) */ - { 0xffe8, 0x38 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Meta_R */ - { 0xffeb, 0x5b | GUAC_SPICE_SCANCODE_EXTENDED }, /* Super_L (left "Windows") */ - { 0xffec, 0x5c | GUAC_SPICE_SCANCODE_EXTENDED }, /* Super_R (right "Windows") */ - { 0xff67, 0x5d | GUAC_SPICE_SCANCODE_EXTENDED }, /* Menu */ - - /* Function keys */ - { 0xffbe, 0x3b }, /* F1 */ - { 0xffbf, 0x3c }, /* F2 */ - { 0xffc0, 0x3d }, /* F3 */ - { 0xffc1, 0x3e }, /* F4 */ - { 0xffc2, 0x3f }, /* F5 */ - { 0xffc3, 0x40 }, /* F6 */ - { 0xffc4, 0x41 }, /* F7 */ - { 0xffc5, 0x42 }, /* F8 */ - { 0xffc6, 0x43 }, /* F9 */ - { 0xffc7, 0x44 }, /* F10 */ - { 0xffc8, 0x57 }, /* F11 */ - { 0xffc9, 0x58 }, /* F12 */ - - /* Navigation cluster (extended) */ - { 0xff63, 0x52 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Insert */ - { 0xffff, 0x53 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Delete */ - { 0xff50, 0x47 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Home */ - { 0xff57, 0x4f | GUAC_SPICE_SCANCODE_EXTENDED }, /* End */ - { 0xff55, 0x49 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Page_Up (Prior) */ - { 0xff56, 0x51 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Page_Down (Next) */ - { 0xff52, 0x48 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Up */ - { 0xff54, 0x50 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Down */ - { 0xff51, 0x4b | GUAC_SPICE_SCANCODE_EXTENDED }, /* Left */ - { 0xff53, 0x4d | GUAC_SPICE_SCANCODE_EXTENDED }, /* Right */ - - { 0xff61, 0x37 | GUAC_SPICE_SCANCODE_EXTENDED }, /* Print */ - - /* Keypad */ - { 0xff8d, 0x1c | GUAC_SPICE_SCANCODE_EXTENDED }, /* KP_Enter */ - { 0xffaf, 0x35 | GUAC_SPICE_SCANCODE_EXTENDED }, /* KP_Divide */ - { 0xffaa, 0x37 }, /* KP_Multiply */ - { 0xffad, 0x4a }, /* KP_Subtract */ - { 0xffab, 0x4e }, /* KP_Add */ - { 0xffae, 0x53 }, /* KP_Decimal */ - { 0xffb0, 0x52 }, /* KP_0 */ - { 0xffb1, 0x4f }, /* KP_1 */ - { 0xffb2, 0x50 }, /* KP_2 */ - { 0xffb3, 0x51 }, /* KP_3 */ - { 0xffb4, 0x4b }, /* KP_4 */ - { 0xffb5, 0x4c }, /* KP_5 */ - { 0xffb6, 0x4d }, /* KP_6 */ - { 0xffb7, 0x47 }, /* KP_7 */ - { 0xffb8, 0x48 }, /* KP_8 */ - { 0xffb9, 0x49 }, /* KP_9 */ - - /* End of table */ - { 0, 0 } - -}; - -unsigned int guac_spice_keysym_to_scancode(int keysym) { - - /* Search the static mapping table for the given keysym */ - const guac_spice_keysym_mapping* current = guac_spice_keysym_scancodes; - while (current->keysym != 0) { - - if (current->keysym == keysym) - return current->scancode; + /* If name matches, done */ + if (strcmp((*current)->name, name) == 0) + return *current; current++; - } - /* No known mapping */ - return 0; + /* Failure */ + return NULL; } + diff --git a/src/protocols/spice/keymap.h b/src/protocols/spice/keymap.h index b08398f9e8..69a02df256 100644 --- a/src/protocols/spice/keymap.h +++ b/src/protocols/spice/keymap.h @@ -21,19 +21,182 @@ #define GUAC_SPICE_KEYMAP_H /** - * Translates the given X11 keysym into the corresponding PC (AT set 1 / "XT") - * scancode expected by the SPICE inputs channel. Extended scancodes (those - * which are transmitted on the wire with a leading 0xE0 byte) are returned - * with bit 8 (0x100) set, matching the encoding expected by - * spice_inputs_channel_key_press() / spice_inputs_channel_key_release(). + * The X11 keysym for Num Lock. + */ +#define GUAC_SPICE_KEYSYM_NUM_LOCK 0xFF7F + +/** + * The X11 keysym for Scroll Lock. + */ +#define GUAC_SPICE_KEYSYM_SCROLL_LOCK 0xFF14 + +/** + * The X11 keysym for Caps Lock. + */ +#define GUAC_SPICE_KEYSYM_CAPS_LOCK 0xFFE5 + +/** + * The X11 keysym for Kana Lock. + */ +#define GUAC_SPICE_KEYSYM_KANA_LOCK 0xFF2D + +/** + * The X11 keysym for Left Shift. + */ +#define GUAC_SPICE_KEYSYM_LSHIFT 0xFFE1 + +/** + * The X11 keysym for Right Shift. + */ +#define GUAC_SPICE_KEYSYM_RSHIFT 0xFFE2 + +/** + * The X11 keysym for Left Ctrl. + */ +#define GUAC_SPICE_KEYSYM_LCTRL 0xFFE3 + +/** + * The X11 keysym for Right Ctrl. + */ +#define GUAC_SPICE_KEYSYM_RCTRL 0xFFE4 + +/** + * The X11 keysym for Left Alt. + */ +#define GUAC_SPICE_KEYSYM_LALT 0xFFE9 + +/** + * The X11 keysym for Right Alt. + */ +#define GUAC_SPICE_KEYSYM_RALT 0xFFEA + +/** + * The X11 keysym for AltGr. + */ +#define GUAC_SPICE_KEYSYM_ALTGR 0xFE03 + +/** + * Bitwise flag value representing the Shift modifier. + */ +#define GUAC_SPICE_KEYMAP_MODIFIER_SHIFT 1 + +/** + * Bitwise flag value representing the AltGr modifier. + */ +#define GUAC_SPICE_KEYMAP_MODIFIER_ALTGR 2 + +/** + * Represents a keysym-to-scancode mapping for Spice, with extra information + * about the state of prerequisite keysyms. + */ +typedef struct guac_spice_keysym_desc { + + /** + * The keysym being mapped. + */ + int keysym; + + /** + * The scancode this keysym maps to. + */ + int scancode; + + /** + * Required Spice-specific flags that must be sent along with the scancode. + */ + int flags; + + /** + * Bitwise-OR of the flags of any modifiers that must be active for the + * associated scancode to be interpreted as this keysym. + * + * If the associated keysym is pressed, and any of these modifiers are not + * currently active, Guacamole's Spice support must send additional events + * to activate these modifiers prior to sending the scancode for this + * keysym. + * + * @see GUAC_SPICE_KEYMAP_MODIFIER_SHIFT + * @see GUAC_SPICE_KEYMAP_MODIFIER_ALTGR + */ + const unsigned int set_modifiers; + + /** + * Bitwise-OR of the flags of any modifiers that must NOT be active for the + * associated scancode to be interpreted as this keysym. + * + * If the associated keysym is pressed, and any of these modifiers are + * currently active, Guacamole's Spice support must send additional events + * to deactivate these modifiers prior to sending the scancode for this + * keysym. + * + * @see GUAC_SPICE_KEYMAP_MODIFIER_SHIFT + * @see GUAC_SPICE_KEYMAP_MODIFIER_ALTGR + */ + const unsigned int clear_modifiers; + + /** + * Bitwise OR of the flags of all lock keys (ie: Caps lock, Num lock, etc.) + * which must be active for this keysym to be properly typed. Legal flags + * are KBD_SYNC_SCROLL_LOCK, KBD_SYNC_NUM_LOCK, KBD_SYNC_CAPS_LOCK, and + * KBD_SYNC_KANA_LOCK. + */ + const unsigned int set_locks; + + /** + * Bitwise OR of the flags of all lock keys (ie: Caps lock, Num lock, etc.) + * which must be inactive for this keysym to be properly typed. Legal flags + * are KBD_SYNC_SCROLL_LOCK, KBD_SYNC_NUM_LOCK, KBD_SYNC_CAPS_LOCK, and + * KBD_SYNC_KANA_LOCK. + */ + const unsigned int clear_locks; + +} guac_spice_keysym_desc; + +/** + * Hierarchical keysym mapping + */ +typedef struct guac_spice_keymap guac_spice_keymap; +struct guac_spice_keymap { + + /** + * The parent mapping this map will inherit its initial mapping from. + * Any other mapping information will add to or override the mapping + * inherited from the parent. + */ + const guac_spice_keymap* parent; + + /** + * Descriptive name of this keymap + */ + const char* name; + + /** + * Null-terminated array of scancode mappings. + */ + const guac_spice_keysym_desc* mapping; + +}; + +/** + * The name of the default keymap, which MUST exist. + */ +#define GUAC_SPICE_DEFAULT_KEYMAP "en-us-qwerty" + +/** + * NULL-terminated array of all keymaps. + */ +extern const guac_spice_keymap* GUAC_SPICE_KEYMAPS[]; + +/** + * Return the keymap having the given name, if any, or NULL otherwise. * - * @param keysym - * The X11 keysym to translate. + * @param name + * The name of the keymap to find. * * @return - * The corresponding PC scancode (with bit 0x100 set for extended keys), or - * 0 if the keysym has no known scancode mapping. + * The keymap having the given name, or NULL if no such keymap exists. */ -unsigned int guac_spice_keysym_to_scancode(int keysym); +const guac_spice_keymap* guac_spice_keymap_find(const char* name); #endif + diff --git a/src/protocols/spice/keymaps/base.keymap b/src/protocols/spice/keymaps/base.keymap new file mode 100644 index 0000000000..5b93fab6dc --- /dev/null +++ b/src/protocols/spice/keymaps/base.keymap @@ -0,0 +1,93 @@ +# +# 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. +# + +name "base" + +# Typeable characters +map 0x39 ~ " " # Space +map 0x0F ~ 0xff09 # Tab + +# Control characters +map 0x0E ~ 0xff08 # BackSpace +map 0x1C ~ 0xff0d # Return +map 0x01 ~ 0xff1b # Escape +map +ext 0x52 ~ 0xff63 # Insert +map +ext 0x53 ~ 0xffff # Delete +map +ext 0x47 ~ 0xff50 # Home +map +ext 0x4F ~ 0xff57 # End +map +ext 0x4B ~ 0xff51 # Left +map +ext 0x48 ~ 0xff52 # Up +map +ext 0x4D ~ 0xff53 # Right +map +ext 0x50 ~ 0xff54 # Down +map +ext 0x49 ~ 0xff55 # Page_Up +map +ext 0x51 ~ 0xff56 # Page_Down +map +ext 0x37 ~ 0xff61 # Print Screen + +# Locks +map 0x45 ~ 0xff7f # Num_Lock +map 0x46 ~ 0xff14 # Scroll_Lock +map 0x3A ~ 0xffe5 # Caps_Lock + +# Keypad numerals +map -shift +num 0x52 ~ 0xffb0 # KP_0 +map -shift +num 0x4F ~ 0xffb1 # KP_1 +map -shift +num 0x50 ~ 0xffb2 # KP_2 +map -shift +num 0x51 ~ 0xffb3 # KP_3 +map -shift +num 0x4B ~ 0xffb4 # KP_4 +map -shift +num 0x4C ~ 0xffb5 # KP_5 +map -shift +num 0x4D ~ 0xffb6 # KP_6 +map -shift +num 0x47 ~ 0xffb7 # KP_7 +map -shift +num 0x48 ~ 0xffb8 # KP_8 +map -shift +num 0x49 ~ 0xffb9 # KP_9 + +# Keypad operators +map 0x37 ~ 0xffaa # KP_multiply +map 0x4e ~ 0xffab # KP_add +map 0x4a ~ 0xffad # KP_subtract +map 0x53 ~ 0xffae # KP_decimal +map +ext 0x35 ~ 0xffaf # KP_divide + +# F keys +map 0x3B ~ 0xffbe # F1 +map 0x3C ~ 0xffbf # F2 +map 0x3D ~ 0xffc0 # F3 +map 0x3E ~ 0xffc1 # F4 +map 0x3F ~ 0xffc2 # F5 +map 0x40 ~ 0xffc3 # F6 +map 0x41 ~ 0xffc4 # F7 +map 0x42 ~ 0xffc5 # F8 +map 0x43 ~ 0xffc6 # F9 +map 0x44 ~ 0xffc7 # F10 +map 0x57 ~ 0xffc8 # F11 +map 0x58 ~ 0xffc9 # F12 + +# Modifiers +map 0x2A ~ 0xffe1 # Shift_L +map 0x36 ~ 0xffe2 # Shift_R +map 0x1D ~ 0xffe3 # Control_L +map +ext 0x1D ~ 0xffe4 # Control_R +map 0x38 ~ 0xffe9 # Alt_L +map +ext 0x38 ~ 0xffea # Alt_R +map +ext 0x38 ~ 0xfe03 # AltGr +map +ext 0x5B ~ 0xffe7 # Meta_L +map +ext 0x5C ~ 0xffe8 # Meta_R +map +ext 0x5B ~ 0xffeb # Super_L +map +ext 0x5C ~ 0xffec # Super_R +map +ext 0x5D ~ 0xff67 # Menu + diff --git a/src/protocols/spice/keymaps/da_dk_qwerty.keymap b/src/protocols/spice/keymaps/da_dk_qwerty.keymap new file mode 100644 index 0000000000..07611e888b --- /dev/null +++ b/src/protocols/spice/keymaps/da_dk_qwerty.keymap @@ -0,0 +1,73 @@ +# +# 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. +# + +parent "base" +name "da-dk-qwerty" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0C ~ "§1234567890+" +map -caps -altgr -shift 0x10..0x1A ~ "qwertyuiopå" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "asdfghjklæø'" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "ZXCVBNM;:_" + +map +caps -altgr -shift 0x29 0x02..0x0C ~ "§1234567890+" +map +caps -altgr -shift 0x10..0x1A ~ "QWERTYUIOPÅ" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "ASDFGHJKLÆØ'" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "zxcvbnm;:_" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x03 ~ "@" +map +altgr -shift 0x04 ~ "£" +map +altgr -shift 0x05 ~ "$" +map +altgr -shift 0x08 ~ "{" +map +altgr -shift 0x09 ~ "[" +map +altgr -shift 0x0A ~ "]" +map +altgr -shift 0x0B ~ "}" +map +altgr -shift 0x56 ~ "\" + +map +altgr -shift 0x12 ~ "€" + +map +altgr -shift 0x0D ~ "|" +map +altgr -shift 0x32 ~ "µ" + +# +# Dead keys +# + +map -altgr -shift 0x0D ~ 0xFE51 # Dead acute +map -altgr +shift 0x0D ~ 0xFE50 # Dead grave +map -altgr -shift 0x1B ~ 0xFE57 # Dead umlaut +map -altgr +shift 0x1B ~ 0xFE52 # Dead circumflex +map +altgr -shift 0x1B ~ 0xFE53 # Dead tilde diff --git a/src/protocols/spice/keymaps/de_ch_qwertz.keymap b/src/protocols/spice/keymaps/de_ch_qwertz.keymap new file mode 100644 index 0000000000..a4ba4a1ca6 --- /dev/null +++ b/src/protocols/spice/keymaps/de_ch_qwertz.keymap @@ -0,0 +1,67 @@ +# +# 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. +# + +parent "base" +name "de-ch-qwertz" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0C ~ "§1234567890'" +map -caps -altgr -shift 0x10..0x1A ~ "qwertzuiopü" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "asdfghjklöä$" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "YXCVBNM;:_" + +map +caps -altgr -shift 0x29 0x02..0x0C ~ "§1234567890'" +map +caps -altgr -shift 0x10..0x1A ~ "QWERTZUIOPÜ" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "ASDFGHJKLÖÄ$" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "yxcvbnm;:_" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x02..0x04 ~ "¦@#" +map +altgr -shift 0x07..0x09 ~ "¬|¢" +map +altgr -shift 0x1A..0x1B ~ "[]" +map +altgr -shift 0x28 ~ "{" +map +altgr -shift 0x2B ~ "}" +map +altgr -shift 0x56 ~ "\" +map +altgr -shift 0x12 ~ "€" + +# +# Dead keys +# + +map +altgr -shift 0x0C ~ 0xFE51 # Dead acute +map -altgr -shift 0x0D ~ 0xFE52 # Dead circumflex +map -altgr +shift 0x0D ~ 0xFE50 # Dead grave +map +altgr -shift 0x0D ~ 0xFE53 # Dead tilde +map -altgr -shift 0x1B ~ 0xFE57 # Dead umlaut diff --git a/src/protocols/spice/keymaps/de_de_qwertz.keymap b/src/protocols/spice/keymaps/de_de_qwertz.keymap new file mode 100644 index 0000000000..bf7a0aafe9 --- /dev/null +++ b/src/protocols/spice/keymaps/de_de_qwertz.keymap @@ -0,0 +1,74 @@ +# +# 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. +# + +parent "base" +name "de-de-qwertz" + +# +# Basic keys +# + +map -caps -altgr -shift 0x02..0x0C ~ "1234567890ß" +map -caps -altgr -shift 0x10..0x1B ~ "qwertzuiopü+" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "asdfghjklöä#" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "YXCVBNM;:_" + +map +caps -altgr -shift 0x02..0x0C ~ "!"§$%&/()=?" +map +caps -altgr -shift 0x10..0x1B ~ "QWERTZUIOPÜ*" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "ASDFGHJKLÖÄ'" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "yxcvbnm,._" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x03 ~ "²" +map +altgr -shift 0x04 ~ "³" +map +altgr -shift 0x08 ~ "{" +map +altgr -shift 0x09 ~ "[" +map +altgr -shift 0x0A ~ "]" +map +altgr -shift 0x0B ~ "}" +map +altgr -shift 0x0C ~ "\" + +map +altgr -shift 0x10 ~ "@" +map +altgr -shift 0x12 ~ "€" +map +altgr -shift 0x1B ~ "~" + +map +altgr -shift 0x56 ~ "|" +map +altgr -shift 0x32 ~ "µ" + +# +# Dead keys +# + +map -altgr +shift 0x0D ~ 0xFE50 # Dead grave +map -altgr -shift 0x0D ~ 0xFE51 # Dead acute +map -altgr -shift 0x29 ~ 0xFE52 # Dead circumflex +map +altgr -shift 0x0C ~ 0xFE53 # Dead tilde + diff --git a/src/protocols/spice/keymaps/en_gb_qwerty.keymap b/src/protocols/spice/keymaps/en_gb_qwerty.keymap new file mode 100644 index 0000000000..c14485b228 --- /dev/null +++ b/src/protocols/spice/keymaps/en_gb_qwerty.keymap @@ -0,0 +1,79 @@ +# +# 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. +# + +parent "base" +name "en-gb-qwerty" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0D ~ "`1234567890-=" +map -caps -altgr -shift 0x10..0x1B ~ "qwertyuiop[]" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "asdfghjkl;'#" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "\zxcvbnm,./" + +map -caps -altgr +shift 0x29 0x02..0x0D ~ "¬!"£$%^&*()_+" +map -caps -altgr +shift 0x10..0x1B ~ "QWERTYUIOP{}" +map -caps -altgr +shift 0x1E..0x28 0x2B ~ "ASDFGHJKL:@~" +map -caps -altgr +shift 0x56 0x2C..0x35 ~ "|ZXCVBNM<>?" + +map +caps -altgr -shift 0x29 0x02..0x0D ~ "`1234567890-=" +map +caps -altgr -shift 0x10..0x1B ~ "QWERTYUIOP[]" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "ASDFGHJKL;'#" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "\ZXCVBNM,./" + +map +caps -altgr +shift 0x29 0x02..0x0D ~ "¬!"£$%^&*()_+" +map +caps -altgr +shift 0x10..0x1B ~ "qwertyuiop{}" +map +caps -altgr +shift 0x1E..0x28 0x2B ~ "asdfghjkl:@~" +map +caps -altgr +shift 0x56 0x2C..0x35 ~ "|zxcvbnm<>?" + +# +# Keys requiring AltGr (some of which are affected by Caps Lock) +# + +map +altgr -shift 0x29 ~ "¦" +map +altgr -shift 0x05 ~ "€" + +map -caps +altgr -shift 0x12 ~ "é" +map -caps +altgr +shift 0x12 ~ "É" +map -caps +altgr -shift 0x16 ~ "ú" +map -caps +altgr +shift 0x16 ~ "Ú" +map -caps +altgr -shift 0x17 ~ "í" +map -caps +altgr +shift 0x17 ~ "Í" +map -caps +altgr -shift 0x18 ~ "ó" +map -caps +altgr +shift 0x18 ~ "Ó" +map -caps +altgr -shift 0x1E ~ "á" +map -caps +altgr +shift 0x1E ~ "Á" +map -caps +altgr -shift 0x2E ~ "ç" +map -caps +altgr +shift 0x2E ~ "Ç" + +map +caps +altgr +shift 0x12 ~ "é" +map +caps +altgr -shift 0x12 ~ "É" +map +caps +altgr +shift 0x16 ~ "ú" +map +caps +altgr -shift 0x16 ~ "Ú" +map +caps +altgr +shift 0x17 ~ "í" +map +caps +altgr -shift 0x17 ~ "Í" +map +caps +altgr +shift 0x18 ~ "ó" +map +caps +altgr -shift 0x18 ~ "Ó" +map +caps +altgr +shift 0x1E ~ "á" +map +caps +altgr -shift 0x1E ~ "Á" +map +caps +altgr +shift 0x2E ~ "ç" +map +caps +altgr -shift 0x2E ~ "Ç" + diff --git a/src/protocols/spice/keymaps/en_us_qwerty.keymap b/src/protocols/spice/keymaps/en_us_qwerty.keymap new file mode 100644 index 0000000000..8af29e3195 --- /dev/null +++ b/src/protocols/spice/keymaps/en_us_qwerty.keymap @@ -0,0 +1,42 @@ +# +# 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. +# + +parent "base" +name "en-us-qwerty" + +map -caps -shift 0x29 0x02..0x0D ~ "`1234567890-=" +map -caps -shift 0x10..0x1B 0x2B ~ "qwertyuiop[]\" +map -caps -shift 0x1E..0x28 ~ "asdfghjkl;'" +map -caps -shift 0x2C..0x35 ~ "zxcvbnm,./" + +map -caps +shift 0x29 0x02..0x0D ~ "~!@#$%^&*()_+" +map -caps +shift 0x10..0x1B 0x2B ~ "QWERTYUIOP{}|" +map -caps +shift 0x1E..0x28 ~ "ASDFGHJKL:"" +map -caps +shift 0x2C..0x35 ~ "ZXCVBNM<>?" + +map +caps -shift 0x29 0x02..0x0D ~ "`1234567890-=" +map +caps -shift 0x10..0x1B 0x2B ~ "QWERTYUIOP[]\" +map +caps -shift 0x1E..0x28 ~ "ASDFGHJKL;'" +map +caps -shift 0x2C..0x35 ~ "ZXCVBNM,./" + +map +caps +shift 0x29 0x02..0x0D ~ "~!@#$%^&*()_+" +map +caps +shift 0x10..0x1B 0x2B ~ "qwertyuiop{}|" +map +caps +shift 0x1E..0x28 ~ "asdfghjkl:"" +map +caps +shift 0x2C..0x35 ~ "zxcvbnm<>?" + diff --git a/src/protocols/spice/keymaps/es_es_qwerty.keymap b/src/protocols/spice/keymaps/es_es_qwerty.keymap new file mode 100644 index 0000000000..fa00104f16 --- /dev/null +++ b/src/protocols/spice/keymaps/es_es_qwerty.keymap @@ -0,0 +1,63 @@ +# +# 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. +# + +parent "base" +name "es-es-qwerty" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0D ~ "º1234567890'¡" +map -caps -altgr -shift 0x10..0x19 0x1B ~ "qwertyuiop+" +map -caps -altgr -shift 0x1E..0x27 0x2B ~ "asdfghjklñç" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "ZXCVBNM;:_" + +map +caps -altgr -shift 0x29 0x02..0x0D ~ "º1234567890'¡" +map +caps -altgr -shift 0x10..0x19 0x1B ~ "QWERTYUIOP+" +map +caps -altgr -shift 0x1E..0x27 0x2B ~ "ASDFGHJKLÑÇ" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "zxcvbnm;:_" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x29 0x02..0x04 0x07 ~ "\|@#¬" +map +altgr -shift 0x12 0x1A 0x1B ~ "€[]" +map +altgr -shift 0x28 0x2B ~ "{}" + +# +# Dead keys +# + +map -altgr -shift 0x1A ~ 0xFE50 # Dead grave +map -altgr -shift 0x28 ~ 0xFE51 # Dead acute +map -altgr +shift 0x1A ~ 0xFE52 # Dead circumflex +map +altgr -shift 0x05 ~ 0xFE53 # Dead tilde +map -altgr +shift 0x28 ~ 0xFE57 # Dead diaeresis (umlaut) diff --git a/src/protocols/spice/keymaps/es_latam_qwerty.keymap b/src/protocols/spice/keymaps/es_latam_qwerty.keymap new file mode 100644 index 0000000000..260f94d571 --- /dev/null +++ b/src/protocols/spice/keymaps/es_latam_qwerty.keymap @@ -0,0 +1,63 @@ +# +# 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. +# + +parent "base" +name "es-latam-qwerty" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0D ~ "|1234567890'¿" +map -caps -altgr -shift 0x10..0x19 0x1B ~ "qwertyuiop+" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "asdfghjklñ{}" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "ZXCVBNM;:_" + +map +caps -altgr -shift 0x29 0x02..0x0D ~ "|1234567890'¿" +map +caps -altgr -shift 0x10..0x19 0x1B ~ "QWERTYUIOP+" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "ASDFGHJKLÑ{}" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "zxcvbnm;:_" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x29 0x0C ~ "¬\" +map +altgr -shift 0x10 ~ "€" +map +altgr -shift 0x28 ~ "^" + +# +# Dead keys +# + +map +altgr -shift 0x2B ~ 0xFE50 # Dead grave +map -altgr -shift 0x1A ~ 0xFE51 # Dead acute +map -altgr +shift 0x1A ~ 0xFE57 # Dead diaeresis (umlaut) +map +altgr -shift 0x1B ~ 0xFE53 # Dead tilde + diff --git a/src/protocols/spice/keymaps/failsafe.keymap b/src/protocols/spice/keymaps/failsafe.keymap new file mode 100644 index 0000000000..76625ca729 --- /dev/null +++ b/src/protocols/spice/keymaps/failsafe.keymap @@ -0,0 +1,22 @@ +# +# 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. +# + +parent "base" +name "failsafe" + diff --git a/src/protocols/spice/keymaps/fr_be_azerty.keymap b/src/protocols/spice/keymaps/fr_be_azerty.keymap new file mode 100644 index 0000000000..0a5b3848ec --- /dev/null +++ b/src/protocols/spice/keymaps/fr_be_azerty.keymap @@ -0,0 +1,76 @@ +# +# 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. +# + +parent "base" +name "fr-be-azerty" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0D ~ "²&é"'(§è!çà)-" +map -caps -altgr -shift 0x10..0x19 0x1B ~ "azertyuiop$" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "qsdfghjklmùµ" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "WXCVBN?./+" + +map +caps -altgr -shift 0x29 0x02..0x0D ~ "²1234567890°_" +map +caps -altgr -shift 0x10..0x19 0x1B ~ "AZERTYUIOP£" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "QSDFGHJKLM%£" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "wxcvbn,;:=" + +# +# Keys requiring AltGr (unaffected by Caps Lock, but Shift must not be pressed) +# + +map +altgr -shift 0x02..0x04 ~ "|@#" +map +altgr -shift 0x0A..0x0B ~ "{}" +map +altgr -shift 0x1A..0x1B ~ "[]" + +map +altgr -shift 0x12 ~ "€" +map +altgr -shift 0x56 ~ "\" +map +altgr -shift 0x07 ~ "^" + +# +# Dead keys requiring AltGr (unaffected by Caps Lock or Shift) +# + +map +altgr 0x35 ~ 0xFE53 # Dead tilde +map +altgr 0x28 ~ 0xFE51 # Dead acute +map +altgr 0x2B ~ 0xFE50 # Dead grave + +# +# Dead keys (affected by Caps Lock and Shift) +# + +map -caps -altgr -shift 0x1A ~ 0xFE52 # Dead circumflex +map -caps -altgr +shift 0x1A ~ 0xFE57 # Dead umlaut + +map +caps -altgr -shift 0x1A ~ 0xFE57 # Dead umlaut +map +caps -altgr +shift 0x1A ~ 0xFE52 # Dead circumflex + diff --git a/src/protocols/spice/keymaps/fr_ca_qwerty.keymap b/src/protocols/spice/keymaps/fr_ca_qwerty.keymap new file mode 100644 index 0000000000..6f4a912b2e --- /dev/null +++ b/src/protocols/spice/keymaps/fr_ca_qwerty.keymap @@ -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. +# + +parent "base" +name "fr-ca-qwerty" + +# +# Basic keys +# + +map -altgr -shift 0x29 0x02..0x0D ~ "#1234567890-=" +map -altgr -shift 0x10..0x1B 0x2B ~ "qwertyuiop^¸<" +map -altgr -shift 0x1E..0x28 ~ "asdfghjkl;`" +map -altgr -shift 0x2C..0x35 ~ "zxcvbnm,.é" + +map -altgr +shift 0x29 0x02..0x0D ~ "|!"/$%?&*()_+" +map -altgr +shift 0x10..0x1B 0x2B ~ "QWERTYUIOP^¨>" +map -altgr +shift 0x1E..0x28 ~ "ASDFGHJKL:`" +map -altgr +shift 0x2C..0x35 ~ "ZXCVBNM'.É" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x29 0x02..0x0D ~ "\±@£¢¤¬¦²³¼½¾" +map +altgr -shift 0x18..0x1B 0x2B ~ "§¶[]}" +map +altgr -shift 0x27..0x28 ~ "~{" +map +altgr -shift 0x32..0x33 0x35 ~ "µ¯´" + +# +# Combined accents +# + +map -altgr -shift 0x1A ~ 0x0302 # COMBINING CIRCUMFLEX ACCENT +map -altgr -shift 0x1B ~ 0x0327 # COMBINING CEDILLA +map -altgr +shift 0x1B ~ 0x0308 # COMBINING DIAERESIS +map -altgr -shift 0x28 ~ 0x0300 # COMBINING GRAVE ACCENT +map +altgr -shift 0x35 ~ 0x0301 # COMBINING ACUTE ACCENT diff --git a/src/protocols/spice/keymaps/fr_ch_qwertz.keymap b/src/protocols/spice/keymaps/fr_ch_qwertz.keymap new file mode 100644 index 0000000000..bd41387193 --- /dev/null +++ b/src/protocols/spice/keymaps/fr_ch_qwertz.keymap @@ -0,0 +1,68 @@ +# +# 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. +# + +parent "base" +name "fr-ch-qwertz" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0C ~ "§1234567890'" +map -caps -altgr -shift 0x10..0x1A ~ "qwertzuiopè" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "asdfghjkléà$" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "YXCVBNM;:_" + +map +caps -altgr -shift 0x29 0x02..0x0C ~ "§1234567890'" +map +caps -altgr -shift 0x10..0x1A ~ "QWERTZUIOPè" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "ASDFGHJKLéà$" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "yxcvbnm;:_" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x02..0x04 ~ "¦@#" +map +altgr -shift 0x07..0x09 ~ "¬|¢" +map +altgr -shift 0x1A 0x1B ~ "[]" +map +altgr -shift 0x28 0x2B ~ "{}" +map +altgr -shift 0x56 ~ "\" + +map +altgr -shift 0x12 ~ "€" + +# +# Dead keys +# + +map -altgr -shift 0x1B ~ 0xFE57 # Dead umlaut +map +altgr -shift 0x0C ~ 0xFE51 # Dead acute +map -altgr -shift 0x0D ~ 0xFE52 # Dead circumflex +map -altgr +shift 0x0D ~ 0xFE50 # Dead grave +map +altgr -shift 0x0D ~ 0xFE53 # Dead tilde + diff --git a/src/protocols/spice/keymaps/fr_fr_azerty.keymap b/src/protocols/spice/keymaps/fr_fr_azerty.keymap new file mode 100644 index 0000000000..09101a7f76 --- /dev/null +++ b/src/protocols/spice/keymaps/fr_fr_azerty.keymap @@ -0,0 +1,65 @@ +# +# 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. +# + +parent "base" +name "fr-fr-azerty" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0D ~ "²&é"'(-è_çà)=" +map -caps -altgr -shift 0x10..0x19 0x1B ~ "azertyuiop$" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "qsdfghjklmù*" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "WXCVBN?./§" + +map +caps -altgr -shift 0x29 0x02..0x0D ~ "²1234567890°+" +map +caps -altgr -shift 0x10..0x19 0x1B ~ "AZERTYUIOP£" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "QSDFGHJKLM%µ" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "wxcvbn,;:!" + +# +# Keys requiring AltGr (unaffected by Caps Lock, but Shift must not be pressed) +# + +map +altgr -shift 0x03..0x0D ~ "~#{[|`\^@]}" + +map +altgr -shift 0x12 ~ "€" +map +altgr -shift 0x1B ~ "¤" + +# +# Dead keys (affected by Caps Lock and Shift) +# + +map -caps -altgr -shift 0x1A ~ 0xFE52 # Dead circumflex +map -caps -altgr +shift 0x1A ~ 0xFE57 # Dead umlaut + +map +caps -altgr -shift 0x1A ~ 0xFE57 # Dead umlaut +map +caps -altgr +shift 0x1A ~ 0xFE52 # Dead circumflex + diff --git a/src/protocols/spice/keymaps/generate.pl b/src/protocols/spice/keymaps/generate.pl new file mode 100755 index 0000000000..7b884dee53 --- /dev/null +++ b/src/protocols/spice/keymaps/generate.pl @@ -0,0 +1,259 @@ +#!/usr/bin/env perl +# +# 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. +# + +# +# generate.pl +# +# Parse .keymap files, producing corresponding .c files that can be included +# into the Spice plugin source. +# + +# We need at least Perl 5.8 for Unicode's sake +use 5.008; + +sub keymap_symbol { + my $name = shift; + $name =~ s/-/_/g; + return 'guac_spice_keymap_' . $name; +} + +# +# _generated_keymaps.c +# + +my @keymaps = (); + +open OUTPUT, ">", "_generated_keymaps.c"; +print OUTPUT + '#include "config.h"' . "\n" + . '#include "keymap.h"' . "\n" + . "\n" + . '#include ' . "\n" + . '#include ' . "\n" + . "\n"; + +for my $filename (@ARGV) { + + my $content = ""; + my $parent = ""; + my $layout_name = ""; + + # Parse file + open INPUT, '<', "$filename"; + binmode INPUT, ":encoding(utf8)"; + while () { + + chomp; + + # Comments + if (m/^\s*#/) {} + + # Name + elsif ((my $name) = m/^\s*name\s+"(.*)"\s*(?:#.*)?$/) { + $layout_name = $name; + } + + # Parent map + elsif ((my $name) = m/^\s*parent\s+"(.*)"\s*(?:#.*)?$/) { + $parent = keymap_symbol($name); + } + + # Map + elsif ((my $range, my $onto) = + m/^\s*map\s+([^~]*)\s+~\s+(".*"|0x[0-9A-Fa-f]+)\s*(?:#.*)?$/) { + + my @keysyms = (); + my @scancodes = (); + + my $ext_flags = 0; + my $set_shift = 0; + my $set_altgr = 0; + my $set_caps = 0; + my $set_num = 0; + + my $clear_shift = 0; + my $clear_altgr = 0; + my $clear_caps = 0; + my $clear_num = 0; + + # Parse ranges and options + foreach $_ (split(/\s+/, $range)) { + + # Set option/modifier + if ((my $opt) = m/^\+([a-z]+)$/) { + if ($opt eq "shift") { $set_shift = 1; } + elsif ($opt eq "altgr") { $set_altgr = 1; } + elsif ($opt eq "caps") { $set_caps = 1; } + elsif ($opt eq "num") { $set_num = 1; } + elsif ($opt eq "ext") { $ext_flags = 1; } + else { + die "$filename: $.: ERROR: " + . "Invalid set option\n"; + } + } + + # Clear option/modifier + elsif ((my $opt) = m/^-([a-z]+)$/) { + if ($opt eq "shift") { $clear_shift = 1; } + elsif ($opt eq "altgr") { $clear_altgr = 1; } + elsif ($opt eq "caps") { $clear_caps = 1; } + elsif ($opt eq "num") { $clear_num = 1; } + else { + die "$filename: $.: ERROR: " + . "Invalid clear option\n"; + } + } + + # Single scancode + elsif ((my $scancode) = m/^(0x[0-9A-Fa-f]+)$/) { + $scancodes[++$#scancodes] = hex($scancode); + } + + # Range of scancodes + elsif ((my $start, my $end) = + m/^(0x[0-9A-Fa-f]+)\.\.(0x[0-9A-Fa-f]+)$/) { + for (my $i=hex($start); $i<=hex($end); $i++) { + $scancodes[++$#scancodes] = $i; + } + } + + # Invalid token + else { + die "$filename: $.: ERROR: " + . "Invalid token\n"; + } + + } + + # Parse onto + if ($onto =~ m/^0x/) { + $keysyms[0] = hex($onto); + } + else { + foreach my $char (split('', + substr($onto, 1, length($onto)-2))) { + my $codepoint = ord($char); + if ($codepoint >= 0x0100) { + $keysyms[++$#keysyms] = 0x01000000 | $codepoint; + } + else { + $keysyms[++$#keysyms] = $codepoint; + } + } + } + + # Check mapping + if ($#keysyms != $#scancodes) { + die "$filename: $.: ERROR: " + . "Keysym and scancode range lengths differ\n"; + } + + # Write keysym/scancode pairs + for (my $i=0; $i<=$#keysyms; $i++) { + + # Encode "extended" scancodes (those requiring a leading 0xE0 + # byte on the wire) using the 0x100 bit, matching the encoding + # expected by spice-gtk's spice_inputs_channel_key_press(). + my $scancode = $scancodes[$i]; + $scancode |= 0x100 if $ext_flags; + + $content .= " {" + . " .keysym = " . $keysyms[$i] . "," + . " .scancode = " . $scancode; + + # Modifiers that must be active + $content .= ", .set_modifiers = 0"; + $content .= " | GUAC_SPICE_KEYMAP_MODIFIER_SHIFT" if $set_shift; + $content .= " | GUAC_SPICE_KEYMAP_MODIFIER_ALTGR" if $set_altgr; + + # Modifiers that must be inactive + $content .= ", .clear_modifiers = 0"; + $content .= " | GUAC_SPICE_KEYMAP_MODIFIER_SHIFT" if $clear_shift; + $content .= " | GUAC_SPICE_KEYMAP_MODIFIER_ALTGR" if $clear_altgr; + + # Locks that must be set + $content .= ", .set_locks = 0"; + $content .= " | SPICE_INPUTS_NUM_LOCK" if $set_num; + $content .= " | SPICE_INPUTS_CAPS_LOCK" if $set_caps; + + # Locks that must NOT be set + $content .= ", .clear_locks = 0"; + $content .= " | SPICE_INPUTS_NUM_LOCK" if $clear_num; + $content .= " | SPICE_INPUTS_CAPS_LOCK" if $clear_caps; + + $content .= " },\n"; + + } + + } + + # Invalid token + elsif (m/\S/) { + die "$filename: $.: ERROR: " + . "Invalid token\n"; + } + + } + close INPUT; + + # Header + my $sym = keymap_symbol($layout_name); + print OUTPUT + "\n" + . '/* Autogenerated from ' . $filename . ' */' . "\n" + . 'static guac_spice_keysym_desc __' . $sym . '[] = {' . "\n" + . $content + . ' {0}' . "\n" + . '};' . "\n"; + + # Desc header + print OUTPUT "\n" + . 'static const guac_spice_keymap ' . $sym . ' = { ' . "\n"; + + # Layout name + print OUTPUT " .name = \"$layout_name\",\n"; + + # Parent layout (if any) + if ($parent) { + print OUTPUT " .parent = &$parent,\n"; + } + + # Desc footer + print OUTPUT + ' .mapping = __' . $sym . "\n" + . '};' . "\n"; + + $keymaps[++$#keymaps] = $sym; + print STDERR "Added: $layout_name\n"; + +} + +print OUTPUT "\n" + . 'const guac_spice_keymap* GUAC_SPICE_KEYMAPS[] = {' . "\n"; + +foreach my $keymap (@keymaps) { + print OUTPUT " &$keymap,\n"; +} +print OUTPUT + ' NULL' . "\n" + . '};' . "\n"; + +close OUTPUT; + diff --git a/src/protocols/spice/keymaps/hu_hu_qwertz.keymap b/src/protocols/spice/keymaps/hu_hu_qwertz.keymap new file mode 100644 index 0000000000..b559cc4253 --- /dev/null +++ b/src/protocols/spice/keymaps/hu_hu_qwertz.keymap @@ -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. +# + +parent "base" +name "hu-hu-qwertz" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0D ~ "0123456789öüó" +map -caps -altgr -shift 0x10..0x1B ~ "qwertzuıopőú" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "asdfghjkléáű" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "íyxcvbnm,.-" + +map -caps -altgr +shift 0x29 0x02..0x0D ~ "§'"+!%/=()ÖÜÓ" +map -caps -altgr +shift 0x10..0x1B ~ "QWERTZUIOPŐÚ" +map -caps -altgr +shift 0x1E..0x28 0x2B ~ "ASDFGHJKLÉÁŰ" +map -caps -altgr +shift 0x56 0x2C..0x35 ~ "ÍYXCVBNM?:_" + +map +caps -altgr -shift 0x29 0x02..0x0D ~ "0123456789ÖÜÓ" +map +caps -altgr -shift 0x10..0x1B ~ "QWERTZUIOPŐÚ" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "ASDFGHJKLÉÁŰ" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "ÍYXCVBNM,.-" + +map +caps -altgr +shift 0x29 0x02..0x0D ~ "§'"+!%/=()öüó" +map +caps -altgr +shift 0x10..0x1B ~ "qwertzuiopőú" +map +caps -altgr +shift 0x1E..0x28 0x2B ~ "asdfghjkléáű" +map +caps -altgr +shift 0x56 0x2C..0x35 ~ "íyxcvbnm?:_" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x02 ~ "~" +map +altgr -shift 0x08 ~ "`" + +map +altgr -shift 0x10 ~ "\" +map +altgr -shift 0x11 ~ "|" +map +altgr -shift 0x12 ~ "Ä" +map +altgr -shift 0x16 ~ "€" +map +altgr -shift 0x17 ~ "Í" +map +altgr -shift 0x1A ~ "÷" +map +altgr -shift 0x1B ~ "×" + +map +altgr -shift 0x1E ~ "ä" +map +altgr -shift 0x1F ~ "đ" +map +altgr -shift 0x20 ~ "Đ" +map +altgr -shift 0x21 ~ "[" +map +altgr -shift 0x22 ~ "]" +map +altgr -shift 0x24 ~ "í" +map +altgr -shift 0x25 ~ "ł" +map +altgr -shift 0x26 ~ "Ł" +map +altgr -shift 0x27 ~ "$" +map +altgr -shift 0x28 ~ "ß" +map +altgr -shift 0x2B ~ "¤" + +map +altgr -shift 0x56 ~ "<" +map +altgr -shift 0x2C ~ ">" +map +altgr -shift 0x2D ~ "#" +map +altgr -shift 0x2E ~ "&" +map +altgr -shift 0x2F ~ "@" +map +altgr -shift 0x30 ~ "{" +map +altgr -shift 0x31 ~ "}" +map +altgr -shift 0x32 ~ "<" +map +altgr -shift 0x33 ~ ";" +map +altgr -shift 0x34 ~ ">" +map +altgr -shift 0x35 ~ "*" + + +# +# Keys requiring AltGr & Shift +# + + +# +# Dead keys +# + +map +altgr -shift 0x03 ~ 0xFE5A # Dead caron +map +altgr -shift 0x04 ~ 0xFE52 # Dead circumflex +map +altgr -shift 0x05 ~ 0xFE55 # Dead breve +map +altgr -shift 0x06 ~ 0xFE58 # Dead abovering +map +altgr -shift 0x07 ~ 0xFE5C # Dead ogonek +map +altgr -shift 0x09 ~ 0xFE56 # Dead abovedot +map +altgr -shift 0x0A ~ 0xFE51 # Dead acute +map +altgr -shift 0x0B ~ 0xFE59 # Dead doubleacute +map +altgr -shift 0x0C ~ 0xFE57 # Dead diaeresis +map +altgr -shift 0x0D ~ 0xFE5B # Dead cedilla + + +# END diff --git a/src/protocols/spice/keymaps/it_it_qwerty.keymap b/src/protocols/spice/keymaps/it_it_qwerty.keymap new file mode 100644 index 0000000000..74da0bf9e0 --- /dev/null +++ b/src/protocols/spice/keymaps/it_it_qwerty.keymap @@ -0,0 +1,59 @@ +# +# 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. +# + +parent "base" +name "it-it-qwerty" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0D ~ "\1234567890'ì" +map -caps -altgr -shift 0x10..0x1B ~ "qwertyuiopè+" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "asdfghjklòàù" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "ZXCVBNM;:_" + +map +caps -altgr -shift 0x29 0x02..0x0D ~ "\1234567890'ì" +map +caps -altgr -shift 0x10..0x1B ~ "QWERTYUIOPè+" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "ASDFGHJKLòàù" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "zxcvbnm;:_" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x12 ~ "€" +map +altgr -shift 0x1A ~ "[" +map +altgr -shift 0x1B ~ "]" +map +altgr -shift 0x27 ~ "@" +map +altgr -shift 0x28 ~ "#" + +map +altgr +shift 0x1A ~ "{" +map +altgr +shift 0x1B ~ "}" + diff --git a/src/protocols/spice/keymaps/ja_jp_qwerty.keymap b/src/protocols/spice/keymaps/ja_jp_qwerty.keymap new file mode 100644 index 0000000000..dc879fb71a --- /dev/null +++ b/src/protocols/spice/keymaps/ja_jp_qwerty.keymap @@ -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. +# + +parent "base" +name "ja-jp-qwerty" + +map -shift 0x02..0x0D 0x7D ~ "1234567890-^\" +map -shift 0x10..0x1B ~ "qwertyuiop@[" +map -shift 0x1E..0x28 0x2B ~ "asdfghjkl;:]" +map -shift 0x2C..0x35 0x73 ~ "zxcvbnm,./\" + +map +shift 0x02..0x0A 0x0C 0x0D 0x7D ~ "!"#$%&'()=~|" +map +shift 0x10..0x1B ~ "QWERTYUIOP`{" +map +shift 0x1E..0x28 0x2B ~ "ASDFGHJKL+*}" +map +shift 0x2C..0x35 0x73 ~ "ZXCVBNM<>?_" + +map -shift 0x29 ~ 0xFF28 +map -shift 0x29 ~ 0xFF2A +map +shift 0x29 ~ 0xFF29 diff --git a/src/protocols/spice/keymaps/no_no_qwerty.keymap b/src/protocols/spice/keymaps/no_no_qwerty.keymap new file mode 100644 index 0000000000..0661a58db3 --- /dev/null +++ b/src/protocols/spice/keymaps/no_no_qwerty.keymap @@ -0,0 +1,75 @@ +# +# 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. +# + +parent "base" +name "no-no-qwerty" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0C ~ "|1234567890+" +map -caps -altgr -shift 0x10..0x1A ~ "qwertyuiopå" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "asdfghjkløæ'" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "ZXCVBNM;:_" + +map +caps -altgr -shift 0x29 0x02..0x0C ~ "|1234567890+" +map +caps -altgr -shift 0x10..0x1A ~ "QWERTYUIOPÅ" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "ASDFGHJKLØÆ'" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "zxcvbnm;:_" + +map -altgr -shift 0x0D ~ "\" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x03 ~ "@" +map +altgr -shift 0x04 ~ "£" +map +altgr -shift 0x05 ~ "$" +map +altgr -shift 0x08 ~ "{" +map +altgr -shift 0x09 ~ "[" +map +altgr -shift 0x0A ~ "]" +map +altgr -shift 0x0B ~ "}" +map +altgr -shift 0x56 ~ "\" + +map +altgr -shift 0x12 ~ "€" + +map +altgr -shift 0x0D ~ "|" +map +altgr -shift 0x32 ~ "µ" + +# +# Dead keys +# + +map +altgr -shift 0x0D ~ 0xFE51 # Dead acute +map -altgr +shift 0x0D ~ 0xFE50 # Dead grave +map -altgr -shift 0x1B ~ 0xFE57 # Dead umlaut +map -altgr +shift 0x1B ~ 0xFE52 # Dead circumflex +map +altgr -shift 0x1B ~ 0xFE53 # Dead tilde diff --git a/src/protocols/spice/keymaps/pl_pl_qwerty.keymap b/src/protocols/spice/keymaps/pl_pl_qwerty.keymap new file mode 100644 index 0000000000..1072971ec3 --- /dev/null +++ b/src/protocols/spice/keymaps/pl_pl_qwerty.keymap @@ -0,0 +1,63 @@ +# +# 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. +# + +parent "base" +name "pl-pl-qwerty" + +map -caps -shift 0x29 0x02..0x0D ~ "`1234567890-=" +map -caps -shift 0x10..0x1B 0x2B ~ "qwertyuiop[]\" +map -caps -shift 0x1E..0x28 ~ "asdfghjkl;'" +map -caps -shift 0x2C..0x35 ~ "zxcvbnm,./" + +map -caps +shift 0x29 0x02..0x0D ~ "~!@#$%^&*()_+" +map -caps +shift 0x10..0x1B 0x2B ~ "QWERTYUIOP{}|" +map -caps +shift 0x1E..0x28 ~ "ASDFGHJKL:"" +map -caps +shift 0x2C..0x35 ~ "ZXCVBNM<>?" + +map +caps -shift 0x29 0x02..0x0D ~ "`1234567890-=" +map +caps -shift 0x10..0x1B 0x2B ~ "QWERTYUIOP[]\" +map +caps -shift 0x1E..0x28 ~ "ASDFGHJKL;'" +map +caps -shift 0x2C..0x35 ~ "ZXCVBNM,./" + +map +caps +shift 0x29 0x02..0x0D ~ "~!@#$%^&*()_+" +map +caps +shift 0x10..0x1B 0x2B ~ "qwertyuiop{}|" +map +caps +shift 0x1E..0x28 ~ "asdfghjkl:"" +map +caps +shift 0x2C..0x35 ~ "zxcvbnm<>?" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x16 ~ "€" + +map -caps -shift +altgr 0x12 0x18 ~ "ęó" +map -caps -shift +altgr 0x1E 0x1F 0x26 ~ "ąśł" +map -caps -shift +altgr 0x2C 0x2D 0x2E 0x31 ~ "żźćń" + +map -caps +shift +altgr 0x12 0x18 ~ "ĘÓ" +map -caps +shift +altgr 0x1E 0x1F 0x26 ~ "ĄŚŁ" +map -caps +shift +altgr 0x2C 0x2D 0x2E 0x31 ~ "ŻŹĆŃ" + +map +caps -shift +altgr 0x12 0x18 ~ "ĘÓ" +map +caps -shift +altgr 0x1E 0x1F 0x26 ~ "ĄŚŁ" +map +caps -shift +altgr 0x2C 0x2D 0x2E 0x31 ~ "ŻŹĆŃ" + +map +caps +shift +altgr 0x12 0x18 ~ "ęó" +map +caps +shift +altgr 0x1E 0x1F 0x26 ~ "ąśł" +map +caps +shift +altgr 0x2C 0x2D 0x2E 0x31 ~ "żźćń" diff --git a/src/protocols/spice/keymaps/pt_br_qwerty.keymap b/src/protocols/spice/keymaps/pt_br_qwerty.keymap new file mode 100644 index 0000000000..b1d0a2efb4 --- /dev/null +++ b/src/protocols/spice/keymaps/pt_br_qwerty.keymap @@ -0,0 +1,66 @@ +# +# 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. +# + +parent "base" +name "pt-br-qwerty" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0D ~ "'1234567890-=" +map -caps -altgr -shift 0x10..0x19 0x1B ~ "qwertyuiop[" +map -caps -altgr -shift 0x1E..0x27 0x2B ~ "asdfghjklç]" +map -caps -altgr -shift 0x56 0x2C..0x35 0x73 ~ "\zxcvbnm,.;/" + +map -caps -altgr +shift 0x29 0x02..0x06 0x08..0x0D ~ ""!@#$%&*()_+" +map -caps -altgr +shift 0x10..0x19 0x1B ~ "QWERTYUIOP{" +map -caps -altgr +shift 0x1E..0x27 0x2B ~ "ASDFGHJKLÇ}" +map -caps -altgr +shift 0x56 0x2C..0x35 0x73 ~ "|ZXCVBNM<>:?" + +map +caps -altgr -shift 0x29 0x02..0x0D ~ "'1234567890-=" +map +caps -altgr -shift 0x10..0x19 0x1B ~ "QWERTYUIOP[" +map +caps -altgr -shift 0x1E..0x27 0x2B ~ "ASDFGHJKLÇ]" +map +caps -altgr -shift 0x56 0x2C..0x35 0x73 ~ "\ZXCVBNM,.;/" + +map +caps -altgr +shift 0x29 0x02..0x06 0x08..0x0D ~ ""!@#$%&*()_+" +map +caps -altgr +shift 0x10..0x19 0x1B ~ "qwertyuiop{" +map +caps -altgr +shift 0x1E..0x27 0x2B ~ "asdfghjklç}" +map +caps -altgr +shift 0x56 0x2C..0x35 0x73 ~ "|zxcvbnm<>:?" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x02..0x07 0x0D ~ "¹²³£¢¬§" +map +altgr -shift 0x10..0x11 ~ "/?" +map +altgr -shift 0x12 ~ "°" +map +altgr -shift 0x1B ~ "ª" +map +altgr -shift 0x2B ~ "º" +map +altgr -shift 0x2E ~ "₢" + +# +# Dead keys +# + +map -altgr +shift 0x07 ~ 0xFE57 # Dead diaeresis (umlaut) +map -altgr +shift 0x1A ~ 0xFE50 # Dead grave +map -altgr -shift 0x1A ~ 0xFE51 # Dead acute +map -altgr +shift 0x28 ~ 0xFE52 # Dead circumflex +map -altgr -shift 0x28 ~ 0xFE53 # Dead tilde \ No newline at end of file diff --git a/src/protocols/spice/keymaps/sv_se_qwerty.keymap b/src/protocols/spice/keymaps/sv_se_qwerty.keymap new file mode 100644 index 0000000000..9edfccb3d3 --- /dev/null +++ b/src/protocols/spice/keymaps/sv_se_qwerty.keymap @@ -0,0 +1,74 @@ +# +# 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. +# + +parent "base" +name "sv-se-qwerty" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0C ~ "§1234567890+" +map -caps -altgr -shift 0x10..0x1A ~ "qwertyuiopå" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "asdfghjklöä'" +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "ZXCVBNM;:_" + +map +caps -altgr -shift 0x29 0x02..0x0C ~ "§1234567890+" +map +caps -altgr -shift 0x10..0x1A ~ "QWERTYUIOPÅ" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "ASDFGHJKLÖÄ'" +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "zxcvbnm;:_" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x03 ~ "@" +map +altgr -shift 0x04 ~ "£" +map +altgr -shift 0x05 ~ "$" +map +altgr -shift 0x08 ~ "{" +map +altgr -shift 0x09 ~ "[" +map +altgr -shift 0x0A ~ "]" +map +altgr -shift 0x0B ~ "}" +map +altgr -shift 0x0C ~ "\" + +map +altgr -shift 0x12 ~ "€" + +map +altgr -shift 0x56 ~ "|" +map +altgr -shift 0x32 ~ "µ" + +# +# Dead keys +# + +map -altgr -shift 0x0D ~ 0xFE51 # Dead acute +map -altgr +shift 0x0D ~ 0xFE50 # Dead grave +map -altgr -shift 0x1B ~ 0xFE57 # Dead umlaut +map -altgr +shift 0x1B ~ 0xFE52 # Dead circumflex +map +altgr -shift 0x1B ~ 0xFE53 # Dead tilde + diff --git a/src/protocols/spice/keymaps/tr_tr_qwerty.keymap b/src/protocols/spice/keymaps/tr_tr_qwerty.keymap new file mode 100644 index 0000000000..5df30005ec --- /dev/null +++ b/src/protocols/spice/keymaps/tr_tr_qwerty.keymap @@ -0,0 +1,92 @@ +# +# 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. +# + +parent "base" +name "tr-tr-qwerty" + +# +# Basic keys +# + +map -caps -altgr -shift 0x29 0x02..0x0D ~ ""1234567890*-" +map -caps -altgr -shift 0x10..0x1B ~ "qwertyuıopğü" +map -caps -altgr -shift 0x1E..0x28 0x2B ~ "asdfghjklşi," +map -caps -altgr -shift 0x56 0x2C..0x35 ~ "ZXCVBNMÖÇ:" + +map +caps -altgr -shift 0x29 0x02..0x0D ~ ""1234567890*-" +map +caps -altgr -shift 0x10..0x1B ~ "QWERTYUIOPĞÜ" +map +caps -altgr -shift 0x1E..0x28 0x2B ~ "ASDFGHJKLŞİ," +map +caps -altgr -shift 0x56 0x2C..0x35 ~ "zxcvbnmöç:" + +# +# Keys requiring AltGr +# + +map +altgr -shift 0x29 0x02..0x06 ~ "<>£#$½" +map +altgr -shift 0x08..0x0D ~ "{[]}\|" + +map +altgr -shift 0x10 ~ "@" +map +altgr -shift 0x12 ~ "€" +map +altgr -shift 0x14 ~ "₺" +map +altgr -shift 0x1F ~ "ß" + +map +altgr -shift 0x56 ~ "|" + + +# +# Keys requiring AltGr and vary by Shift and Caps Lock +# + +map -caps +altgr -shift 0x17 ~ "i" +map -caps +altgr -shift 0x1E ~ "æ" + +map -caps +altgr +shift 0x17 ~ "İ" +map -caps +altgr +shift 0x1E ~ "Æ" + +map +caps +altgr -shift 0x17 ~ "İ" +map +caps +altgr -shift 0x1E ~ "Æ" + +map +caps +altgr +shift 0x1E ~ "æ" +map +caps +altgr +shift 0x17 ~ "i" + +# +# Dead keys +# + +map -altgr +shift 0x04 ~ 0xFE52 # Dead circumflex + +map +altgr -shift 0x1A ~ 0xFE57 # Dead diaeresis (umlaut) +map +altgr -shift 0x1B ~ 0xFE53 # Dead tilde + +map +altgr -shift 0x27 ~ 0xFE51 # Dead acute +map +altgr -shift 0x2B ~ 0xFE50 # Dead grave + +# END diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c index 07ef82963e..5a1bdac421 100644 --- a/src/protocols/spice/settings.c +++ b/src/protocols/spice/settings.c @@ -20,6 +20,7 @@ #include "config.h" #include "argv.h" +#include "keymap.h" #include "settings.h" #include @@ -43,6 +44,7 @@ const char* GUAC_SPICE_CLIENT_ARGS[] = { "ignore-cert", "cert-subject", "color-depth", + "server-layout", "read-only", "disable-display-resize", "disable-copy", @@ -143,6 +145,12 @@ enum SPICE_ARGS_IDX { */ IDX_COLOR_DEPTH, + /** + * The name of the keyboard layout to use when translating keysyms into + * SPICE scancodes (e.g. "en-us-qwerty", "es-es-qwerty", "es-latam-qwerty"). + */ + IDX_SERVER_LAYOUT, + /** * "true" if this connection should be read-only, "false" or blank * otherwise. @@ -400,6 +408,10 @@ guac_spice_settings* guac_spice_parse_args(guac_user* user, guac_user_parse_args_int(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_COLOR_DEPTH, 0); + settings->server_layout = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SERVER_LAYOUT, GUAC_SPICE_DEFAULT_KEYMAP); + settings->read_only = guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_READ_ONLY, false); @@ -578,6 +590,7 @@ void guac_spice_settings_free(guac_spice_settings* settings) { guac_mem_free(settings->password); guac_mem_free(settings->ca_file); guac_mem_free(settings->cert_subject); + guac_mem_free(settings->server_layout); guac_mem_free(settings->drive_path); guac_mem_free(settings->recording_name); guac_mem_free(settings->recording_path); diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h index 6325bb59a2..fcf8dd96b5 100644 --- a/src/protocols/spice/settings.h +++ b/src/protocols/spice/settings.h @@ -116,6 +116,12 @@ typedef struct guac_spice_settings { */ int color_depth; + /** + * The name of the keyboard layout used to translate keysyms into SPICE + * scancodes (e.g. "en-us-qwerty", "es-es-qwerty", "es-latam-qwerty"). + */ + char* server_layout; + /** * Whether this connection is read-only, and user input should be dropped. */ diff --git a/src/protocols/spice/spice.c b/src/protocols/spice/spice.c index 8d8076386f..fd7fbc30a5 100644 --- a/src/protocols/spice/spice.c +++ b/src/protocols/spice/spice.c @@ -206,17 +206,34 @@ void* guac_spice_client_thread(void* data) { } } - /* Create a private GLib main context for this connection and make it the - * thread-default so that all SPICE channels created on this thread - * dispatch their signals within our event loop */ - spice_client->main_context = g_main_context_new(); - g_main_context_push_thread_default(spice_client->main_context); - spice_client->main_loop = g_main_loop_new(spice_client->main_context, FALSE); + /* Drive all SPICE channel I/O from this thread's default GLib main context. + * spice-gtk schedules each channel's connection coroutine on the default + * main context, so the event loop we run below must be that same context. + * guacd forks a dedicated process per connection, so the default context is + * private to this connection and safe to use. */ + spice_client->main_context = NULL; + spice_client->main_loop = g_main_loop_new(NULL, FALSE); /* Allocate and configure the SPICE session */ spice_client->spice_session = spice_session_new(); guac_spice_session_configure(client, spice_client->spice_session); + /* Allocate keyboard, translating keysyms to scancodes according to the + * keyboard layout requested by the connection (falling back to the default + * layout if the requested layout is unknown) */ + const guac_spice_keymap* keymap = + guac_spice_keymap_find(settings->server_layout); + if (keymap == NULL) { + guac_client_log(client, GUAC_LOG_WARNING, "Unknown keyboard layout " + "\"%s\". Falling back to \"%s\".", + settings->server_layout, GUAC_SPICE_DEFAULT_KEYMAP); + keymap = guac_spice_keymap_find(GUAC_SPICE_DEFAULT_KEYMAP); + } + + pthread_rwlock_wrlock(&spice_client->lock); + spice_client->keyboard = guac_spice_keyboard_alloc(client, keymap); + pthread_rwlock_unlock(&spice_client->lock); + /* Dispatch newly-created and destroyed channels */ g_signal_connect(spice_client->spice_session, "channel-new", G_CALLBACK(guac_spice_channel_new), client); @@ -271,7 +288,7 @@ void* guac_spice_client_thread(void* data) { GSource* state_source = g_timeout_source_new(GUAC_SPICE_STATE_CHECK_INTERVAL); g_source_set_callback(state_source, guac_spice_state_check, client, NULL); - g_source_attach(state_source, spice_client->main_context); + g_source_attach(state_source, NULL); /* Run the SPICE event loop until the connection ends */ g_main_loop_run(spice_client->main_loop); @@ -290,9 +307,6 @@ void* guac_spice_client_thread(void* data) { spice_client->render_thread = NULL; } - /* Relinquish the thread-default main context */ - g_main_context_pop_thread_default(spice_client->main_context); - /* Kill client and finish connection */ guac_client_stop(client); guac_client_log(client, GUAC_LOG_INFO, "Internal SPICE client disconnected"); diff --git a/src/protocols/spice/spice.h b/src/protocols/spice/spice.h index b7f5d02a6d..ff189c6ab6 100644 --- a/src/protocols/spice/spice.h +++ b/src/protocols/spice/spice.h @@ -21,6 +21,7 @@ #define GUAC_SPICE_SPICE_H #include "common/clipboard.h" +#include "keyboard.h" #include "settings.h" #include @@ -79,6 +80,27 @@ typedef struct guac_spice_client { */ SpiceInputsChannel* inputs_channel; + /** + * The current keyboard state, including the mapping of keysyms to SPICE + * scancodes for the negotiated keyboard layout. NULL until allocated within + * the SPICE client thread. + */ + guac_spice_keyboard* keyboard; + + /** + * Lock which serializes messages sent to the SPICE server (such as keyboard + * scancode and lock-synchronization events), as these may be generated from + * multiple user input threads concurrently. + */ + pthread_mutex_t message_lock; + + /** + * Lock which guards access to the keyboard state. Input handlers acquire a + * read lock while translating and sending key events, while allocation and + * teardown of the keyboard acquire a write lock. + */ + pthread_rwlock_t lock; + /** * The SPICE cursor channel, providing the remote cursor shape. NULL until * the channel has been opened. From 57f45aad0a54afc3c81dee47c5af931c95a513e9 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Tue, 30 Jun 2026 15:53:07 -0300 Subject: [PATCH 04/36] GUACAMOLE-261: add proxy, TLS public-key pinning, and swap-red-blue params Adds the connection parameters from the upstream draft (GUACAMOLE-261) that map cleanly onto spice-gtk / the guac_display rendering path: * proxy - connect to the SPICE server through a proxy (SpiceSession "proxy" property). * pubkey - base64 (DER) public key to pin the server's TLS certificate against (SpiceSession "pubkey" + SPICE_SESSION_VERIFY_PUBKEY). * swap-red-blue - swap the red/blue channels when copying the remote framebuffer, for servers reporting BGR rather than RGB. The remaining draft parameters are intentionally not ported: "clipboard- encoding" (SPICE vd_agent clipboard is always UTF-8), "encodings"/"cursor" (tied to the deprecated guac_common_surface rendering the draft used, not the guac_display path used here), and "autoretry" (guacd connections are single-shot by design, matching the VNC/RDP modules). Verified: default connections and swap-red-blue=true both render correctly against a QEMU/SPICE desktop. --- src/protocols/spice/auth.c | 15 ++++++++++++++ src/protocols/spice/display.c | 27 +++++++++++++++++++++---- src/protocols/spice/settings.c | 36 ++++++++++++++++++++++++++++++++++ src/protocols/spice/settings.h | 19 ++++++++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/protocols/spice/auth.c b/src/protocols/spice/auth.c index 532a82a290..05eb238dad 100644 --- a/src/protocols/spice/auth.c +++ b/src/protocols/spice/auth.c @@ -37,6 +37,10 @@ void guac_spice_session_configure(guac_client* client, SpiceSession* session) { if (settings->port != NULL) g_object_set(session, "port", settings->port, NULL); + /* Connect through a proxy, if one was specified */ + if (settings->proxy != NULL) + g_object_set(session, "proxy", settings->proxy, NULL); + /* Configure TLS port and verification policy, if TLS is in use */ if (settings->tls && settings->tls_port != NULL) { @@ -50,6 +54,15 @@ void guac_spice_session_configure(guac_client* client, SpiceSession* session) { if (settings->cert_subject != NULL) g_object_set(session, "cert-subject", settings->cert_subject, NULL); + /* Pin the server's expected public key, if given */ + if (!settings->ignore_cert && settings->pubkey != NULL) { + gsize pubkey_len = 0; + guchar* pubkey_der = g_base64_decode(settings->pubkey, &pubkey_len); + GByteArray* pubkey = g_byte_array_new_take(pubkey_der, pubkey_len); + g_object_set(session, "pubkey", pubkey, NULL); + g_byte_array_unref(pubkey); + } + /* Determine certificate verification policy. Self-signed certificates * (as used by default by QEMU/libvirt) require verification to be * disabled entirely. */ @@ -58,6 +71,8 @@ void guac_spice_session_configure(guac_client* client, SpiceSession* session) { verify = 0; else if (settings->cert_subject != NULL) verify = SPICE_SESSION_VERIFY_SUBJECT; + else if (settings->pubkey != NULL) + verify = SPICE_SESSION_VERIFY_PUBKEY; else verify = SPICE_SESSION_VERIFY_HOSTNAME | SPICE_SESSION_VERIFY_PUBKEY; diff --git a/src/protocols/spice/display.c b/src/protocols/spice/display.c index 8f36ce7ba4..b052b1bc0e 100644 --- a/src/protocols/spice/display.c +++ b/src/protocols/spice/display.c @@ -144,10 +144,29 @@ static void guac_spice_display_invalidate(SpiceChannel* channel, size_t row_length = (size_t) width * GUAC_DISPLAY_LAYER_RAW_BPP; - for (int row = 0; row < height; row++) { - memcpy(dst_row, src_row, row_length); - dst_row += context->stride; - src_row += surface_stride; + /* Swap the red and blue channels per-pixel if requested (for the rare + * server which reports BGR instead of RGB); otherwise copy each row + * directly, as the formats are byte-compatible. */ + if (spice_client->settings->swap_red_blue) { + for (int row = 0; row < height; row++) { + for (int col = 0; col < width; col++) { + const unsigned char* sp = src_row + (size_t) col * GUAC_DISPLAY_LAYER_RAW_BPP; + unsigned char* dp = dst_row + (size_t) col * GUAC_DISPLAY_LAYER_RAW_BPP; + dp[0] = sp[2]; + dp[1] = sp[1]; + dp[2] = sp[0]; + dp[3] = sp[3]; + } + dst_row += context->stride; + src_row += surface_stride; + } + } + else { + for (int row = 0; row < height; row++) { + memcpy(dst_row, src_row, row_length); + dst_row += context->stride; + src_row += surface_stride; + } } /* Mark the modified region as dirty */ diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c index 5a1bdac421..e7a560c4d5 100644 --- a/src/protocols/spice/settings.c +++ b/src/protocols/spice/settings.c @@ -43,7 +43,10 @@ const char* GUAC_SPICE_CLIENT_ARGS[] = { "ca-cert", "ignore-cert", "cert-subject", + "pubkey", + "proxy", "color-depth", + "swap-red-blue", "server-layout", "read-only", "disable-display-resize", @@ -140,11 +143,30 @@ enum SPICE_ARGS_IDX { */ IDX_CERT_SUBJECT, + /** + * The base64-encoded (DER) public key which the SPICE server's TLS + * certificate is expected to present, for public-key pinning. + */ + IDX_PUBKEY, + + /** + * The proxy server, if any, to connect through when reaching the SPICE + * server (e.g. "http://proxy.example.com:3128"). + */ + IDX_PROXY, + /** * The color depth to request, in bits. */ IDX_COLOR_DEPTH, + /** + * "true" if the red and blue color channels of the remote framebuffer + * should be swapped, "false" or blank otherwise. Needed for the rare SPICE + * server which reports its surface in BGR rather than RGB order. + */ + IDX_SWAP_RED_BLUE, + /** * The name of the keyboard layout to use when translating keysyms into * SPICE scancodes (e.g. "en-us-qwerty", "es-es-qwerty", "es-latam-qwerty"). @@ -404,10 +426,22 @@ guac_spice_settings* guac_spice_parse_args(guac_user* user, guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_CERT_SUBJECT, NULL); + settings->pubkey = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_PUBKEY, NULL); + + settings->proxy = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_PROXY, NULL); + settings->color_depth = guac_user_parse_args_int(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_COLOR_DEPTH, 0); + settings->swap_red_blue = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SWAP_RED_BLUE, false); + settings->server_layout = guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_SERVER_LAYOUT, GUAC_SPICE_DEFAULT_KEYMAP); @@ -590,6 +624,8 @@ void guac_spice_settings_free(guac_spice_settings* settings) { guac_mem_free(settings->password); guac_mem_free(settings->ca_file); guac_mem_free(settings->cert_subject); + guac_mem_free(settings->pubkey); + guac_mem_free(settings->proxy); guac_mem_free(settings->server_layout); guac_mem_free(settings->drive_path); guac_mem_free(settings->recording_name); diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h index fcf8dd96b5..8618ba5a61 100644 --- a/src/protocols/spice/settings.h +++ b/src/protocols/spice/settings.h @@ -110,12 +110,31 @@ typedef struct guac_spice_settings { */ bool ignore_cert; + /** + * The base64-encoded (DER) public key the SPICE server's TLS certificate is + * expected to present, for public-key pinning, or NULL if pinning should + * not be used. + */ + char* pubkey; + + /** + * The proxy server, if any, through which the SPICE connection should be + * established, or NULL if no proxy should be used. + */ + char* proxy; + /** * The color depth to request, in bits. SPICE negotiates its own surface * format; this value is retained for informational/compatibility purposes. */ int color_depth; + /** + * Whether the red and blue color channels of the remote framebuffer should + * be swapped when copied into the Guacamole display. + */ + bool swap_red_blue; + /** * The name of the keyboard layout used to translate keysyms into SPICE * scancodes (e.g. "en-us-qwerty", "es-es-qwerty", "es-latam-qwerty"). From 2e6ba56337c2354388eaf15828a2277729ccbb3b Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Tue, 30 Jun 2026 16:03:02 -0300 Subject: [PATCH 05/36] GUACAMOLE-261: add audio input (microphone) support via the record channel Forwards audio from the connected Guacamole user to the SPICE server, mirroring the playback path in reverse: * The SPICE record channel is connected (gated on the new "enable-audio-input" parameter), with record-start/record-stop signal handlers that ack the owner's audio input stream so the client starts or stops sending audio when the guest opens or closes its capture device. * The user's audio handler parses the offered audio/L16 mimetype and wires a blob handler that forwards received PCM data to the record channel via spice_record_channel_send_data(). * "enable-audio" is negotiated when either playback or input is requested, since spice-gtk's single property governs both audio channels. Verified against a QEMU/SPICE desktop with an intel-hda duplex codec: capturing audio in the guest (arecord) fires the record-start handler, and the record/playback channels connect as expected. Ported from the draft by Nick Couchman in apache/guacamole-server#394 (GUACAMOLE-261). Co-authored-by: Virtually Nick --- src/protocols/spice/audio.c | 193 +++++++++++++++++++++++++++++++++ src/protocols/spice/audio.h | 37 +++++++ src/protocols/spice/auth.c | 6 +- src/protocols/spice/channels.c | 8 ++ src/protocols/spice/settings.c | 11 ++ src/protocols/spice/settings.h | 6 + src/protocols/spice/spice.h | 14 +++ src/protocols/spice/user.c | 5 + 8 files changed, 278 insertions(+), 2 deletions(-) diff --git a/src/protocols/spice/audio.c b/src/protocols/spice/audio.c index acdf7d760b..bb3df4f458 100644 --- a/src/protocols/spice/audio.c +++ b/src/protocols/spice/audio.c @@ -24,8 +24,17 @@ #include #include +#include +#include +#include +#include #include +#include +#include +#include +#include + /** * The number of bits per audio sample. The SPICE playback channel always * delivers signed 16-bit PCM (SPICE_AUDIO_FMT_S16). @@ -121,3 +130,187 @@ void guac_spice_playback_channel_connect(guac_client* client, G_CALLBACK(guac_spice_playback_stop), client); } + +/** + * Parses the given raw audio mimetype, producing the corresponding rate, + * number of channels, and bytes per sample. Only "audio/L16" (signed 16-bit + * PCM) is supported, matching the format expected by the SPICE record channel. + * + * @return + * Zero if the mimetype was successfully parsed, non-zero otherwise. + */ +static int guac_spice_audio_parse_mimetype(const char* mimetype, int* rate, + int* channels, int* bps) { + + int parsed_rate = -1; + int parsed_channels = 1; + int parsed_bps; + + /* PCM audio with two bytes per sample */ + if (strncmp(mimetype, "audio/L16;", 10) == 0) { + mimetype += 9; /* Advance to semicolon ONLY */ + parsed_bps = 2; + } + + /* Unsupported mimetype */ + else + return 1; + + /* Parse each parameter name/value pair within the mimetype */ + do { + + /* Advance to first character of parameter (current is either a + * semicolon or a comma) */ + mimetype++; + + /* Parse number of channels */ + if (strncmp(mimetype, "channels=", 9) == 0) { + mimetype += 9; + parsed_channels = strtol(mimetype, (char**) &mimetype, 10); + if (errno == EINVAL || errno == ERANGE) + return 1; + } + + /* Parse sample rate */ + else if (strncmp(mimetype, "rate=", 5) == 0) { + mimetype += 5; + parsed_rate = strtol(mimetype, (char**) &mimetype, 10); + if (errno == EINVAL || errno == ERANGE) + return 1; + } + + /* Advance to next parameter */ + mimetype = strchr(mimetype, ','); + + } while (mimetype != NULL); + + /* Mimetype is invalid if rate was not specified */ + if (parsed_rate == -1) + return 1; + + *rate = parsed_rate; + *channels = parsed_channels; + *bps = parsed_bps; + return 0; + +} + +/** + * Handler for "blob" instructions received on the audio input stream. Forwards + * the received PCM audio data to the SPICE record channel. + */ +static int guac_spice_audio_blob_handler(guac_user* user, guac_stream* stream, + void* data, int length) { + + guac_spice_client* spice_client = (guac_spice_client*) user->client->data; + + if (spice_client->record_channel != NULL) + spice_record_channel_send_data(spice_client->record_channel, + data, length, (unsigned long) time(NULL)); + + return 0; + +} + +/** + * Handler for the "end" instruction on the audio input stream. + */ +static int guac_spice_audio_end_handler(guac_user* user, guac_stream* stream) { + /* Nothing to do - the record channel simply stops receiving data */ + return 0; +} + +int guac_spice_client_audio_record_handler(guac_user* user, guac_stream* stream, + char* mimetype) { + + guac_spice_client* spice_client = (guac_spice_client*) user->client->data; + spice_client->audio_input = stream; + + int rate, channels, bps; + + /* Deny the stream if the offered audio format cannot be handled */ + if (guac_spice_audio_parse_mimetype(mimetype, &rate, &channels, &bps)) { + guac_user_log(user, GUAC_LOG_WARNING, "Denying user audio stream with " + "unsupported mimetype: \"%s\"", mimetype); + guac_protocol_send_ack(user->socket, stream, + "Unsupported audio mimetype", + GUAC_PROTOCOL_STATUS_CLIENT_BAD_TYPE); + return 0; + } + + /* Set up handlers for the audio input stream */ + stream->blob_handler = guac_spice_audio_blob_handler; + stream->end_handler = guac_spice_audio_end_handler; + + return 0; + +} + +/** + * Sends an "ack" for the audio input stream to the connection owner, if the + * owner and stream are both present. + */ +static void guac_spice_audio_stream_ack(guac_user* user, guac_stream* stream, + const char* message, guac_protocol_status status) { + + if (user == NULL || stream == NULL) + return; + + guac_protocol_send_ack(user->socket, stream, message, status); + guac_socket_flush(user->socket); + +} + +/** + * Owner callback invoked when the SPICE server begins recording, acknowledging + * the owner's audio input stream so the client starts sending audio. + */ +static void* spice_client_record_start_callback(guac_user* owner, void* data) { + guac_spice_client* spice_client = (guac_spice_client*) data; + guac_spice_audio_stream_ack(owner, spice_client->audio_input, "OK", + GUAC_PROTOCOL_STATUS_SUCCESS); + return NULL; +} + +/** + * Owner callback invoked when the SPICE server stops recording, closing the + * owner's audio input stream so the client stops sending audio. + */ +static void* spice_client_record_stop_callback(guac_user* owner, void* data) { + guac_spice_client* spice_client = (guac_spice_client*) data; + guac_spice_audio_stream_ack(owner, spice_client->audio_input, "CLOSED", + GUAC_PROTOCOL_STATUS_RESOURCE_CLOSED); + return NULL; +} + +void guac_spice_client_audio_record_start_handler(SpiceRecordChannel* channel, + gint format, gint channels, gint rate, guac_client* client) { + guac_client_log(client, GUAC_LOG_DEBUG, "SPICE audio recording started."); + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_client_for_owner(client, spice_client_record_start_callback, spice_client); +} + +void guac_spice_client_audio_record_stop_handler(SpiceRecordChannel* channel, + guac_client* client) { + guac_client_log(client, GUAC_LOG_DEBUG, "SPICE audio recording stopped."); + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_client_for_owner(client, spice_client_record_stop_callback, spice_client); +} + +void guac_spice_record_channel_connect(guac_client* client, + SpiceChannel* channel) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Do nothing if audio input has not been enabled */ + if (!spice_client->settings->audio_input_enabled) + return; + + spice_client->record_channel = SPICE_RECORD_CHANNEL(channel); + + g_signal_connect(channel, "record-start", + G_CALLBACK(guac_spice_client_audio_record_start_handler), client); + g_signal_connect(channel, "record-stop", + G_CALLBACK(guac_spice_client_audio_record_stop_handler), client); + +} diff --git a/src/protocols/spice/audio.h b/src/protocols/spice/audio.h index 8c4e801f9e..00af8af5f9 100644 --- a/src/protocols/spice/audio.h +++ b/src/protocols/spice/audio.h @@ -21,6 +21,8 @@ #define GUAC_SPICE_AUDIO_H #include +#include +#include #include /** @@ -37,4 +39,39 @@ void guac_spice_playback_channel_connect(guac_client* client, SpiceChannel* channel); +/** + * Connects the necessary signal handlers to the given SPICE record channel so + * that audio received from the connected Guacamole user (e.g. a microphone) is + * forwarded to the SPICE server. Does nothing if audio input has not been + * enabled for the connection. + * + * @param client + * The guac_client associated with the SPICE connection. + * + * @param channel + * The SPICE record channel to handle. + */ +void guac_spice_record_channel_connect(guac_client* client, + SpiceChannel* channel); + +/** + * Handler for inbound audio ("audio" instruction) from a Guacamole user, + * establishing an audio input stream whose data will be forwarded to the SPICE + * record channel. + */ +int guac_spice_client_audio_record_handler(guac_user* user, + guac_stream* stream, char* mimetype); + +/** + * Signal handler invoked when the SPICE server begins audio recording. + */ +void guac_spice_client_audio_record_start_handler(SpiceRecordChannel* channel, + gint format, gint channels, gint rate, guac_client* client); + +/** + * Signal handler invoked when the SPICE server stops audio recording. + */ +void guac_spice_client_audio_record_stop_handler(SpiceRecordChannel* channel, + guac_client* client); + #endif diff --git a/src/protocols/spice/auth.c b/src/protocols/spice/auth.c index 05eb238dad..102e53a978 100644 --- a/src/protocols/spice/auth.c +++ b/src/protocols/spice/auth.c @@ -91,8 +91,10 @@ void guac_spice_session_configure(guac_client* client, SpiceSession* session) { /* Reflect read-only state to the session */ g_object_set(session, "read-only", settings->read_only, NULL); - /* Enable or disable audio playback negotiation */ - g_object_set(session, "enable-audio", settings->audio_enabled, NULL); + /* Enable audio negotiation if either playback or input (record) is + * requested; spice-gtk's "enable-audio" governs both audio channels. */ + g_object_set(session, "enable-audio", + settings->audio_enabled || settings->audio_input_enabled, NULL); /* USB redirection and smartcard passthrough are not meaningful for a * headless proxy such as guacd, which has no local physical devices to diff --git a/src/protocols/spice/channels.c b/src/protocols/spice/channels.c index f320435024..781c9b5fcc 100644 --- a/src/protocols/spice/channels.c +++ b/src/protocols/spice/channels.c @@ -145,6 +145,12 @@ void guac_spice_channel_new(SpiceSession* session, SpiceChannel* channel, guac_spice_playback_channel_connect(client, channel); } + /* Record channel: audio input (e.g. microphone) */ + else if (SPICE_IS_RECORD_CHANNEL(channel)) { + guac_client_log(client, GUAC_LOG_DEBUG, "Connecting SPICE record channel."); + guac_spice_record_channel_connect(client, channel); + } + #ifdef ENABLE_SPICE_WEBDAV /* WebDAV channel: folder sharing (driven internally by spice-gtk) */ else if (SPICE_IS_WEBDAV_CHANNEL(channel)) { @@ -181,5 +187,7 @@ void guac_spice_channel_destroy(SpiceSession* session, SpiceChannel* channel, spice_client->inputs_channel = NULL; else if (SPICE_IS_CURSOR_CHANNEL(channel)) spice_client->cursor_channel = NULL; + else if (SPICE_IS_RECORD_CHANNEL(channel)) + spice_client->record_channel = NULL; } diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c index e7a560c4d5..df346c2015 100644 --- a/src/protocols/spice/settings.c +++ b/src/protocols/spice/settings.c @@ -55,6 +55,7 @@ const char* GUAC_SPICE_CLIENT_ARGS[] = { "disable-clipboard", "clipboard-buffer-size", "enable-audio", + "enable-audio-input", "enable-drive", "drive-path", "drive-read-only", @@ -213,6 +214,12 @@ enum SPICE_ARGS_IDX { */ IDX_ENABLE_AUDIO, + /** + * "true" if audio input (e.g. microphone) should be enabled, "false" or + * blank otherwise. + */ + IDX_ENABLE_AUDIO_INPUT, + /** * "true" if folder sharing should be enabled, "false" or blank otherwise. */ @@ -475,6 +482,10 @@ guac_spice_settings* guac_spice_parse_args(guac_user* user, guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_ENABLE_AUDIO, false); + settings->audio_input_enabled = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_ENABLE_AUDIO_INPUT, false); + settings->enable_drive = guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_ENABLE_DRIVE, false); diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h index 8618ba5a61..259aa0d162 100644 --- a/src/protocols/spice/settings.h +++ b/src/protocols/spice/settings.h @@ -182,6 +182,12 @@ typedef struct guac_spice_settings { */ bool audio_enabled; + /** + * Whether audio input (e.g. microphone) from the connected user should be + * forwarded to the SPICE server via the record channel. + */ + bool audio_input_enabled; + /** * Whether folder sharing (shared directory via the SPICE WebDAV channel) * should be enabled. diff --git a/src/protocols/spice/spice.h b/src/protocols/spice/spice.h index ff189c6ab6..b85bba0bcd 100644 --- a/src/protocols/spice/spice.h +++ b/src/protocols/spice/spice.h @@ -214,6 +214,20 @@ typedef struct guac_spice_client { */ int audio_rate; + /** + * The SPICE record channel, used to forward audio (e.g. microphone) input + * from the connected Guacamole user to the SPICE server. NULL until the + * channel has been opened. + */ + SpiceRecordChannel* record_channel; + + /** + * The Guacamole audio input stream over which a user is currently sending + * audio to be forwarded to the SPICE record channel, or NULL if no such + * stream is active. + */ + guac_stream* audio_input; + #ifdef ENABLE_COMMON_SSH /** * The user and credentials used to authenticate for SFTP. diff --git a/src/protocols/spice/user.c b/src/protocols/spice/user.c index 68ae508d79..36a0c243b5 100644 --- a/src/protocols/spice/user.c +++ b/src/protocols/spice/user.c @@ -20,6 +20,7 @@ #include "config.h" #include "argv.h" +#include "audio.h" #include "clipboard.h" #include "common/clipboard.h" #include "input.h" @@ -87,6 +88,10 @@ int guac_spice_user_join_handler(guac_user* user, int argc, char** argv) { if (!settings->disable_paste && !settings->disable_clipboard) user->clipboard_handler = guac_spice_clipboard_handler; + /* Inbound (client-to-server) audio, if enabled */ + if (settings->audio_input_enabled) + user->audio_handler = guac_spice_client_audio_record_handler; + #ifdef ENABLE_COMMON_SSH /* Set generic (non-filesystem) file upload handler */ if (settings->enable_sftp && !settings->sftp_disable_upload) From 542c841611aa3e273e21e45e7a38858fb9776e02 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Tue, 30 Jun 2026 16:26:57 -0300 Subject: [PATCH 06/36] GUACAMOLE-261: add native file transfer (shared-folder file browser) Exposes a host-side directory to the connected Guacamole user as a file browser (upload/download/directory listing) and to the SPICE guest via the WebDAV shared-folder channel, ported from the upstream draft (GUACAMOLE-261) and modeled on the existing SFTP/RDP-drive filesystem. * channels/file.c/.h - guac_spice_folder: a local-directory filesystem exposed as a Guacamole filesystem object, plus an auto-served "Download/" subfolder watched via inotify. * channels/file-ls.c - directory listing (stream-index JSON). * channels/file-download.c - download/get handler + the Download/ monitor. * channels/file-upload.c - upload/put handler. New parameters: file-transfer, file-directory, file-transfer-create-folder, file-transfer-ro, disable-download, disable-upload. When file-transfer is set, spice.c sets the spice-gtk "shared-dir"/"share-dir-ro" properties (taking precedence over the basic enable-drive path), allocates the folder, and exposes it to the connection owner; client.c frees it on disconnect; user.c wires the drag-and-drop upload handler. Two bugs in the ported draft were fixed during integration: * file-upload.c generated upload paths with a leading Windows-style '\' (RDP heritage), which guac_spice_folder_normalize_path() rejects; use '/'. * file.c created the Download/ folder by mkdir()'ing the wrong path (the shared root rather than .../Download) into an undersized strdup'd buffer (a heap overflow via guac_strlcat); use a correctly-sized buffer and the Download path. Verified end to end against a QEMU/SPICE desktop: directory listing, file download, and file upload all work through the exposed filesystem object, the Download/ folder and its inotify monitor initialize cleanly, and SFTP remains available alongside. Ported from the draft by Nick Couchman in apache/guacamole-server#394 (GUACAMOLE-261). Co-authored-by: Virtually Nick --- src/protocols/spice/Makefile.am | 9 + src/protocols/spice/channels/file-download.c | 323 +++++++++ src/protocols/spice/channels/file-download.h | 83 +++ src/protocols/spice/channels/file-ls.c | 127 ++++ src/protocols/spice/channels/file-ls.h | 66 ++ src/protocols/spice/channels/file-upload.c | 263 +++++++ src/protocols/spice/channels/file-upload.h | 72 ++ src/protocols/spice/channels/file.c | 699 +++++++++++++++++++ src/protocols/spice/channels/file.h | 531 ++++++++++++++ src/protocols/spice/client.c | 4 + src/protocols/spice/settings.c | 66 ++ src/protocols/spice/settings.h | 36 + src/protocols/spice/spice.c | 28 + src/protocols/spice/spice.h | 8 + src/protocols/spice/user.c | 7 + 15 files changed, 2322 insertions(+) create mode 100644 src/protocols/spice/channels/file-download.c create mode 100644 src/protocols/spice/channels/file-download.h create mode 100644 src/protocols/spice/channels/file-ls.c create mode 100644 src/protocols/spice/channels/file-ls.h create mode 100644 src/protocols/spice/channels/file-upload.c create mode 100644 src/protocols/spice/channels/file-upload.h create mode 100644 src/protocols/spice/channels/file.c create mode 100644 src/protocols/spice/channels/file.h diff --git a/src/protocols/spice/Makefile.am b/src/protocols/spice/Makefile.am index 3fb05ea4ae..38ebc07a4e 100644 --- a/src/protocols/spice/Makefile.am +++ b/src/protocols/spice/Makefile.am @@ -36,6 +36,10 @@ libguac_client_spice_la_SOURCES = \ auth.c \ audio.c \ channels.c \ + channels/file.c \ + channels/file-download.c \ + channels/file-ls.c \ + channels/file-upload.c \ client.c \ clipboard.c \ cursor.c \ @@ -54,6 +58,10 @@ noinst_HEADERS = \ auth.h \ audio.h \ channels.h \ + channels/file.h \ + channels/file-download.h \ + channels/file-ls.h \ + channels/file-upload.h \ client.h \ clipboard.h \ cursor.h \ @@ -73,6 +81,7 @@ noinst_HEADERS = \ # -pedantic would reject within those system headers. libguac_client_spice_la_CFLAGS = \ -Werror -Wall \ + -I$(srcdir) \ @COMMON_INCLUDE@ \ @COMMON_SSH_INCLUDE@ \ @LIBGUAC_INCLUDE@ \ diff --git a/src/protocols/spice/channels/file-download.c b/src/protocols/spice/channels/file-download.c new file mode 100644 index 0000000000..4afb31d7e5 --- /dev/null +++ b/src/protocols/spice/channels/file-download.c @@ -0,0 +1,323 @@ +/* + * 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 "common/json.h" +#include "file-download.h" +#include "file-ls.h" +#include "file.h" +#include "spice.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +void* guac_spice_file_download_monitor(void* data) { + + guac_spice_folder* folder = (guac_spice_folder*) data; + char download_path[GUAC_SPICE_FOLDER_MAX_PATH]; + char download_events[GUAC_SPICE_FOLDER_MAX_EVENTS]; + char file_path[GUAC_SPICE_FOLDER_MAX_PATH]; + const struct inotify_event *event; + + guac_client_log(folder->client, GUAC_LOG_DEBUG, "%s: Starting up file monitor thread.", __func__); + + /* If folder has already been freed, or isn't open, yet, don't do anything. */ + if (folder == NULL) + return NULL; + + download_path[0] = '\0'; + guac_strlcat(download_path, folder->path, GUAC_SPICE_FOLDER_MAX_PATH); + guac_strlcat(download_path, "/Download", GUAC_SPICE_FOLDER_MAX_PATH); + + guac_client_log(folder->client, GUAC_LOG_DEBUG, "%s: Watching folder at path \"%s\".", __func__, download_path); + + int notify = inotify_init(); + + if (notify == -1) { + guac_client_log(folder->client, GUAC_LOG_ERROR, + "%s: Failed to start inotify, automatic downloads will not work: %s", + __func__, strerror(errno)); + return NULL; + } + + if(inotify_add_watch(notify, download_path, IN_CREATE | IN_ATTRIB | IN_CLOSE_WRITE | IN_MOVED_TO | IN_ONLYDIR | IN_EXCL_UNLINK) == -1) { + guac_client_log(folder->client, GUAC_LOG_ERROR, + "%s: Failed to set inotify flags for \"%s\".", + __func__, download_path); + return NULL; + } + + while (true) { + int events = read(notify, download_events, sizeof(download_events)); + if (events == -1 && errno != EAGAIN) { + guac_client_log(folder->client, GUAC_LOG_ERROR, + "%s: Failed to read inotify events: %s", + __func__, strerror(errno)); + return NULL; + } + + if (events <= 0) + continue; + + + for (char* ptr = download_events; ptr < download_events + events; ptr += sizeof(struct inotify_event) + event->len) { + + event = (const struct inotify_event *) ptr; + + if (event->mask & IN_ISDIR) { + guac_client_log(folder->client, GUAC_LOG_DEBUG, "%s: Ignoring event 0x%x for directory %s.", __func__, event->mask, event->name); + continue; + } + + guac_client_log(folder->client, GUAC_LOG_ERROR, + "%s: 0x%x - Downloading the file: %s", __func__, event->mask, event->name, event->cookie); + + file_path[0] = '\0'; + guac_strlcat(file_path, "/Download/", GUAC_SPICE_FOLDER_MAX_PATH); + guac_strlcat(file_path, event->name, GUAC_SPICE_FOLDER_MAX_PATH); + // guac_client_for_owner(folder->client, guac_spice_file_download_to_user, file_path); + //int fileid = guac_spice_folder_open(folder, file_path, O_WRONLY, 0, 0); + // guac_spice_folder_delete(folder, fileid); + + + } + + } + + return NULL; + +} + +int guac_spice_file_download_ack_handler(guac_user* user, guac_stream* stream, + char* message, guac_protocol_status status) { + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_spice_file_download_status* download_status = (guac_spice_file_download_status*) stream->data; + + /* Get folder, return error if no folder */ + guac_spice_folder* folder = spice_client->shared_folder; + if (folder == NULL) { + guac_protocol_send_ack(user->socket, stream, "FAIL (NO FOLDER)", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + return 0; + } + + /* If successful, read data */ + if (status == GUAC_PROTOCOL_STATUS_SUCCESS) { + + /* Attempt read into buffer */ + char buffer[4096]; + int bytes_read = guac_spice_folder_read(folder, + download_status->file_id, + download_status->offset, buffer, sizeof(buffer)); + + /* If bytes read, send as blob */ + if (bytes_read > 0) { + download_status->offset += bytes_read; + guac_protocol_send_blob(user->socket, stream, + buffer, bytes_read); + } + + /* If EOF, send end */ + else if (bytes_read == 0) { + guac_protocol_send_end(user->socket, stream); + guac_user_free_stream(user, stream); + free(download_status); + } + + /* Otherwise, fail stream */ + else { + guac_user_log(user, GUAC_LOG_ERROR, + "Error reading file for download"); + guac_protocol_send_end(user->socket, stream); + guac_user_free_stream(user, stream); + free(download_status); + } + + guac_socket_flush(user->socket); + + } + + /* Otherwise, return stream to user */ + else + guac_user_free_stream(user, stream); + + return 0; + +} + +int guac_spice_file_download_get_handler(guac_user* user, guac_object* object, + char* name) { + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + int flags = 0; + + /* Get folder, ignore request if no folder */ + guac_spice_folder* folder = spice_client->shared_folder; + if (folder == NULL) + return 0; + + flags |= O_RDONLY; + + guac_user_log(user, GUAC_LOG_DEBUG, "%s: folder->path=%s, name=%s", __func__, folder->path, name); + + /* Attempt to open file for reading */ + int file_id = guac_spice_folder_open(folder, name, flags, 0, 0); + if (file_id < 0) { + guac_user_log(user, GUAC_LOG_INFO, "Unable to read file \"%s\"", + name); + return 0; + } + + /* Get opened file */ + guac_spice_folder_file* file = guac_spice_folder_get_file(folder, file_id); + if (file == NULL) { + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: Successful open produced bad file_id: %i", + __func__, file_id); + return 0; + } + + /* If directory, send contents of directory */ + if (S_ISDIR(file->stmode)) { + + /* Create stream data */ + guac_spice_file_ls_status* ls_status = guac_mem_alloc(sizeof(guac_spice_file_ls_status)); + ls_status->folder = folder; + ls_status->file_id = file_id; + guac_strlcpy(ls_status->directory_name, name, + sizeof(ls_status->directory_name)); + + /* Allocate stream for body */ + guac_stream* stream = guac_user_alloc_stream(user); + stream->ack_handler = guac_spice_file_ls_ack_handler; + stream->data = ls_status; + + /* Init JSON object state */ + guac_common_json_begin_object(user, stream, + &ls_status->json_state); + + /* Associate new stream with get request */ + guac_protocol_send_body(user->socket, object, stream, + GUAC_USER_STREAM_INDEX_MIMETYPE, name); + + } + + /* Otherwise, send file contents if downloads are allowed */ + else if (!folder->disable_download) { + + /* Create stream data */ + guac_spice_file_download_status* download_status = guac_mem_alloc(sizeof(guac_spice_file_download_status)); + download_status->file_id = file_id; + download_status->offset = 0; + + /* Allocate stream for body */ + guac_stream* stream = guac_user_alloc_stream(user); + stream->data = download_status; + stream->ack_handler = guac_spice_file_download_ack_handler; + + /* Associate new stream with get request */ + guac_protocol_send_body(user->socket, object, stream, + "application/octet-stream", name); + + } + + else + guac_client_log(client, GUAC_LOG_INFO, "Unable to download file " + "\"%s\", file downloads have been disabled.", name); + + guac_socket_flush(user->socket); + return 0; +} + +void* guac_spice_file_download_to_user(guac_user* user, void* data) { + + /* Do not bother attempting the download if the user has left */ + if (user == NULL) + return NULL; + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_spice_folder* folder = spice_client->shared_folder; + int flags = 0; + + /* Ignore download if folder has been unloaded */ + if (folder == NULL) + return NULL; + + /* Ignore download if downloads have been disabled */ + if (folder->disable_download) { + guac_client_log(client, GUAC_LOG_WARNING, "A download attempt has " + "been blocked due to downloads being disabled, however it " + "should have been blocked at a higher level. This is likely " + "a bug."); + return NULL; + } + + /* Attempt to open requested file */ + char* path = (char*) data; + flags |= O_RDONLY; + int file_id = guac_spice_folder_open(folder, path, + flags, 0, 0); + + /* If file opened successfully, start stream */ + if (file_id >= 0) { + + /* Associate stream with transfer status */ + guac_stream* stream = guac_user_alloc_stream(user); + guac_spice_file_download_status* download_status = guac_mem_alloc(sizeof(guac_spice_file_download_status)); + stream->data = download_status; + stream->ack_handler = guac_spice_file_download_ack_handler; + download_status->file_id = file_id; + download_status->offset = 0; + + guac_user_log(user, GUAC_LOG_DEBUG, "%s: Initiating download " + "of \"%s\"", __func__, path); + + /* Begin stream */ + guac_protocol_send_file(user->socket, stream, + "application/octet-stream", guac_spice_folder_basename(path)); + guac_socket_flush(user->socket); + + /* Download started successfully */ + return stream; + + } + + /* Download failed */ + guac_user_log(user, GUAC_LOG_ERROR, "Unable to download \"%s\"", path); + return NULL; + +} + diff --git a/src/protocols/spice/channels/file-download.h b/src/protocols/spice/channels/file-download.h new file mode 100644 index 0000000000..b3b518be2f --- /dev/null +++ b/src/protocols/spice/channels/file-download.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_SPICE_FILE_DOWNLOAD_H +#define GUAC_SPICE_FILE_DOWNLOAD_H + +#include "common/json.h" + +#include +#include +#include + +#include + +/** + * The transfer status of a file being downloaded. + */ +typedef struct guac_spice_file_download_status { + + /** + * The file ID of the file being downloaded. + */ + int file_id; + + /** + * The current position within the file. + */ + uint64_t offset; + +} guac_spice_file_download_status; + +/** + * Function which uses Linux's inotify facility to monitor the "Download" + * directory of a shared folder for changes and trigger the automatic download + * of that data to the Guacamole user who has access to the shared folder. + * + * @param data + * A pointer to the guac_spice_folder structure in which the Download + * folder is located. + * + * @return + * Always NULL + */ +void* guac_spice_file_download_monitor(void* data); + +/** + * Handler for acknowledgements of receipt of data related to file downloads. + */ +guac_user_ack_handler guac_spice_file_download_ack_handler; + +/** + * Handler for get messages. In context of downloads and the filesystem exposed + * via the Guacamole protocol, get messages request the body of a file within + * the filesystem. + */ +guac_user_get_handler guac_spice_file_download_get_handler; + +/** + * Callback for guac_client_for_user() and similar functions which initiates a + * file download to a specific user if that user is still connected. The path + * for the file to be downloaded must be passed as the arbitrary data parameter + * for the function invoking this callback. + */ +guac_user_callback guac_spice_file_download_to_user; + +#endif + diff --git a/src/protocols/spice/channels/file-ls.c b/src/protocols/spice/channels/file-ls.c new file mode 100644 index 0000000000..b6bee65f57 --- /dev/null +++ b/src/protocols/spice/channels/file-ls.c @@ -0,0 +1,127 @@ +/* + * 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 "file.h" +#include "file-ls.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +int guac_spice_file_ls_ack_handler(guac_user* user, guac_stream* stream, + char* message, guac_protocol_status status) { + + int blob_written = 0; + const char* filename; + + guac_spice_file_ls_status* ls_status = (guac_spice_file_ls_status*) stream->data; + + guac_user_log(user, GUAC_LOG_DEBUG, "%s: folder=\"%s\"", __func__, ls_status->folder->path); + + /* If unsuccessful, free stream and abort */ + if (status != GUAC_PROTOCOL_STATUS_SUCCESS) { + guac_spice_folder_close(ls_status->folder, ls_status->file_id); + guac_user_free_stream(user, stream); + free(ls_status); + return 0; + } + + /* While directory entries remain */ + while ((filename = guac_spice_folder_read_dir(ls_status->folder, + ls_status->file_id)) != NULL + && !blob_written) { + + char absolute_path[GUAC_SPICE_FOLDER_MAX_PATH]; + + /* Skip current and parent directory entries */ + if (strcmp(filename, ".") == 0 || strcmp(filename, "..") == 0) + continue; + + /* Concatenate into absolute path - skip if invalid */ + if (!guac_spice_folder_append_filename(absolute_path, + ls_status->directory_name, filename)) { + + guac_user_log(user, GUAC_LOG_DEBUG, + "Skipping filename \"%s\" - filename is invalid or " + "resulting path is too long", filename); + + continue; + } + + guac_user_log(user, GUAC_LOG_DEBUG, "%s: absolute_path=\"%s\"", __func__, absolute_path); + + /* Attempt to open file to determine type */ + int flags = (0 | O_RDONLY); + int file_id = guac_spice_folder_open(ls_status->folder, absolute_path, + flags, 0, 0); + if (file_id < 0) + continue; + + /* Get opened file */ + guac_spice_folder_file* file = guac_spice_folder_get_file(ls_status->folder, file_id); + if (file == NULL) { + guac_user_log(user, GUAC_LOG_DEBUG, "%s: Successful open produced " + "bad file_id: %i", __func__, file_id); + return 0; + } + + /* Determine mimetype */ + const char* mimetype; + if (S_ISDIR(file->stmode)) + mimetype = GUAC_USER_STREAM_INDEX_MIMETYPE; + else + mimetype = "application/octet-stream"; + + /* Write entry */ + blob_written |= guac_common_json_write_property(user, stream, + &ls_status->json_state, absolute_path, mimetype); + + guac_spice_folder_close(ls_status->folder, file_id); + + } + + /* Complete JSON and cleanup at end of directory */ + if (filename == NULL) { + + /* Complete JSON object */ + guac_common_json_end_object(user, stream, &ls_status->json_state); + guac_common_json_flush(user, stream, &ls_status->json_state); + + /* Clean up resources */ + guac_spice_folder_close(ls_status->folder, ls_status->file_id); + free(ls_status); + + /* Signal of stream */ + guac_protocol_send_end(user->socket, stream); + guac_user_free_stream(user, stream); + + } + + guac_socket_flush(user->socket); + return 0; + +} + diff --git a/src/protocols/spice/channels/file-ls.h b/src/protocols/spice/channels/file-ls.h new file mode 100644 index 0000000000..f9d00c1f36 --- /dev/null +++ b/src/protocols/spice/channels/file-ls.h @@ -0,0 +1,66 @@ +/* + * 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_RDP_LS_H +#define GUAC_RDP_LS_H + +#include "common/json.h" +#include "file.h" + +#include +#include +#include + +#include + +/** + * The current state of a directory listing operation. + */ +typedef struct guac_spice_file_ls_status { + + /** + * The filesystem associated with the directory being listed. + */ + guac_spice_folder* folder; + + /** + * The file ID of the directory being listed. + */ + int file_id; + + /** + * The absolute path of the directory being listed. + */ + char directory_name[GUAC_SPICE_FOLDER_MAX_PATH]; + + /** + * The current state of the JSON directory object being written. + */ + guac_common_json_state json_state; + +} guac_spice_file_ls_status; + +/** + * Handler for ack messages received due to receipt of a "body" or "blob" + * instruction associated with a directory list operation. + */ +guac_user_ack_handler guac_spice_file_ls_ack_handler; + +#endif + diff --git a/src/protocols/spice/channels/file-upload.c b/src/protocols/spice/channels/file-upload.c new file mode 100644 index 0000000000..63e0bc7be8 --- /dev/null +++ b/src/protocols/spice/channels/file-upload.c @@ -0,0 +1,263 @@ +/* + * 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 "file.h" +#include "spice.h" +#include "file-upload.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +/** + * Writes the given filename to the given upload path, sanitizing the filename + * and translating the filename to the root directory. + * + * @param filename + * The filename to sanitize and move to the root directory. + * + * @param path + * A pointer to a buffer which should receive the sanitized path. The + * buffer must have at least GUAC_SPICE_FOLDER_MAX_PATH bytes available. + */ +static void __generate_upload_path(const char* filename, char* path) { + + int i; + + /* Add initial slash (the shared folder uses absolute, forward-slash + * paths, as required by guac_spice_folder_normalize_path()) */ + *(path++) = '/'; + + for (i=1; iclient; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + int file_id; + char file_path[GUAC_SPICE_FOLDER_MAX_PATH]; + + /* Get filesystem, return error if no filesystem */ + guac_spice_folder* folder = spice_client->shared_folder; + if (folder == NULL) { + guac_protocol_send_ack(user->socket, stream, "FAIL (NO FS)", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + return 0; + } + + /* Ignore upload if uploads have been disabled */ + if (folder->disable_upload) { + guac_client_log(client, GUAC_LOG_WARNING, "A upload attempt has " + "been blocked due to uploads being disabled, however it " + "should have been blocked at a higher level. This is likely " + "a bug."); + guac_protocol_send_ack(user->socket, stream, "FAIL (UPLOAD DISABLED)", + GUAC_PROTOCOL_STATUS_CLIENT_FORBIDDEN); + guac_socket_flush(user->socket); + return 0; + } + + /* Translate name */ + __generate_upload_path(filename, file_path); + + /* Open file */ + file_id = guac_spice_folder_open(folder, file_path, (O_WRONLY | O_CREAT | O_TRUNC), + 1, 0); + if (file_id < 0) { + guac_protocol_send_ack(user->socket, stream, "FAIL (CANNOT OPEN)", + GUAC_PROTOCOL_STATUS_CLIENT_FORBIDDEN); + guac_socket_flush(user->socket); + return 0; + } + + /* Init upload status */ + guac_spice_file_upload_status* upload_status = guac_mem_alloc(sizeof(guac_spice_file_upload_status)); + upload_status->offset = 0; + upload_status->file_id = file_id; + stream->data = upload_status; + stream->blob_handler = guac_spice_file_upload_blob_handler; + stream->end_handler = guac_spice_file_upload_end_handler; + + guac_protocol_send_ack(user->socket, stream, "OK (STREAM BEGIN)", + GUAC_PROTOCOL_STATUS_SUCCESS); + guac_socket_flush(user->socket); + return 0; + +} + +int guac_spice_file_upload_blob_handler(guac_user* user, guac_stream* stream, + void* data, int length) { + + int bytes_written; + guac_spice_file_upload_status* upload_status = (guac_spice_file_upload_status*) stream->data; + + /* Get filesystem, return error if no filesystem */ + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_spice_folder* folder = spice_client->shared_folder; + if (folder == NULL) { + guac_protocol_send_ack(user->socket, stream, "FAIL (NO FOLDER)", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + return 0; + } + + /* Write entire block */ + while (length > 0) { + + /* Attempt write */ + bytes_written = guac_spice_folder_write(folder, upload_status->file_id, + upload_status->offset, data, length); + + /* On error, abort */ + if (bytes_written < 0) { + guac_protocol_send_ack(user->socket, stream, + "FAIL (BAD WRITE)", + GUAC_PROTOCOL_STATUS_CLIENT_FORBIDDEN); + guac_socket_flush(user->socket); + return 0; + } + + /* Update counters */ + upload_status->offset += bytes_written; + data = (char *)data + bytes_written; + length -= bytes_written; + + } + + guac_protocol_send_ack(user->socket, stream, "OK (DATA RECEIVED)", + GUAC_PROTOCOL_STATUS_SUCCESS); + guac_socket_flush(user->socket); + return 0; + +} + +int guac_spice_file_upload_end_handler(guac_user* user, guac_stream* stream) { + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + guac_spice_file_upload_status* upload_status = (guac_spice_file_upload_status*) stream->data; + + /* Get folder, return error if no filesystem */ + guac_spice_folder* folder = spice_client->shared_folder; + if (folder == NULL) { + guac_protocol_send_ack(user->socket, stream, "FAIL (NO FOLDER)", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + return 0; + } + + /* Close file */ + guac_spice_folder_close(folder, upload_status->file_id); + + /* Acknowledge stream end */ + guac_protocol_send_ack(user->socket, stream, "OK (STREAM END)", + GUAC_PROTOCOL_STATUS_SUCCESS); + guac_socket_flush(user->socket); + + free(upload_status); + return 0; + +} + +int guac_spice_file_upload_put_handler(guac_user* user, guac_object* object, + guac_stream* stream, char* mimetype, char* name) { + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Get folder, return error if no filesystem */ + guac_spice_folder* folder = spice_client->shared_folder; + if (folder == NULL) { + guac_protocol_send_ack(user->socket, stream, "FAIL (NO FOLDER)", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + return 0; + } + + /* Ignore upload if uploads have been disabled */ + if (folder->disable_upload) { + guac_client_log(client, GUAC_LOG_WARNING, "A upload attempt has " + "been blocked due to uploads being disabled, however it " + "should have been blocked at a higher level. This is likely " + "a bug."); + guac_protocol_send_ack(user->socket, stream, "FAIL (UPLOAD DISABLED)", + GUAC_PROTOCOL_STATUS_CLIENT_FORBIDDEN); + guac_socket_flush(user->socket); + return 0; + } + + /* Open file */ + int file_id = guac_spice_folder_open(folder, name, (O_WRONLY | O_CREAT | O_TRUNC), + 1, 0); + + /* Abort on failure */ + if (file_id < 0) { + guac_protocol_send_ack(user->socket, stream, "FAIL (CANNOT OPEN)", + GUAC_PROTOCOL_STATUS_CLIENT_FORBIDDEN); + guac_socket_flush(user->socket); + return 0; + } + + /* Init upload stream data */ + guac_spice_file_upload_status* upload_status = guac_mem_alloc(sizeof(guac_spice_file_upload_status)); + upload_status->offset = 0; + upload_status->file_id = file_id; + + /* Allocate stream, init for file upload */ + stream->data = upload_status; + stream->blob_handler = guac_spice_file_upload_blob_handler; + stream->end_handler = guac_spice_file_upload_end_handler; + + /* Acknowledge stream creation */ + guac_protocol_send_ack(user->socket, stream, "OK (STREAM BEGIN)", + GUAC_PROTOCOL_STATUS_SUCCESS); + guac_socket_flush(user->socket); + return 0; +} + diff --git a/src/protocols/spice/channels/file-upload.h b/src/protocols/spice/channels/file-upload.h new file mode 100644 index 0000000000..d17e170bb5 --- /dev/null +++ b/src/protocols/spice/channels/file-upload.h @@ -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. + */ + +#ifndef GUAC_SPICE_FILE_UPLOAD_H +#define GUAC_SPICE_FILE_UPLOAD_H + +#include "common/json.h" + +#include +#include +#include + +#include + +/** + * Structure which represents the current state of an upload. + */ +typedef struct guac_spice_file_upload_status { + + /** + * The overall offset within the file that the next write should + * occur at. + */ + uint64_t offset; + + /** + * The ID of the file being written to. + */ + int file_id; + +} guac_spice_file_upload_status; + +/** + * Handler for inbound files related to file uploads. + */ +guac_user_file_handler guac_spice_file_upload_file_handler; + +/** + * Handler for stream data related to file uploads. + */ +guac_user_blob_handler guac_spice_file_upload_blob_handler; + +/** + * Handler for end-of-stream related to file uploads. + */ +guac_user_end_handler guac_spice_file_upload_end_handler; + +/** + * Handler for put messages. In context of uploads and the filesystem exposed + * via the Guacamole protocol, put messages request write access to a file + * within the filesystem. + */ +guac_user_put_handler guac_spice_file_upload_put_handler; + +#endif + diff --git a/src/protocols/spice/channels/file.c b/src/protocols/spice/channels/file.c new file mode 100644 index 0000000000..6d31e21a18 --- /dev/null +++ b/src/protocols/spice/channels/file.c @@ -0,0 +1,699 @@ +/* + * 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 "file.h" +#include "file-download.h" +#include "file-ls.h" +#include "file-upload.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +/** + * Translates an absolute path for a shared folder to an absolute path which is + * within the real "shared folder" path specified in the connection settings. + * No checking is performed on the path provided, which is assumed to have + * already been normalized and validated as absolute. + * + * @param folder + * The folder containing the file whose path is being translated. + * + * @param virtual_path + * The absolute path to the file on the simulated folder, relative to the + * shared folder root. + * + * @param real_path + * The buffer in which to store the absolute path to the real file on the + * local filesystem. + */ +static void __guac_spice_folder_translate_path(guac_spice_folder* folder, + const char* virtual_path, char* real_path) { + + guac_client_log(folder->client, GUAC_LOG_DEBUG, "%s: virtual_path=\"%s\", drive_path=\"%s\"", __func__, virtual_path, folder->path); + + /* Get drive path */ + char* path = folder->path; + + int i; + + /* Start with path from settings */ + for (i=0; iclient, GUAC_LOG_DEBUG, "%s: virtual_path=\"%s\", real_path=\"%s\"", __func__, virtual_path, real_path); + +} + +guac_spice_folder* guac_spice_folder_alloc(guac_client* client, const char* folder_path, + int create_folder, int disable_download, int disable_upload) { + + guac_client_log(client, GUAC_LOG_DEBUG, "Initializing shared folder at " + "\"%s\".", folder_path); + + /* Create folder if it does not exist */ + if (create_folder) { + guac_client_log(client, GUAC_LOG_DEBUG, + "%s: Creating folder \"%s\" if necessary.", + __func__, folder_path); + + /* Log error if directory creation fails */ + if (mkdir(folder_path, S_IRWXU) && errno != EEXIST) { + guac_client_log(client, GUAC_LOG_ERROR, + "Unable to create folder \"%s\": %s", + folder_path, strerror(errno)); + } + } + + guac_spice_folder* folder = guac_mem_alloc(sizeof(guac_spice_folder)); + + folder->client = client; + folder->path = guac_strdup(folder_path); + folder->file_id_pool = guac_pool_alloc(0); + folder->open_files = 0; + folder->disable_download = disable_download; + folder->disable_upload = disable_upload; + + /* Set up Download directory and watch it. */ + if (!disable_download) { + + guac_client_log(client, GUAC_LOG_DEBUG, "%s: Setting up Download/ folder watch.", __func__); + + if (create_folder) { + guac_client_log(client, GUAC_LOG_DEBUG, "%s: Creating Download/ folder.", + __func__); + + char download_path[GUAC_SPICE_FOLDER_MAX_PATH]; + guac_strlcpy(download_path, folder_path, sizeof(download_path)); + guac_strlcat(download_path, "/Download", sizeof(download_path)); + + if (mkdir(download_path, S_IRWXU) && errno != EEXIST) { + guac_client_log(client, GUAC_LOG_ERROR, + "%s: Unable to create folder \"%s\": %s", __func__, + download_path, strerror(errno)); + } + + } + + if(pthread_create(&(folder->download_thread), NULL, guac_spice_file_download_monitor, (void*) folder)) { + guac_client_log(client, GUAC_LOG_ERROR, + "%s: Unable to create Download folder thread monitor.", __func__); + } + + } + + return folder; + +} + +void guac_spice_folder_free(guac_spice_folder* folder) { + guac_pool_free(folder->file_id_pool); + guac_mem_free(folder->path); + guac_mem_free(folder); +} + +guac_object* guac_spice_folder_alloc_object(guac_spice_folder *folder, guac_user* user) { + + /* Init folder */ + guac_object* folder_object = guac_user_alloc_object(user); + folder_object->get_handler = guac_spice_file_download_get_handler; + + /* Assign upload handler only if uploads are not disabled. */ + if (!folder->disable_upload) + folder_object->put_handler = guac_spice_file_upload_put_handler; + + folder_object->data = folder; + + /* Send filesystem to user */ + guac_protocol_send_filesystem(user->socket, folder_object, "Shared Folder"); + guac_socket_flush(user->socket); + + return folder_object; + +} + +int guac_spice_folder_append_filename(char* fullpath, const char* path, + const char* filename) { + + int i; + + /* Disallow "." as a filename */ + if (strcmp(filename, ".") == 0) + return 0; + + /* Disallow ".." as a filename */ + if (strcmp(filename, "..") == 0) + return 0; + + /* Copy path, append trailing slash */ + for (i=0; i 0 && path[i-1] != '/' && path[i-1] != '\\') + fullpath[i++] = '/'; + break; + } + + /* Copy character if not end of string */ + fullpath[i] = c; + + } + + /* Append filename */ + for (; iclient, GUAC_LOG_DEBUG, + "%s: Ignoring close for bad file_id: %i", + __func__, file_id); + return; + } + + file = &(folder->files[file_id]); + + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: Closed \"%s\" (file_id=%i)", + __func__, file->absolute_path, file_id); + + /* Close directory, if open */ + if (file->dir != NULL) + closedir(file->dir); + + /* Close file */ + close(file->fd); + + /* Free paths */ + guac_mem_free(file->absolute_path); + guac_mem_free(file->real_path); + + /* Free ID back to pool */ + guac_pool_free_int(folder->file_id_pool, file_id); + folder->open_files--; + +} + +int guac_spice_folder_delete(guac_spice_folder* folder, int file_id) { + + /* Get file */ + guac_spice_folder_file* file = guac_spice_folder_get_file(folder, file_id); + if (file == NULL) { + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: Delete of bad file_id: %i", __func__, file_id); + return GUAC_SPICE_FOLDER_EINVAL; + } + + /* If directory, attempt removal */ + if (S_ISDIR(file->stmode)) { + if (rmdir(file->real_path)) { + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: rmdir() failed: \"%s\"", __func__, file->real_path); + return guac_spice_folder_get_errorcode(errno); + } + } + + /* Otherwise, attempt deletion */ + else if (unlink(file->real_path)) { + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: unlink() failed: \"%s\"", __func__, file->real_path); + return guac_spice_folder_get_errorcode(errno); + } + + return 0; + +} + +void* guac_spice_folder_expose(guac_user* user, void* data) { + + guac_spice_folder* folder = (guac_spice_folder*) data; + + guac_user_log(user, GUAC_LOG_DEBUG, "%s: Exposing folder \"%s\" to user.", __func__, folder->path); + + /* No need to expose if there is no folder or the user has left */ + if (user == NULL || folder == NULL) + return NULL; + + /* Allocate and expose folder object for user */ + return guac_spice_folder_alloc_object(folder, user); + +} + +int guac_spice_folder_get_errorcode(int err) { + + /* Translate errno codes to GUAC_SPICE_FOLDER codes */ + switch(err) { + case ENFILE: + return GUAC_SPICE_FOLDER_ENFILE; + + case ENOENT: + return GUAC_SPICE_FOLDER_ENOENT; + + case ENOTDIR: + return GUAC_SPICE_FOLDER_ENOTDIR; + + case ENOSPC: + return GUAC_SPICE_FOLDER_ENOSPC; + + case EISDIR: + return GUAC_SPICE_FOLDER_EISDIR; + + case EACCES: + return GUAC_SPICE_FOLDER_EACCES; + + case EEXIST: + return GUAC_SPICE_FOLDER_EEXIST; + + case EINVAL: + return GUAC_SPICE_FOLDER_EINVAL; + + case ENOSYS: + return GUAC_SPICE_FOLDER_ENOSYS; + + case ENOTSUP: + return GUAC_SPICE_FOLDER_ENOTSUP; + + default: + return GUAC_SPICE_FOLDER_EINVAL; + + } + +} + +guac_spice_folder_file* guac_spice_folder_get_file(guac_spice_folder* folder, + int file_id) { + + /* Validate ID */ + if (file_id < 0 || file_id >= GUAC_SPICE_FOLDER_MAX_FILES) + return NULL; + + /* Return file at given ID */ + return &(folder->files[file_id]); + +} + +int guac_spice_folder_normalize_path(const char* path, char* abs_path) { + + int path_depth = 0; + const char* path_components[GUAC_SPICE_FOLDER_MAX_PATH_DEPTH]; + + /* If original path is not absolute, normalization fails */ + if (path[0] != '/') + return 1; + + /* Create scratch copy of path excluding leading slash (we will be + * replacing path separators with null terminators and referencing those + * substrings directly as path components) */ + char path_scratch[GUAC_SPICE_FOLDER_MAX_PATH - 1]; + int length = guac_strlcpy(path_scratch, path + 1, + sizeof(path_scratch)); + + /* Fail if provided path is too long */ + if (length >= sizeof(path_scratch)) + return 1; + + /* Locate all path components within path */ + const char* current_path_component = &(path_scratch[0]); + for (int i = 0; i <= length; i++) { + + /* If current character is a path separator, parse as component */ + char c = path_scratch[i]; + if (c == '/' || c == '\0') { + + /* Terminate current component */ + path_scratch[i] = '\0'; + + /* If component refers to parent, just move up in depth */ + if (strcmp(current_path_component, "..") == 0) { + if (path_depth > 0) + path_depth--; + } + + /* Otherwise, if component not current directory, add to list */ + else if (strcmp(current_path_component, ".") != 0 + && strcmp(current_path_component, "") != 0) { + + /* Fail normalization if path is too deep */ + if (path_depth >= GUAC_SPICE_FOLDER_MAX_PATH_DEPTH) + return 1; + + path_components[path_depth++] = current_path_component; + + } + + /* Update start of next component */ + current_path_component = &(path_scratch[i+1]); + + } /* end if separator */ + + /* We do not currently support named streams */ + else if (c == ':') + return 1; + + } /* end for each character */ + + /* Add leading slash for resulting absolute path */ + abs_path[0] = '/'; + + /* Append normalized components to path, separated by slashes */ + guac_strljoin(abs_path + 1, path_components, path_depth, + "/", GUAC_SPICE_FOLDER_MAX_PATH - 1); + + return 0; + +} + +int guac_spice_folder_open(guac_spice_folder* folder, const char* path, + int flags, bool overwrite, bool directory) { + + char real_path[GUAC_SPICE_FOLDER_MAX_PATH]; + char normalized_path[GUAC_SPICE_FOLDER_MAX_PATH]; + + struct stat file_stat; + int fd; + int file_id; + guac_spice_folder_file* file; + + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: path=\"%s\", flags=0x%x, overwrite=0x%x, " + "directory=0x%x", __func__, path, flags, overwrite, directory); + + /* If no files available, return too many open */ + if (folder->open_files >= GUAC_SPICE_FOLDER_MAX_FILES) { + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: Too many open files.", + __func__, path); + return GUAC_SPICE_FOLDER_ENFILE; + } + + /* If path empty, return an error */ + if (path[0] == '\0') + return GUAC_SPICE_FOLDER_EINVAL; + + /* If path is relative, the file does not exist */ + else if (path[0] != '\\' && path[0] != '/') { + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: Access denied - supplied path \"%s\" is relative.", + __func__, path); + return GUAC_SPICE_FOLDER_ENOENT; + } + + /* Translate access into flags */ + if (directory) + flags |= O_DIRECTORY; + + else if (overwrite) + flags |= O_TRUNC; + + /* Normalize path, return no-such-file if invalid */ + if (guac_spice_folder_normalize_path(path, normalized_path)) { + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: Normalization of path \"%s\" failed.", __func__, path); + return GUAC_SPICE_FOLDER_ENOENT; + } + + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: Normalized path \"%s\" to \"%s\".", + __func__, path, normalized_path); + + /* Translate normalized path to real path */ + __guac_spice_folder_translate_path(folder, normalized_path, real_path); + + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: Translated path \"%s\" to \"%s\".", + __func__, normalized_path, real_path); + + /* Create directory first, if necessary */ + if (directory && (flags & O_CREAT)) { + + /* Create directory */ + if (mkdir(real_path, S_IRWXU)) { + if (errno != EEXIST || (flags & O_EXCL)) { + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: mkdir() failed: %s", + __func__, strerror(errno)); + return guac_spice_folder_get_errorcode(errno); + } + } + + /* Unset O_CREAT and O_EXCL as directory must exist before open() */ + flags &= ~(O_CREAT | O_EXCL); + + } + + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: native open: real_path=\"%s\", flags=0x%x", + __func__, real_path, flags); + + /* Open file */ + fd = open(real_path, flags, S_IRUSR | S_IWUSR); + + /* If file open failed as we're trying to write a dir, retry as read-only */ + if (fd == -1 && errno == EISDIR) { + flags &= ~(O_WRONLY | O_RDWR); + flags |= O_RDONLY; + fd = open(real_path, flags, S_IRUSR | S_IWUSR); + } + + if (fd == -1) { + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: open() failed: %s", __func__, strerror(errno)); + return guac_spice_folder_get_errorcode(errno); + } + + /* Get file ID, init file */ + file_id = guac_pool_next_int(folder->file_id_pool); + file = &(folder->files[file_id]); + file->id = file_id; + file->fd = fd; + file->dir = NULL; + file->dir_pattern[0] = '\0'; + file->absolute_path = guac_strdup(normalized_path); + file->real_path = guac_strdup(real_path); + file->bytes_written = 0; + + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: Opened \"%s\" as file_id=%i", + __func__, normalized_path, file_id); + + /* Attempt to pull file information */ + if (fstat(fd, &file_stat) == 0) { + + /* Load size and times */ + file->size = file_stat.st_size; + file->ctime = file_stat.st_ctime; + file->mtime = file_stat.st_mtime; + file->atime = file_stat.st_atime; + file->stmode = file_stat.st_mode; + + } + + /* If information cannot be retrieved, fake it */ + else { + + /* Init information to 0, lacking any alternative */ + file->size = 0; + file->ctime = 0; + file->mtime = 0; + file->atime = 0; + file->stmode = 0; + + } + + folder->open_files++; + + return file_id; + +} + +int guac_spice_folder_read(guac_spice_folder* folder, int file_id, uint64_t offset, + void* buffer, int length) { + + guac_client_log(folder->client, GUAC_LOG_DEBUG, "%s: Attempt to read from file: %s", __func__, folder->path); + + int bytes_read; + + guac_spice_folder_file* file = guac_spice_folder_get_file(folder, file_id); + if (file == NULL) { + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: Read from bad file_id: %i", __func__, file_id); + return GUAC_SPICE_FOLDER_EINVAL; + } + + /* Attempt read */ + lseek(file->fd, offset, SEEK_SET); + bytes_read = read(file->fd, buffer, length); + + /* Translate errno on error */ + if (bytes_read < 0) + return guac_spice_folder_get_errorcode(errno); + + return bytes_read; + +} + +const char* guac_spice_folder_read_dir(guac_spice_folder* folder, int file_id) { + + guac_client_log(folder->client, GUAC_LOG_DEBUG, "%s: Attempt to read directory: %s", __func__, folder->path); + + guac_spice_folder_file* file; + + struct dirent* result; + + /* Only read if file ID is valid */ + if (file_id < 0 || file_id >= GUAC_SPICE_FOLDER_MAX_FILES) + return NULL; + + file = &(folder->files[file_id]); + + /* Open directory if not yet open, stop if error */ + if (file->dir == NULL) { + file->dir = fdopendir(file->fd); + if (file->dir == NULL) + return NULL; + } + + /* Read next entry, stop if error or no more entries */ + if ((result = readdir(file->dir)) == NULL) + return NULL; + + /* Return filename */ + return result->d_name; + +} + +int guac_spice_folder_write(guac_spice_folder* folder, int file_id, uint64_t offset, + void* buffer, int length) { + + guac_client_log(folder->client, GUAC_LOG_DEBUG, "%s: Attempt to write file: %s", __func__, folder->path); + + int bytes_written; + + guac_spice_folder_file* file = guac_spice_folder_get_file(folder, file_id); + if (file == NULL) { + guac_client_log(folder->client, GUAC_LOG_DEBUG, + "%s: Write to bad file_id: %i", __func__, file_id); + return GUAC_SPICE_FOLDER_EINVAL; + } + + /* Attempt write */ + lseek(file->fd, offset, SEEK_SET); + bytes_written = write(file->fd, buffer, length); + + /* Translate errno on error */ + if (bytes_written < 0) + return guac_spice_folder_get_errorcode(errno); + + file->bytes_written += bytes_written; + return bytes_written; + +} + +void guac_spice_client_file_transfer_handler(SpiceMainChannel* main_channel, + SpiceFileTransferTask* task, guac_client* client) { + + guac_client_log(client, GUAC_LOG_DEBUG, "File transfer handler."); + +} \ No newline at end of file diff --git a/src/protocols/spice/channels/file.h b/src/protocols/spice/channels/file.h new file mode 100644 index 0000000000..920ceff219 --- /dev/null +++ b/src/protocols/spice/channels/file.h @@ -0,0 +1,531 @@ +/* + * 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_SPICE_FILE_H +#define GUAC_SPICE_FILE_H + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +/** + * Error code returned when too many files are open. + */ +#define GUAC_SPICE_FOLDER_ENFILE -1 + +/** + * Error code returned when no such file exists. + */ +#define GUAC_SPICE_FOLDER_ENOENT -2 + +/** + * Error code returned when a directory operation is attempted on a + * non-directory. + */ +#define GUAC_SPICE_FOLDER_ENOTDIR -3 + +/** + * Error code returned when insufficient space exists to complete the operation. + */ +#define GUAC_SPICE_FOLDER_ENOSPC -4 + +/** + * Error code returned when a non-directory operation is attempted on a + * directory. + */ +#define GUAC_SPICE_FOLDER_EISDIR -5 + +/** + * Error code returned when the user lacks sufficient privileges. + */ +#define GUAC_SPICE_FOLDER_EACCES -6 + +/** + * Error code returned when the file already exists. + */ +#define GUAC_SPICE_FOLDER_EEXIST -7 + +/** + * Error code returned when the operation is invalid. + */ +#define GUAC_SPICE_FOLDER_EINVAL -8 + +/** + * Error code returned when the operation is not implemented. + */ +#define GUAC_SPICE_FOLDER_ENOSYS -9 + +/** + * Error code returned when the operation is not supported. + */ +#define GUAC_SPICE_FOLDER_ENOTSUP -10 + +/** + * The maximum number of bytes worth of inotify events that may be read at once + * when monitoring the Download folder for changes. + */ +#define GUAC_SPICE_FOLDER_MAX_EVENTS 256 + +/** + * The maximum number of bytes in a path string, including null terminator. + */ +#define GUAC_SPICE_FOLDER_MAX_PATH 4096 + +/** + * The maximum number of files that may be open within the shared folder at any + * given time. + */ +#define GUAC_SPICE_FOLDER_MAX_FILES 128 + +/** + * The maximum number of path components (directories) in any normalized path. + */ +#define GUAC_SPICE_FOLDER_MAX_PATH_DEPTH 64 + +/** + * An arbitrary file on the shared folder. + */ +typedef struct guac_spice_folder_file { + + /** + * The ID of this file. + */ + int id; + + /** + * The absolute path, including filename, of this file on the simulated filesystem. + */ + char* absolute_path; + + /** + * The real path, including filename, of this file on the local filesystem. + */ + char* real_path; + + /** + * Associated local file descriptor. + */ + int fd; + + /** + * Associated directory stream, if any. This field only applies + * if the file is being used as a directory. + */ + DIR* dir; + + /** + * The pattern the check directory contents against, if any. + */ + char dir_pattern[GUAC_SPICE_FOLDER_MAX_PATH]; + + /** + * The size of this file, in bytes. + */ + uint64_t size; + + /** + * The time this file was created, as a UNIX timestamp. + */ + uint64_t ctime; + + /** + * The time this file was last modified, as a UNIX timestamp. + */ + uint64_t mtime; + + /** + * The time this file was last accessed, as a UNIX timestamp. + */ + uint64_t atime; + + /** + * THe mode field of the file, as retrieved by a call to the stat() family + * of functions; + */ + mode_t stmode; + + /** + * The number of bytes written to the file. + */ + uint64_t bytes_written; + +} guac_spice_folder_file; + +/** + * A shared folder for the Spice protocol. + */ +typedef struct guac_spice_folder { + + /** + * The guac_client object this folder is associated with. + */ + guac_client* client; + + /** + * The path to the shared folder. + */ + char* path; + + /** + * The number of currently open files in the folder. + */ + int open_files; + + /** + * A pool of file IDs. + */ + guac_pool* file_id_pool; + + /** + * All available file structures. + */ + guac_spice_folder_file files[GUAC_SPICE_FOLDER_MAX_FILES]; + + /** + * Whether uploads from the client to the shared folder should be disabled. + */ + int disable_download; + + /** + * Whether downloads from the shared folder to the client should be disabled. + */ + int disable_upload; + + /** + * Thread which watches the Download folder and triggers the automatic + * download of files within this subfolder. + */ + pthread_t download_thread; + +} guac_spice_folder; + +/** + * Allocates a new filesystem given a root path which will be shared with the + * user and the remote server via WebDAV. + * + * @param client + * The guac_client associated with the current RDP session. + * + * @param folder_path + * The local directory to use as the root directory of the shared folder. + * + * @param create_folder + * Non-zero if the folder at the path specified should be automatically + * created if it does not yet exist, zero otherwise. + * + * @param disable_download + * Non-zero if downloads from the remote server to the local browser should + * be disabled. + * + * @param disable_upload + * Non-zero if uploads from the browser to the remote server should be + * disabled. + * + * @return + * The newly-allocated filesystem. + */ +guac_spice_folder* guac_spice_folder_alloc(guac_client* client, const char* folder_path, + int create_folder, int disable_download, int disable_upload); + +/** + * Frees the given filesystem. + * + * @param folder + * The folder to free. + */ +void guac_spice_folder_free(guac_spice_folder* folder); + +/** + * Creates and exposes a new filesystem guac_object to the given user, + * providing access to the files within the given Spice shared folder. The + * allocated guac_object must eventually be freed via guac_user_free_object(). + * + * @param folder + * The guac_spice_folder object to expose. + * + * @param user + * The user that the folder should be exposed to. + * + * @return + * A new Guacamole filesystem object, configured to use Spice for uploading + * and downloading files. + */ +guac_object* guac_spice_folder_alloc_object(guac_spice_folder* folder, guac_user* user); + +/** + * Concatenates the given filename with the given path, separating the two + * with a single forward slash. The full result must be no more than + * GUAC_SPICE_FOLDER_MAX_PATH bytes long, counting null terminator. + * + * @param fullpath + * The buffer to store the result within. This buffer must be at least + * GUAC_SPICE_FOLDER_MAX_PATH bytes long. + * + * @param path + * The path to append the filename to. + * + * @param filename + * The filename to append to the path. + * + * @return + * Non-zero if the filename is valid and was successfully appended to the + * path, zero otherwise. + */ +int guac_spice_folder_append_filename(char* fullpath, const char* path, + const char* filename); + +/** + * Given an arbitrary path, returns a pointer to the first character following + * the last path separator in the path (the basename of the path). For example, + * given "/foo/bar/baz", this function would return a pointer to "baz". + * + * @param path + * The path to determine the basename of. + * + * @return + * A pointer to the first character of the basename within the path. + */ +const char* guac_spice_folder_basename(const char* path); + +/** + * Frees the given file ID, allowing future open operations to reuse it. + * + * @param folder + * The folder containing the file to close. + * + * @param file_id + * The ID of the file to close, as returned by guac_spice_folder_open(). + */ +void guac_spice_folder_close(guac_spice_folder* folder, int file_id); + +/** + * Deletes the file with the given ID. + * + * @param folder + * The folder containing the file to delete. + * + * @param file_id + * The ID of the file to delete, as returned by guac_spice_folder_open(). + * + * @return + * Zero if deletion succeeded, or an error code if an error occurs. All + * error codes are negative values and correspond to GUAC_SPICE_FOLDER + * constants, such as GUAC_SPICE_FOLDER_ENOENT. + */ +int guac_spice_folder_delete(guac_spice_folder* folder, int file_id); + +/** + * Allocates a new filesystem guac_object for the given user, returning the + * resulting guac_object. This function is provided for convenience, as it is + * can be used as the callback for guac_client_foreach_user() or + * guac_client_for_owner(). Note that this guac_object will be tracked + * internally by libguac, will be provided to us in the parameters of handlers + * related to that guac_object, and will automatically be freed when the + * associated guac_user is freed, so the return value of this function can + * safely be ignored. + * + * If either the given user or the given filesystem are NULL, then this + * function has no effect. + * + * @param user + * The use to expose the filesystem to, or NULL if nothing should be + * exposed. + * + * @param data + * A pointer to the guac_spice_folder instance to expose to the given user, + * or NULL if nothing should be exposed. + * + * @return + * The guac_object allocated for the newly-exposed filesystem, or NULL if + * no filesystem object could be allocated. + */ +void* guac_spice_folder_expose(guac_user* user, void* data); + +/** + * Translates the given errno error code to a GUAC_SPICE_FOLDER error code. + * + * @param err + * The error code, as returned within errno by a system call. + * + * @return + * A GUAC_SPICE_FOLDER error code, such as GUAC_SPICE_FOLDER_ENFILE, + * GUAC_SPICE_FOLDER_ENOENT, etc. + */ +int guac_spice_folder_get_errorcode(int err); + +/** + * Returns the file having the given ID, or NULL if no such file exists. + * + * @param folder + * The folder containing the desired file. + * + * @param file_id + * The ID of the desired, as returned by guac_spice_folder_open(). + * + * @return + * The file having the given ID, or NULL is no such file exists. + */ +guac_spice_folder_file* guac_spice_folder_get_file(guac_spice_folder* folder, + int file_id); + +/** + * Given an arbitrary path, which may contain ".." and ".", creates an + * absolute path which does NOT contain ".." or ".". The given path MUST + * be absolute. + * + * @param path + * The path to normalize. + * + * @param abs_path + * The buffer to populate with the normalized path. The normalized path + * will not contain relative path components like ".." or ".". + * + * @return + * Zero if normalization succeeded, non-zero otherwise. + */ +int guac_spice_folder_normalize_path(const char* path, char* abs_path); + +/** + * Opens the given file, returning the a new file ID, or an error code less + * than zero if an error occurs. The given path MUST be absolute, and will be + * translated to be relative to the drive path of the simulated filesystem. + * + * @param folder + * The shared folder to use when opening the file. + * + * @param path + * The absolute path to the file within the simulated filesystem. + * + * @param flags + * A bitwise-OR of various standard POSIX flags to use when opening the + * file or directory. + * + * @param overwrite + * True if the file should be overwritten when opening it, otherwise false. + * + * @param directory + * True if the path specified is a directory, otherwise false. + * + * @return + * A new file ID, which will always be a positive value, or an error code + * if an error occurs. All error codes are negative values and correspond + * to GUAC_SPICE_FOLDER constants, such as GUAC_SPICE_FOLDER_ENOENT. + */ +int guac_spice_folder_open(guac_spice_folder* folder, const char* path, + int flags, bool overwrite, bool directory); + +/** + * Reads up to the given length of bytes from the given offset within the + * file having the given ID. Returns the number of bytes read, zero on EOF, + * and an error code if an error occurs. + * + * @param folder + * The folder containing the file from which data is to be read. + * + * @param file_id + * The ID of the file to read data from, as returned by guac_spice_folder_open(). + * + * @param offset + * The byte offset within the file to start reading from. + * + * @param buffer + * The buffer to fill with data from the file. + * + * @param length + * The maximum number of bytes to read from the file. + * + * @return + * The number of bytes actually read, zero on EOF, or an error code if an + * error occurs. All error codes are negative values and correspond to + * GUAC_SPICE_FOLDER constants, such as GUAC_SPICE_FOLDER_ENOENT. + */ +int guac_spice_folder_read(guac_spice_folder* folder, int file_id, uint64_t offset, + void* buffer, int length); + +/** + * Returns the next filename within the directory having the given file ID, + * or NULL if no more files. + * + * @param folder + * The foleer containing the file to read directory entries from. + * + * @param file_id + * The ID of the file to read directory entries from, as returned by + * guac_spice_folder_open(). + * + * @return + * The name of the next filename within the directory, or NULL if the last + * file in the directory has already been returned by a previous call. + */ +const char* guac_spice_folder_read_dir(guac_spice_folder* folder, int file_id); + +/** + * Writes up to the given length of bytes from the given offset within the + * file having the given ID. Returns the number of bytes written, and an + * error code if an error occurs. + * + * @param folder + * The folder containing the file to which data is to be written. + * + * @param file_id + * The ID of the file to write data to, as returned by guac_spice_folder_open(). + * + * @param offset + * The byte offset within the file to start writinging at. + * + * @param buffer + * The buffer containing the data to write. + * + * @param length + * The maximum number of bytes to write to the file. + * + * @return + * The number of bytes actually written, or an error code if an error + * occurs. All error codes are negative values and correspond to + * GUAC_SPICE_FOLDER constants, such as GUAC_SPICE_FOLDER_ENOENT. + */ +int guac_spice_folder_write(guac_spice_folder* folder, int file_id, uint64_t offset, + void* buffer, int length); + +/** + * A handler that is called when the SPICE client receives notification of + * a new file transfer task. + * + * @param main_channel + * The main channel associated with the SPICE session. + * + * @param task + * The file transfer task that triggered this function call. + * + * @param client + * The guac_client object associated with this session. + */ +void guac_spice_client_file_transfer_handler(SpiceMainChannel* main_channel, + SpiceFileTransferTask* task, guac_client* client); + +#endif /* GUAC_SPICE_FILE_H */ + diff --git a/src/protocols/spice/client.c b/src/protocols/spice/client.c index 98cbb15ee8..4ffe48bc6e 100644 --- a/src/protocols/spice/client.c +++ b/src/protocols/spice/client.c @@ -164,6 +164,10 @@ int guac_spice_client_free_handler(guac_client* client) { guac_common_ssh_uninit(); #endif + /* Free shared folder, if allocated */ + if (spice_client->shared_folder != NULL) + guac_spice_folder_free(spice_client->shared_folder); + /* Clean up recording, if in progress */ if (spice_client->recording != NULL) guac_recording_free(spice_client->recording); diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c index df346c2015..6a283f346a 100644 --- a/src/protocols/spice/settings.c +++ b/src/protocols/spice/settings.c @@ -59,6 +59,12 @@ const char* GUAC_SPICE_CLIENT_ARGS[] = { "enable-drive", "drive-path", "drive-read-only", + "file-transfer", + "file-directory", + "file-transfer-create-folder", + "file-transfer-ro", + "disable-download", + "disable-upload", #ifdef ENABLE_COMMON_SSH "enable-sftp", @@ -236,6 +242,41 @@ enum SPICE_ARGS_IDX { */ IDX_DRIVE_READ_ONLY, + /** + * "true" if the web-UI file browser over the shared folder should be + * enabled, "false" or blank otherwise. + */ + IDX_FILE_TRANSFER, + + /** + * The host-side path of the shared folder exposed to the file browser. + */ + IDX_FILE_DIRECTORY, + + /** + * "true" if the shared folder should be created if it does not yet exist, + * "false" or blank otherwise. + */ + IDX_FILE_TRANSFER_CREATE_FOLDER, + + /** + * "true" if the shared folder should be exposed read-only to the SPICE + * server, "false" or blank otherwise. + */ + IDX_FILE_TRANSFER_RO, + + /** + * "true" if file downloads from the shared folder should be disabled, + * "false" or blank otherwise. + */ + IDX_DISABLE_DOWNLOAD, + + /** + * "true" if file uploads to the shared folder should be disabled, "false" + * or blank otherwise. + */ + IDX_DISABLE_UPLOAD, + #ifdef ENABLE_COMMON_SSH /** * "true" if SFTP should be enabled, "false" or blank otherwise. @@ -498,6 +539,30 @@ guac_spice_settings* guac_spice_parse_args(guac_user* user, guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_DRIVE_READ_ONLY, false); + settings->file_transfer = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_FILE_TRANSFER, false); + + settings->file_directory = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_FILE_DIRECTORY, NULL); + + settings->file_transfer_create_folder = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_FILE_TRANSFER_CREATE_FOLDER, false); + + settings->file_transfer_ro = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_FILE_TRANSFER_RO, false); + + settings->disable_download = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_DISABLE_DOWNLOAD, false); + + settings->disable_upload = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_DISABLE_UPLOAD, false); + /* If neither a plaintext nor a TLS port was provided, fall back to the * default SPICE port for the appropriate transport */ if (strcmp(settings->port, "") == 0 && strcmp(settings->tls_port, "") == 0) { @@ -639,6 +704,7 @@ void guac_spice_settings_free(guac_spice_settings* settings) { guac_mem_free(settings->proxy); guac_mem_free(settings->server_layout); guac_mem_free(settings->drive_path); + guac_mem_free(settings->file_directory); guac_mem_free(settings->recording_name); guac_mem_free(settings->recording_path); diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h index 259aa0d162..63905950fb 100644 --- a/src/protocols/spice/settings.h +++ b/src/protocols/spice/settings.h @@ -205,6 +205,42 @@ typedef struct guac_spice_settings { */ bool drive_read_only; + /** + * Whether the Guacamole web-UI file browser (upload/download/ls) over the + * shared folder should be enabled. + */ + bool file_transfer; + + /** + * The host-side path of the shared folder exposed to the file browser, or + * NULL if file transfer is disabled. + */ + char* file_directory; + + /** + * Whether the shared folder should be automatically created if it does not + * already exist. + */ + bool file_transfer_create_folder; + + /** + * Whether the shared folder should be exposed read-only to the SPICE + * server. + */ + bool file_transfer_ro; + + /** + * Whether file downloads from the shared folder to the client should be + * disabled. + */ + bool disable_download; + + /** + * Whether file uploads from the client to the shared folder should be + * disabled. + */ + bool disable_upload; + #ifdef ENABLE_COMMON_SSH /** * Whether SFTP should be enabled for the SPICE connection. diff --git a/src/protocols/spice/spice.c b/src/protocols/spice/spice.c index fd7fbc30a5..3ffc151c41 100644 --- a/src/protocols/spice/spice.c +++ b/src/protocols/spice/spice.c @@ -218,6 +218,34 @@ void* guac_spice_client_thread(void* data) { spice_client->spice_session = spice_session_new(); guac_spice_session_configure(client, spice_client->spice_session); + /* Set up the shared folder file browser, if file transfer is enabled. The + * shared-dir property here takes precedence over the basic enable-drive + * sharing configured within guac_spice_session_configure(). */ + if (settings->file_transfer && settings->file_directory != NULL) { + + guac_client_log(client, GUAC_LOG_INFO, + "Enabling shared folder file transfer for \"%s\"%s.", + settings->file_directory, + settings->file_transfer_ro ? " (read-only)" : ""); + + g_object_set(spice_client->spice_session, + "shared-dir", settings->file_directory, NULL); + g_object_set(spice_client->spice_session, + "share-dir-ro", settings->file_transfer_ro, NULL); + + /* Allocate the shared folder, exposing it to the connection owner as a + * Guacamole filesystem object */ + spice_client->shared_folder = guac_spice_folder_alloc(client, + settings->file_directory, + settings->file_transfer_create_folder, + settings->disable_download, + settings->disable_upload); + + guac_client_for_owner(client, guac_spice_folder_expose, + spice_client->shared_folder); + + } + /* Allocate keyboard, translating keysyms to scancodes according to the * keyboard layout requested by the connection (falling back to the default * layout if the requested layout is unknown) */ diff --git a/src/protocols/spice/spice.h b/src/protocols/spice/spice.h index b85bba0bcd..0a598c4c96 100644 --- a/src/protocols/spice/spice.h +++ b/src/protocols/spice/spice.h @@ -20,6 +20,7 @@ #ifndef GUAC_SPICE_SPICE_H #define GUAC_SPICE_SPICE_H +#include "channels/file.h" #include "common/clipboard.h" #include "keyboard.h" #include "settings.h" @@ -228,6 +229,13 @@ typedef struct guac_spice_client { */ guac_stream* audio_input; + /** + * The shared folder exposed to the connected user as a Guacamole filesystem + * object (file browser) and to the SPICE server via the WebDAV channel, or + * NULL if file transfer has not been enabled. + */ + guac_spice_folder* shared_folder; + #ifdef ENABLE_COMMON_SSH /** * The user and credentials used to authenticate for SFTP. diff --git a/src/protocols/spice/user.c b/src/protocols/spice/user.c index 36a0c243b5..1ebb881245 100644 --- a/src/protocols/spice/user.c +++ b/src/protocols/spice/user.c @@ -21,6 +21,7 @@ #include "argv.h" #include "audio.h" +#include "channels/file-upload.h" #include "clipboard.h" #include "common/clipboard.h" #include "input.h" @@ -98,6 +99,12 @@ int guac_spice_user_join_handler(guac_user* user, int argc, char** argv) { user->file_handler = guac_spice_sftp_file_handler; #endif + /* Generic (non-filesystem) uploads to the shared folder take precedence + * over SFTP when the file browser is enabled. The exposed filesystem + * object handles its own get/put (download/ls/upload) requests. */ + if (settings->file_transfer && !settings->disable_upload) + user->file_handler = guac_spice_file_upload_file_handler; + } /* Update connection parameters (e.g. password) if we own the connection */ From 644d3994341eda98bf1ea8b5f91d6eb270c622a7 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Wed, 1 Jul 2026 03:32:42 -0300 Subject: [PATCH 07/36] GUACAMOLE-261: add preferred-compression param and honor playback mute * preferred-compression: new connection parameter mapping "off", "auto-glz", "auto-lz", "quic", "glz", "lz", or "lz4" to the SpiceImageCompression enum and setting the session "preferred-compression" property. Unknown values log a warning and are ignored (the connection still proceeds). * Audio mute: the playback channel's mute state (notify::mute) is tracked and received PCM is dropped while the SPICE server reports playback as muted. The channel's volume is intentionally not re-applied -- the guest's PCM is already scaled to the reported volume, so re-applying would double-attenuate. Verified against a QEMU/SPICE desktop: preferred-compression=quic renders, an unknown value warns and still renders, and the mute-state handler fires. Closes the low-hanging-fruit items from the SPICE enhancement backlog (#9, #13). --- src/protocols/spice/audio.c | 30 ++++++++++++++++++++++++++- src/protocols/spice/auth.c | 37 ++++++++++++++++++++++++++++++++++ src/protocols/spice/settings.c | 13 ++++++++++++ src/protocols/spice/settings.h | 7 +++++++ src/protocols/spice/spice.h | 7 +++++++ 5 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/protocols/spice/audio.c b/src/protocols/spice/audio.c index bb3df4f458..8956610214 100644 --- a/src/protocols/spice/audio.c +++ b/src/protocols/spice/audio.c @@ -89,7 +89,8 @@ static void guac_spice_playback_data(SpicePlaybackChannel* channel, guac_client* client = (guac_client*) user_data; guac_spice_client* spice_client = (guac_spice_client*) client->data; - if (spice_client->audio == NULL) + /* Drop audio entirely while playback is muted */ + if (spice_client->audio == NULL || spice_client->audio_muted) return; guac_audio_stream_write_pcm(spice_client->audio, @@ -113,6 +114,29 @@ static void guac_spice_playback_stop(SpicePlaybackChannel* channel, } +/** + * Handler for changes to the SPICE playback channel's "mute" property. Records + * the current mute state so that playback data can be dropped while muted. + * + * Note that the channel's "volume" property is intentionally not tracked: the + * guest's PCM stream is already scaled to the reported volume, so re-applying + * it here would double-attenuate the audio. + */ +static void guac_spice_playback_mute_changed(SpicePlaybackChannel* channel, + GParamSpec* pspec, gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + gboolean muted = FALSE; + g_object_get(channel, "mute", &muted, NULL); + spice_client->audio_muted = muted; + + guac_client_log(client, GUAC_LOG_DEBUG, "SPICE audio playback is now %s.", + muted ? "muted" : "unmuted"); + +} + void guac_spice_playback_channel_connect(guac_client* client, SpiceChannel* channel) { @@ -129,6 +153,10 @@ void guac_spice_playback_channel_connect(guac_client* client, g_signal_connect(channel, "playback-stop", G_CALLBACK(guac_spice_playback_stop), client); + /* Track mute state reported by the SPICE server */ + g_signal_connect(channel, "notify::mute", + G_CALLBACK(guac_spice_playback_mute_changed), client); + } /** diff --git a/src/protocols/spice/auth.c b/src/protocols/spice/auth.c index 102e53a978..e205c73053 100644 --- a/src/protocols/spice/auth.c +++ b/src/protocols/spice/auth.c @@ -25,6 +25,8 @@ #include #include +#include + void guac_spice_session_configure(guac_client* client, SpiceSession* session) { guac_spice_client* spice_client = (guac_spice_client*) client->data; @@ -88,6 +90,41 @@ void guac_spice_session_configure(guac_client* client, SpiceSession* session) { if (settings->username != NULL) g_object_set(session, "username", settings->username, NULL); + /* Configure preferred image compression, if requested */ + if (settings->preferred_compression != NULL) { + + static const struct { + const char* name; + SpiceImageCompression value; + } compression_types[] = { + { "off", SPICE_IMAGE_COMPRESSION_OFF }, + { "auto-glz", SPICE_IMAGE_COMPRESSION_AUTO_GLZ }, + { "auto-lz", SPICE_IMAGE_COMPRESSION_AUTO_LZ }, + { "quic", SPICE_IMAGE_COMPRESSION_QUIC }, + { "glz", SPICE_IMAGE_COMPRESSION_GLZ }, + { "lz", SPICE_IMAGE_COMPRESSION_LZ }, + { "lz4", SPICE_IMAGE_COMPRESSION_LZ4 }, + { NULL, SPICE_IMAGE_COMPRESSION_INVALID } + }; + + SpiceImageCompression compression = SPICE_IMAGE_COMPRESSION_INVALID; + for (int i = 0; compression_types[i].name != NULL; i++) { + if (strcmp(settings->preferred_compression, + compression_types[i].name) == 0) { + compression = compression_types[i].value; + break; + } + } + + if (compression == SPICE_IMAGE_COMPRESSION_INVALID) + guac_client_log(client, GUAC_LOG_WARNING, "Ignoring unknown " + "preferred image compression \"%s\".", + settings->preferred_compression); + else + g_object_set(session, "preferred-compression", compression, NULL); + + } + /* Reflect read-only state to the session */ g_object_set(session, "read-only", settings->read_only, NULL); diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c index 6a283f346a..e4a725c685 100644 --- a/src/protocols/spice/settings.c +++ b/src/protocols/spice/settings.c @@ -47,6 +47,7 @@ const char* GUAC_SPICE_CLIENT_ARGS[] = { "proxy", "color-depth", "swap-red-blue", + "preferred-compression", "server-layout", "read-only", "disable-display-resize", @@ -174,6 +175,13 @@ enum SPICE_ARGS_IDX { */ IDX_SWAP_RED_BLUE, + /** + * The preferred image compression for the SPICE session (one of "off", + * "auto-glz", "auto-lz", "quic", "glz", "lz", "lz4"), or blank to let the + * server decide. + */ + IDX_PREFERRED_COMPRESSION, + /** * The name of the keyboard layout to use when translating keysyms into * SPICE scancodes (e.g. "en-us-qwerty", "es-es-qwerty", "es-latam-qwerty"). @@ -490,6 +498,10 @@ guac_spice_settings* guac_spice_parse_args(guac_user* user, guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_SWAP_RED_BLUE, false); + settings->preferred_compression = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_PREFERRED_COMPRESSION, NULL); + settings->server_layout = guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_SERVER_LAYOUT, GUAC_SPICE_DEFAULT_KEYMAP); @@ -702,6 +714,7 @@ void guac_spice_settings_free(guac_spice_settings* settings) { guac_mem_free(settings->cert_subject); guac_mem_free(settings->pubkey); guac_mem_free(settings->proxy); + guac_mem_free(settings->preferred_compression); guac_mem_free(settings->server_layout); guac_mem_free(settings->drive_path); guac_mem_free(settings->file_directory); diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h index 63905950fb..a8787114f4 100644 --- a/src/protocols/spice/settings.h +++ b/src/protocols/spice/settings.h @@ -135,6 +135,13 @@ typedef struct guac_spice_settings { */ bool swap_red_blue; + /** + * The preferred image compression for the SPICE session (one of "off", + * "auto-glz", "auto-lz", "quic", "glz", "lz", "lz4"), or NULL to let the + * server decide. + */ + char* preferred_compression; + /** * The name of the keyboard layout used to translate keysyms into SPICE * scancodes (e.g. "en-us-qwerty", "es-es-qwerty", "es-latam-qwerty"). diff --git a/src/protocols/spice/spice.h b/src/protocols/spice/spice.h index 0a598c4c96..d979364227 100644 --- a/src/protocols/spice/spice.h +++ b/src/protocols/spice/spice.h @@ -215,6 +215,13 @@ typedef struct guac_spice_client { */ int audio_rate; + /** + * Whether audio playback is currently muted, as reported by the SPICE + * playback channel. While set, received PCM data is not forwarded to the + * Guacamole audio stream. + */ + int audio_muted; + /** * The SPICE record channel, used to forward audio (e.g. microphone) input * from the connected Guacamole user to the SPICE server. NULL until the From dc8322cd4be561eabe5c3bb1ff32dc6c855fb964 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 08:35:28 -0300 Subject: [PATCH 08/36] GUACAMOLE-261: match audio input format to what the SPICE record channel expects The audio input (microphone) path forwarded the connected user's PCM to the SPICE record channel unchanged, even when the user was capturing at a different sample rate or channel count than the rate/channels the record channel negotiated (reported by record-start). That mismatch is sent to the guest mislabeled, producing wrong-pitch/artifacted audio. Record the format requested by record-start, remember the format advertised by the user, and convert inbound audio to the expected rate/channels (linear resampling + simple mono<->stereo mixing) before spice_record_channel_send_data when they differ. Matching formats are forwarded directly as before. Ported from the draft by Nick Couchman in apache/guacamole-server#394 (GUACAMOLE-261). Co-authored-by: Virtually Nick --- src/protocols/spice/audio.c | 114 +++++++++++++++++++++++++++++++++++- src/protocols/spice/spice.h | 16 +++++ 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/protocols/spice/audio.c b/src/protocols/spice/audio.c index 8956610214..17b3ab1637 100644 --- a/src/protocols/spice/audio.c +++ b/src/protocols/spice/audio.c @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -31,6 +32,7 @@ #include #include +#include #include #include #include @@ -223,18 +225,111 @@ static int guac_spice_audio_parse_mimetype(const char* mimetype, int* rate, } +/** + * Converts signed 16-bit interleaved PCM from one sample rate and channel count + * to another using linear interpolation (for rate) and simple up/down mixing + * (mono<->stereo, or passthrough for matching counts). Returns a newly + * allocated buffer, which the caller must free with guac_mem_free(), storing + * its length in bytes in *out_length, or NULL on failure. + */ +static int16_t* guac_spice_resample_s16(const int16_t* in, int in_bytes, + int in_rate, int in_channels, int out_rate, int out_channels, + int* out_length) { + + *out_length = 0; + + if (in_rate <= 0 || out_rate <= 0 || in_channels <= 0 || out_channels <= 0) + return NULL; + + int in_frames = in_bytes / (2 * in_channels); + if (in_frames <= 0) + return NULL; + + long out_frames = (long) in_frames * out_rate / in_rate; + if (out_frames <= 0) + return NULL; + + int16_t* out = guac_mem_alloc(out_frames * out_channels * sizeof(int16_t)); + if (out == NULL) + return NULL; + + for (long o = 0; o < out_frames; o++) { + + /* Fractional source frame position for linear interpolation */ + double src = (double) o * in_rate / out_rate; + long i0 = (long) src; + long i1 = (i0 + 1 < in_frames) ? i0 + 1 : i0; + double frac = src - i0; + + /* Interpolate the source channels into a working left/right pair */ + int16_t l0 = in[i0 * in_channels]; + int16_t l1 = in[i1 * in_channels]; + double left = l0 + (l1 - l0) * frac; + + double right; + if (in_channels >= 2) { + int16_t r0 = in[i0 * in_channels + 1]; + int16_t r1 = in[i1 * in_channels + 1]; + right = r0 + (r1 - r0) * frac; + } + else + right = left; + + /* Emit the requested number of output channels */ + int16_t* frame = &out[o * out_channels]; + if (out_channels == 1) + frame[0] = (int16_t) ((left + right) / 2.0); + else { + frame[0] = (int16_t) left; + frame[1] = (int16_t) right; + for (int c = 2; c < out_channels; c++) + frame[c] = (int16_t) left; + } + + } + + *out_length = (int) (out_frames * out_channels * sizeof(int16_t)); + return out; + +} + /** * Handler for "blob" instructions received on the audio input stream. Forwards - * the received PCM audio data to the SPICE record channel. + * the received PCM audio data to the SPICE record channel, converting it to the + * rate/channels the server expects if the connected user is capturing in a + * different format. */ static int guac_spice_audio_blob_handler(guac_user* user, guac_stream* stream, void* data, int length) { guac_spice_client* spice_client = (guac_spice_client*) user->client->data; - if (spice_client->record_channel != NULL) + if (spice_client->record_channel == NULL) + return 0; + + int rr = spice_client->record_rate; + int rc = spice_client->record_channels; + int ir = spice_client->input_rate; + int ic = spice_client->input_channels; + + /* Forward directly if the server's format is not yet known or already + * matches the inbound stream */ + if (rr <= 0 || rc <= 0 || (rr == ir && rc == ic)) { spice_record_channel_send_data(spice_client->record_channel, data, length, (unsigned long) time(NULL)); + return 0; + } + + /* Otherwise convert to the format the SPICE record channel expects */ + int out_length = 0; + int16_t* resampled = guac_spice_resample_s16((const int16_t*) data, length, + ir, ic, rr, rc, &out_length); + + if (resampled != NULL) { + spice_record_channel_send_data(spice_client->record_channel, + resampled, out_length, (unsigned long) time(NULL)); + guac_mem_free(resampled); + } return 0; @@ -266,6 +361,11 @@ int guac_spice_client_audio_record_handler(guac_user* user, guac_stream* stream, return 0; } + /* Remember the format the user is sending so it can be converted to the + * rate/channels the SPICE record channel expects, if they differ */ + spice_client->input_rate = rate; + spice_client->input_channels = channels; + /* Set up handlers for the audio input stream */ stream->blob_handler = guac_spice_audio_blob_handler; stream->end_handler = guac_spice_audio_end_handler; @@ -313,8 +413,16 @@ static void* spice_client_record_stop_callback(guac_user* owner, void* data) { void guac_spice_client_audio_record_start_handler(SpiceRecordChannel* channel, gint format, gint channels, gint rate, guac_client* client) { - guac_client_log(client, GUAC_LOG_DEBUG, "SPICE audio recording started."); guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Record the format the SPICE server expects so inbound audio can be + * converted to match if the connected user is capturing at a different + * rate or channel count */ + spice_client->record_rate = rate; + spice_client->record_channels = channels; + + guac_client_log(client, GUAC_LOG_DEBUG, "SPICE audio recording started " + "(%d Hz, %d channel(s)).", rate, channels); guac_client_for_owner(client, spice_client_record_start_callback, spice_client); } diff --git a/src/protocols/spice/spice.h b/src/protocols/spice/spice.h index d979364227..6691f159fe 100644 --- a/src/protocols/spice/spice.h +++ b/src/protocols/spice/spice.h @@ -236,6 +236,22 @@ typedef struct guac_spice_client { */ guac_stream* audio_input; + /** + * The sample rate (in Hz) and channel count the SPICE record channel + * expects, as reported by the record-start signal. Zero until recording + * has started. + */ + int record_rate; + int record_channels; + + /** + * The sample rate (in Hz) and channel count of the inbound audio stream, as + * advertised by the connected user in the audio input mimetype. Zero until + * an audio input stream has been established. + */ + int input_rate; + int input_channels; + /** * The shared folder exposed to the connected user as a Guacamole filesystem * object (file browser) and to the SPICE server via the WebDAV channel, or From e079e4572dde9bdd10ac6b2c7b94018469b9582d Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 12:55:35 -0300 Subject: [PATCH 09/36] GUACAMOLE-261: add disable-audio-opus parameter Add a "disable-audio-opus" connection parameter which, when set, exports SPICE_DISABLE_OPUS=1 before the SPICE session is created, causing spice-gtk to negotiate the legacy CELT audio codec instead of Opus. guacd runs a dedicated process per connection, so the environment variable is scoped to the individual connection. This is the only client-side audio-codec control spice-gtk exposes; it is intended for compatibility with SPICE servers whose Opus support is missing or unreliable, and does not improve audio quality. Choosing raw/lossless playback remains a server-side setting (QEMU -spice playback-compression=off). --- src/protocols/spice/settings.c | 12 ++++++++++++ src/protocols/spice/settings.h | 8 ++++++++ src/protocols/spice/spice.c | 8 ++++++++ 3 files changed, 28 insertions(+) diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c index e4a725c685..a6ff0e3f16 100644 --- a/src/protocols/spice/settings.c +++ b/src/protocols/spice/settings.c @@ -57,6 +57,7 @@ const char* GUAC_SPICE_CLIENT_ARGS[] = { "clipboard-buffer-size", "enable-audio", "enable-audio-input", + "disable-audio-opus", "enable-drive", "drive-path", "drive-read-only", @@ -234,6 +235,13 @@ enum SPICE_ARGS_IDX { */ IDX_ENABLE_AUDIO_INPUT, + /** + * "true" if the Opus audio codec should be disabled, causing the SPICE + * connection to fall back to the legacy CELT codec, "false" or blank + * otherwise. + */ + IDX_DISABLE_AUDIO_OPUS, + /** * "true" if folder sharing should be enabled, "false" or blank otherwise. */ @@ -539,6 +547,10 @@ guac_spice_settings* guac_spice_parse_args(guac_user* user, guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_ENABLE_AUDIO_INPUT, false); + settings->disable_audio_opus = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_DISABLE_AUDIO_OPUS, false); + settings->enable_drive = guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_ENABLE_DRIVE, false); diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h index a8787114f4..ae9764f2f5 100644 --- a/src/protocols/spice/settings.h +++ b/src/protocols/spice/settings.h @@ -195,6 +195,14 @@ typedef struct guac_spice_settings { */ bool audio_input_enabled; + /** + * Whether the Opus audio codec should be disabled, forcing the SPICE + * connection to negotiate the legacy CELT codec instead. This is only + * useful for compatibility with SPICE servers whose Opus support is + * missing or unreliable; it does not improve audio quality. + */ + bool disable_audio_opus; + /** * Whether folder sharing (shared directory via the SPICE WebDAV channel) * should be enabled. diff --git a/src/protocols/spice/spice.c b/src/protocols/spice/spice.c index 3ffc151c41..c6b254ee7e 100644 --- a/src/protocols/spice/spice.c +++ b/src/protocols/spice/spice.c @@ -44,6 +44,7 @@ #include #include +#include #include /** @@ -214,6 +215,13 @@ void* guac_spice_client_thread(void* data) { spice_client->main_context = NULL; spice_client->main_loop = g_main_loop_new(NULL, FALSE); + /* Disable the Opus audio codec for this connection if requested, forcing + * spice-gtk to negotiate the legacy CELT codec instead. guacd runs a + * dedicated process per connection, so this environment variable affects + * only the current connection. */ + if (settings->disable_audio_opus) + setenv("SPICE_DISABLE_OPUS", "1", 1); + /* Allocate and configure the SPICE session */ spice_client->spice_session = spice_session_new(); guac_spice_session_configure(client, spice_client->spice_session); From c70e127c0c2e2524b324659fe3991bee039e81e6 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 14:37:39 -0300 Subject: [PATCH 10/36] GUACAMOLE-261: clarify disable-audio-opus behavior in comments Empirically, disabling Opus does not force CELT on modern SPICE servers: QEMU/spice-server dropped CELT 0.5.1 years ago, so the server falls back to raw (lossless) audio when the client stops advertising Opus. Only older servers fall back to CELT. Update the comments to describe this accurately (the option can therefore yield lossless raw audio on a LAN, not merely a legacy compatibility fallback). --- src/protocols/spice/settings.c | 3 ++- src/protocols/spice/settings.h | 9 +++++---- src/protocols/spice/spice.c | 6 ++++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c index a6ff0e3f16..f367c46be1 100644 --- a/src/protocols/spice/settings.c +++ b/src/protocols/spice/settings.c @@ -237,7 +237,8 @@ enum SPICE_ARGS_IDX { /** * "true" if the Opus audio codec should be disabled, causing the SPICE - * connection to fall back to the legacy CELT codec, "false" or blank + * server to fall back to its next available audio mode (raw on modern + * servers, or the legacy CELT codec on older ones), "false" or blank * otherwise. */ IDX_DISABLE_AUDIO_OPUS, diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h index ae9764f2f5..6ff5be9fb0 100644 --- a/src/protocols/spice/settings.h +++ b/src/protocols/spice/settings.h @@ -196,10 +196,11 @@ typedef struct guac_spice_settings { bool audio_input_enabled; /** - * Whether the Opus audio codec should be disabled, forcing the SPICE - * connection to negotiate the legacy CELT codec instead. This is only - * useful for compatibility with SPICE servers whose Opus support is - * missing or unreliable; it does not improve audio quality. + * Whether the Opus audio codec should be disabled. When set, spice-gtk + * stops advertising Opus, so the SPICE server falls back to its next + * available audio mode: raw/lossless on modern servers (which no longer + * support the legacy CELT codec), or CELT on older servers. On a LAN this + * is a simple way to obtain lossless raw audio, at the cost of bandwidth. */ bool disable_audio_opus; diff --git a/src/protocols/spice/spice.c b/src/protocols/spice/spice.c index c6b254ee7e..54675aa3da 100644 --- a/src/protocols/spice/spice.c +++ b/src/protocols/spice/spice.c @@ -215,8 +215,10 @@ void* guac_spice_client_thread(void* data) { spice_client->main_context = NULL; spice_client->main_loop = g_main_loop_new(NULL, FALSE); - /* Disable the Opus audio codec for this connection if requested, forcing - * spice-gtk to negotiate the legacy CELT codec instead. guacd runs a + /* Disable the Opus audio codec for this connection if requested. spice-gtk + * then stops advertising Opus and the SPICE server falls back to its next + * available audio mode: raw/lossless on modern servers (which no longer + * support the legacy CELT codec), or CELT on older servers. guacd runs a * dedicated process per connection, so this environment variable affects * only the current connection. */ if (settings->disable_audio_opus) From 1ee9d1cb6f4bb95e779dc2ed34529279d8f7e9ab Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 18:01:13 -0300 Subject: [PATCH 11/36] GUACAMOLE-261: fix keyboard lock-vs-modifier cost so shifted symbols work on shift-lock layouts guac_spice_keyboard_get_cost() estimated the cost of a key definition using the modifier fields (set_modifiers/clear_modifiers vs keyboard->modifiers) for BOTH the lock term and the modifier term, never reading the separate set_locks/ clear_locks fields the keymaps actually carry. On layouts that model Caps Lock as Shift-Lock (e.g. de-de-qwertz), a shifted symbol has two definitions -- one via Shift and one via Caps Lock -- and the mis-costed function selected the Caps-Lock definition, releasing Shift and toggling Caps Lock. On a modern guest (where Caps Lock does not shift the number row) this produced digits instead of symbols, matching the German-keyboard bug reported against the original SPICE draft (GUACAMOLE-261, apache/guacamole-server#394). Use def->set_locks/clear_locks against the lock state (keyboard->modifiers, in SPICE_INPUTS_* form) for the lock term, mirroring the RDP keyboard subsystem. Closes #19. --- src/protocols/spice/keyboard.c | 60 ++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/src/protocols/spice/keyboard.c b/src/protocols/spice/keyboard.c index b0a9660e67..e24847d4f1 100644 --- a/src/protocols/spice/keyboard.c +++ b/src/protocols/spice/keyboard.c @@ -90,16 +90,36 @@ static int guac_spice_keyboard_lock_flag(int keysym) { * @param pressed * Non-zero if the key is being pressed, zero if the key is being released. */ +/** + * Handler which performs a deferred SPICE key press on the event-loop thread. + */ +static void guac_spice_do_key_press(guac_spice_deferred_call* call) { + spice_inputs_channel_key_press((SpiceInputsChannel*) call->channel, + call->args[0]); +} + +/** + * Handler which performs a deferred SPICE key release on the event-loop thread. + */ +static void guac_spice_do_key_release(guac_spice_deferred_call* call) { + spice_inputs_channel_key_release((SpiceInputsChannel*) call->channel, + call->args[0]); +} + static void guac_spice_send_key_event(guac_spice_client* spice_client, int scancode, int flags, int pressed) { - /* Send actual key press or release */ - pthread_mutex_lock(&(spice_client->message_lock)); - if (pressed) - spice_inputs_channel_key_press(spice_client->inputs_channel, scancode); - else - spice_inputs_channel_key_release(spice_client->inputs_channel, scancode); - pthread_mutex_unlock(&(spice_client->message_lock)); + /* Nothing to send if the inputs channel is not yet connected */ + if (spice_client->inputs_channel == NULL) + return; + + /* Marshal the key event onto the SPICE event-loop thread; spice-gtk channel + * functions must not be called directly from user threads */ + guac_spice_deferred_call* call = g_new0(guac_spice_deferred_call, 1); + call->handler = pressed ? guac_spice_do_key_press : guac_spice_do_key_release; + call->channel = spice_client->inputs_channel; + call->args[0] = (unsigned int) scancode; + guac_spice_defer_call(call); } @@ -117,6 +137,15 @@ static void guac_spice_send_key_event(guac_spice_client* spice_client, * if any, as dictated by the Spice protocol. If no flags are set, then no * lock keys will be active. */ +/** + * Handler which performs a deferred SPICE key-lock synchronization on the + * event-loop thread. + */ +static void guac_spice_do_set_key_locks(guac_spice_deferred_call* call) { + spice_inputs_channel_set_key_locks((SpiceInputsChannel*) call->channel, + call->args[0]); +} + static void guac_spice_send_synchronize_event(guac_spice_client* spice_client, unsigned int modifiers) { @@ -124,10 +153,12 @@ static void guac_spice_send_synchronize_event(guac_spice_client* spice_client, if (spice_client->inputs_channel == NULL) return; - /* Synchronize lock key states */ - pthread_mutex_lock(&(spice_client->message_lock)); - spice_inputs_channel_set_key_locks(spice_client->inputs_channel, modifiers); - pthread_mutex_unlock(&(spice_client->message_lock)); + /* Marshal the lock-key synchronization onto the SPICE event-loop thread */ + guac_spice_deferred_call* call = g_new0(guac_spice_deferred_call, 1); + call->handler = guac_spice_do_set_key_locks; + call->channel = spice_client->inputs_channel; + call->args[0] = modifiers; + guac_spice_defer_call(call); } @@ -226,8 +257,11 @@ static int guac_spice_keyboard_get_cost(guac_spice_keyboard* keyboard, /* Each change to any key requires one event, by definition */ int cost = 1; - /* Each change to a lock requires roughly two key events */ - unsigned int update_locks = (def->set_modifiers & ~keyboard->modifiers) | (def->clear_modifiers & keyboard->modifiers); + /* Each change to a lock requires roughly two key events. Locks (Caps Lock, + * Num Lock) are tracked separately from modifiers: the key's lock + * requirements are described by set_locks/clear_locks (in SPICE_INPUTS_* + * form), and the current lock state is held in keyboard->modifiers. */ + unsigned int update_locks = (def->set_locks & ~keyboard->modifiers) | (def->clear_locks & keyboard->modifiers); cost += guac_spice_count_bits(update_locks) * 2; /* Each change to a modifier requires one key event */ From cc9b746f529a74872d6f6ddf3d68292675569575 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 18:01:13 -0300 Subject: [PATCH 12/36] GUACAMOLE-261: marshal spice-gtk channel calls onto the event-loop thread spice-gtk is not thread-safe: each channel's outbound messages are dispatched by a coroutine on the GMainContext driven by the SPICE client thread, and invoking channel functions from other threads races with that loop via spice_channel_wakeup(). Keyboard, mouse, and clipboard-grab calls were made directly from Guacamole user/handler threads, which can freeze the session under load -- the crash reported against the original SPICE draft (GUACAMOLE-261, Nick Downer: "narrowed the clipboard grab freeze to spice_channel_wakeup"). Add guac_spice_defer_call(), which schedules a spice-gtk call on the default GMainContext (owned by the loop thread) via g_main_context_invoke_full(), and route the off-thread inputs-channel (key/mouse/lock) and main-channel (clipboard grab) calls through it. The clipboard "selection" and "request" paths already ran inside spice-gtk signal handlers (on the loop thread) and are unchanged. Closes #20. --- src/protocols/spice/clipboard.c | 28 ++++++++- src/protocols/spice/input.c | 108 +++++++++++++++++++++++++------- src/protocols/spice/spice.c | 49 +++++++++++++++ src/protocols/spice/spice.h | 54 ++++++++++++++++ 4 files changed, 214 insertions(+), 25 deletions(-) diff --git a/src/protocols/spice/clipboard.c b/src/protocols/spice/clipboard.c index edeb529ac8..51a2b6ec84 100644 --- a/src/protocols/spice/clipboard.c +++ b/src/protocols/spice/clipboard.c @@ -40,6 +40,15 @@ */ #define GUAC_SPICE_CLIPBOARD_SELECTION VD_AGENT_CLIPBOARD_SELECTION_CLIPBOARD +/** + * Handler which performs a deferred SPICE clipboard-selection grab on the + * event-loop thread. The offered types array is carried in call->data. + */ +static void guac_spice_do_clipboard_grab(guac_spice_deferred_call* call) { + spice_main_channel_clipboard_selection_grab((SpiceMainChannel*) call->channel, + call->args[0], (guint32*) call->data, (int) call->args[1]); +} + /** * Signal handler for the SPICE main channel "main-clipboard-selection-grab" * signal, invoked when the remote guest takes ownership of the clipboard. If @@ -209,9 +218,22 @@ int guac_spice_clipboard_end_handler(guac_user* user, guac_stream* stream) { * request the data via "main-clipboard-selection-request". */ if (spice_client->main_channel != NULL && !spice_client->settings->disable_paste) { - guint32 types[] = { VD_AGENT_CLIPBOARD_UTF8_TEXT }; - spice_main_channel_clipboard_selection_grab(spice_client->main_channel, - GUAC_SPICE_CLIPBOARD_SELECTION, types, 1); + + /* Marshal the grab onto the SPICE event-loop thread; this handler runs + * on a Guacamole user thread, and spice-gtk channel functions must not + * be called off the loop thread (see guac_spice_defer_call) */ + guint32* types = g_new(guint32, 1); + types[0] = VD_AGENT_CLIPBOARD_UTF8_TEXT; + + guac_spice_deferred_call* call = g_new0(guac_spice_deferred_call, 1); + call->handler = guac_spice_do_clipboard_grab; + call->channel = spice_client->main_channel; + call->args[0] = GUAC_SPICE_CLIPBOARD_SELECTION; + call->args[1] = 1; /* number of offered types */ + call->data = types; + call->data_len = sizeof(guint32); + guac_spice_defer_call(call); + } return 0; diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index 8850c87d65..fe5edabcc2 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -73,10 +73,83 @@ static int guac_spice_button_mask(int mask) { * @param button_state * The current SPICE button state mask. */ +/* Deferred handlers, run on the SPICE event-loop thread. spice-gtk is not + * thread-safe, so mouse events generated on Guacamole user threads must be + * marshalled onto the loop thread via guac_spice_defer_call(). */ + +static void guac_spice_do_button_press(guac_spice_deferred_call* call) { + spice_inputs_channel_button_press((SpiceInputsChannel*) call->channel, + (int) call->args[0], (int) call->args[1]); +} + +static void guac_spice_do_button_release(guac_spice_deferred_call* call) { + spice_inputs_channel_button_release((SpiceInputsChannel*) call->channel, + (int) call->args[0], (int) call->args[1]); +} + +static void guac_spice_do_motion(guac_spice_deferred_call* call) { + spice_inputs_channel_motion((SpiceInputsChannel*) call->channel, + (int) call->args[0], (int) call->args[1], (int) call->args[2]); +} + +static void guac_spice_do_position(guac_spice_deferred_call* call) { + spice_inputs_channel_position((SpiceInputsChannel*) call->channel, + (int) call->args[0], (int) call->args[1], (int) call->args[2], + (int) call->args[3]); +} + +/** + * Marshals a SPICE mouse button press or release onto the event-loop thread. + */ +static void guac_spice_defer_button(SpiceInputsChannel* inputs, int button, + int mask, int pressed) { + if (inputs == NULL) + return; + guac_spice_deferred_call* call = g_new0(guac_spice_deferred_call, 1); + call->handler = pressed ? guac_spice_do_button_press : guac_spice_do_button_release; + call->channel = inputs; + call->args[0] = (unsigned int) button; + call->args[1] = (unsigned int) mask; + guac_spice_defer_call(call); +} + +/** + * Marshals relative SPICE pointer motion onto the event-loop thread. + */ +static void guac_spice_defer_motion(SpiceInputsChannel* inputs, int dx, int dy, + int mask) { + if (inputs == NULL) + return; + guac_spice_deferred_call* call = g_new0(guac_spice_deferred_call, 1); + call->handler = guac_spice_do_motion; + call->channel = inputs; + call->args[0] = (unsigned int) dx; + call->args[1] = (unsigned int) dy; + call->args[2] = (unsigned int) mask; + guac_spice_defer_call(call); +} + +/** + * Marshals an absolute SPICE pointer position onto the event-loop thread. + */ +static void guac_spice_defer_position(SpiceInputsChannel* inputs, int x, int y, + int display, int mask) { + if (inputs == NULL) + return; + guac_spice_deferred_call* call = g_new0(guac_spice_deferred_call, 1); + call->handler = guac_spice_do_position; + call->channel = inputs; + call->args[0] = (unsigned int) x; + call->args[1] = (unsigned int) y; + call->args[2] = (unsigned int) display; + call->args[3] = (unsigned int) mask; + guac_spice_defer_call(call); +} + static void guac_spice_scroll(SpiceInputsChannel* inputs, int button, int button_state) { - spice_inputs_channel_button_press(inputs, button, button_state); - spice_inputs_channel_button_release(inputs, button, button_state); + guac_spice_defer_button(inputs, button, button_state, 1); + guac_spice_defer_button(inputs, button, button_state, 0); } int guac_spice_user_mouse_handler(guac_user* user, int x, int y, int mask) { @@ -103,39 +176,30 @@ int guac_spice_user_mouse_handler(guac_user* user, int x, int y, int mask) { /* Update pointer position. SPICE client mouse mode accepts absolute * coordinates directly; server mode requires relative motion deltas. */ if (spice_client->mouse_mode == SPICE_MOUSE_MODE_SERVER) - spice_inputs_channel_motion(inputs, + guac_spice_defer_motion(inputs, x - spice_client->last_mouse_x, y - spice_client->last_mouse_y, spice_mask); else - spice_inputs_channel_position(inputs, x, y, 0, spice_mask); + guac_spice_defer_position(inputs, x, y, 0, spice_mask); /* Send press/release events for any of the primary buttons that changed * state since the previous mouse event */ if (spice_mask != prev_mask) { if ((spice_mask & SPICE_MOUSE_BUTTON_MASK_LEFT) - != (prev_mask & SPICE_MOUSE_BUTTON_MASK_LEFT)) { - if (spice_mask & SPICE_MOUSE_BUTTON_MASK_LEFT) - spice_inputs_channel_button_press(inputs, SPICE_MOUSE_BUTTON_LEFT, spice_mask); - else - spice_inputs_channel_button_release(inputs, SPICE_MOUSE_BUTTON_LEFT, spice_mask); - } + != (prev_mask & SPICE_MOUSE_BUTTON_MASK_LEFT)) + guac_spice_defer_button(inputs, SPICE_MOUSE_BUTTON_LEFT, spice_mask, + spice_mask & SPICE_MOUSE_BUTTON_MASK_LEFT); if ((spice_mask & SPICE_MOUSE_BUTTON_MASK_MIDDLE) - != (prev_mask & SPICE_MOUSE_BUTTON_MASK_MIDDLE)) { - if (spice_mask & SPICE_MOUSE_BUTTON_MASK_MIDDLE) - spice_inputs_channel_button_press(inputs, SPICE_MOUSE_BUTTON_MIDDLE, spice_mask); - else - spice_inputs_channel_button_release(inputs, SPICE_MOUSE_BUTTON_MIDDLE, spice_mask); - } + != (prev_mask & SPICE_MOUSE_BUTTON_MASK_MIDDLE)) + guac_spice_defer_button(inputs, SPICE_MOUSE_BUTTON_MIDDLE, spice_mask, + spice_mask & SPICE_MOUSE_BUTTON_MASK_MIDDLE); if ((spice_mask & SPICE_MOUSE_BUTTON_MASK_RIGHT) - != (prev_mask & SPICE_MOUSE_BUTTON_MASK_RIGHT)) { - if (spice_mask & SPICE_MOUSE_BUTTON_MASK_RIGHT) - spice_inputs_channel_button_press(inputs, SPICE_MOUSE_BUTTON_RIGHT, spice_mask); - else - spice_inputs_channel_button_release(inputs, SPICE_MOUSE_BUTTON_RIGHT, spice_mask); - } + != (prev_mask & SPICE_MOUSE_BUTTON_MASK_RIGHT)) + guac_spice_defer_button(inputs, SPICE_MOUSE_BUTTON_RIGHT, spice_mask, + spice_mask & SPICE_MOUSE_BUTTON_MASK_RIGHT); } diff --git a/src/protocols/spice/spice.c b/src/protocols/spice/spice.c index 54675aa3da..79e9cbc867 100644 --- a/src/protocols/spice/spice.c +++ b/src/protocols/spice/spice.c @@ -167,6 +167,55 @@ static int guac_spice_start_sftp(guac_client* client) { } #endif +/** + * GSourceFunc which runs a deferred spice-gtk call on the SPICE event-loop + * thread. Always returns G_SOURCE_REMOVE so the call is dispatched exactly once. + * + * @param user_data + * The guac_spice_deferred_call to dispatch. + * + * @return + * G_SOURCE_REMOVE, always. + */ +static gboolean guac_spice_deferred_run(gpointer user_data) { + + guac_spice_deferred_call* call = (guac_spice_deferred_call*) user_data; + + if (call->handler != NULL) + call->handler(call); + + return G_SOURCE_REMOVE; + +} + +/** + * GDestroyNotify which frees a deferred spice-gtk call and any payload it holds. + * Invoked after the call has been dispatched, or when the loop terminates before + * dispatch, ensuring no leak in either case. + * + * @param user_data + * The guac_spice_deferred_call to free. + */ +static void guac_spice_deferred_free(gpointer user_data) { + + guac_spice_deferred_call* call = (guac_spice_deferred_call*) user_data; + + g_free(call->data); + g_free(call); + +} + +void guac_spice_defer_call(guac_spice_deferred_call* call) { + + /* Schedule the call on the default GMainContext, which is driven by the + * SPICE client thread. If invoked from that thread (e.g. a spice-gtk signal + * handler), it runs immediately; otherwise it is queued and executed by the + * loop, guaranteeing all channel I/O occurs on a single thread. */ + g_main_context_invoke_full(NULL, G_PRIORITY_DEFAULT, + guac_spice_deferred_run, call, guac_spice_deferred_free); + +} + void* guac_spice_client_thread(void* data) { /* Thread name spice-worker: main SPICE client thread; runs the spice-gtk diff --git a/src/protocols/spice/spice.h b/src/protocols/spice/spice.h index 6691f159fe..7d0876ad4c 100644 --- a/src/protocols/spice/spice.h +++ b/src/protocols/spice/spice.h @@ -284,6 +284,60 @@ typedef struct guac_spice_client { } guac_spice_client; +/** + * A spice-gtk channel operation deferred to run on the SPICE event-loop thread. + * + * spice-gtk is not thread-safe: each channel's outbound messages are dispatched + * by a coroutine scheduled on the GMainContext driven by guac_spice_client_thread. + * Invoking channel functions (keyboard, mouse, clipboard) directly from + * Guacamole user/handler threads races with that loop (via spice_channel_wakeup) + * and can freeze the session. Such calls must therefore be marshalled onto the + * loop thread using guac_spice_defer_call(). + */ +typedef struct guac_spice_deferred_call { + + /** + * Handler which performs the actual spice-gtk call. Invoked on the SPICE + * event-loop thread. + */ + void (*handler)(struct guac_spice_deferred_call* call); + + /** + * The spice-gtk channel (SpiceInputsChannel*, SpiceMainChannel*, etc.) the + * call operates on. + */ + gpointer channel; + + /** + * Generic integer arguments for the deferred call. Signed values may be + * stored here and recovered by casting back to int. + */ + unsigned int args[5]; + + /** + * Optional heap-allocated payload (e.g. a copy of clipboard data or a types + * array), or NULL. Freed automatically after the call has been dispatched. + */ + gpointer data; + + /** + * The length of data, in bytes. + */ + gsize data_len; + +} guac_spice_deferred_call; + +/** + * Schedules the given deferred spice-gtk call to run on the SPICE event-loop + * thread. The call structure must be allocated with g_new0(); ownership is + * transferred to this function, which frees both the structure and call->data + * once the call has been dispatched (or the loop has terminated). + * + * @param call + * The heap-allocated deferred call to schedule. + */ +void guac_spice_defer_call(guac_spice_deferred_call* call); + /** * SPICE client thread. This thread establishes the SPICE session and runs the * GLib main loop which drives all SPICE event processing for the duration of From 4058cb6abebb425d1227baef91ac61f4bc7c9eca Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 19:52:44 -0300 Subject: [PATCH 13/36] GUACAMOLE-261: implement client-to-guest display resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disable-display-resize parameter was parsed but never used: there was no user size_handler, so resizing the client window did not resize the guest (only the reverse direction — resizing the Guacamole display to match the SPICE server's surface — was handled). Add guac_spice_user_size_handler, registered for each user unless disable-display-resize is set. It requests that the guest resize its primary display via spice_main_channel_update_display(), marshalled onto the SPICE event-loop thread with guac_spice_defer_call() (spice-gtk channel functions must not be called from user threads). Width is aligned down to a multiple of 8, as SPICE guests generally require. Closes #5. --- src/protocols/spice/input.c | 40 +++++++++++++++++++++++++++++++++++++ src/protocols/spice/input.h | 7 +++++++ src/protocols/spice/user.c | 4 ++++ 3 files changed, 51 insertions(+) diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index fe5edabcc2..71646fdc9b 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -247,3 +247,43 @@ int guac_spice_user_key_handler(guac_user* user, int keysym, int pressed) { return retval; } + +/** + * Handler which performs a deferred SPICE primary-display resize on the + * event-loop thread. The requested width and height are carried in the + * deferred call's args. + */ +static void guac_spice_do_update_display(guac_spice_deferred_call* call) { + spice_main_channel_update_display((SpiceMainChannel*) call->channel, + 0, 0, 0, (int) call->args[0], (int) call->args[1], TRUE); +} + +int guac_spice_user_size_handler(guac_user* user, int width, int height) { + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Ignore if the main channel is not yet ready or the requested size is + * degenerate */ + if (spice_client->main_channel == NULL || width <= 0 || height <= 0) + return 0; + + /* SPICE guests generally require the display width to be a multiple of 8 */ + width &= ~0x7; + + guac_client_log(client, GUAC_LOG_DEBUG, + "Requesting guest display resize to %dx%d", width, height); + + /* Marshal the display-configuration update onto the SPICE event-loop + * thread; spice-gtk channel functions must not be called from user + * threads (see guac_spice_defer_call) */ + guac_spice_deferred_call* call = g_new0(guac_spice_deferred_call, 1); + call->handler = guac_spice_do_update_display; + call->channel = spice_client->main_channel; + call->args[0] = (unsigned int) width; + call->args[1] = (unsigned int) height; + guac_spice_defer_call(call); + + return 0; + +} diff --git a/src/protocols/spice/input.h b/src/protocols/spice/input.h index b2d60c1b82..8b18289e51 100644 --- a/src/protocols/spice/input.h +++ b/src/protocols/spice/input.h @@ -34,4 +34,11 @@ guac_user_mouse_handler guac_spice_user_mouse_handler; */ guac_user_key_handler guac_spice_user_key_handler; +/** + * Handler for Guacamole size events, requesting that the SPICE guest resize its + * primary display to match the connected client's resolution (unless display + * resize has been disabled). + */ +guac_user_size_handler guac_spice_user_size_handler; + #endif diff --git a/src/protocols/spice/user.c b/src/protocols/spice/user.c index 1ebb881245..49965884f7 100644 --- a/src/protocols/spice/user.c +++ b/src/protocols/spice/user.c @@ -85,6 +85,10 @@ int guac_spice_user_join_handler(guac_user* user, int argc, char** argv) { user->mouse_handler = guac_spice_user_mouse_handler; user->key_handler = guac_spice_user_key_handler; + /* Dynamic display resize (client resolution -> guest), unless disabled */ + if (!settings->disable_display_resize) + user->size_handler = guac_spice_user_size_handler; + /* Inbound (client-to-server) clipboard transfer */ if (!settings->disable_paste && !settings->disable_clipboard) user->clipboard_handler = guac_spice_clipboard_handler; From 61dd550c4408abae13245af9451e47b3097ff9b9 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 20:02:02 -0300 Subject: [PATCH 14/36] GUACAMOLE-261: enable the primary monitor before resizing so the guest connects the output --- src/protocols/spice/input.c | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index 71646fdc9b..0a759bfcee 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -254,8 +254,14 @@ int guac_spice_user_key_handler(guac_user* user, int keysym, int pressed) { * deferred call's args. */ static void guac_spice_do_update_display(guac_spice_deferred_call* call) { - spice_main_channel_update_display((SpiceMainChannel*) call->channel, - 0, 0, 0, (int) call->args[0], (int) call->args[1], TRUE); + SpiceMainChannel* main_channel = (SpiceMainChannel*) call->channel; + + /* Ensure the primary monitor is enabled so the guest connects the output, + * then set its geometry and push the resulting monitors config to the + * guest agent */ + spice_main_channel_update_display_enabled(main_channel, 0, TRUE, FALSE); + spice_main_channel_update_display(main_channel, 0, 0, 0, + (int) call->args[0], (int) call->args[1], TRUE); } int guac_spice_user_size_handler(guac_user* user, int width, int height) { From 18dde7e83a56b1875d133ad48ea20139ee7f0597 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 20:18:11 -0300 Subject: [PATCH 15/36] GUACAMOLE-261: force monitors-config send on resize so successive resizes reach the guest agent spice-gtk debounces the implicit monitors-config send (update=TRUE), so only the first resize per session reached spice-vdagent. Set the geometry without the implicit send and call spice_main_channel_send_monitor_config() explicitly. --- src/protocols/spice/input.c | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index 0a759bfcee..f6122ae216 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -257,11 +257,16 @@ static void guac_spice_do_update_display(guac_spice_deferred_call* call) { SpiceMainChannel* main_channel = (SpiceMainChannel*) call->channel; /* Ensure the primary monitor is enabled so the guest connects the output, - * then set its geometry and push the resulting monitors config to the - * guest agent */ + * and set its geometry. The implicit (update=TRUE) send is debounced by a + * timer within spice-gtk and does not reliably re-fire for successive + * resizes, so request no automatic send here... */ spice_main_channel_update_display_enabled(main_channel, 0, TRUE, FALSE); spice_main_channel_update_display(main_channel, 0, 0, 0, - (int) call->args[0], (int) call->args[1], TRUE); + (int) call->args[0], (int) call->args[1], FALSE); + + /* ...and explicitly push the resulting monitors config to the guest agent, + * ensuring every resize request reaches spice-vdagent */ + spice_main_channel_send_monitor_config(main_channel); } int guac_spice_user_size_handler(guac_user* user, int width, int height) { From 96fe3642d6b80a9b1384aac5802b5bde4f1d7883 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 20:23:01 -0300 Subject: [PATCH 16/36] GUACAMOLE-261: revert to update_display(...,update=TRUE) for resize spice_main_channel_send_monitor_config() did not send the config (the first resize stopped working), so revert to the implicit send via update=TRUE, which reliably applies the first resize of a session. --- src/protocols/spice/input.c | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index f6122ae216..0a759bfcee 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -257,16 +257,11 @@ static void guac_spice_do_update_display(guac_spice_deferred_call* call) { SpiceMainChannel* main_channel = (SpiceMainChannel*) call->channel; /* Ensure the primary monitor is enabled so the guest connects the output, - * and set its geometry. The implicit (update=TRUE) send is debounced by a - * timer within spice-gtk and does not reliably re-fire for successive - * resizes, so request no automatic send here... */ + * then set its geometry and push the resulting monitors config to the + * guest agent */ spice_main_channel_update_display_enabled(main_channel, 0, TRUE, FALSE); spice_main_channel_update_display(main_channel, 0, 0, 0, - (int) call->args[0], (int) call->args[1], FALSE); - - /* ...and explicitly push the resulting monitors config to the guest agent, - * ensuring every resize request reaches spice-vdagent */ - spice_main_channel_send_monitor_config(main_channel); + (int) call->args[0], (int) call->args[1], TRUE); } int guac_spice_user_size_handler(guac_user* user, int width, int height) { From 0978423b50c43738efc413e8c2be451620d9fddb Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 20:47:11 -0300 Subject: [PATCH 17/36] GUACAMOLE-261: gate display resize on guest readiness and send deterministically Dynamic resize was unreliable: the size handler sent a monitors config at a fixed point after connect, but spice-gtk only delivers one when the agent is connected with VD_AGENT_CAP_MONITORS_CONFIG and the display's primary surface exists; sent earlier it is silently dropped. update_display(...,update=TRUE) also only arms a one-second coalescing timer, so successive resizes collapsed or were lost. Queue the requested size and send it only once ready: track agent readiness via the main channel "notify::agent-connected" (checking the monitors-config capability) and display readiness via display-primary-create. guac_spice_resize_try() sends the queued size with an explicit spice_main_channel_send_monitor_config() (update=FALSE) so every resize is delivered, and is re-invoked whenever a readiness condition changes to flush a request queued before the guest was ready. Refs #5. --- src/protocols/spice/channels.c | 7 +++ src/protocols/spice/display.c | 6 +++ src/protocols/spice/input.c | 98 ++++++++++++++++++++++++++++------ src/protocols/spice/input.h | 20 +++++++ src/protocols/spice/spice.h | 14 +++++ 5 files changed, 128 insertions(+), 17 deletions(-) diff --git a/src/protocols/spice/channels.c b/src/protocols/spice/channels.c index 781c9b5fcc..6108cddb49 100644 --- a/src/protocols/spice/channels.c +++ b/src/protocols/spice/channels.c @@ -24,6 +24,7 @@ #include "clipboard.h" #include "cursor.h" #include "display.h" +#include "input.h" #include "spice.h" #include @@ -113,6 +114,12 @@ void guac_spice_channel_new(SpiceSession* session, SpiceChannel* channel, spice_client->main_channel = SPICE_MAIN_CHANNEL(channel); g_signal_connect(channel, "main-mouse-update", G_CALLBACK(guac_spice_main_mouse_update), client); + + /* Track agent readiness (connection + monitors-config support) so a + * queued dynamic display resize can be sent once the guest is ready */ + g_signal_connect(channel, "notify::agent-connected", + G_CALLBACK(guac_spice_resize_agent_update), client); + guac_spice_clipboard_connect(client, SPICE_MAIN_CHANNEL(channel)); } diff --git a/src/protocols/spice/display.c b/src/protocols/spice/display.c index b052b1bc0e..15983731de 100644 --- a/src/protocols/spice/display.c +++ b/src/protocols/spice/display.c @@ -20,6 +20,7 @@ #include "config.h" #include "display.h" +#include "input.h" #include "spice.h" #include @@ -63,6 +64,11 @@ static void guac_spice_display_primary_create(SpiceChannel* channel, guac_display_default_layer(spice_client->display), width, height); + /* The display is now ready to receive a guest resize; flush any queued + * client-requested resize that was waiting on the primary surface */ + spice_client->resize_display_ready = 1; + guac_spice_resize_try(client); + } /** diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index 0a759bfcee..3169c5b769 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -29,6 +29,7 @@ #include #include #include +#include /** * Translates a Guacamole mouse button mask into the equivalent SPICE button @@ -253,15 +254,81 @@ int guac_spice_user_key_handler(guac_user* user, int keysym, int pressed) { * event-loop thread. The requested width and height are carried in the * deferred call's args. */ -static void guac_spice_do_update_display(guac_spice_deferred_call* call) { - SpiceMainChannel* main_channel = (SpiceMainChannel*) call->channel; - - /* Ensure the primary monitor is enabled so the guest connects the output, - * then set its geometry and push the resulting monitors config to the - * guest agent */ - spice_main_channel_update_display_enabled(main_channel, 0, TRUE, FALSE); - spice_main_channel_update_display(main_channel, 0, 0, 0, - (int) call->args[0], (int) call->args[1], TRUE); +void guac_spice_resize_try(guac_client* client) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Nothing to do unless a resize is queued */ + if (!spice_client->resize_pending) + return; + + /* Wait until the guest is ready to receive a monitors config: the main + * channel must be up, the agent connected and advertising monitors-config + * support, and the display's primary surface created. spice-gtk silently + * drops a monitors config sent before these conditions hold. */ + if (spice_client->main_channel == NULL + || !spice_client->resize_agent_ready + || !spice_client->resize_display_ready) + return; + + int width = spice_client->resize_pending_width; + int height = spice_client->resize_pending_height; + + /* Enable and size monitor 0 without the debounced implicit send, then push + * the monitors config to the guest agent immediately. The implicit + * (update=TRUE) send arms a one-second coalescing timer within spice-gtk + * and does not reliably re-fire for successive resizes, so send + * explicitly instead. */ + spice_main_channel_update_display_enabled(spice_client->main_channel, 0, TRUE, FALSE); + spice_main_channel_update_display(spice_client->main_channel, 0, 0, 0, + width, height, FALSE); + spice_main_channel_send_monitor_config(spice_client->main_channel); + + spice_client->resize_pending = 0; + guac_client_log(client, GUAC_LOG_DEBUG, + "Sent guest display resize to %dx%d", width, height); + +} + +void guac_spice_resize_agent_update(SpiceMainChannel* channel, GParamSpec* pspec, + guac_client* client) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + gboolean connected = FALSE; + g_object_get(channel, "agent-connected", &connected, NULL); + + /* The agent can drive a resize only if it is connected and advertises + * monitors-config support */ + spice_client->resize_agent_ready = (connected + && spice_main_channel_agent_test_capability(channel, + VD_AGENT_CAP_MONITORS_CONFIG)); + + guac_client_log(client, GUAC_LOG_DEBUG, + "SPICE agent %s (monitors-config resize %s)", + connected ? "connected" : "disconnected", + spice_client->resize_agent_ready ? "available" : "unavailable"); + + /* A resize may have been queued before the agent became ready */ + guac_spice_resize_try(client); + +} + +/** + * Handler which, on the SPICE event-loop thread, records a queued resize + * request and attempts to send it (subject to guest readiness). + */ +static void guac_spice_do_queue_resize(guac_spice_deferred_call* call) { + + guac_client* client = (guac_client*) call->channel; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + spice_client->resize_pending_width = (int) call->args[0]; + spice_client->resize_pending_height = (int) call->args[1]; + spice_client->resize_pending = 1; + + guac_spice_resize_try(client); + } int guac_spice_user_size_handler(guac_user* user, int width, int height) { @@ -277,15 +344,12 @@ int guac_spice_user_size_handler(guac_user* user, int width, int height) { /* SPICE guests generally require the display width to be a multiple of 8 */ width &= ~0x7; - guac_client_log(client, GUAC_LOG_DEBUG, - "Requesting guest display resize to %dx%d", width, height); - - /* Marshal the display-configuration update onto the SPICE event-loop - * thread; spice-gtk channel functions must not be called from user - * threads (see guac_spice_defer_call) */ + /* Queue the resize on the SPICE event-loop thread; spice-gtk channel + * functions must not be called from user threads (see guac_spice_defer_call). + * The actual send is gated on guest readiness within guac_spice_resize_try. */ guac_spice_deferred_call* call = g_new0(guac_spice_deferred_call, 1); - call->handler = guac_spice_do_update_display; - call->channel = spice_client->main_channel; + call->handler = guac_spice_do_queue_resize; + call->channel = client; call->args[0] = (unsigned int) width; call->args[1] = (unsigned int) height; guac_spice_defer_call(call); diff --git a/src/protocols/spice/input.h b/src/protocols/spice/input.h index 8b18289e51..103e3641d0 100644 --- a/src/protocols/spice/input.h +++ b/src/protocols/spice/input.h @@ -21,6 +21,7 @@ #define GUAC_SPICE_INPUT_H #include +#include /** * Handler for Guacamole mouse events, translating them into SPICE pointer @@ -41,4 +42,23 @@ guac_user_key_handler guac_spice_user_key_handler; */ guac_user_size_handler guac_spice_user_size_handler; +/** + * Attempts to send any queued guest display-resize request, provided the guest + * is ready (agent connected with monitors-config support and the display's + * primary surface created). Must be called from the SPICE event-loop thread. + * Invoked both when a resize is queued and when a readiness condition changes. + * + * @param client + * The guac_client associated with the SPICE connection. + */ +void guac_spice_resize_try(guac_client* client); + +/** + * Signal handler for the SPICE main channel "notify::agent-connected" event. + * Updates display-resize readiness based on the agent's connection state and + * monitors-config capability, then flushes any queued resize. + */ +void guac_spice_resize_agent_update(SpiceMainChannel* channel, + GParamSpec* pspec, guac_client* client); + #endif diff --git a/src/protocols/spice/spice.h b/src/protocols/spice/spice.h index 7d0876ad4c..26728b3705 100644 --- a/src/protocols/spice/spice.h +++ b/src/protocols/spice/spice.h @@ -194,6 +194,20 @@ typedef struct guac_spice_client { */ int last_mouse_y; + /** + * Dynamic display-resize state. All fields are accessed only from the SPICE + * event-loop thread. A client-requested resize is queued in + * resize_pending_width/height and sent to the guest only once the SPICE + * agent (advertising monitors-config support) and the display's primary + * surface are both ready — the readiness spice-gtk itself requires before a + * monitors config will actually be sent. + */ + int resize_agent_ready; + int resize_display_ready; + int resize_pending; + int resize_pending_width; + int resize_pending_height; + /** * Internal clipboard. */ From c7a4a1f9f0408644bb073c50ea6ab66f95a04627 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 20:51:00 -0300 Subject: [PATCH 18/36] GUACAMOLE-261: re-check agent monitors-config capability on main-agent-update notify::agent-connected fires before the guest agent announces its capabilities, so VD_AGENT_CAP_MONITORS_CONFIG was not yet available and the gated resize never sent. Also watch main-agent-update (emitted when capabilities arrive) and re-evaluate readiness there, flushing any queued resize. --- src/protocols/spice/channels.c | 6 +++++- src/protocols/spice/input.c | 33 +++++++++++++++++++++++++-------- src/protocols/spice/input.h | 9 +++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/protocols/spice/channels.c b/src/protocols/spice/channels.c index 6108cddb49..54d204ce74 100644 --- a/src/protocols/spice/channels.c +++ b/src/protocols/spice/channels.c @@ -116,9 +116,13 @@ void guac_spice_channel_new(SpiceSession* session, SpiceChannel* channel, G_CALLBACK(guac_spice_main_mouse_update), client); /* Track agent readiness (connection + monitors-config support) so a - * queued dynamic display resize can be sent once the guest is ready */ + * queued dynamic display resize can be sent once the guest is ready. + * The capability is announced after connection, so watch both the + * connection state and subsequent agent updates. */ g_signal_connect(channel, "notify::agent-connected", G_CALLBACK(guac_spice_resize_agent_update), client); + g_signal_connect(channel, "main-agent-update", + G_CALLBACK(guac_spice_resize_agent_updated), client); guac_spice_clipboard_connect(client, SPICE_MAIN_CHANNEL(channel)); } diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index 3169c5b769..9be1a46cdd 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -290,7 +290,13 @@ void guac_spice_resize_try(guac_client* client) { } -void guac_spice_resize_agent_update(SpiceMainChannel* channel, GParamSpec* pspec, +/** + * Re-evaluates whether the SPICE agent can drive a display resize (connected and + * advertising monitors-config support) and flushes any queued resize. The + * monitors-config capability is announced shortly after the agent connects, so + * this is invoked both on agent connection and on subsequent agent updates. + */ +static void guac_spice_resize_set_agent_ready(SpiceMainChannel* channel, guac_client* client) { guac_spice_client* spice_client = (guac_spice_client*) client->data; @@ -298,22 +304,33 @@ void guac_spice_resize_agent_update(SpiceMainChannel* channel, GParamSpec* pspec gboolean connected = FALSE; g_object_get(channel, "agent-connected", &connected, NULL); - /* The agent can drive a resize only if it is connected and advertises - * monitors-config support */ - spice_client->resize_agent_ready = (connected + int ready = (connected && spice_main_channel_agent_test_capability(channel, VD_AGENT_CAP_MONITORS_CONFIG)); - guac_client_log(client, GUAC_LOG_DEBUG, - "SPICE agent %s (monitors-config resize %s)", - connected ? "connected" : "disconnected", - spice_client->resize_agent_ready ? "available" : "unavailable"); + /* Log only on a change to avoid noise from repeated agent updates */ + if (ready != spice_client->resize_agent_ready) + guac_client_log(client, GUAC_LOG_DEBUG, + "SPICE agent monitors-config resize %s", + ready ? "available" : "unavailable"); + + spice_client->resize_agent_ready = ready; /* A resize may have been queued before the agent became ready */ guac_spice_resize_try(client); } +void guac_spice_resize_agent_update(SpiceMainChannel* channel, GParamSpec* pspec, + guac_client* client) { + guac_spice_resize_set_agent_ready(channel, client); +} + +void guac_spice_resize_agent_updated(SpiceMainChannel* channel, + guac_client* client) { + guac_spice_resize_set_agent_ready(channel, client); +} + /** * Handler which, on the SPICE event-loop thread, records a queued resize * request and attempts to send it (subject to guest readiness). diff --git a/src/protocols/spice/input.h b/src/protocols/spice/input.h index 103e3641d0..3d19eb6a5a 100644 --- a/src/protocols/spice/input.h +++ b/src/protocols/spice/input.h @@ -61,4 +61,13 @@ void guac_spice_resize_try(guac_client* client); void guac_spice_resize_agent_update(SpiceMainChannel* channel, GParamSpec* pspec, guac_client* client); +/** + * Signal handler for the SPICE main channel "main-agent-update" event, invoked + * when the guest agent's state or capabilities change (the monitors-config + * capability is announced shortly after the agent connects). Re-evaluates + * display-resize readiness and flushes any queued resize. + */ +void guac_spice_resize_agent_updated(SpiceMainChannel* channel, + guac_client* client); + #endif From 9e153848e1ff247073fb4172d9aae1f322bf29b1 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 20:55:21 -0300 Subject: [PATCH 19/36] GUACAMOLE-261: deliver gated resize via update_display timer send send_monitor_config() did not reliably deliver the monitors config to the guest agent (session vdagent received nothing). With the readiness gate now ensuring the agent is connected and monitors-config-capable, use update=TRUE (spice-gtk's short coalescing-timer send), which does reach the agent; real resizes are spaced beyond the timer interval so each is delivered. --- src/protocols/spice/input.c | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index 9be1a46cdd..d0cce363a0 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -274,15 +274,15 @@ void guac_spice_resize_try(guac_client* client) { int width = spice_client->resize_pending_width; int height = spice_client->resize_pending_height; - /* Enable and size monitor 0 without the debounced implicit send, then push - * the monitors config to the guest agent immediately. The implicit - * (update=TRUE) send arms a one-second coalescing timer within spice-gtk - * and does not reliably re-fire for successive resizes, so send - * explicitly instead. */ + /* Enable and size monitor 0, requesting the update be delivered to the + * guest agent. update=TRUE schedules the monitors config on spice-gtk's + * short coalescing timer; because we only reach this point once the guest + * is ready and real resizes are spaced further apart than the timer + * interval, each resize is delivered. (send_monitor_config() was tried but + * does not reliably deliver the config to the agent.) */ spice_main_channel_update_display_enabled(spice_client->main_channel, 0, TRUE, FALSE); spice_main_channel_update_display(spice_client->main_channel, 0, 0, 0, - width, height, FALSE); - spice_main_channel_send_monitor_config(spice_client->main_channel); + width, height, TRUE); spice_client->resize_pending = 0; guac_client_log(client, GUAC_LOG_DEBUG, From e6124e6edc7098db814d6d71dd0e5e0107f45291 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 20:59:17 -0300 Subject: [PATCH 20/36] GUACAMOLE-261: TEMP diagnostic - log send_monitor_config return + agent cap --- src/protocols/spice/input.c | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index d0cce363a0..c408a79d7a 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -274,19 +274,18 @@ void guac_spice_resize_try(guac_client* client) { int width = spice_client->resize_pending_width; int height = spice_client->resize_pending_height; - /* Enable and size monitor 0, requesting the update be delivered to the - * guest agent. update=TRUE schedules the monitors config on spice-gtk's - * short coalescing timer; because we only reach this point once the guest - * is ready and real resizes are spaced further apart than the timer - * interval, each resize is delivered. (send_monitor_config() was tried but - * does not reliably deliver the config to the agent.) */ + /* Enable and size monitor 0, then explicitly push the monitors config */ spice_main_channel_update_display_enabled(spice_client->main_channel, 0, TRUE, FALSE); spice_main_channel_update_display(spice_client->main_channel, 0, 0, 0, - width, height, TRUE); + width, height, FALSE); + gboolean sent = spice_main_channel_send_monitor_config(spice_client->main_channel); spice_client->resize_pending = 0; - guac_client_log(client, GUAC_LOG_DEBUG, - "Sent guest display resize to %dx%d", width, height); + guac_client_log(client, GUAC_LOG_INFO, + "DIAG: resize %dx%d send_monitor_config=%d agent_cap=%d", + width, height, sent, + spice_main_channel_agent_test_capability(spice_client->main_channel, + VD_AGENT_CAP_MONITORS_CONFIG)); } From e18592ca29a7037d7d00a744f8aa00cc1409ef5e Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 23:47:57 -0300 Subject: [PATCH 21/36] GUACAMOLE-261: finalize gated resize send (drop diagnostic logging) The readiness gate (agent connected + VD_AGENT_CAP_MONITORS_CONFIG, detected via main-agent-update) plus explicit spice_main_channel_send_monitor_config() is validated: send_monitor_config returns TRUE and the config is delivered to the guest agent every resize. Remove the temporary diagnostic INFO logging. --- src/protocols/spice/input.c | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index c408a79d7a..997ff123aa 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -274,18 +274,17 @@ void guac_spice_resize_try(guac_client* client) { int width = spice_client->resize_pending_width; int height = spice_client->resize_pending_height; - /* Enable and size monitor 0, then explicitly push the monitors config */ + /* Enable and size monitor 0, then explicitly push the monitors config to + * the guest agent. Sending explicitly (rather than via update_display's + * debounced timer) ensures every resize is delivered deterministically. */ spice_main_channel_update_display_enabled(spice_client->main_channel, 0, TRUE, FALSE); spice_main_channel_update_display(spice_client->main_channel, 0, 0, 0, width, height, FALSE); - gboolean sent = spice_main_channel_send_monitor_config(spice_client->main_channel); + spice_main_channel_send_monitor_config(spice_client->main_channel); spice_client->resize_pending = 0; - guac_client_log(client, GUAC_LOG_INFO, - "DIAG: resize %dx%d send_monitor_config=%d agent_cap=%d", - width, height, sent, - spice_main_channel_agent_test_capability(spice_client->main_channel, - VD_AGENT_CAP_MONITORS_CONFIG)); + guac_client_log(client, GUAC_LOG_DEBUG, + "Sent guest display resize to %dx%d", width, height); } From 6415740bb6c4e975e400b0b91e1b08637536e317 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Fri, 3 Jul 2026 08:43:02 -0300 Subject: [PATCH 22/36] GUACAMOLE-261: request efficient video codecs (H264/VP9/VP8) for streamed regions spice-server chooses the video-stream codec from an ordered list whose default puts MJPEG first, and only reorders it when the client sends a preferred-codec message. guacd never sent one, so streamed video was always MJPEG even when the GStreamer H264/VP9/VP8 encoders/decoders are present. On display-channel open, call spice_display_channel_change_preferred_video_codec_types() with [H264, VP9, VP8, MJPEG] so the server prefers efficient codecs (MJPEG fallback). No-op if the server lacks SPICE_DISPLAY_CAP_PREF_VIDEO_CODEC_TYPE. --- src/protocols/spice/channels.c | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/protocols/spice/channels.c b/src/protocols/spice/channels.c index 54d204ce74..318ecacc7d 100644 --- a/src/protocols/spice/channels.c +++ b/src/protocols/spice/channels.c @@ -45,6 +45,40 @@ static void guac_spice_channel_event(SpiceChannel* channel, case SPICE_CHANNEL_OPENED: guac_client_log(client, GUAC_LOG_DEBUG, "SPICE channel opened."); + + /* Once the display channel is open (capabilities negotiated), + * request that the server prefer efficient GStreamer video codecs + * for streamed video regions. The spice-server's default codec + * order lists MJPEG first, so without an explicit client preference + * the streamed codec is always MJPEG even when H.264/VP9/VP8 + * encoding is available. This sends the preference (falling back to + * MJPEG) and is a no-op if the server lacks the capability. Runs on + * the SPICE event-loop thread, so no marshalling is required. */ + if (SPICE_IS_DISPLAY_CHANNEL(channel)) { + + const gint preferred_codecs[] = { + SPICE_VIDEO_CODEC_TYPE_H264, + SPICE_VIDEO_CODEC_TYPE_VP9, + SPICE_VIDEO_CODEC_TYPE_VP8, + SPICE_VIDEO_CODEC_TYPE_MJPEG + }; + + GError* codec_error = NULL; + if (spice_display_channel_change_preferred_video_codec_types( + channel, preferred_codecs, + G_N_ELEMENTS(preferred_codecs), &codec_error)) + guac_client_log(client, GUAC_LOG_DEBUG, + "Requested preferred SPICE video codecs " + "(H264, VP9, VP8, MJPEG)."); + else { + guac_client_log(client, GUAC_LOG_DEBUG, + "Preferred video codec selection unavailable: %s", + codec_error != NULL ? codec_error->message + : "unsupported by server"); + g_clear_error(&codec_error); + } + + } break; case SPICE_CHANNEL_ERROR_AUTH: From 5e2e4323034cba49beeb11182827e4467aee822e Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Fri, 3 Jul 2026 08:58:08 -0300 Subject: [PATCH 23/36] GUACAMOLE-261: make preferred video codec opt-in via preferred-video-codec param Unconditionally requesting H.264 exposed a crash: libspice-server 0.15.2's GStreamer encoder segfaults when it actually encodes H.264/VP8/VP9, taking down the whole VM (MJPEG never crashes). Requesting a non-MJPEG codec by default is therefore unsafe against buggy servers. Gate the preferred-codec request behind a new "preferred-video-codec" parameter (h264|vp9|vp8|mjpeg), NULL/blank by default so guacd leaves the server's default (MJPEG-first) untouched and never triggers the crash out of the box. When set, send the requested codec first with MJPEG appended as fallback. Opt-in lets deployments with a fixed/newer spice-server use efficient codecs. --- src/protocols/spice/channels.c | 92 +++++++++++++++++++++++----------- src/protocols/spice/settings.c | 15 ++++++ src/protocols/spice/settings.h | 11 ++++ 3 files changed, 88 insertions(+), 30 deletions(-) diff --git a/src/protocols/spice/channels.c b/src/protocols/spice/channels.c index 318ecacc7d..c18e4d0e16 100644 --- a/src/protocols/spice/channels.c +++ b/src/protocols/spice/channels.c @@ -31,6 +31,8 @@ #include #include +#include + /** * Signal handler for the "channel-event" signal of any SPICE channel. Handles * connection-level errors and closure by aborting or stopping the Guacamole @@ -40,42 +42,72 @@ static void guac_spice_channel_event(SpiceChannel* channel, SpiceChannelEvent event, gpointer data) { guac_client* client = (guac_client*) data; + guac_spice_client* spice_client = (guac_spice_client*) client->data; switch (event) { case SPICE_CHANNEL_OPENED: guac_client_log(client, GUAC_LOG_DEBUG, "SPICE channel opened."); - /* Once the display channel is open (capabilities negotiated), - * request that the server prefer efficient GStreamer video codecs - * for streamed video regions. The spice-server's default codec - * order lists MJPEG first, so without an explicit client preference - * the streamed codec is always MJPEG even when H.264/VP9/VP8 - * encoding is available. This sends the preference (falling back to - * MJPEG) and is a no-op if the server lacks the capability. Runs on - * the SPICE event-loop thread, so no marshalling is required. */ - if (SPICE_IS_DISPLAY_CHANNEL(channel)) { - - const gint preferred_codecs[] = { - SPICE_VIDEO_CODEC_TYPE_H264, - SPICE_VIDEO_CODEC_TYPE_VP9, - SPICE_VIDEO_CODEC_TYPE_VP8, - SPICE_VIDEO_CODEC_TYPE_MJPEG - }; - - GError* codec_error = NULL; - if (spice_display_channel_change_preferred_video_codec_types( - channel, preferred_codecs, - G_N_ELEMENTS(preferred_codecs), &codec_error)) - guac_client_log(client, GUAC_LOG_DEBUG, - "Requested preferred SPICE video codecs " - "(H264, VP9, VP8, MJPEG)."); - else { - guac_client_log(client, GUAC_LOG_DEBUG, - "Preferred video codec selection unavailable: %s", - codec_error != NULL ? codec_error->message - : "unsupported by server"); - g_clear_error(&codec_error); + /* Once the display channel is open (capabilities negotiated), and + * only if the user configured a preferred video codec, ask the + * server to prefer it for streamed video regions. The server's + * default codec order lists MJPEG first, so without an explicit + * preference the streamed codec is always MJPEG even when + * H.264/VP9/VP8 encoding is available. This is opt-in (NULL by + * default) because some spice-server builds crash when actually + * encoding a GStreamer codec. Runs on the SPICE event-loop thread, + * so no marshalling is required. */ + if (SPICE_IS_DISPLAY_CHANNEL(channel) + && spice_client->settings->preferred_video_codec != NULL) { + + const char* pref = spice_client->settings->preferred_video_codec; + + /* Build an ordered preference list: the requested codec first, + * with MJPEG appended as a universally-supported fallback. */ + gint codecs[4]; + int n = 0; + + if (strcmp(pref, "h264") == 0) { + codecs[n++] = SPICE_VIDEO_CODEC_TYPE_H264; + codecs[n++] = SPICE_VIDEO_CODEC_TYPE_VP9; + codecs[n++] = SPICE_VIDEO_CODEC_TYPE_VP8; + } + else if (strcmp(pref, "vp9") == 0) { + codecs[n++] = SPICE_VIDEO_CODEC_TYPE_VP9; + codecs[n++] = SPICE_VIDEO_CODEC_TYPE_VP8; + codecs[n++] = SPICE_VIDEO_CODEC_TYPE_H264; + } + else if (strcmp(pref, "vp8") == 0) { + codecs[n++] = SPICE_VIDEO_CODEC_TYPE_VP8; + codecs[n++] = SPICE_VIDEO_CODEC_TYPE_VP9; + codecs[n++] = SPICE_VIDEO_CODEC_TYPE_H264; + } + else if (strcmp(pref, "mjpeg") != 0) { + guac_client_log(client, GUAC_LOG_WARNING, + "Ignoring unknown preferred-video-codec \"%s\" " + "(expected h264, vp9, vp8, or mjpeg).", pref); + n = -1; + } + + if (n >= 0) { + + codecs[n++] = SPICE_VIDEO_CODEC_TYPE_MJPEG; + + GError* codec_error = NULL; + if (spice_display_channel_change_preferred_video_codec_types( + channel, codecs, n, &codec_error)) + guac_client_log(client, GUAC_LOG_INFO, + "Requested SPICE server prefer \"%s\" video " + "encoding for streamed regions.", pref); + else { + guac_client_log(client, GUAC_LOG_DEBUG, + "Preferred video codec selection unavailable: " + "%s", codec_error != NULL ? codec_error->message + : "unsupported by server"); + g_clear_error(&codec_error); + } + } } diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c index f367c46be1..af891e6519 100644 --- a/src/protocols/spice/settings.c +++ b/src/protocols/spice/settings.c @@ -98,6 +98,7 @@ const char* GUAC_SPICE_CLIENT_ARGS[] = { "wol-broadcast-addr", "wol-udp-port", "wol-wait-time", + "preferred-video-codec", NULL }; @@ -439,6 +440,15 @@ enum SPICE_ARGS_IDX { */ IDX_WOL_WAIT_TIME, + /** + * The video codec the SPICE server should prefer for streamed video + * regions ("h264", "vp9", "vp8", or "mjpeg"), or blank to leave the + * server's default preference (which starts with MJPEG) untouched. + * Requesting a non-MJPEG codec depends on the server being built with, and + * having, a working GStreamer encoder for it. + */ + IDX_PREFERRED_VIDEO_CODEC, + SPICE_ARGS_COUNT }; @@ -552,6 +562,10 @@ guac_spice_settings* guac_spice_parse_args(guac_user* user, guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_DISABLE_AUDIO_OPUS, false); + settings->preferred_video_codec = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_PREFERRED_VIDEO_CODEC, NULL); + settings->enable_drive = guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_ENABLE_DRIVE, false); @@ -719,6 +733,7 @@ void guac_spice_settings_free(guac_spice_settings* settings) { /* Free settings strings */ guac_mem_free(settings->hostname); + guac_mem_free(settings->preferred_video_codec); guac_mem_free(settings->port); guac_mem_free(settings->tls_port); guac_mem_free(settings->username); diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h index 6ff5be9fb0..12c904cf52 100644 --- a/src/protocols/spice/settings.h +++ b/src/protocols/spice/settings.h @@ -204,6 +204,17 @@ typedef struct guac_spice_settings { */ bool disable_audio_opus; + /** + * The video codec the SPICE server should be asked to prefer for streamed + * video regions ("h264", "vp9", "vp8", or "mjpeg"), or NULL to leave the + * server's default preference untouched. The server's default lists MJPEG + * first, so an explicit preference is required for it to use a GStreamer + * codec. NULL by default because requesting a non-MJPEG codec relies on the + * server having a working GStreamer encoder for it (some spice-server + * builds crash when encoding H.264/VP8/VP9). + */ + char* preferred_video_codec; + /** * Whether folder sharing (shared directory via the SPICE WebDAV channel) * should be enabled. From d848c80d0685056c8188783390de177ec1c083e7 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sat, 4 Jul 2026 00:47:17 -0300 Subject: [PATCH 24/36] GUACAMOLE-261: apply drive-read-only to the shared folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drive-read-only parameter was parsed and logged but never applied — guacd set spice-gtk's shared-dir but not share-dir-ro, so drive-read-only=true was a no-op and the shared folder was always writable. Set share-dir-ro from settings->drive_read_only. --- src/protocols/spice/auth.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/protocols/spice/auth.c b/src/protocols/spice/auth.c index e205c73053..e6d19481ce 100644 --- a/src/protocols/spice/auth.c +++ b/src/protocols/spice/auth.c @@ -145,6 +145,7 @@ void guac_spice_session_configure(guac_client* client, SpiceSession* session) { * WebDAV server automatically once "shared-dir" is set. */ if (settings->enable_drive && settings->drive_path != NULL) { g_object_set(session, "shared-dir", settings->drive_path, NULL); + g_object_set(session, "share-dir-ro", settings->drive_read_only, NULL); guac_client_log(client, GUAC_LOG_INFO, "Sharing directory \"%s\" with the SPICE server%s.", settings->drive_path, From 636c1288571e9c66ad79dc74470e13aea20792b1 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sat, 4 Jul 2026 00:58:50 -0300 Subject: [PATCH 25/36] GUACAMOLE-261: add unit tests for the SPICE protocol module Adds a CUnit test suite (test_spice) for libguac-client-spice, following the existing per-module tests/ convention (RDP/kubernetes). Covers the keymap lookup API: guac_spice_keymap_find() resolution, registry well-formedness, and mapping-array integrity. Wires the tests/ subdir into the build (SUBDIRS, configure.ac substitutions + AC_CONFIG_FILES). --- configure.ac | 7 +- src/protocols/spice/Makefile.am | 2 + src/protocols/spice/tests/Makefile.am | 65 +++++++++++++++ src/protocols/spice/tests/keymap/find.c | 101 ++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/protocols/spice/tests/Makefile.am create mode 100644 src/protocols/spice/tests/keymap/find.c diff --git a/configure.ac b/configure.ac index 28708430be..d7d4831281 100644 --- a/configure.ac +++ b/configure.ac @@ -214,6 +214,10 @@ AC_SUBST([LIBGUAC_CLIENT_KUBERNETES_INCLUDE], '-I$(top_srcdir)/src/protocols/kub AC_SUBST([LIBGUAC_CLIENT_RDP_LTLIB], '$(top_builddir)/src/protocols/rdp/libguac-client-rdp.la') AC_SUBST([LIBGUAC_CLIENT_RDP_INCLUDE], '-I$(top_srcdir)/src/protocols/rdp') +# SPICE support +AC_SUBST([LIBGUAC_CLIENT_SPICE_LTLIB], '$(top_builddir)/src/protocols/spice/libguac-client-spice.la') +AC_SUBST([LIBGUAC_CLIENT_SPICE_INCLUDE], '-I$(top_srcdir)/src/protocols/spice') + # Terminal emulator AC_SUBST([TERMINAL_LTLIB], '$(top_builddir)/src/terminal/libguac-terminal.la') AC_SUBST([TERMINAL_INCLUDE], '-I$(top_srcdir)/src/terminal $(PANGO_CFLAGS) $(PANGOCAIRO_CFLAGS) $(COMMON_INCLUDE)') @@ -1529,7 +1533,8 @@ AC_CONFIG_FILES([Makefile src/protocols/ssh/Makefile src/protocols/telnet/Makefile src/protocols/vnc/Makefile - src/protocols/spice/Makefile]) + src/protocols/spice/Makefile + src/protocols/spice/tests/Makefile]) AC_OUTPUT # diff --git a/src/protocols/spice/Makefile.am b/src/protocols/spice/Makefile.am index 38ebc07a4e..24eea7d0e6 100644 --- a/src/protocols/spice/Makefile.am +++ b/src/protocols/spice/Makefile.am @@ -26,6 +26,8 @@ AUTOMAKE_OPTIONS = foreign ACLOCAL_AMFLAGS = -I m4 +SUBDIRS = . tests + lib_LTLIBRARIES = libguac-client-spice.la nodist_libguac_client_spice_la_SOURCES = \ diff --git a/src/protocols/spice/tests/Makefile.am b/src/protocols/spice/tests/Makefile.am new file mode 100644 index 0000000000..a52e184de2 --- /dev/null +++ b/src/protocols/spice/tests/Makefile.am @@ -0,0 +1,65 @@ +# +# 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 + +# +# Unit tests for SPICE support +# + +check_PROGRAMS = test_spice +TESTS = $(check_PROGRAMS) + +test_spice_SOURCES = \ + keymap/find.c + +test_spice_CFLAGS = \ + -Werror -Wall -pedantic \ + @LIBGUAC_CLIENT_SPICE_INCLUDE@ \ + @LIBGUAC_INCLUDE@ + +test_spice_LDADD = \ + @CUNIT_LIBS@ \ + @LIBGUAC_CLIENT_SPICE_LTLIB@ \ + @LIBGUAC_LTLIB@ + +# +# Autogenerate test runner +# + +GEN_RUNNER = $(top_srcdir)/util/generate-test-runner.pl +CLEANFILES = _generated_runner.c + +_generated_runner.c: $(test_spice_SOURCES) + $(AM_V_GEN) $(GEN_RUNNER) $(test_spice_SOURCES) > $@ + +nodist_test_spice_SOURCES = \ + _generated_runner.c + +# Use automake's TAP test driver for running any tests +LOG_DRIVER = \ + env AM_TAP_AWK='$(AWK)' \ + $(SHELL) $(top_srcdir)/build-aux/tap-driver.sh diff --git a/src/protocols/spice/tests/keymap/find.c b/src/protocols/spice/tests/keymap/find.c new file mode 100644 index 0000000000..358dc0c9b7 --- /dev/null +++ b/src/protocols/spice/tests/keymap/find.c @@ -0,0 +1,101 @@ +/* + * 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 "keymap.h" + +#include + +/** + * Test which verifies that guac_spice_keymap_find() returns the requested + * keymap for known layout names and NULL for names that do not exist. + */ +void test_keymap__find(void) { + + /* The default keymap MUST exist and be named accordingly */ + const guac_spice_keymap* def = + guac_spice_keymap_find(GUAC_SPICE_DEFAULT_KEYMAP); + CU_ASSERT_PTR_NOT_NULL_FATAL(def); + CU_ASSERT_STRING_EQUAL(def->name, GUAC_SPICE_DEFAULT_KEYMAP); + + /* A known, non-default keymap should also be found and correctly named */ + const guac_spice_keymap* de = guac_spice_keymap_find("de-de-qwertz"); + CU_ASSERT_PTR_NOT_NULL_FATAL(de); + CU_ASSERT_STRING_EQUAL(de->name, "de-de-qwertz"); + + /* Names which do not correspond to any keymap must return NULL */ + CU_ASSERT_PTR_NULL(guac_spice_keymap_find("no-such-keymap")); + CU_ASSERT_PTR_NULL(guac_spice_keymap_find("")); + +} + +/** + * Test which verifies that the global keymap registry is well-formed: it is + * NULL-terminated, every entry has a name and mapping, and every registered + * keymap can be retrieved by its own name via guac_spice_keymap_find(). + */ +void test_keymap__registry(void) { + + int count = 0; + + for (const guac_spice_keymap** current = GUAC_SPICE_KEYMAPS; + *current != NULL; current++) { + + const guac_spice_keymap* map = *current; + + /* Each registered keymap must have a name and a mapping */ + CU_ASSERT_PTR_NOT_NULL_FATAL(map->name); + CU_ASSERT_PTR_NOT_NULL(map->mapping); + + /* ... and must be retrievable by that name */ + CU_ASSERT_PTR_EQUAL(guac_spice_keymap_find(map->name), map); + + count++; + + } + + /* At least the default keymap must be registered */ + CU_ASSERT_TRUE(count >= 1); + +} + +/** + * Test which verifies that the mapping array of the default keymap is a valid, + * zero-terminated array of key descriptors and defines at least one key. + */ +void test_keymap__mapping(void) { + + const guac_spice_keymap* def = + guac_spice_keymap_find(GUAC_SPICE_DEFAULT_KEYMAP); + CU_ASSERT_PTR_NOT_NULL_FATAL(def); + CU_ASSERT_PTR_NOT_NULL_FATAL(def->mapping); + + /* Walk to the terminating (keysym == 0) entry, bounded to guard against a + * malformed, non-terminated array */ + int entries = 0; + const guac_spice_keysym_desc* desc = def->mapping; + while (desc->keysym != 0 && entries < 100000) { + desc++; + entries++; + } + + /* The default keymap must define at least one key and be terminated */ + CU_ASSERT_TRUE(entries > 0); + CU_ASSERT_EQUAL(desc->keysym, 0); + +} From efc77ec5be1a740a706281c0962726c09f5112a6 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sat, 4 Jul 2026 08:47:58 -0300 Subject: [PATCH 26/36] GUACAMOLE-288: Port the libguac multi-monitor primitive from upstream Ports the reusable, protocol-agnostic multi-monitor support added upstream in apache/guacamole-server#560 (GUACAMOLE-288) by Corentin Soriano (@corentin-soriano): * guac_user_size_handler gains per-monitor x_position/top_offset * new "multimon-layout" default-layer parameter (a JSON monitor map) * __guac_handle_size parses the new size-instruction fields * guac_rect_shrink guards against divide-by-zero Every protocol's size handler is updated to the new signature; the new per-monitor arguments are ignored by all protocols except SPICE (see the following commit), preserving existing single-monitor behavior. Original work by @corentin-soriano, adapted for this fork: https://github.com/apache/guacamole-server/pull/560 --- src/libguac/guacamole/protocol-constants.h | 11 +++++++++++ src/libguac/guacamole/user-fntypes.h | 8 +++++++- src/libguac/rect.c | 7 +++++++ src/libguac/user-handlers.c | 4 +++- src/protocols/kubernetes/input.c | 3 ++- src/protocols/rdp/input.c | 3 ++- src/protocols/ssh/input.c | 3 ++- src/protocols/telnet/input.c | 3 ++- src/protocols/vnc/input.c | 3 ++- 9 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/libguac/guacamole/protocol-constants.h b/src/libguac/guacamole/protocol-constants.h index a8fc977734..6ee487438d 100644 --- a/src/libguac/guacamole/protocol-constants.h +++ b/src/libguac/guacamole/protocol-constants.h @@ -64,5 +64,16 @@ */ #define GUAC_PROTOCOL_LAYER_PARAMETER_MULTI_TOUCH "multi-touch" +/** + * The name of the layer parameter defining the multimonitor layout. + * + * This parameter contains a json string that describes the layout of the + * monitors, such as: + * {"0": {"left":0,"top":0,"width":1920,"height":1080}} + * + * @see guac_protocol_send_set() + */ +#define GUAC_PROTOCOL_LAYER_PARAMETER_MULTIMON_LAYOUT "multimon-layout" + #endif diff --git a/src/libguac/guacamole/user-fntypes.h b/src/libguac/guacamole/user-fntypes.h index fa3cac6f8e..191ebf9df3 100644 --- a/src/libguac/guacamole/user-fntypes.h +++ b/src/libguac/guacamole/user-fntypes.h @@ -220,12 +220,18 @@ typedef int guac_user_clipboard_handler(guac_user* user, guac_stream* stream, * @param height * The desired height of the display, in pixels. * + * @param x_position + * The position of the monitor to resize, relative to other monitors. + * + * @param top_offset + * The offset of the monitor from the top of the screen, in pixels. + * * @return * Zero if the size event has been successfully handled, non-zero * otherwise. */ typedef int guac_user_size_handler(guac_user* user, - int width, int height); + int width, int height, int x_position, int top_offset); /** * Handler for Guacamole file streams received from a user. Each such file diff --git a/src/libguac/rect.c b/src/libguac/rect.c index 2d372fe67f..2b2a237169 100644 --- a/src/libguac/rect.c +++ b/src/libguac/rect.c @@ -129,6 +129,13 @@ void guac_rect_shrink(guac_rect* rect, int max_width, int max_height) { scale_denominator = original_height; } + /* Cannot divide by zero */ + if (!scale_denominator) { + rect->right = 0; + rect->bottom = 0; + return; + } + rect->right = rect->left + original_width * scale_numerator / scale_denominator; rect->bottom = rect->top + original_height * scale_numerator / scale_denominator; diff --git a/src/libguac/user-handlers.c b/src/libguac/user-handlers.c index ff3e73fe4e..f1f1fbddbe 100644 --- a/src/libguac/user-handlers.c +++ b/src/libguac/user-handlers.c @@ -375,7 +375,9 @@ int __guac_handle_size(guac_user* user, int argc, char** argv) { return user->size_handler( user, atoi(argv[0]), /* width */ - atoi(argv[1]) /* height */ + atoi(argv[1]), /* height */ + (argc >= 3 ? atoi(argv[2]) : 0), /* Monitor x-position */ + (argc >= 4 ? atoi(argv[3]) : 0) /* Top offset */ ); return 0; } diff --git a/src/protocols/kubernetes/input.c b/src/protocols/kubernetes/input.c index b61d556436..0618aa6ddb 100644 --- a/src/protocols/kubernetes/input.c +++ b/src/protocols/kubernetes/input.c @@ -70,7 +70,8 @@ int guac_kubernetes_user_key_handler(guac_user* user, int keysym, int pressed) { } -int guac_kubernetes_user_size_handler(guac_user* user, int width, int height) { +int guac_kubernetes_user_size_handler(guac_user* user, int width, int height, + int x_position, int top_offset) { /* Get terminal */ guac_client* client = user->client; diff --git a/src/protocols/rdp/input.c b/src/protocols/rdp/input.c index 63c0be2d42..019e88f4fc 100644 --- a/src/protocols/rdp/input.c +++ b/src/protocols/rdp/input.c @@ -101,7 +101,8 @@ int guac_rdp_user_key_handler(guac_user* user, int keysym, int pressed) { } -int guac_rdp_user_size_handler(guac_user* user, int width, int height) { +int guac_rdp_user_size_handler(guac_user* user, int width, int height, + int x_position, int top_offset) { guac_client* client = user->client; guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; diff --git a/src/protocols/ssh/input.c b/src/protocols/ssh/input.c index f60e1c9fa2..eb3b580559 100644 --- a/src/protocols/ssh/input.c +++ b/src/protocols/ssh/input.c @@ -68,7 +68,8 @@ int guac_ssh_user_key_handler(guac_user* user, int keysym, int pressed) { return 0; } -int guac_ssh_user_size_handler(guac_user* user, int width, int height) { +int guac_ssh_user_size_handler(guac_user* user, int width, int height, + int x_position, int top_offset) { /* Get terminal */ guac_client* client = user->client; diff --git a/src/protocols/telnet/input.c b/src/protocols/telnet/input.c index 849d1897b8..2cbab91d44 100644 --- a/src/protocols/telnet/input.c +++ b/src/protocols/telnet/input.c @@ -120,7 +120,8 @@ int guac_telnet_user_key_handler(guac_user* user, int keysym, int pressed) { } -int guac_telnet_user_size_handler(guac_user* user, int width, int height) { +int guac_telnet_user_size_handler(guac_user* user, int width, int height, + int x_position, int top_offset) { /* Get terminal */ guac_client* client = user->client; diff --git a/src/protocols/vnc/input.c b/src/protocols/vnc/input.c index 5c4700b7db..70c5cb2fe8 100644 --- a/src/protocols/vnc/input.c +++ b/src/protocols/vnc/input.c @@ -65,7 +65,8 @@ int guac_vnc_user_key_handler(guac_user* user, int keysym, int pressed) { } #ifdef LIBVNC_HAS_RESIZE_SUPPORT -int guac_vnc_user_size_handler(guac_user* user, int width, int height) { +int guac_vnc_user_size_handler(guac_user* user, int width, int height, + int x_position, int top_offset) { guac_user_log(user, GUAC_LOG_TRACE, "Running user size handler."); From 40323c14df904f70e094f33fb33c55e0f8df8dbf Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sat, 4 Jul 2026 08:48:15 -0300 Subject: [PATCH 27/36] GUACAMOLE-288: Implement multi-monitor support for SPICE Builds on the ported libguac multi-monitor primitive to add client-driven multi-monitor support to the SPICE protocol, following the design of the RDP implementation in apache/guacamole-server#560 (by @corentin-soriano) adapted to SPICE's model, where a QXL guest presents all heads as regions of a single combined framebuffer. * settings: new "secondary-monitors" parameter (max_secondary_monitors, default 0 = disabled) * the size handler tracks a per-monitor array (fixed, up to GUAC_SPICE_MAX_MONITORS) keyed by x_position, tiling monitors left-to-right and computing each monitor's left_offset; a non-positive size closes a secondary monitor * resize pushes the whole layout to the guest agent in a single monitors config (enabling/positioning active monitors and disabling removed ones) * on each combined-surface (re)create, the current layout is sent to the client as the "multimon-layout" parameter on the default layer so a multi-monitor client can split it into per-monitor windows * joining users are told the permitted secondary-monitor count via a "secondary-monitors" argv stream The monitor state uses a fixed array (no dynamic allocation), avoiding the pointer-ownership concerns raised in review of the upstream RDP change, and the default layer is referenced via GUAC_DEFAULT_LAYER rather than casting a guac_display_layer. --- src/protocols/spice/client.c | 12 +++ src/protocols/spice/display.c | 75 +++++++++++++++++++ src/protocols/spice/input.c | 129 +++++++++++++++++++++++++++------ src/protocols/spice/settings.c | 13 ++++ src/protocols/spice/settings.h | 6 ++ src/protocols/spice/spice.h | 82 +++++++++++++++++++-- 6 files changed, 287 insertions(+), 30 deletions(-) diff --git a/src/protocols/spice/client.c b/src/protocols/spice/client.c index 4ffe48bc6e..4e661723c7 100644 --- a/src/protocols/spice/client.c +++ b/src/protocols/spice/client.c @@ -37,6 +37,7 @@ #include #include #include +#include #include @@ -79,6 +80,17 @@ static int guac_spice_join_pending_handler(guac_client* client) { guac_client_foreach_pending_user(client, guac_spice_sync_pending_user_audio, spice_client->audio); + /* Advertise the maximum number of secondary monitors permitted for this + * connection so a multi-monitor client can offer the right number of + * monitor windows */ + if (spice_client->settings != NULL) { + char max_monitors[12]; + guac_itoa(max_monitors, + spice_client->settings->max_secondary_monitors); + guac_client_stream_argv(client, broadcast_socket, "text/plain", + "secondary-monitors", max_monitors); + } + /* Synchronize with current display */ if (spice_client->display != NULL) { guac_display_dup(spice_client->display, broadcast_socket); diff --git a/src/protocols/spice/display.c b/src/protocols/spice/display.c index 15983731de..c6204dcac7 100644 --- a/src/protocols/spice/display.c +++ b/src/protocols/spice/display.c @@ -25,12 +25,83 @@ #include #include +#include +#include #include +#include #include #include +#include #include +/** + * Sends the current monitor layout to the connected client as a JSON + * "multimon-layout" parameter on the default layer, allowing a multi-monitor + * client to split the combined framebuffer into per-monitor windows. Does + * nothing unless multi-monitor support is enabled for this connection. Must be + * called on the SPICE event-loop thread. + * + * @param client + * The guac_client whose monitor layout should be sent. + */ +static void guac_spice_display_send_monitor_layout(guac_client* client) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Only relevant when multi-monitor support is enabled and at least one + * monitor has been established by a client resize request */ + if (spice_client->settings->max_secondary_monitors <= 0 + || spice_client->monitors_count <= 0) + return; + + /* Build a JSON object mapping each monitor index to its position and size, + * e.g. {"0":{"left":0,"top":0,"width":1024,"height":768},"1":{...}}. Every + * value is an integer, so no escaping is required and the fixed buffer + * cannot be overrun for the supported number of monitors; the length is + * bounded-checked regardless. */ + char json[GUAC_SPICE_MULTIMON_LAYOUT_SIZE]; + int pos = 0; + int written = 0; + + json[pos++] = '{'; + + for (int i = 0; i < spice_client->monitors_count; i++) { + + guac_spice_monitor* monitor = &spice_client->monitors[i]; + if (monitor->width <= 0 || monitor->height <= 0) + continue; + + int remaining = (int) sizeof(json) - pos; + int length = snprintf(json + pos, remaining, + "%s\"%d\":{\"left\":%d,\"top\":%d,\"width\":%d,\"height\":%d}", + (written ? "," : ""), i, + monitor->left_offset, monitor->top_offset, + monitor->width, monitor->height); + + /* Stop if the entry would not fit (guard only; cannot happen for the + * supported number of integer-only monitors) */ + if (length < 0 || length >= remaining) + break; + + pos += length; + written++; + + } + + /* Terminate the JSON object, leaving room for '}' and the null terminator */ + if (pos > (int) sizeof(json) - 2) + pos = (int) sizeof(json) - 2; + json[pos++] = '}'; + json[pos] = '\0'; + + /* Set the layout parameter on the default layer (layer 0) */ + guac_protocol_send_set(client->socket, GUAC_DEFAULT_LAYER, + GUAC_PROTOCOL_LAYER_PARAMETER_MULTIMON_LAYOUT, json); + guac_socket_flush(client->socket); + +} + /** * Signal handler for the SPICE display channel "display-primary-create" * signal. Records the location and dimensions of the new primary surface and @@ -69,6 +140,10 @@ static void guac_spice_display_primary_create(SpiceChannel* channel, spice_client->resize_display_ready = 1; guac_spice_resize_try(client); + /* Inform any multi-monitor client of the layout of the (now resized) + * combined framebuffer so it can split it into per-monitor windows */ + guac_spice_display_send_monitor_layout(client); + } /** diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index 997ff123aa..ffb0f2bf13 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -250,9 +250,28 @@ int guac_spice_user_key_handler(guac_user* user, int keysym, int pressed) { } /** - * Handler which performs a deferred SPICE primary-display resize on the - * event-loop thread. The requested width and height are carried in the - * deferred call's args. + * Recomputes the x_position and left_offset of every active monitor. Monitors + * are tiled left-to-right, so each monitor's left_offset is the sum of the + * widths of the monitors before it and its x_position is its array index. Must + * be called on the SPICE event-loop thread whenever the monitor set changes. + * + * @param spice_client + * The SPICE client whose monitor offsets should be recomputed. + */ +static void guac_spice_monitors_recalc_offsets(guac_spice_client* spice_client) { + + int left = 0; + for (int i = 0; i < spice_client->monitors_count; i++) { + spice_client->monitors[i].x_position = i; + spice_client->monitors[i].left_offset = left; + left += spice_client->monitors[i].width; + } + +} + +/** + * Performs a deferred SPICE multi-monitor resize on the event-loop thread, + * pushing the current monitor layout to the guest as a single monitors config. */ void guac_spice_resize_try(guac_client* client) { @@ -271,20 +290,33 @@ void guac_spice_resize_try(guac_client* client) { || !spice_client->resize_display_ready) return; - int width = spice_client->resize_pending_width; - int height = spice_client->resize_pending_height; + /* Enable and position every active monitor */ + for (int i = 0; i < spice_client->monitors_count; i++) { + guac_spice_monitor* monitor = &spice_client->monitors[i]; + spice_main_channel_update_display_enabled(spice_client->main_channel, + i, TRUE, FALSE); + spice_main_channel_update_display(spice_client->main_channel, i, + monitor->left_offset, monitor->top_offset, + monitor->width, monitor->height, FALSE); + } + + /* Disable any monitors that were previously enabled but have since been + * removed */ + for (int i = spice_client->monitors_count; + i < spice_client->resize_monitors_pushed; i++) + spice_main_channel_update_display_enabled(spice_client->main_channel, + i, FALSE, FALSE); - /* Enable and size monitor 0, then explicitly push the monitors config to - * the guest agent. Sending explicitly (rather than via update_display's - * debounced timer) ensures every resize is delivered deterministically. */ - spice_main_channel_update_display_enabled(spice_client->main_channel, 0, TRUE, FALSE); - spice_main_channel_update_display(spice_client->main_channel, 0, 0, 0, - width, height, FALSE); + /* Push the whole layout to the guest agent in a single, explicit monitors + * config (rather than via update_display's debounced timer) so every + * resize is delivered deterministically. */ spice_main_channel_send_monitor_config(spice_client->main_channel); + spice_client->resize_monitors_pushed = spice_client->monitors_count; spice_client->resize_pending = 0; guac_client_log(client, GUAC_LOG_DEBUG, - "Sent guest display resize to %dx%d", width, height); + "Sent guest display config: %d monitor(s)", + spice_client->monitors_count); } @@ -330,34 +362,83 @@ void guac_spice_resize_agent_updated(SpiceMainChannel* channel, } /** - * Handler which, on the SPICE event-loop thread, records a queued resize - * request and attempts to send it (subject to guest readiness). + * Handler which, on the SPICE event-loop thread, applies a queued per-monitor + * resize request to the monitor set and attempts to send the resulting + * monitors config (subject to guest readiness). */ static void guac_spice_do_queue_resize(guac_spice_deferred_call* call) { guac_client* client = (guac_client*) call->channel; guac_spice_client* spice_client = (guac_spice_client*) client->data; - spice_client->resize_pending_width = (int) call->args[0]; - spice_client->resize_pending_height = (int) call->args[1]; - spice_client->resize_pending = 1; + int width = (int) call->args[0]; + int height = (int) call->args[1]; + int x_position = (int) call->args[2]; + int top_offset = (int) call->args[3]; + + /* Number of monitors permitted (primary plus the configured secondaries), + * capped at the number of heads guacd supports */ + int max_monitors = spice_client->settings->max_secondary_monitors + 1; + if (max_monitors > GUAC_SPICE_MAX_MONITORS) + max_monitors = GUAC_SPICE_MAX_MONITORS; + + /* A positive size adds or updates the monitor at x_position */ + if (width > 0 && height > 0) { + /* Ignore out-of-range or disallowed indexes, and refuse to create a + * gap in the (contiguous) monitor layout */ + if (x_position < 0 || x_position >= max_monitors + || x_position > spice_client->monitors_count) + return; + + guac_spice_monitor* monitor = &spice_client->monitors[x_position]; + monitor->width = width; + monitor->height = height; + monitor->top_offset = top_offset; + + /* Grow the active monitor count if this request adds a new monitor */ + if (x_position == spice_client->monitors_count) + spice_client->monitors_count = x_position + 1; + + } + + /* A non-positive size closes the secondary monitor at x_position, shifting + * any monitors to its right down to keep the layout contiguous. The + * primary monitor (index 0) cannot be closed. */ + else { + + if (x_position <= 0 || x_position >= spice_client->monitors_count) + return; + + for (int i = x_position; i < spice_client->monitors_count - 1; i++) + spice_client->monitors[i] = spice_client->monitors[i + 1]; + + spice_client->monitors_count--; + + } + + guac_spice_monitors_recalc_offsets(spice_client); + + spice_client->resize_pending = 1; guac_spice_resize_try(client); } -int guac_spice_user_size_handler(guac_user* user, int width, int height) { +int guac_spice_user_size_handler(guac_user* user, int width, int height, + int x_position, int top_offset) { guac_client* client = user->client; guac_spice_client* spice_client = (guac_spice_client*) client->data; - /* Ignore if the main channel is not yet ready or the requested size is - * degenerate */ - if (spice_client->main_channel == NULL || width <= 0 || height <= 0) + /* Ignore if the main channel is not yet ready */ + if (spice_client->main_channel == NULL) return 0; - /* SPICE guests generally require the display width to be a multiple of 8 */ - width &= ~0x7; + /* SPICE guests generally require the display width to be a multiple of 8. A + * non-positive size is a monitor-close request and is passed through + * unchanged. */ + if (width > 0) + width &= ~0x7; /* Queue the resize on the SPICE event-loop thread; spice-gtk channel * functions must not be called from user threads (see guac_spice_defer_call). @@ -367,6 +448,8 @@ int guac_spice_user_size_handler(guac_user* user, int width, int height) { call->channel = client; call->args[0] = (unsigned int) width; call->args[1] = (unsigned int) height; + call->args[2] = (unsigned int) x_position; + call->args[3] = (unsigned int) top_offset; guac_spice_defer_call(call); return 0; diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c index af891e6519..83450b2c25 100644 --- a/src/protocols/spice/settings.c +++ b/src/protocols/spice/settings.c @@ -99,6 +99,7 @@ const char* GUAC_SPICE_CLIENT_ARGS[] = { "wol-udp-port", "wol-wait-time", "preferred-video-codec", + "secondary-monitors", NULL }; @@ -449,6 +450,13 @@ enum SPICE_ARGS_IDX { */ IDX_PREFERRED_VIDEO_CODEC, + /** + * The maximum number of secondary monitors the client may request, in + * addition to the primary monitor. 0 (the default) disables multi-monitor + * support. + */ + IDX_SECONDARY_MONITORS, + SPICE_ARGS_COUNT }; @@ -566,6 +574,11 @@ guac_spice_settings* guac_spice_parse_args(guac_user* user, guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_PREFERRED_VIDEO_CODEC, NULL); + /* Maximum secondary monitors (default 0 = multi-monitor disabled) */ + settings->max_secondary_monitors = + guac_user_parse_args_int(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_SECONDARY_MONITORS, 0); + settings->enable_drive = guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_ENABLE_DRIVE, false); diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h index 12c904cf52..a13ce894cc 100644 --- a/src/protocols/spice/settings.h +++ b/src/protocols/spice/settings.h @@ -159,6 +159,12 @@ typedef struct guac_spice_settings { */ bool disable_display_resize; + /** + * The maximum number of secondary monitors (in addition to the primary + * monitor) that the client may request. 0 disables multi-monitor support. + */ + int max_secondary_monitors; + /** * The maximum number of bytes to allow within the clipboard. */ diff --git a/src/protocols/spice/spice.h b/src/protocols/spice/spice.h index 26728b3705..702f7fe735 100644 --- a/src/protocols/spice/spice.h +++ b/src/protocols/spice/spice.h @@ -46,6 +46,57 @@ */ #define GUAC_SPICE_STATE_CHECK_INTERVAL 250 +/** + * The maximum number of monitors (primary plus secondary) that guacd will + * request from a SPICE guest. QXL practically supports up to four heads. + */ +#define GUAC_SPICE_MAX_MONITORS 4 + +/** + * The size, in bytes, of the buffer used to build the "multimon-layout" JSON + * describing the monitor layout sent to the client. All values are integers, + * so this fixed bound is sufficient for GUAC_SPICE_MAX_MONITORS monitors. + */ +#define GUAC_SPICE_MULTIMON_LAYOUT_SIZE 512 + +/** + * The size and position of a single SPICE monitor (head) within the combined + * display. Monitors are tiled left-to-right; left_offset is derived from the + * widths of the monitors to the left, while top_offset is provided by the + * client to allow vertical shifting. + */ +typedef struct guac_spice_monitor { + + /** + * The requested width of this monitor, in pixels. + */ + int width; + + /** + * The requested height of this monitor, in pixels. + */ + int height; + + /** + * The position (index) of this monitor relative to the other monitors, + * starting at 0 for the primary monitor. + */ + int x_position; + + /** + * The offset of this monitor from the top of the combined display, in + * pixels. + */ + int top_offset; + + /** + * The offset of this monitor from the left of the combined display, in + * pixels (the sum of the widths of all monitors to its left). + */ + int left_offset; + +} guac_spice_monitor; + /** * SPICE-specific client data. */ @@ -196,17 +247,34 @@ typedef struct guac_spice_client { /** * Dynamic display-resize state. All fields are accessed only from the SPICE - * event-loop thread. A client-requested resize is queued in - * resize_pending_width/height and sent to the guest only once the SPICE - * agent (advertising monitors-config support) and the display's primary - * surface are both ready — the readiness spice-gtk itself requires before a - * monitors config will actually be sent. + * event-loop thread. A client-requested resize updates the monitors array + * below and is sent to the guest only once the SPICE agent (advertising + * monitors-config support) and the display's primary surface are both + * ready — the readiness spice-gtk itself requires before a monitors config + * will actually be sent. */ int resize_agent_ready; int resize_display_ready; int resize_pending; - int resize_pending_width; - int resize_pending_height; + + /** + * The monitors (heads) currently requested by the client, tiled + * left-to-right. monitors[0] is the primary monitor. Only the first + * monitors_count entries are valid. + */ + guac_spice_monitor monitors[GUAC_SPICE_MAX_MONITORS]; + + /** + * The number of currently-active monitors (always at least one once a + * resize has been requested). + */ + int monitors_count; + + /** + * The number of monitors last pushed to the guest via a monitors config. + * Used to disable monitors which have since been removed. + */ + int resize_monitors_pushed; /** * Internal clipboard. From 4f0b8369011c4c30e1eb5a9589fcb2735d0b0217 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 15:16:17 -0300 Subject: [PATCH 28/36] GUACAMOLE-288: publish SPICE multimon-layout from guest-actual config Derive the multimon-layout sent to the client from the guest's actual SpiceDisplayMonitorConfig (read via the display channel's monitors property) instead of the client-requested geometry, clamped to the current combined surface and keyed by the guest head id. Republish on notify::monitors, and reset the recorded surface dimensions on display-primary-destroy so a delayed publish cannot validate against a destroyed surface. Fixes secondary windows rendering blank/mirrored when the guest's real layout diverged from what the client requested. --- src/protocols/spice/display.c | 213 +++++++++++++++++++++++++++++----- 1 file changed, 186 insertions(+), 27 deletions(-) diff --git a/src/protocols/spice/display.c b/src/protocols/spice/display.c index c6204dcac7..47f90919b5 100644 --- a/src/protocols/spice/display.c +++ b/src/protocols/spice/display.c @@ -35,6 +35,52 @@ #include #include +/** + * Appends a single monitor entry to the "multimon-layout" JSON object being + * built in the given buffer, e.g. {@code "0":{"left":0,"top":0,"width":1024, + * "height":768}}. Every value is an integer, so no escaping is required. + * + * @param json + * The buffer holding the JSON object under construction. + * + * @param pos + * The current write offset within json (the index of the next free byte). + * + * @param size + * The total size of json, in bytes. + * + * @param written + * The number of entries already appended, used to decide whether a leading + * comma separator is required. + * + * @param index + * The monitor index to use as the JSON key. + * + * @param left + * @param top + * @param width + * @param height + * The position and size of the monitor within the combined framebuffer. + * + * @return + * The new write offset within json, or a negative value if the entry would + * not fit (in which case json is left unmodified past pos). + */ +static int guac_spice_layout_append(char* json, int pos, int size, + int written, int index, int left, int top, int width, int height) { + + int remaining = size - pos; + int length = snprintf(json + pos, remaining, + "%s\"%d\":{\"left\":%d,\"top\":%d,\"width\":%d,\"height\":%d}", + (written ? "," : ""), index, left, top, width, height); + + if (length < 0 || length >= remaining) + return -1; + + return pos + length; + +} + /** * Sends the current monitor layout to the connected client as a JSON * "multimon-layout" parameter on the default layer, allowing a multi-monitor @@ -42,51 +88,133 @@ * nothing unless multi-monitor support is enabled for this connection. Must be * called on the SPICE event-loop thread. * + * The layout is derived from the guest's ACTUAL monitor configuration (the + * SpiceDisplayMonitorConfig reported by the display channel), clamped to the + * bounds of the current combined surface, so that a guest which mirrors, + * overlaps, or otherwise diverges from the client-requested geometry cannot + * publish an out-of-range layout. Until the guest has reported a usable + * configuration, the client-requested layout is published as a fallback. + * * @param client * The guac_client whose monitor layout should be sent. + * + * @param channel + * The SPICE display channel whose guest monitor configuration should be + * published. */ -static void guac_spice_display_send_monitor_layout(guac_client* client) { +static void guac_spice_display_send_monitor_layout(guac_client* client, + SpiceChannel* channel) { guac_spice_client* spice_client = (guac_spice_client*) client->data; - /* Only relevant when multi-monitor support is enabled and at least one - * monitor has been established by a client resize request */ - if (spice_client->settings->max_secondary_monitors <= 0 - || spice_client->monitors_count <= 0) + /* Only relevant when multi-monitor support is enabled for this connection */ + if (spice_client->settings->max_secondary_monitors <= 0 || channel == NULL) return; - /* Build a JSON object mapping each monitor index to its position and size, - * e.g. {"0":{"left":0,"top":0,"width":1024,"height":768},"1":{...}}. Every - * value is an integer, so no escaping is required and the fixed buffer - * cannot be overrun for the supported number of monitors; the length is - * bounded-checked regardless. */ + /* Read the bounds of the current combined surface for clamping. Zero + * dimensions mean no primary surface currently exists (never created, or + * destroyed), in which case the guest-reported geometry cannot be validated + * against the framebuffer and the client-requested fallback is used. */ + pthread_mutex_lock(&spice_client->surface_lock); + int surface_width = spice_client->surface_width; + int surface_height = spice_client->surface_height; + pthread_mutex_unlock(&spice_client->surface_lock); + + /* Build a JSON object mapping each monitor index to its position and size. + * Every value is an integer, so the fixed buffer cannot be overrun for the + * supported number of monitors; the length is bounded-checked regardless. */ char json[GUAC_SPICE_MULTIMON_LAYOUT_SIZE]; int pos = 0; int written = 0; json[pos++] = '{'; - for (int i = 0; i < spice_client->monitors_count; i++) { + /* Prefer the guest's actual monitor configuration as the source of truth, + * but only once a primary surface exists to validate it against */ + GArray* monitors = NULL; + if (surface_width > 0 && surface_height > 0) + g_object_get(SPICE_DISPLAY_CHANNEL(channel), "monitors", &monitors, NULL); + + int had_guest_config = (monitors != NULL); + + if (monitors != NULL) { + + for (guint i = 0; i < monitors->len; i++) { + + SpiceDisplayMonitorConfig* config = + &g_array_index(monitors, SpiceDisplayMonitorConfig, i); + + /* Key by the guest's head id (not the array index) so that a + * disabled intermediate head does not renumber the surviving + * monitors from the client's perspective */ + if (config->id >= GUAC_SPICE_MAX_MONITORS) + continue; - guac_spice_monitor* monitor = &spice_client->monitors[i]; - if (monitor->width <= 0 || monitor->height <= 0) - continue; + /* SpiceDisplayMonitorConfig coordinates are unsigned. Trim the + * guest-reported region to the combined surface using subtraction + * ordered to avoid any signed overflow: a head whose origin is at + * or beyond a surface bound is treated as disabled. */ + guint gx = config->x, gy = config->y; + guint gw = config->width, gh = config->height; - int remaining = (int) sizeof(json) - pos; - int length = snprintf(json + pos, remaining, - "%s\"%d\":{\"left\":%d,\"top\":%d,\"width\":%d,\"height\":%d}", - (written ? "," : ""), i, - monitor->left_offset, monitor->top_offset, - monitor->width, monitor->height); + if (gx >= (guint) surface_width || gy >= (guint) surface_height) + continue; - /* Stop if the entry would not fit (guard only; cannot happen for the - * supported number of integer-only monitors) */ - if (length < 0 || length >= remaining) - break; + int left = (int) gx; + int top = (int) gy; + int width = (gw > (guint) (surface_width - left)) + ? surface_width - left : (int) gw; + int height = (gh > (guint) (surface_height - top)) + ? surface_height - top : (int) gh; - pos += length; - written++; + /* Skip disabled or zero-extent heads */ + if (width <= 0 || height <= 0) + continue; + int next = guac_spice_layout_append(json, pos, sizeof(json), + written, (int) config->id, left, top, width, height); + if (next < 0) + break; + + pos = next; + written++; + + } + + g_clear_pointer(&monitors, g_array_unref); + + } + + /* Whether the published entries came from the guest (vs the fallback) */ + int from_guest = (written > 0); + + /* Fall back to the client-requested layout if the guest has not yet + * reported any usable monitor configuration */ + if (!written) { + for (int i = 0; i < spice_client->monitors_count; i++) { + + guac_spice_monitor* monitor = &spice_client->monitors[i]; + if (monitor->width <= 0 || monitor->height <= 0) + continue; + + int next = guac_spice_layout_append(json, pos, sizeof(json), + written, i, monitor->left_offset, monitor->top_offset, + monitor->width, monitor->height); + if (next < 0) + break; + + pos = next; + written++; + + } + } + + /* Nothing to publish (no guest config yet and no client-requested layout) */ + if (!written) { + guac_client_log(client, GUAC_LOG_DEBUG, "multimon-layout: nothing to " + "publish (surface %dx%d, guest config %s)", surface_width, + surface_height, had_guest_config ? "present" : "absent"); + return; } /* Terminate the JSON object, leaving room for '}' and the null terminator */ @@ -100,6 +228,11 @@ static void guac_spice_display_send_monitor_layout(guac_client* client) { GUAC_PROTOCOL_LAYER_PARAMETER_MULTIMON_LAYOUT, json); guac_socket_flush(client->socket); + guac_client_log(client, GUAC_LOG_DEBUG, + "multimon-layout published (%s source, %d monitor(s)): %s", + from_guest ? "guest-actual" : "client-requested", + written, json); + } /** @@ -142,7 +275,7 @@ static void guac_spice_display_primary_create(SpiceChannel* channel, /* Inform any multi-monitor client of the layout of the (now resized) * combined framebuffer so it can split it into per-monitor windows */ - guac_spice_display_send_monitor_layout(client); + guac_spice_display_send_monitor_layout(client, channel); } @@ -159,6 +292,12 @@ static void guac_spice_display_primary_destroy(SpiceChannel* channel, pthread_mutex_lock(&spice_client->surface_lock); spice_client->surface_data = NULL; + + /* Reset the recorded dimensions so a subsequent monitor-layout publish does + * not validate guest geometry against the bounds of the destroyed surface */ + spice_client->surface_width = 0; + spice_client->surface_height = 0; + spice_client->surface_stride = 0; pthread_mutex_unlock(&spice_client->surface_lock); } @@ -263,6 +402,20 @@ static void guac_spice_display_invalidate(SpiceChannel* channel, } +/** + * Signal handler for the SPICE display channel "notify::monitors" signal, + * fired whenever the guest publishes a new monitor configuration. Re-publishes + * the (now updated) guest-actual layout to the connected client so that a + * repositioning which does not recreate the primary surface is still reflected. + */ +static void guac_spice_display_monitors_updated(SpiceChannel* channel, + GParamSpec* pspec, gpointer data) { + + guac_client* client = (guac_client*) data; + guac_spice_display_send_monitor_layout(client, channel); + +} + void guac_spice_display_channel_connect(guac_client* client, SpiceChannel* channel) { @@ -277,4 +430,10 @@ void guac_spice_display_channel_connect(guac_client* client, g_signal_connect(channel, "display-invalidate", G_CALLBACK(guac_spice_display_invalidate), client); + /* Re-publish the monitor layout whenever the guest changes its monitor + * configuration, keeping the client's per-monitor split in sync with the + * guest's actual geometry even without a primary-surface recreate */ + g_signal_connect(channel, "notify::monitors", + G_CALLBACK(guac_spice_display_monitors_updated), client); + } From f83670d0583232c54de6382c5b9c00de2f9c4e85 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 15:16:17 -0300 Subject: [PATCH 29/36] GUACAMOLE-288: debounce SPICE monitor-config sends to stop guest thrash Coalesce rapid client resize requests into a single guest monitors config, sent 500ms after the first request via a GLib timeout on the SPICE event-loop context. A burst of resize events (primary + secondary windows) previously pushed the monitors config to the guest several times per second, so the guest agent never settled and thrashed between clone/extend/disabled states. The timer is cancelled on teardown so it cannot fire after the client is freed. --- src/protocols/spice/input.c | 88 ++++++++++++++++++++++++++++++++----- src/protocols/spice/spice.c | 7 +++ src/protocols/spice/spice.h | 11 +++++ 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/src/protocols/spice/input.c b/src/protocols/spice/input.c index ffb0f2bf13..dd3b7e03f8 100644 --- a/src/protocols/spice/input.c +++ b/src/protocols/spice/input.c @@ -270,25 +270,43 @@ static void guac_spice_monitors_recalc_offsets(guac_spice_client* spice_client) } /** - * Performs a deferred SPICE multi-monitor resize on the event-loop thread, - * pushing the current monitor layout to the guest as a single monitors config. + * The debounce interval, in milliseconds, over which rapid client resize + * requests are coalesced into a single guest monitors config. A burst of + * resize requests (e.g. from a primary and secondary monitor window both + * resizing) results in exactly one config push per interval, giving the guest + * SPICE agent time to apply the layout rather than being forced to reconfigure + * on every request (which causes the guest to thrash between clone, extend, and + * disabled states). */ -void guac_spice_resize_try(guac_client* client) { +#define GUAC_SPICE_RESIZE_DEBOUNCE_MS 500 +/** + * Applies the currently requested monitor layout to the guest as a single + * monitors config. Invoked from the debounced timer scheduled by + * guac_spice_resize_try(); runs on the SPICE event-loop thread. + * + * @param data + * The guac_client associated with the SPICE connection. + * + * @return + * Always G_SOURCE_REMOVE, so the one-shot timer is not rescheduled + * automatically. + */ +static gboolean guac_spice_resize_flush(gpointer data) { + + guac_client* client = (guac_client*) data; guac_spice_client* spice_client = (guac_spice_client*) client->data; - /* Nothing to do unless a resize is queued */ - if (!spice_client->resize_pending) - return; + /* The scheduled timer has now fired and is no longer pending */ + spice_client->resize_timer_source = 0; - /* Wait until the guest is ready to receive a monitors config: the main - * channel must be up, the agent connected and advertising monitors-config - * support, and the display's primary surface created. spice-gtk silently - * drops a monitors config sent before these conditions hold. */ - if (spice_client->main_channel == NULL + /* Skip if the resize was already handled or the guest is no longer ready; + * a subsequent resize request will reschedule once readiness returns. */ + if (!spice_client->resize_pending + || spice_client->main_channel == NULL || !spice_client->resize_agent_ready || !spice_client->resize_display_ready) - return; + return G_SOURCE_REMOVE; /* Enable and position every active monitor */ for (int i = 0; i < spice_client->monitors_count; i++) { @@ -298,6 +316,10 @@ void guac_spice_resize_try(guac_client* client) { spice_main_channel_update_display(spice_client->main_channel, i, monitor->left_offset, monitor->top_offset, monitor->width, monitor->height, FALSE); + guac_client_log(client, GUAC_LOG_DEBUG, + "resize_try: sending monitor %d -> +%d+%d %dx%d", + i, monitor->left_offset, monitor->top_offset, + monitor->width, monitor->height); } /* Disable any monitors that were previously enabled but have since been @@ -318,6 +340,48 @@ void guac_spice_resize_try(guac_client* client) { "Sent guest display config: %d monitor(s)", spice_client->monitors_count); + return G_SOURCE_REMOVE; + +} + +/** + * Requests that the current monitor layout be pushed to the guest, coalescing + * rapid successive requests. The actual send is debounced: the first request + * schedules a flush GUAC_SPICE_RESIZE_DEBOUNCE_MS milliseconds later, and + * further requests within that window update the monitor layout but reuse the + * already-pending timer, so the guest receives a single stable config per + * window. Must be called on the SPICE event-loop thread. + */ +void guac_spice_resize_try(guac_client* client) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Nothing to do unless a resize is queued */ + if (!spice_client->resize_pending) + return; + + /* Wait until the guest is ready to receive a monitors config: the main + * channel must be up, the agent connected and advertising monitors-config + * support, and the display's primary surface created. spice-gtk silently + * drops a monitors config sent before these conditions hold. */ + if (spice_client->main_channel == NULL + || !spice_client->resize_agent_ready + || !spice_client->resize_display_ready) + return; + + /* Coalesce: if a flush is already scheduled, the latest monitor layout will + * be picked up when it fires — no need to schedule another. */ + if (spice_client->resize_timer_source != 0) + return; + + /* Schedule the debounced flush on the SPICE event-loop's private main + * context (the same context that drives all channel I/O) */ + GSource* source = g_timeout_source_new(GUAC_SPICE_RESIZE_DEBOUNCE_MS); + g_source_set_callback(source, guac_spice_resize_flush, client, NULL); + spice_client->resize_timer_source = + g_source_attach(source, spice_client->main_context); + g_source_unref(source); + } /** diff --git a/src/protocols/spice/spice.c b/src/protocols/spice/spice.c index 79e9cbc867..527a0f3d61 100644 --- a/src/protocols/spice/spice.c +++ b/src/protocols/spice/spice.c @@ -385,6 +385,13 @@ void* guac_spice_client_thread(void* data) { } + /* Cancel any pending debounced monitors-config flush so its timer cannot + * fire after the client state is torn down */ + if (spice_client->resize_timer_source != 0) { + g_source_remove(spice_client->resize_timer_source); + spice_client->resize_timer_source = 0; + } + /* Disconnect the SPICE session */ spice_session_disconnect(spice_client->spice_session); diff --git a/src/protocols/spice/spice.h b/src/protocols/spice/spice.h index 702f7fe735..5d4080927e 100644 --- a/src/protocols/spice/spice.h +++ b/src/protocols/spice/spice.h @@ -276,6 +276,17 @@ typedef struct guac_spice_client { */ int resize_monitors_pushed; + /** + * The GLib source ID of the pending debounced monitors-config flush, or 0 + * if none is currently scheduled. Rapid client resize requests are + * coalesced into a single guest monitors config, sent when this timer + * fires, so that the guest's SPICE agent has time to apply the layout + * without being forced to reconfigure repeatedly (which otherwise causes + * the guest to thrash between clone/extend/disabled states). Accessed only + * from the SPICE event-loop thread. + */ + guint resize_timer_source; + /** * Internal clipboard. */ From e94da33fcdae7ca959987e1232e4b4730742e113 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 16:25:24 -0300 Subject: [PATCH 30/36] GUACAMOLE-261: add image clipboard support (closes #7, part 1) Extend the previously text-only SPICE clipboard to images (image/png, image/bmp, image/jpeg, image/tiff) in both directions. Maps Guacamole clipboard mimetypes to/from VD_AGENT clipboard types, requests the most-preferred type the guest offers (text, then PNG, then other image formats), and offers the client's clipboard to the guest using the type matching its mimetype. Data is handled by length throughout, so binary image payloads are safe. The clipboard lock is held across the guest's request so a concurrent paste cannot swap contents mid-transfer. --- src/protocols/spice/clipboard.c | 188 ++++++++++++++++++++++++++------ 1 file changed, 157 insertions(+), 31 deletions(-) diff --git a/src/protocols/spice/clipboard.c b/src/protocols/spice/clipboard.c index 51a2b6ec84..83c6218670 100644 --- a/src/protocols/spice/clipboard.c +++ b/src/protocols/spice/clipboard.c @@ -30,6 +30,8 @@ #include #include +#include +#include #include #include @@ -40,6 +42,97 @@ */ #define GUAC_SPICE_CLIPBOARD_SELECTION VD_AGENT_CLIPBOARD_SELECTION_CLIPBOARD +/** + * The order in which VD_AGENT clipboard data types are preferred when the guest + * offers several at once. UTF-8 text is preferred over images (it is the most + * common paste target and cheapest to transfer), and among image formats PNG is + * preferred, as it is the format every image-capable SPICE peer is required to + * support. Only types Guacamole can represent as a clipboard mimetype are + * listed; any other offered type is ignored. + */ +static const guint32 guac_spice_clipboard_preference[] = { + VD_AGENT_CLIPBOARD_UTF8_TEXT, + VD_AGENT_CLIPBOARD_IMAGE_PNG, + VD_AGENT_CLIPBOARD_IMAGE_BMP, + VD_AGENT_CLIPBOARD_IMAGE_JPG, + VD_AGENT_CLIPBOARD_IMAGE_TIFF, +}; + +/** + * Returns the VD_AGENT clipboard data type corresponding to the given + * Guacamole clipboard mimetype, or VD_AGENT_CLIPBOARD_NONE if the mimetype has + * no SPICE equivalent. Any textual ("text/...") mimetype maps to UTF-8 text. + * + * @param mimetype + * The Guacamole clipboard mimetype to translate. + * + * @return + * The corresponding VD_AGENT_CLIPBOARD_* type, or VD_AGENT_CLIPBOARD_NONE + * if unsupported. + */ +static guint32 guac_spice_clipboard_type_for_mimetype(const char* mimetype) { + + if (mimetype == NULL) + return VD_AGENT_CLIPBOARD_NONE; + + if (strncmp(mimetype, "text/", 5) == 0) + return VD_AGENT_CLIPBOARD_UTF8_TEXT; + + if (strcmp(mimetype, "image/png") == 0) + return VD_AGENT_CLIPBOARD_IMAGE_PNG; + + if (strcmp(mimetype, "image/bmp") == 0 + || strcmp(mimetype, "image/x-bmp") == 0 + || strcmp(mimetype, "image/x-ms-bmp") == 0) + return VD_AGENT_CLIPBOARD_IMAGE_BMP; + + if (strcmp(mimetype, "image/jpeg") == 0 + || strcmp(mimetype, "image/jpg") == 0) + return VD_AGENT_CLIPBOARD_IMAGE_JPG; + + if (strcmp(mimetype, "image/tiff") == 0) + return VD_AGENT_CLIPBOARD_IMAGE_TIFF; + + return VD_AGENT_CLIPBOARD_NONE; + +} + +/** + * Returns the Guacamole clipboard mimetype corresponding to the given VD_AGENT + * clipboard data type, or NULL if the type has no Guacamole equivalent. + * + * @param type + * The VD_AGENT_CLIPBOARD_* type to translate. + * + * @return + * A statically-allocated Guacamole mimetype string, or NULL if unsupported. + */ +static const char* guac_spice_clipboard_mimetype_for_type(guint32 type) { + + switch (type) { + + case VD_AGENT_CLIPBOARD_UTF8_TEXT: + return "text/plain"; + + case VD_AGENT_CLIPBOARD_IMAGE_PNG: + return "image/png"; + + case VD_AGENT_CLIPBOARD_IMAGE_BMP: + return "image/bmp"; + + case VD_AGENT_CLIPBOARD_IMAGE_JPG: + return "image/jpeg"; + + case VD_AGENT_CLIPBOARD_IMAGE_TIFF: + return "image/tiff"; + + default: + return NULL; + + } + +} + /** * Handler which performs a deferred SPICE clipboard-selection grab on the * event-loop thread. The offered types array is carried in call->data. @@ -51,8 +144,9 @@ static void guac_spice_do_clipboard_grab(guac_spice_deferred_call* call) { /** * Signal handler for the SPICE main channel "main-clipboard-selection-grab" - * signal, invoked when the remote guest takes ownership of the clipboard. If - * the guest is offering UTF-8 text, the data is requested from the guest. + * signal, invoked when the remote guest takes ownership of the clipboard. The + * best Guacamole-representable type offered by the guest (see + * guac_spice_clipboard_preference) is requested from the guest. */ static void guac_spice_clipboard_grab(SpiceMainChannel* channel, guint selection, gpointer types, guint ntypes, gpointer data) { @@ -65,13 +159,17 @@ static void guac_spice_clipboard_grab(SpiceMainChannel* channel, if (spice_client->settings->disable_copy) return; - /* Request UTF-8 text from the guest, if offered */ + /* Request the most-preferred type the guest is offering that Guacamole can + * represent (text over image, PNG over other image formats) */ guint32* offered = (guint32*) types; - for (guint i = 0; i < ntypes; i++) { - if (offered[i] == VD_AGENT_CLIPBOARD_UTF8_TEXT) { - spice_main_channel_clipboard_selection_request(channel, selection, - VD_AGENT_CLIPBOARD_UTF8_TEXT); - return; + for (unsigned int p = 0; + p < sizeof(guac_spice_clipboard_preference) / sizeof(guint32); p++) { + for (guint i = 0; i < ntypes; i++) { + if (offered[i] == guac_spice_clipboard_preference[p]) { + spice_main_channel_clipboard_selection_request(channel, + selection, guac_spice_clipboard_preference[p]); + return; + } } } @@ -80,7 +178,8 @@ static void guac_spice_clipboard_grab(SpiceMainChannel* channel, /** * Signal handler for the SPICE main channel "main-clipboard-selection" signal, * invoked when the remote guest sends clipboard data (in response to an earlier - * request). The received data is pushed to all connected Guacamole users. + * request). The received data is pushed to all connected Guacamole users. Both + * UTF-8 text and image data (PNG, BMP, JPEG, TIFF) are supported. */ static void guac_spice_clipboard_selection(SpiceMainChannel* channel, guint selection, guint type, gpointer data, guint size, @@ -89,16 +188,28 @@ static void guac_spice_clipboard_selection(SpiceMainChannel* channel, guac_client* client = (guac_client*) user_data; guac_spice_client* spice_client = (guac_spice_client*) client->data; - /* Ignore inbound clipboard if outbound transfer is disabled or unsupported - * data type is received */ + /* Ignore inbound clipboard if outbound transfer is disabled or no clipboard + * structure is available */ if (spice_client->settings->disable_copy - || type != VD_AGENT_CLIPBOARD_UTF8_TEXT || spice_client->clipboard == NULL) return; - /* Replace clipboard contents with received text and broadcast to users */ - guac_common_clipboard_reset(spice_client->clipboard, "text/plain"); - guac_common_clipboard_append(spice_client->clipboard, (char*) data, size); + /* Ignore data of a type Guacamole cannot represent */ + const char* mimetype = guac_spice_clipboard_mimetype_for_type(type); + if (mimetype == NULL) + return; + + /* Reject an implausibly large payload rather than passing a size that would + * wrap negative when narrowed to the int length taken by the clipboard + * (the clipboard itself caps the retained data at clipboard-buffer-size) */ + if (size > (guint) INT_MAX) + return; + + /* Replace clipboard contents with received data and broadcast to users. The + * data may be binary (for images); guac_common_clipboard stores and streams + * it by length, so this is safe for non-text mimetypes. */ + guac_common_clipboard_reset(spice_client->clipboard, mimetype); + guac_common_clipboard_append(spice_client->clipboard, (char*) data, (int) size); guac_common_clipboard_send(spice_client->clipboard, client); } @@ -107,7 +218,7 @@ static void guac_spice_clipboard_selection(SpiceMainChannel* channel, * Signal handler for the SPICE main channel "main-clipboard-selection-request" * signal, invoked when the remote guest requests the current clipboard * contents. The most recently received Guacamole clipboard data is sent to the - * guest. + * guest, provided the requested type matches the type Guacamole currently holds. * * @return * TRUE if the request was handled, FALSE otherwise. @@ -118,20 +229,31 @@ static gboolean guac_spice_clipboard_request(SpiceMainChannel* channel, guac_client* client = (guac_client*) data; guac_spice_client* spice_client = (guac_spice_client*) client->data; - /* Ignore requests if inbound (client-to-remote) transfer is disabled, the - * requested type is unsupported, or no clipboard data is available */ - if (spice_client->settings->disable_paste - || type != VD_AGENT_CLIPBOARD_UTF8_TEXT - || spice_client->clipboard == NULL) + /* Ignore requests if inbound (client-to-remote) transfer is disabled or no + * clipboard data is available */ + guac_common_clipboard* clipboard = spice_client->clipboard; + if (spice_client->settings->disable_paste || clipboard == NULL) return FALSE; - /* Provide the current clipboard contents to the guest */ - spice_main_channel_clipboard_selection_notify(channel, selection, - VD_AGENT_CLIPBOARD_UTF8_TEXT, - (const guchar*) spice_client->clipboard->buffer, - spice_client->clipboard->length); + /* Hold the clipboard lock across the type check and notify so a concurrent + * paste from a Guacamole user thread (which resets/appends the clipboard) + * cannot swap the contents out from under us mid-request, which would send + * the guest a mimetype/buffer/length mismatch (especially for images). */ + pthread_mutex_lock(&clipboard->lock); + + gboolean handled = FALSE; + + /* Only respond if the guest is requesting the type we actually hold; we do + * not transcode between clipboard formats */ + if (guac_spice_clipboard_type_for_mimetype(clipboard->mimetype) == type) { + spice_main_channel_clipboard_selection_notify(channel, selection, type, + (const guchar*) clipboard->buffer, clipboard->length); + handled = TRUE; + } + + pthread_mutex_unlock(&clipboard->lock); - return TRUE; + return handled; } @@ -214,16 +336,20 @@ int guac_spice_clipboard_end_handler(guac_user* user, guac_stream* stream) { guac_recording_report_clipboard_end(spice_client->recording, stream); /* Take ownership of the SPICE clipboard on behalf of the client, offering - * the newly-received text to the remote guest. The guest will subsequently - * request the data via "main-clipboard-selection-request". */ + * the newly-received data to the remote guest. The guest will subsequently + * request the data via "main-clipboard-selection-request". The offered type + * matches the mimetype the client sent (text or image); an unsupported + * mimetype is not offered. */ + guint32 type = guac_spice_clipboard_type_for_mimetype(clipboard->mimetype); if (spice_client->main_channel != NULL - && !spice_client->settings->disable_paste) { + && !spice_client->settings->disable_paste + && type != VD_AGENT_CLIPBOARD_NONE) { /* Marshal the grab onto the SPICE event-loop thread; this handler runs * on a Guacamole user thread, and spice-gtk channel functions must not * be called off the loop thread (see guac_spice_defer_call) */ guint32* types = g_new(guint32, 1); - types[0] = VD_AGENT_CLIPBOARD_UTF8_TEXT; + types[0] = type; guac_spice_deferred_call* call = g_new0(guac_spice_deferred_call, 1); call->handler = guac_spice_do_clipboard_grab; From ca2947e8b8792b5e568f687b906e54f65c426054 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 16:25:24 -0300 Subject: [PATCH 31/36] GUACAMOLE-261: support clipboard recording (closes #7, part 2) Add the recording-include-clipboard connection parameter (matching RDP and VNC) and pass it to guac_recording_create, so clipboard state changes are captured in session recordings. Previously hardcoded off. --- src/protocols/spice/settings.c | 11 +++++++++++ src/protocols/spice/settings.h | 6 ++++++ src/protocols/spice/spice.c | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c index 83450b2c25..121305a9ec 100644 --- a/src/protocols/spice/settings.c +++ b/src/protocols/spice/settings.c @@ -91,6 +91,7 @@ const char* GUAC_SPICE_CLIENT_ARGS[] = { "recording-exclude-output", "recording-exclude-mouse", "recording-include-keys", + "recording-include-clipboard", "create-recording-path", "recording-write-existing", "wol-send-packet", @@ -403,6 +404,12 @@ enum SPICE_ARGS_IDX { */ IDX_RECORDING_INCLUDE_KEYS, + /** + * Whether clipboard state changes should be included in the screen + * recording. + */ + IDX_RECORDING_INCLUDE_CLIPBOARD, + /** * Whether the specified screen recording path should automatically be * created if it does not yet exist. @@ -710,6 +717,10 @@ guac_spice_settings* guac_spice_parse_args(guac_user* user, guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_RECORDING_INCLUDE_KEYS, false); + settings->recording_include_clipboard = + guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_RECORDING_INCLUDE_CLIPBOARD, false); + settings->create_recording_path = guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_CREATE_RECORDING_PATH, false); diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h index a13ce894cc..b07352a33b 100644 --- a/src/protocols/spice/settings.h +++ b/src/protocols/spice/settings.h @@ -393,6 +393,12 @@ typedef struct guac_spice_settings { */ bool recording_include_keys; + /** + * Whether clipboard state changes should be included in the session + * recording. + */ + bool recording_include_clipboard; + /** * Whether existing files should be appended to when creating a new * recording. diff --git a/src/protocols/spice/spice.c b/src/protocols/spice/spice.c index 527a0f3d61..05b4bfbe63 100644 --- a/src/protocols/spice/spice.c +++ b/src/protocols/spice/spice.c @@ -351,7 +351,7 @@ void* guac_spice_client_thread(void* data) { 0, /* Touch events not supported */ settings->recording_include_keys, settings->recording_write_existing, - 0); /* Clipboard recording not yet supported for SPICE */ + settings->recording_include_clipboard); } #ifdef ENABLE_COMMON_SSH From 84828eac5ba0cc1df7085183d6537c4a82a14679 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 16:55:22 -0300 Subject: [PATCH 32/36] GUACAMOLE-261: record guest-to-client clipboard and annotate direction (#7) The guest-to-client clipboard (data copied OUT of the guest) is broadcast per-user and so was not captured by the recording's client-socket tee, leaving the data-exfiltration direction unrecorded. Record it explicitly via guac_recording_report_clipboard, and annotate every clipboard transfer in the recording with a direction/mimetype/stream log line (guest-to-client vs client-to-guest) so recordings can be audited for clipboard-based data movement. The annotation is written only to the recording socket and is gated identically to the clipboard data it describes. --- src/protocols/spice/clipboard.c | 72 ++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/src/protocols/spice/clipboard.c b/src/protocols/spice/clipboard.c index 83c6218670..ea2dd69f22 100644 --- a/src/protocols/spice/clipboard.c +++ b/src/protocols/spice/clipboard.c @@ -24,7 +24,9 @@ #include "spice.h" #include +#include #include +#include #include #include #include @@ -133,6 +135,56 @@ static const char* guac_spice_clipboard_mimetype_for_type(guint32 type) { } +/** + * Records a clipboard transfer within the session recording, annotated with its + * direction. The annotation is emitted as a log instruction directly to the + * recording socket (not to any live client), so playback tooling ignores it + * while an audit of the recording can distinguish data copied *out* of the + * guest ("guest-to-client" — a potential data-exfiltration path) from data + * pasted *into* the guest ("client-to-guest"). Does nothing unless clipboard + * recording is active. + * + * @param recording + * The session recording, or NULL if no recording is in progress. + * + * @param outbound + * Non-zero if the transfer is guest-to-client (leaving the guest), zero if + * it is client-to-guest (entering the guest). + * + * @param stream_index + * The index of the recording stream carrying the clipboard data, allowing + * the annotation to be correlated with the following clipboard/blob/end + * instructions. + * + * @param mimetype + * The mimetype of the clipboard data. + * + * @param length + * The length of the clipboard data in bytes, or a negative value if not yet + * known (as when a client-to-guest transfer is only beginning). + */ +static void guac_spice_clipboard_record_direction(guac_recording* recording, + int outbound, int stream_index, const char* mimetype, int length) { + + /* Match the gating of guac_recording_report_clipboard() so the annotation + * is present exactly when the clipboard data it describes is */ + if (recording == NULL || !recording->include_clipboard + || recording->socket == NULL) + return; + + const char* direction = outbound ? "guest-to-client" : "client-to-guest"; + + if (length >= 0) + guac_protocol_send_log(recording->socket, + "clipboard stream=%d direction=%s mimetype=%s bytes=%d", + stream_index, direction, mimetype, length); + else + guac_protocol_send_log(recording->socket, + "clipboard stream=%d direction=%s mimetype=%s", + stream_index, direction, mimetype); + +} + /** * Handler which performs a deferred SPICE clipboard-selection grab on the * event-loop thread. The offered types array is carried in call->data. @@ -212,6 +264,18 @@ static void guac_spice_clipboard_selection(SpiceMainChannel* channel, guac_common_clipboard_append(spice_client->clipboard, (char*) data, (int) size); guac_common_clipboard_send(spice_client->clipboard, client); + /* Record this guest-to-client transfer. Unlike client-to-guest pastes (which + * are recorded from their inbound stream), the outbound clipboard is + * broadcast per-user and so is not otherwise captured by the recording. This + * is the data-exfiltration direction, so it is important to audit. */ + guac_recording* recording = spice_client->recording; + if (recording != NULL && recording->clipboard_stream != NULL) { + guac_spice_clipboard_record_direction(recording, 1, + recording->clipboard_stream->index, mimetype, (int) size); + guac_recording_report_clipboard(recording, mimetype, + (const char*) data, (int) size); + } + } /** @@ -292,10 +356,14 @@ int guac_spice_clipboard_handler(guac_user* user, guac_stream* stream, stream->blob_handler = guac_spice_clipboard_blob_handler; stream->end_handler = guac_spice_clipboard_end_handler; - /* Report clipboard within recording */ - if (spice_client->recording != NULL) + /* Report clipboard within recording, annotated as a client-to-guest paste + * (the length is not yet known as the data is still streaming) */ + if (spice_client->recording != NULL) { + guac_spice_clipboard_record_direction(spice_client->recording, 0, + stream->index, mimetype, -1); guac_recording_report_clipboard_begin(spice_client->recording, stream, mimetype); + } return 0; From 2a6ec2d58e901ab5d2b92884e7251a369f05bf77 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 19:59:03 -0300 Subject: [PATCH 33/36] GUACAMOLE-261: add agent-based file upload via file-transfer-mode (closes #14) Add a client->guest upload path over the SPICE agent (spice_main_channel_file_copy_async), selected by a new file-transfer-mode parameter: none | agent | drive | both. There is only one Guacamole upload handler, so the mode deterministically routes the drag/upload gesture: - agent: push files into the guest via spice-vdagent (no mount, upload-only) - drive: existing bidirectional WebDAV shared folder - both: browse/download via the folder, drag-drop upload via the agent A blank mode preserves the legacy file-transfer behaviour (drive if enabled). Uploaded files are staged in a private temp dir (O_EXCL|O_NOFOLLOW) and pushed on the SPICE event-loop thread. A data_destroy hook on deferred calls guarantees cleanup if the loop ends before dispatch, an aborted flag prevents pushing a truncated file after a staging error, and the live main channel is resolved on the loop thread to avoid dangling references. Completion is logged with direction and mechanism for auditing. --- src/protocols/spice/channels/file-upload.c | 369 +++++++++++++++++++++ src/protocols/spice/channels/file-upload.h | 20 ++ src/protocols/spice/settings.c | 39 +++ src/protocols/spice/settings.h | 43 +++ src/protocols/spice/spice.c | 21 +- src/protocols/spice/spice.h | 12 +- src/protocols/spice/user.c | 28 +- 7 files changed, 521 insertions(+), 11 deletions(-) diff --git a/src/protocols/spice/channels/file-upload.c b/src/protocols/spice/channels/file-upload.c index 63e0bc7be8..0578916ebc 100644 --- a/src/protocols/spice/channels/file-upload.c +++ b/src/protocols/spice/channels/file-upload.c @@ -29,9 +29,14 @@ #include #include +#include + #include +#include #include +#include #include +#include /** * Writes the given filename to the given upload path, sanitizing the filename @@ -261,3 +266,367 @@ int guac_spice_file_upload_put_handler(guac_user* user, guac_object* object, return 0; } +/** + * The in-progress state of a single file being uploaded directly to the guest + * via the SPICE agent. The file is staged on the guacd host under a private + * temporary directory (named with the original filename so it reaches the guest + * unchanged) and then handed to spice_main_channel_file_copy_async(). + */ +typedef struct guac_spice_agent_upload { + + /** + * The guac_client associated with the upload, used for logging. + */ + guac_client* client; + + /** + * The private temporary directory created to stage the file, or NULL. + */ + char* tmpdir; + + /** + * The full path of the staged file within tmpdir, or NULL. + */ + char* tmppath; + + /** + * The sanitized original filename, used for logging. + */ + char* filename; + + /** + * The open file descriptor of the staged file while blobs are being + * written, or -1 once closed. + */ + int fd; + + /** + * Non-zero if the upload was aborted (e.g. a write to the staged file + * failed). An aborted upload must not be pushed to the guest, as the staged + * file would be truncated/incomplete. + */ + int aborted; + + /** + * The total number of bytes staged so far. + */ + uint64_t bytes; + +} guac_spice_agent_upload; + +/** + * Frees a SPICE-agent upload context, removing the staged file and its + * temporary directory. Safe to call with NULL. + * + * @param upload + * The upload context to free. + */ +static void guac_spice_agent_upload_free(guac_spice_agent_upload* upload) { + + if (upload == NULL) + return; + + if (upload->fd >= 0) + close(upload->fd); + + if (upload->tmppath != NULL) { + unlink(upload->tmppath); + g_free(upload->tmppath); + } + + if (upload->tmpdir != NULL) { + rmdir(upload->tmpdir); + g_free(upload->tmpdir); + } + + g_free(upload->filename); + g_free(upload); + +} + +/** + * Sanitizes a client-supplied filename into a bare basename (no path + * separators), suitable for use as the name of the staged file. + * + * @param filename + * The client-supplied filename. + * + * @param name + * A buffer of at least GUAC_SPICE_FOLDER_MAX_PATH bytes to receive the + * sanitized basename. + */ +static void __sanitize_filename(const char* filename, char* name) { + + int i; + + for (i = 0; i < GUAC_SPICE_FOLDER_MAX_PATH - 1; i++) { + + char c = filename[i]; + if (c == '\0') + break; + + /* Replace path separators to keep the name a bare basename */ + if (c == '/' || c == '\\') + c = '_'; + + name[i] = c; + + } + + name[i] = '\0'; + + /* Never allow an empty name or the special directory entries "." / ".." — + * substitute a safe default so the staged basename is always a real file */ + if (i == 0 || strcmp(name, ".") == 0 || strcmp(name, "..") == 0) + strcpy(name, "upload.bin"); + +} + +/** + * GAsyncReadyCallback invoked on the SPICE event-loop thread once a direct + * guest upload has completed (or failed). Logs the outcome with explicit + * direction and mechanism for auditing, then frees the upload context. + */ +static void guac_spice_agent_copy_ready(GObject* source_object, + GAsyncResult* result, gpointer user_data) { + + guac_spice_agent_upload* upload = (guac_spice_agent_upload*) user_data; + SpiceMainChannel* main_channel = SPICE_MAIN_CHANNEL(source_object); + GError* error = NULL; + + if (spice_main_channel_file_copy_finish(main_channel, result, &error)) + guac_client_log(upload->client, GUAC_LOG_INFO, + "File transfer client->guest complete: \"%s\" (%" PRIu64 + " bytes) pushed into the guest via the SPICE agent (the guest " + "agent saves it to the user's download/desktop location).", + upload->filename, upload->bytes); + else + guac_client_log(upload->client, GUAC_LOG_WARNING, + "File transfer client->guest failed for \"%s\" via the SPICE " + "agent: %s", upload->filename, + error != NULL ? error->message : "unknown error"); + + g_clear_error(&error); + guac_spice_agent_upload_free(upload); + +} + +/** + * Deferred-call handler which starts the asynchronous SPICE agent file copy on + * the event-loop thread. Ownership of the upload context is taken from the + * deferred call (call->data is cleared so it is not double-freed) and handed to + * the async callback. + */ +static void guac_spice_agent_copy_dispatch(guac_spice_deferred_call* call) { + + guac_spice_agent_upload* upload = (guac_spice_agent_upload*) call->data; + + /* Take ownership; prevent guac_spice_deferred_free() from freeing it */ + call->data = NULL; + + if (upload == NULL) + return; + + /* Read the live main channel on the event-loop thread, which is the + * authoritative owner of this pointer. Capturing it on the user thread + * would risk a dangling reference if the channel were torn down before + * dispatch. */ + guac_spice_client* spice_client = + (guac_spice_client*) upload->client->data; + SpiceMainChannel* main_channel = spice_client->main_channel; + + /* Verify the SPICE agent is connected before attempting the push. This is + * the authoritative check (the user-thread handler only did a cheap + * NULL-channel pre-screen). */ + gboolean agent_connected = FALSE; + if (main_channel != NULL) + g_object_get(main_channel, "agent-connected", &agent_connected, NULL); + + if (!agent_connected) { + guac_client_log(upload->client, GUAC_LOG_WARNING, + "File transfer client->guest aborted for \"%s\": the SPICE " + "guest agent is not connected.", upload->filename); + guac_spice_agent_upload_free(upload); + return; + } + + /* Push the staged file into the guest. spice-gtk refs the source GFiles + * internally, so the local array/reference can be released immediately. */ + GFile* source = g_file_new_for_path(upload->tmppath); + GFile* sources[] = { source, NULL }; + + spice_main_channel_file_copy_async(main_channel, sources, + G_FILE_COPY_NONE, NULL, NULL, NULL, + guac_spice_agent_copy_ready, upload); + + g_object_unref(source); + +} + +int guac_spice_file_upload_agent_handler(guac_user* user, guac_stream* stream, + char* mimetype, char* filename) { + + guac_client* client = user->client; + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Cheap pre-screen on the user thread: reject early if there is no SPICE + * main channel at all. The authoritative "agent-connected" check is done on + * the event-loop thread just before the push (guac_spice_agent_copy_dispatch), + * since spice-gtk objects are owned by that thread. */ + if (spice_client->main_channel == NULL) { + guac_client_log(client, GUAC_LOG_WARNING, "Direct upload to guest " + "rejected: the SPICE guest agent is not connected."); + guac_protocol_send_ack(user->socket, stream, + "FAIL (AGENT UNAVAILABLE)", + GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE); + guac_socket_flush(user->socket); + return 0; + } + + /* Stage the incoming file under a private temporary directory, keeping the + * original filename so it reaches the guest unchanged */ + GError* error = NULL; + char* tmpdir = g_dir_make_tmp("guac-spice-upload-XXXXXX", &error); + if (tmpdir == NULL) { + guac_client_log(client, GUAC_LOG_ERROR, "Unable to stage direct " + "upload: %s", error != NULL ? error->message + : "could not create temporary directory"); + g_clear_error(&error); + guac_protocol_send_ack(user->socket, stream, "FAIL (NO STAGING)", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + return 0; + } + + char safe_name[GUAC_SPICE_FOLDER_MAX_PATH]; + __sanitize_filename(filename, safe_name); + + /* Create the staged file fresh within the private temp dir. O_EXCL | + * O_NOFOLLOW guarantee we create a brand-new regular file and never follow + * a pre-planted symlink (defense-in-depth on top of the 0700 temp dir). */ + char* tmppath = g_build_filename(tmpdir, safe_name, NULL); + int fd = open(tmppath, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600); + if (fd < 0) { + guac_client_log(client, GUAC_LOG_ERROR, "Unable to stage direct " + "upload file \"%s\".", tmppath); + rmdir(tmpdir); + g_free(tmppath); + g_free(tmpdir); + guac_protocol_send_ack(user->socket, stream, "FAIL (CANNOT OPEN)", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + return 0; + } + + /* Track upload state for the duration of the stream */ + guac_spice_agent_upload* upload = g_new0(guac_spice_agent_upload, 1); + upload->client = client; + upload->tmpdir = tmpdir; + upload->tmppath = tmppath; + upload->filename = g_strdup(safe_name); + upload->fd = fd; + upload->bytes = 0; + + stream->data = upload; + stream->blob_handler = guac_spice_file_upload_agent_blob_handler; + stream->end_handler = guac_spice_file_upload_agent_end_handler; + + guac_protocol_send_ack(user->socket, stream, "OK (STREAM BEGIN)", + GUAC_PROTOCOL_STATUS_SUCCESS); + guac_socket_flush(user->socket); + return 0; + +} + +int guac_spice_file_upload_agent_blob_handler(guac_user* user, + guac_stream* stream, void* data, int length) { + + guac_spice_agent_upload* upload = (guac_spice_agent_upload*) stream->data; + + if (upload == NULL || upload->fd < 0) { + guac_protocol_send_ack(user->socket, stream, "FAIL (NO UPLOAD)", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + return 0; + } + + /* Write the entire block to the staged file */ + int total = 0; + while (total < length) { + + ssize_t written = write(upload->fd, (char*) data + total, + length - total); + + if (written < 0) { + guac_protocol_send_ack(user->socket, stream, "FAIL (BAD WRITE)", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + close(upload->fd); + upload->fd = -1; + upload->aborted = 1; + return 0; + } + + total += written; + + } + + upload->bytes += length; + + guac_protocol_send_ack(user->socket, stream, "OK (DATA RECEIVED)", + GUAC_PROTOCOL_STATUS_SUCCESS); + guac_socket_flush(user->socket); + return 0; + +} + +int guac_spice_file_upload_agent_end_handler(guac_user* user, + guac_stream* stream) { + + guac_spice_agent_upload* upload = (guac_spice_agent_upload*) stream->data; + + /* Detach upload state from the stream */ + stream->data = NULL; + + if (upload == NULL) { + guac_protocol_send_ack(user->socket, stream, "FAIL (NO UPLOAD)", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + return 0; + } + + /* Finish writing the staged file */ + if (upload->fd >= 0) { + close(upload->fd); + upload->fd = -1; + } + + /* If the upload was aborted mid-stream (e.g. a staging write failed), do + * not push the truncated file to the guest — just clean up. The failure was + * already reported to the client on the failing blob. */ + if (upload->aborted) { + guac_spice_agent_upload_free(upload); + guac_protocol_send_ack(user->socket, stream, "FAIL (UPLOAD ABORTED)", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + return 0; + } + + /* Hand the staged file to the SPICE agent on the event-loop thread. The + * deferred call takes ownership of the upload context via call->data; if the + * loop terminates before dispatch, data_destroy ensures the staged file and + * its temp dir are still cleaned up. The live main channel is resolved on + * the loop thread within the dispatch handler, so it is not captured here. */ + guac_spice_deferred_call* call = g_new0(guac_spice_deferred_call, 1); + call->handler = guac_spice_agent_copy_dispatch; + call->data = upload; + call->data_destroy = (GDestroyNotify) guac_spice_agent_upload_free; + guac_spice_defer_call(call); + + guac_protocol_send_ack(user->socket, stream, "OK (STREAM END)", + GUAC_PROTOCOL_STATUS_SUCCESS); + guac_socket_flush(user->socket); + return 0; + +} + diff --git a/src/protocols/spice/channels/file-upload.h b/src/protocols/spice/channels/file-upload.h index d17e170bb5..615e9ce0c5 100644 --- a/src/protocols/spice/channels/file-upload.h +++ b/src/protocols/spice/channels/file-upload.h @@ -68,5 +68,25 @@ guac_user_end_handler guac_spice_file_upload_end_handler; */ guac_user_put_handler guac_spice_file_upload_put_handler; +/** + * Handler for inbound files uploaded directly to the guest via the SPICE agent + * (file-transfer-mode "agent"/"both"). The uploaded file is staged on the + * guacd host and then pushed into the guest's Downloads folder using + * spice_main_channel_file_copy_async(). This mechanism is client-to-guest only; + * it cannot retrieve files from the guest. + */ +guac_user_file_handler guac_spice_file_upload_agent_handler; + +/** + * Handler for stream data related to SPICE-agent file uploads. + */ +guac_user_blob_handler guac_spice_file_upload_agent_blob_handler; + +/** + * Handler for end-of-stream related to SPICE-agent file uploads. Triggers the + * actual push of the staged file into the guest. + */ +guac_user_end_handler guac_spice_file_upload_agent_end_handler; + #endif diff --git a/src/protocols/spice/settings.c b/src/protocols/spice/settings.c index 121305a9ec..c2600f4fee 100644 --- a/src/protocols/spice/settings.c +++ b/src/protocols/spice/settings.c @@ -101,6 +101,7 @@ const char* GUAC_SPICE_CLIENT_ARGS[] = { "wol-wait-time", "preferred-video-codec", "secondary-monitors", + "file-transfer-mode", NULL }; @@ -464,6 +465,16 @@ enum SPICE_ARGS_IDX { */ IDX_SECONDARY_MONITORS, + /** + * How files dragged/uploaded onto the session are transferred to the + * guest: "none", "agent" (SPICE agent push to the guest Downloads folder, + * client-to-guest only), "drive" (the bidirectional WebDAV shared folder), + * or "both" (drive browser for download, agent for the drag-drop upload). + * When blank, the mode is derived from the legacy "file-transfer" param for + * backwards compatibility. + */ + IDX_FILE_TRANSFER_MODE, + SPICE_ARGS_COUNT }; @@ -622,6 +633,34 @@ guac_spice_settings* guac_spice_parse_args(guac_user* user, guac_user_parse_args_boolean(user, GUAC_SPICE_CLIENT_ARGS, argv, IDX_DISABLE_UPLOAD, false); + /* Resolve the file-transfer mode, which decides where dragged/uploaded + * files go. When left blank, fall back to the legacy behaviour: the shared + * folder browser (if file-transfer was enabled) claimed the upload + * gesture. */ + char* transfer_mode = + guac_user_parse_args_string(user, GUAC_SPICE_CLIENT_ARGS, argv, + IDX_FILE_TRANSFER_MODE, NULL); + + if (transfer_mode == NULL || strcmp(transfer_mode, "") == 0) + settings->file_transfer_mode = settings->file_transfer + ? GUAC_SPICE_FILE_TRANSFER_DRIVE + : GUAC_SPICE_FILE_TRANSFER_NONE; + else if (strcmp(transfer_mode, "agent") == 0) + settings->file_transfer_mode = GUAC_SPICE_FILE_TRANSFER_AGENT; + else if (strcmp(transfer_mode, "drive") == 0) + settings->file_transfer_mode = GUAC_SPICE_FILE_TRANSFER_DRIVE; + else if (strcmp(transfer_mode, "both") == 0) + settings->file_transfer_mode = GUAC_SPICE_FILE_TRANSFER_BOTH; + else if (strcmp(transfer_mode, "none") == 0) + settings->file_transfer_mode = GUAC_SPICE_FILE_TRANSFER_NONE; + else { + guac_user_log(user, GUAC_LOG_WARNING, "Unknown file-transfer-mode " + "\"%s\"; file transfer disabled.", transfer_mode); + settings->file_transfer_mode = GUAC_SPICE_FILE_TRANSFER_NONE; + } + + guac_mem_free(transfer_mode); + /* If neither a plaintext nor a TLS port was provided, fall back to the * default SPICE port for the appropriate transport */ if (strcmp(settings->port, "") == 0 && strcmp(settings->tls_port, "") == 0) { diff --git a/src/protocols/spice/settings.h b/src/protocols/spice/settings.h index b07352a33b..8c3018d4e2 100644 --- a/src/protocols/spice/settings.h +++ b/src/protocols/spice/settings.h @@ -50,6 +50,43 @@ */ #define GUAC_SPICE_CLIPBOARD_DEFAULT_BUFFER_SIZE 262144 +/** + * The mechanism used to transfer files that are dragged or uploaded onto the + * session. There is only one Guacamole file-upload handler, so exactly one + * mechanism claims the upload gesture; the WebDAV browser (when present) still + * provides download/browse independently. + */ +typedef enum guac_spice_file_transfer_mode { + + /** + * No file transfer. Dragged/uploaded files are not accepted. + */ + GUAC_SPICE_FILE_TRANSFER_NONE = 0, + + /** + * Client-to-guest only. Uploaded files are pushed straight into the guest + * via the SPICE agent (spice-vdagent), which saves them to the user's + * download/desktop location per the guest's XDG configuration. No mount + * required; downloads are not supported. + */ + GUAC_SPICE_FILE_TRANSFER_AGENT, + + /** + * Bidirectional WebDAV shared folder. Uploaded files land in the shared + * folder, which the user also browses and downloads from. + */ + GUAC_SPICE_FILE_TRANSFER_DRIVE, + + /** + * Both mechanisms: the WebDAV shared folder provides browse/download, while + * the drag-drop upload gesture is routed to the SPICE agent (guest + * Downloads). This is the only configuration in which enabling the agent + * adds a capability the drive does not already provide. + */ + GUAC_SPICE_FILE_TRANSFER_BOTH + +} guac_spice_file_transfer_mode; + /** * SPICE-specific connection settings, parsed from the arguments provided when * a user joins the connection. @@ -221,6 +258,12 @@ typedef struct guac_spice_settings { */ char* preferred_video_codec; + /** + * The destination for files dragged/uploaded onto the session. See + * guac_spice_file_transfer_mode. + */ + guac_spice_file_transfer_mode file_transfer_mode; + /** * Whether folder sharing (shared directory via the SPICE WebDAV channel) * should be enabled. diff --git a/src/protocols/spice/spice.c b/src/protocols/spice/spice.c index 05b4bfbe63..5e13f305ec 100644 --- a/src/protocols/spice/spice.c +++ b/src/protocols/spice/spice.c @@ -200,7 +200,15 @@ static void guac_spice_deferred_free(gpointer user_data) { guac_spice_deferred_call* call = (guac_spice_deferred_call*) user_data; - g_free(call->data); + /* Release the payload, honoring a custom destructor when one was set (a + * handler that took ownership will have cleared call->data first) */ + if (call->data != NULL) { + if (call->data_destroy != NULL) + call->data_destroy(call->data); + else + g_free(call->data); + } + g_free(call); } @@ -277,10 +285,13 @@ void* guac_spice_client_thread(void* data) { spice_client->spice_session = spice_session_new(); guac_spice_session_configure(client, spice_client->spice_session); - /* Set up the shared folder file browser, if file transfer is enabled. The - * shared-dir property here takes precedence over the basic enable-drive - * sharing configured within guac_spice_session_configure(). */ - if (settings->file_transfer && settings->file_directory != NULL) { + /* Set up the shared folder file browser when the file-transfer mode + * includes a WebDAV drive ("drive" or "both"). The shared-dir property here + * takes precedence over the basic enable-drive sharing configured within + * guac_spice_session_configure(). */ + if ((settings->file_transfer_mode == GUAC_SPICE_FILE_TRANSFER_DRIVE + || settings->file_transfer_mode == GUAC_SPICE_FILE_TRANSFER_BOTH) + && settings->file_directory != NULL) { guac_client_log(client, GUAC_LOG_INFO, "Enabling shared folder file transfer for \"%s\"%s.", diff --git a/src/protocols/spice/spice.h b/src/protocols/spice/spice.h index 5d4080927e..0a2387d519 100644 --- a/src/protocols/spice/spice.h +++ b/src/protocols/spice/spice.h @@ -409,10 +409,20 @@ typedef struct guac_spice_deferred_call { /** * Optional heap-allocated payload (e.g. a copy of clipboard data or a types - * array), or NULL. Freed automatically after the call has been dispatched. + * array), or NULL. Freed automatically after the call has been dispatched + * (or if the loop terminates before dispatch), using data_destroy if set + * and g_free() otherwise. A handler that takes ownership of data must set + * this field to NULL to suppress the automatic free. */ gpointer data; + /** + * Optional destructor for data. When NULL, data is released with g_free(). + * Set this when data requires more than a flat free (e.g. owns nested + * allocations, file descriptors, or temporary files). + */ + GDestroyNotify data_destroy; + /** * The length of data, in bytes. */ diff --git a/src/protocols/spice/user.c b/src/protocols/spice/user.c index 49965884f7..1e2ea232b8 100644 --- a/src/protocols/spice/user.c +++ b/src/protocols/spice/user.c @@ -103,11 +103,29 @@ int guac_spice_user_join_handler(guac_user* user, int argc, char** argv) { user->file_handler = guac_spice_sftp_file_handler; #endif - /* Generic (non-filesystem) uploads to the shared folder take precedence - * over SFTP when the file browser is enabled. The exposed filesystem - * object handles its own get/put (download/ls/upload) requests. */ - if (settings->file_transfer && !settings->disable_upload) - user->file_handler = guac_spice_file_upload_file_handler; + /* Route the generic (non-filesystem) upload gesture according to the + * file-transfer mode, taking precedence over SFTP. In "agent"/"both" + * dropped files are pushed into the guest via the SPICE agent; in + * "drive" they are written to the shared folder. The exposed filesystem + * object still handles its own get/put (download/ls/upload) requests + * for the drive browser regardless of this routing. */ + if (!settings->disable_upload) { + switch (settings->file_transfer_mode) { + + case GUAC_SPICE_FILE_TRANSFER_AGENT: + case GUAC_SPICE_FILE_TRANSFER_BOTH: + user->file_handler = guac_spice_file_upload_agent_handler; + break; + + case GUAC_SPICE_FILE_TRANSFER_DRIVE: + user->file_handler = guac_spice_file_upload_file_handler; + break; + + case GUAC_SPICE_FILE_TRANSFER_NONE: + break; + + } + } } From 2c196182749cc52b30bc50f1366a1281dec5645c Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 22:05:07 -0300 Subject: [PATCH 34/36] GUACAMOLE-261: fix security findings F1-F4 from the #21 assessment Hardens the SPICE handler against the memory-safety and DoS findings from the security assessment (ciroiriarte/guacamole-server#21): - F1 (High, CWE-190->787): bound the client-advertised audio rate/channels and use the checked-multiply allocation in the resampler, so a crafted audio format (e.g. rate=1) can no longer overflow the output-size arithmetic into a heap OOB write (32-bit) or a multi-GB allocation (64-bit). - F2 (Medium, CWE-400/770): cap the SPICE-agent upload staging size so an over-large or endless upload cannot exhaust the guacd host's temp storage. - F3 (Medium, CWE-416): do not start the download-monitor inotify thread. Its transfer action was unimplemented (dead code) and it dereferenced the folder after guac_spice_folder_free() released it (use-after-free). A completed feature must re-introduce it with a proper shutdown path. - F4 (Medium, CWE-400/789): clamp SPICE-server-supplied cursor/primary-surface dimensions to GUAC_DISPLAY_MAX_* before resizing, preventing a malicious or MITM'd server from driving an oversized allocation. --- src/protocols/spice/audio.c | 33 ++++++++++++++++++++-- src/protocols/spice/channels/file-upload.c | 26 +++++++++++++++++ src/protocols/spice/channels/file.c | 13 ++++++--- src/protocols/spice/cursor.c | 13 +++++++++ src/protocols/spice/display.c | 9 ++++-- 5 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/protocols/spice/audio.c b/src/protocols/spice/audio.c index 17b3ab1637..1f9e641f83 100644 --- a/src/protocols/spice/audio.c +++ b/src/protocols/spice/audio.c @@ -43,6 +43,23 @@ */ #define GUAC_SPICE_AUDIO_BPS 16 +/** + * Bounds on the client-advertised audio capture format and on the resampler's + * output size. These values are attacker-controlled (parsed from the audio + * stream mimetype) and feed allocation-size arithmetic, so they are bounded to + * prevent integer overflow and oversized allocations (CWE-190/CWE-400). + */ +#define GUAC_SPICE_AUDIO_MIN_RATE 8000 +#define GUAC_SPICE_AUDIO_MAX_RATE 192000 +#define GUAC_SPICE_AUDIO_MAX_CHANNELS 8 + +/** + * The maximum number of output frames a single resample operation may produce. + * A normal input blob is a small PCM chunk, so this bound is far above any + * legitimate value while capping worst-case allocation to a few tens of MB. + */ +#define GUAC_SPICE_AUDIO_MAX_FRAMES (GUAC_SPICE_AUDIO_MAX_RATE * 30) + /** * Signal handler for the SPICE playback channel "playback-start" signal. * Allocates (or reconfigures) the Guacamole audio stream to match the format @@ -218,6 +235,16 @@ static int guac_spice_audio_parse_mimetype(const char* mimetype, int* rate, if (parsed_rate == -1) return 1; + /* Reject implausible sample rates / channel counts. These values are + * attacker-controlled (from the client's audio stream mimetype) and feed + * directly into resampler allocation size arithmetic; bounding them here + * prevents integer overflow / oversized allocations downstream (CWE-190). */ + if (parsed_rate < GUAC_SPICE_AUDIO_MIN_RATE + || parsed_rate > GUAC_SPICE_AUDIO_MAX_RATE) + return 1; + if (parsed_channels < 1 || parsed_channels > GUAC_SPICE_AUDIO_MAX_CHANNELS) + return 1; + *rate = parsed_rate; *channels = parsed_channels; *bps = parsed_bps; @@ -246,10 +273,12 @@ static int16_t* guac_spice_resample_s16(const int16_t* in, int in_bytes, return NULL; long out_frames = (long) in_frames * out_rate / in_rate; - if (out_frames <= 0) + if (out_frames <= 0 || out_frames > GUAC_SPICE_AUDIO_MAX_FRAMES) return NULL; - int16_t* out = guac_mem_alloc(out_frames * out_channels * sizeof(int16_t)); + /* Use the checked-multiply allocation form so the output size cannot + * overflow (out_frames and out_channels are both bounded above) */ + int16_t* out = guac_mem_alloc(out_frames, out_channels, sizeof(int16_t)); if (out == NULL) return NULL; diff --git a/src/protocols/spice/channels/file-upload.c b/src/protocols/spice/channels/file-upload.c index 0578916ebc..a69590b236 100644 --- a/src/protocols/spice/channels/file-upload.c +++ b/src/protocols/spice/channels/file-upload.c @@ -38,6 +38,15 @@ #include #include +/** + * The maximum number of bytes a single SPICE-agent upload may stage to the + * guacd host's temporary directory before it is pushed into the guest. This + * bounds host disk/tmpfs consumption from an over-large or endless upload + * stream (CWE-400 / CWE-770). Uploads exceeding this are rejected and the + * partial staged file is removed. + */ +#define GUAC_SPICE_MAX_AGENT_UPLOAD ((uint64_t) 2 * 1024 * 1024 * 1024) + /** * Writes the given filename to the given upload path, sanitizing the filename * and translating the filename to the root directory. @@ -550,6 +559,23 @@ int guac_spice_file_upload_agent_blob_handler(guac_user* user, return 0; } + /* Enforce a maximum staged size so an over-large or endless upload cannot + * exhaust the guacd host's temporary storage. Abort the transfer and let + * the end/free path remove the partial file. */ + if (length < 0 || upload->bytes + (uint64_t) length + > GUAC_SPICE_MAX_AGENT_UPLOAD) { + guac_client_log(user->client, GUAC_LOG_WARNING, "Direct upload to " + "guest aborted: exceeds the maximum staged size of %" PRIu64 + " bytes.", (uint64_t) GUAC_SPICE_MAX_AGENT_UPLOAD); + guac_protocol_send_ack(user->socket, stream, "FAIL (TOO LARGE)", + GUAC_PROTOCOL_STATUS_CLIENT_OVERRUN); + guac_socket_flush(user->socket); + close(upload->fd); + upload->fd = -1; + upload->aborted = 1; + return 0; + } + /* Write the entire block to the staged file */ int total = 0; while (total < length) { diff --git a/src/protocols/spice/channels/file.c b/src/protocols/spice/channels/file.c index 6d31e21a18..f9417efbbc 100644 --- a/src/protocols/spice/channels/file.c +++ b/src/protocols/spice/channels/file.c @@ -149,10 +149,15 @@ guac_spice_folder* guac_spice_folder_alloc(guac_client* client, const char* fold } - if(pthread_create(&(folder->download_thread), NULL, guac_spice_file_download_monitor, (void*) folder)) { - guac_client_log(client, GUAC_LOG_ERROR, - "%s: Unable to create Download folder thread monitor.", __func__); - } + /* The automatic download-on-create monitor is intentionally NOT + * started. Its transfer action is unimplemented (dead code), and the + * inotify thread held a raw pointer to this folder which + * guac_spice_folder_free() releases at disconnect without stopping or + * joining the thread — a use-after-free. If/when the feature is + * completed, re-introduce the thread together with a proper shutdown + * path (wake the blocking read via eventfd/self-pipe, pthread_join, and + * close the inotify fd inside guac_spice_folder_free() before the + * folder is freed). */ } diff --git a/src/protocols/spice/cursor.c b/src/protocols/spice/cursor.c index 29df8c14ae..32e7ca3cef 100644 --- a/src/protocols/spice/cursor.c +++ b/src/protocols/spice/cursor.c @@ -47,6 +47,19 @@ static void guac_spice_cursor_set(SpiceCursorChannel* channel, if (spice_client->display == NULL || rgba == NULL) return; + /* Ignore implausible cursor geometry supplied by the (untrusted) SPICE + * server. The cursor is a buffer layer, whose resize path does not clamp to + * GUAC_DISPLAY_MAX_*, so an oversized width/height would drive a huge + * allocation (CWE-400/CWE-789). */ + if (width <= 0 || height <= 0 + || width > GUAC_DISPLAY_MAX_WIDTH + || height > GUAC_DISPLAY_MAX_HEIGHT) { + guac_client_log(client, GUAC_LOG_DEBUG, + "Ignoring cursor with implausible dimensions %dx%d.", + width, height); + return; + } + /* Begin drawing operation directly to the cursor layer */ guac_display_layer* cursor_layer = guac_display_cursor(spice_client->display); guac_display_layer_resize(cursor_layer, width, height); diff --git a/src/protocols/spice/display.c b/src/protocols/spice/display.c index 47f90919b5..c6d44d65ff 100644 --- a/src/protocols/spice/display.c +++ b/src/protocols/spice/display.c @@ -262,8 +262,13 @@ static void guac_spice_display_primary_create(SpiceChannel* channel, "SPICE primary surface created: %dx%d (stride %d, format %d).", width, height, stride, format); - /* Resize the Guacamole display to match the new primary surface */ - if (spice_client->display != NULL) + /* Resize the Guacamole display to match the new primary surface, guarding + * against implausible server-supplied dimensions (defense-in-depth against + * an oversized allocation; CWE-400/CWE-789). */ + if (spice_client->display != NULL + && width > 0 && height > 0 + && width <= GUAC_DISPLAY_MAX_WIDTH + && height <= GUAC_DISPLAY_MAX_HEIGHT) guac_display_layer_resize( guac_display_default_layer(spice_client->display), width, height); From 1777093ffd6f44448791b5694547a0388b2f5e97 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Wed, 8 Jul 2026 01:35:37 -0300 Subject: [PATCH 35/36] GUACAMOLE-261: add guacclip, a clipboard-artifact extractor for session recordings (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `guacclip` CLI (peer to guaclog/guacenc) that recovers clipboard artifacts — text and images — from a `.guac` session recording, filling the gap those tools leave (guacenc→video, guaclog→keystrokes, neither touches clipboard). Phase 1 / MVP of #27; no recording-format change, purely additive tooling. Enabled by the clipboard recording added in #7. `guacclip [-f] [-o OUTDIR] [--direction guest-to-client|client-to-guest] [--include image|text|all] [--max-item-bytes N] FILE...` writes `OUTDIR/manifest.json` + `OUTDIR/items/` with per-item direction, timing, mimetype, byte count, and SHA-256. Design mirrors guaclog (guac_parser/guac_socket loop). Clipboard streams are reassembled strictly by the stream index opened by a `clipboard` instruction, so graphical `img`/file/pipe blob streams are never misread as clipboard data. Direction comes from the recording-only `log stream=N direction=...` annotation, buffered per stream index and consumed at stream open; older recordings without it get direction "unknown". A self-contained FIPS 180-4 SHA-256 (sha256.c) keeps the tool dependency-free — it links only libguac. Hardened against hostile recordings: mimetype is slugged to [A-Za-z0-9_] for filenames (no path traversal) and \u-escaped in JSON (valid manifest even with non-ASCII bytes); per-item size is capped (64 MiB default, `0` opts out) and the manifest item count is bounded to prevent OOM; item files are written via O_EXCL|O_NOFOLLOW temp + atomic rename; stream indices are parsed with strtol validation. Builds clean under -Werror -Wall; gated by --disable-guacclip. Part of #27 (Phase 1). Phase 2 (in-browser audit cockpit) is separate. --- Makefile.am | 5 + configure.ac | 16 + src/guacclip/.gitignore | 7 + src/guacclip/Makefile.am | 57 ++ src/guacclip/guacclip.c | 221 ++++++++ src/guacclip/guacclip.h | 37 ++ src/guacclip/instructions.c | 210 +++++++ src/guacclip/instructions.h | 133 +++++ src/guacclip/interpret.c | 152 +++++ src/guacclip/interpret.h | 50 ++ src/guacclip/log.c | 84 +++ src/guacclip/log.h | 70 +++ src/guacclip/man/guacclip.1.in | 123 ++++ src/guacclip/sha256.c | 245 ++++++++ src/guacclip/sha256.h | 130 +++++ src/guacclip/state.c | 988 +++++++++++++++++++++++++++++++++ src/guacclip/state.h | 494 +++++++++++++++++ 17 files changed, 3022 insertions(+) create mode 100644 src/guacclip/.gitignore create mode 100644 src/guacclip/Makefile.am create mode 100644 src/guacclip/guacclip.c create mode 100644 src/guacclip/guacclip.h create mode 100644 src/guacclip/instructions.c create mode 100644 src/guacclip/instructions.h create mode 100644 src/guacclip/interpret.c create mode 100644 src/guacclip/interpret.h create mode 100644 src/guacclip/log.c create mode 100644 src/guacclip/log.h create mode 100644 src/guacclip/man/guacclip.1.in create mode 100644 src/guacclip/sha256.c create mode 100644 src/guacclip/sha256.h create mode 100644 src/guacclip/state.c create mode 100644 src/guacclip/state.h diff --git a/Makefile.am b/Makefile.am index dcb5a5db4e..1f07120056 100644 --- a/Makefile.am +++ b/Makefile.am @@ -34,6 +34,7 @@ DIST_SUBDIRS = \ src/guacd \ src/guacenc \ src/guaclog \ + src/guacclip \ src/pulse \ src/protocols/kubernetes \ src/protocols/rdp \ @@ -94,6 +95,10 @@ if ENABLE_GUACLOG SUBDIRS += src/guaclog endif +if ENABLE_GUACCLIP +SUBDIRS += src/guacclip +endif + EXTRA_DIST = \ .dockerignore \ CONTRIBUTING \ diff --git a/configure.ac b/configure.ac index d7d4831281..54441a1a56 100644 --- a/configure.ac +++ b/configure.ac @@ -1503,6 +1503,18 @@ AC_ARG_ENABLE([guaclog], AM_CONDITIONAL([ENABLE_GUACLOG], [test "x${enable_guaclog}" = "xyes"]) +# +# guacclip +# + +AC_ARG_ENABLE([guacclip], + [AS_HELP_STRING([--disable-guacclip], + [do not build the Guacamole clipboard artifact extraction tool])], + [], + [enable_guacclip=yes]) + +AM_CONDITIONAL([ENABLE_GUACCLIP], [test "x${enable_guacclip}" = "xyes"]) + # # Output Makefiles # @@ -1525,6 +1537,8 @@ AC_CONFIG_FILES([Makefile src/guacenc/man/guacenc.1 src/guaclog/Makefile src/guaclog/man/guaclog.1 + src/guacclip/Makefile + src/guacclip/man/guacclip.1 src/pulse/Makefile src/protocols/kubernetes/Makefile src/protocols/kubernetes/tests/Makefile @@ -1555,6 +1569,7 @@ AM_COND_IF([ENABLE_SPICE], [build_spice=yes], [build_spice=no]) AM_COND_IF([ENABLE_GUACD], [build_guacd=yes], [build_guacd=no]) AM_COND_IF([ENABLE_GUACENC], [build_guacenc=yes], [build_guacenc=no]) AM_COND_IF([ENABLE_GUACLOG], [build_guaclog=yes], [build_guaclog=no]) +AM_COND_IF([ENABLE_GUACCLIP], [build_guacclip=yes], [build_guacclip=no]) # # Init scripts @@ -1618,6 +1633,7 @@ $PACKAGE_NAME version $PACKAGE_VERSION guacd ...... ${build_guacd} guacenc .... ${build_guacenc} guaclog .... ${build_guaclog} + guacclip ... ${build_guacclip} FreeRDP plugins: ${build_rdp_plugins} Init scripts: ${build_init} diff --git a/src/guacclip/.gitignore b/src/guacclip/.gitignore new file mode 100644 index 0000000000..f9341d94e7 --- /dev/null +++ b/src/guacclip/.gitignore @@ -0,0 +1,7 @@ + +# Compiled guacclip +guacclip +guacclip.exe + +# Documentation (built from .in files) +man/guacclip.1 diff --git a/src/guacclip/Makefile.am b/src/guacclip/Makefile.am new file mode 100644 index 0000000000..25a1ae5ec6 --- /dev/null +++ b/src/guacclip/Makefile.am @@ -0,0 +1,57 @@ +# +# 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 + +bin_PROGRAMS = guacclip + +man_MANS = \ + man/guacclip.1 + +noinst_HEADERS = \ + guacclip.h \ + instructions.h \ + interpret.h \ + log.h \ + sha256.h \ + state.h + +guacclip_SOURCES = \ + guacclip.c \ + instructions.c \ + interpret.c \ + log.c \ + sha256.c \ + state.c + +guacclip_CFLAGS = \ + -Werror -Wall \ + @LIBGUAC_INCLUDE@ + +guacclip_LDADD = \ + @LIBGUAC_LTLIB@ + +EXTRA_DIST = \ + man/guacclip.1.in diff --git a/src/guacclip/guacclip.c b/src/guacclip/guacclip.c new file mode 100644 index 0000000000..62d9b635eb --- /dev/null +++ b/src/guacclip/guacclip.c @@ -0,0 +1,221 @@ +/* + * 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 "guacclip.h" +#include "interpret.h" +#include "log.h" +#include "state.h" + +#include +#include +#include +#include +#include + +/** + * Long-option identifiers for options which have no single-character + * equivalent. + */ +enum { + GUACCLIP_OPT_DIRECTION = 256, + GUACCLIP_OPT_INCLUDE, + GUACCLIP_OPT_MAX_ITEM_BYTES +}; + +int main(int argc, char* argv[]) { + + int i; + + /* Load defaults */ + bool force = false; + const char* explicit_outdir = NULL; + bool max_item_bytes_set = false; + + guacclip_options options = { + .outdir = NULL, + .direction_filter = NULL, + .include = GUACCLIP_INCLUDE_ALL, + .max_item_bytes = GUACCLIP_DEFAULT_MAX_ITEM_BYTES + }; + + /* Define long options */ + static struct option long_options[] = { + {"direction", required_argument, NULL, GUACCLIP_OPT_DIRECTION}, + {"include", required_argument, NULL, GUACCLIP_OPT_INCLUDE}, + {"max-item-bytes", required_argument, NULL, GUACCLIP_OPT_MAX_ITEM_BYTES}, + {NULL, 0, NULL, 0} + }; + + /* Parse arguments */ + int opt; + while ((opt = getopt_long(argc, argv, "fo:", long_options, NULL)) != -1) { + + switch (opt) { + + /* -f: Force */ + case 'f': + force = true; + break; + + /* -o: Output directory */ + case 'o': + explicit_outdir = optarg; + break; + + /* --direction: Restrict to a single transfer direction */ + case GUACCLIP_OPT_DIRECTION: + if (strcmp(optarg, "guest-to-client") != 0 + && strcmp(optarg, "client-to-guest") != 0) { + guacclip_log(GUAC_LOG_ERROR, "Invalid --direction value " + "\"%s\" (expected guest-to-client or " + "client-to-guest).", optarg); + goto invalid_options; + } + options.direction_filter = optarg; + break; + + /* --include: Restrict to image, text, or all content */ + case GUACCLIP_OPT_INCLUDE: + if (strcmp(optarg, "image") == 0) + options.include = GUACCLIP_INCLUDE_IMAGE; + else if (strcmp(optarg, "text") == 0) + options.include = GUACCLIP_INCLUDE_TEXT; + else if (strcmp(optarg, "all") == 0) + options.include = GUACCLIP_INCLUDE_ALL; + else { + guacclip_log(GUAC_LOG_ERROR, "Invalid --include value " + "\"%s\" (expected image, text, or all).", optarg); + goto invalid_options; + } + break; + + /* --max-item-bytes: Cap per-item size */ + case GUACCLIP_OPT_MAX_ITEM_BYTES: { + char* endptr = NULL; + long long value = strtoll(optarg, &endptr, 10); + if (endptr == optarg || *endptr != '\0' || value < 0) { + guacclip_log(GUAC_LOG_ERROR, "Invalid --max-item-bytes " + "value \"%s\" (expected a non-negative integer).", + optarg); + goto invalid_options; + } + options.max_item_bytes = (size_t) value; + max_item_bytes_set = true; + break; + } + + /* Invalid option */ + default: + goto invalid_options; + + } + + } + + /* Log start */ + guacclip_log(GUAC_LOG_INFO, "Guacamole clipboard artifact extractor " + "(guacclip) version " VERSION); + + /* Note the effective per-item size cap when using the built-in default */ + if (!max_item_bytes_set) + guacclip_log(GUAC_LOG_DEBUG, "No --max-item-bytes specified; " + "defaulting to a %zu-byte cap per clipboard item (specify " + "--max-item-bytes 0 for no limit).", options.max_item_bytes); + + /* Track number of overall failures */ + int total_files = argc - optind; + int failures = 0; + + /* Abort if no files given */ + if (total_files <= 0) { + guacclip_log(GUAC_LOG_INFO, "No input files specified. Nothing to do."); + return 0; + } + + guacclip_log(GUAC_LOG_INFO, "%i input file(s) provided.", total_files); + + /* Interpret all input files */ + for (i = optind; i < argc; i++) { + + /* Get current filename */ + const char* path = argv[i]; + + /* Determine effective output directory for this recording */ + char default_outdir[4096]; + if (explicit_outdir != NULL) + options.outdir = explicit_outdir; + else { + int len = snprintf(default_outdir, sizeof(default_outdir), + "%s.clipboard", path); + if (len < 0 || (size_t) len >= sizeof(default_outdir)) { + guacclip_log(GUAC_LOG_ERROR, "Cannot derive output directory " + "for \"%s\": Name too long", path); + failures++; + continue; + } + options.outdir = default_outdir; + } + + /* Attempt extraction, log granular success/failure at debug level */ + if (guacclip_interpret(path, &options, force)) { + failures++; + guacclip_log(GUAC_LOG_DEBUG, + "%s was NOT successfully interpreted.", path); + } + else + guacclip_log(GUAC_LOG_DEBUG, "%s was successfully " + "interpreted.", path); + + } + + /* Warn if at least one file failed */ + if (failures != 0) + guacclip_log(GUAC_LOG_WARNING, "Extraction failed for %i of %i " + "file(s).", failures, total_files); + + /* Notify of success */ + else + guacclip_log(GUAC_LOG_INFO, "All files interpreted successfully."); + + /* Interpreting complete */ + return failures == 0 ? 0 : 1; + + /* Display usage and exit with error if options are invalid */ +invalid_options: + + fprintf(stderr, "USAGE: %s" + " [-f]" + " [-o OUTDIR]" + " [--direction guest-to-client|client-to-guest]" + " [--include image|text|all]" + " [--max-item-bytes N]" + " [FILE]...\n" + "\n" + " --max-item-bytes N Caps each extracted clipboard item to " + "N bytes\n" + " (default: %d, i.e. 64 MiB). Specify\n" + " --max-item-bytes 0 to disable the " + "cap.\n", + argv[0], GUACCLIP_DEFAULT_MAX_ITEM_BYTES); + + return 1; + +} diff --git a/src/guacclip/guacclip.h b/src/guacclip/guacclip.h new file mode 100644 index 0000000000..8b37137295 --- /dev/null +++ b/src/guacclip/guacclip.h @@ -0,0 +1,37 @@ +/* + * 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 GUACCLIP_H +#define GUACCLIP_H + +/** + * The default log level below which no messages should be logged. + */ +#define GUACCLIP_DEFAULT_LOG_LEVEL GUAC_LOG_INFO + +/** + * The default maximum number of bytes to accumulate for any single clipboard + * item when the user has not explicitly specified "--max-item-bytes". This + * bounds memory growth against a hostile or malformed recording which + * streams an unbounded number of blobs into a single clipboard stream. Users + * may explicitly opt out of any limit by specifying "--max-item-bytes 0". + */ +#define GUACCLIP_DEFAULT_MAX_ITEM_BYTES (64 * 1024 * 1024) + +#endif diff --git a/src/guacclip/instructions.c b/src/guacclip/instructions.c new file mode 100644 index 0000000000..1b45f241e2 --- /dev/null +++ b/src/guacclip/instructions.c @@ -0,0 +1,210 @@ +/* + * 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 "instructions.h" +#include "log.h" +#include "state.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +/** + * Parses the given string as a Guacamole stream index. Only strictly valid, + * non-negative base-10 integers (with no trailing garbage) are accepted; + * anything else, including empty strings and negative numbers, is rejected. + * This avoids cross-associating malformed instructions (which would + * otherwise parse as index 0 under atoi()) with an unrelated, legitimately + * open stream 0. + * + * @param text + * The string to parse as a stream index. + * + * @param index + * Pointer to an int which will receive the parsed stream index if + * parsing succeeds. + * + * @return + * true if the given string was a valid stream index, false otherwise. + */ +static bool guacclip_parse_stream_index(const char* text, int* index) { + + char* endptr = NULL; + + errno = 0; + long value = strtol(text, &endptr, 10); + + if (endptr == text || *endptr != '\0' || errno != 0 + || value < 0 || value > INT_MAX) + return false; + + *index = (int) value; + return true; + +} + +guacclip_instruction_handler_mapping guacclip_instruction_handler_map[] = { + {"sync", guacclip_handle_sync}, + {"log", guacclip_handle_log}, + {"clipboard", guacclip_handle_clipboard}, + {"blob", guacclip_handle_blob}, + {"end", guacclip_handle_end}, + {NULL, NULL} +}; + +int guacclip_handle_sync(guacclip_state* state, int argc, char** argv) { + + /* The sync instruction carries a single timestamp argument */ + if (argc < 1) + return 0; + + guacclip_state_sync(state, (int64_t) strtoll(argv[0], NULL, 10)); + return 0; + +} + +int guacclip_handle_log(guacclip_state* state, int argc, char** argv) { + + /* The log instruction carries a single human-readable message */ + if (argc < 1) + return 0; + + const char* message = argv[0]; + + int index; + char direction[64]; + char mimetype[256]; + + /* Only clipboard direction annotations are of interest */ + if (sscanf(message, "clipboard stream=%d direction=%63s mimetype=%255s", + &index, direction, mimetype) != 3) + return 0; + + /* The direction must be one of the two recognized values */ + if (strcmp(direction, "guest-to-client") != 0 + && strcmp(direction, "client-to-guest") != 0) + return 0; + + /* Parse the optional byte-count annotation, if present */ + int64_t expected_bytes = -1; + const char* bytes = strstr(message, " bytes="); + if (bytes != NULL) { + long long value; + if (sscanf(bytes, " bytes=%lld", &value) == 1 && value >= 0) + expected_bytes = (int64_t) value; + } + + guacclip_state_buffer_direction(state, index, direction, expected_bytes); + return 0; + +} + +int guacclip_handle_clipboard(guacclip_state* state, int argc, char** argv) { + + /* The clipboard instruction requires a stream index and mimetype */ + if (argc < 2) + return 0; + + int index; + if (!guacclip_parse_stream_index(argv[0], &index)) + return 0; + + const char* mimetype = argv[1]; + + guacclip_state_open(state, index, mimetype); + return 0; + +} + +int guacclip_handle_blob(guacclip_state* state, int argc, char** argv) { + + /* The blob instruction requires a stream index and base64 payload */ + if (argc < 2) + return 0; + + int index; + if (!guacclip_parse_stream_index(argv[0], &index)) + return 0; + + /* Decode the base64 payload in place; the parser owns this memory and + * will reuse it on the next read, so the decoded bytes are immediately + * copied into the stream's accumulation buffer by guacclip_state_append. + * guac_protocol_decode_base64() never returns a negative length; zero + * (an empty or fully-invalid payload) is safely handled by + * guacclip_state_append(). */ + int length = guac_protocol_decode_base64(argv[1]); + + guacclip_state_append(state, index, argv[1], (size_t) length); + return 0; + +} + +int guacclip_handle_end(guacclip_state* state, int argc, char** argv) { + + /* The end instruction requires a stream index */ + if (argc < 1) + return 0; + + int index; + if (!guacclip_parse_stream_index(argv[0], &index)) + return 0; + + guacclip_state_end(state, index); + return 0; + +} + +int guacclip_handle_instruction(guacclip_state* state, const char* opcode, + int argc, char** argv) { + + /* Search through mapping for instruction handler having given opcode */ + guacclip_instruction_handler_mapping* current = + guacclip_instruction_handler_map; + while (current->opcode != NULL) { + + /* Invoke handler if opcode matches (if defined) */ + if (strcmp(current->opcode, opcode) == 0) { + + /* Invoke defined handler */ + guacclip_instruction_handler* handler = current->handler; + if (handler != NULL) + return handler(state, argc, argv); + + /* Log defined but unimplemented instructions */ + guacclip_log(GUAC_LOG_DEBUG, "\"%s\" not implemented", opcode); + return 0; + + } + + /* Next candidate handler */ + current++; + + } /* end opcode search */ + + /* Ignore any unknown instructions */ + return 0; + +} diff --git a/src/guacclip/instructions.h b/src/guacclip/instructions.h new file mode 100644 index 0000000000..0579edac41 --- /dev/null +++ b/src/guacclip/instructions.h @@ -0,0 +1,133 @@ +/* + * 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 GUACCLIP_INSTRUCTIONS_H +#define GUACCLIP_INSTRUCTIONS_H + +#include "state.h" + +/** + * A callback function which, when invoked, handles a particular Guacamole + * instruction. The opcode of the instruction is implied (as it is expected + * that there will be a 1:1 mapping of opcode to callback function), while the + * arguments for that instruction are included in the parameters given to the + * callback. + * + * @param state + * The current state of the guacclip interpreter. + * + * @param argc + * The number of arguments (excluding opcode) passed to the instruction + * being handled by the callback. + * + * @param argv + * All arguments (excluding opcode) associated with the instruction being + * handled by the callback. Note that the parser owns this memory and + * reuses it on the next read, so any data which must persist must be + * copied. + * + * @return + * Zero if the instruction was handled successfully, non-zero if an error + * occurs. + */ +typedef int guacclip_instruction_handler(guacclip_state* state, + int argc, char** argv); + +/** + * Mapping of instruction opcode to corresponding handler function. + */ +typedef struct guacclip_instruction_handler_mapping { + + /** + * The opcode of the instruction that the associated handler function + * should be invoked for. + */ + const char* opcode; + + /** + * The handler function to invoke whenever an instruction having the + * associated opcode is parsed. + */ + guacclip_instruction_handler* handler; + +} guacclip_instruction_handler_mapping; + +/** + * Array of all opcode/handler mappings for all supported opcodes, terminated + * by an entry with a NULL opcode. All opcodes not listed here can be safely + * ignored. + */ +extern guacclip_instruction_handler_mapping guacclip_instruction_handler_map[]; + +/** + * Handles the instruction having the given opcode and arguments, updating + * the state of the interpreter accordingly. + * + * @param state + * The current state of the guacclip interpreter. + * + * @param opcode + * The opcode of the instruction being handled. + * + * @param argc + * The number of arguments (excluding opcode) passed to the instruction + * being handled by the callback. + * + * @param argv + * All arguments (excluding opcode) associated with the instruction being + * handled by the callback. + * + * @return + * Zero if the instruction was handled successfully, non-zero if an error + * occurs. + */ +int guacclip_handle_instruction(guacclip_state* state, + const char* opcode, int argc, char** argv); + +/** + * Handler for the Guacamole "sync" instruction. Tracks the latest recording + * timestamp. + */ +guacclip_instruction_handler guacclip_handle_sync; + +/** + * Handler for the Guacamole "log" instruction. Parses clipboard direction + * annotations emitted by the recording. + */ +guacclip_instruction_handler guacclip_handle_log; + +/** + * Handler for the Guacamole "clipboard" instruction. Begins a clipboard + * stream. + */ +guacclip_instruction_handler guacclip_handle_clipboard; + +/** + * Handler for the Guacamole "blob" instruction. Appends decoded data to an + * open clipboard stream (blobs for non-clipboard streams are ignored). + */ +guacclip_instruction_handler guacclip_handle_blob; + +/** + * Handler for the Guacamole "end" instruction. Ends an open clipboard stream + * (ends for non-clipboard streams are ignored). + */ +guacclip_instruction_handler guacclip_handle_end; + +#endif diff --git a/src/guacclip/interpret.c b/src/guacclip/interpret.c new file mode 100644 index 0000000000..e980891996 --- /dev/null +++ b/src/guacclip/interpret.c @@ -0,0 +1,152 @@ +/* + * 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 "instructions.h" +#include "interpret.h" +#include "log.h" +#include "state.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +/** + * Reads and handles all Guacamole instructions from the given guac_socket + * until end-of-stream is reached. + * + * @param state + * The current state of the guacclip interpreter. + * + * @param path + * The name of the file being parsed (for logging purposes). This file + * must already be open and available through the given socket. + * + * @param socket + * The guac_socket through which instructions should be read. + * + * @return + * Zero on success, non-zero if parsing of Guacamole protocol data through + * the given socket fails. + */ +static int guacclip_read_instructions(guacclip_state* state, + const char* path, guac_socket* socket) { + + /* Obtain Guacamole protocol parser */ + guac_parser* parser = guac_parser_alloc(); + if (parser == NULL) + return 1; + + /* Continuously read and handle all instructions */ + while (!guac_parser_read(parser, socket, -1)) { + guacclip_handle_instruction(state, parser->opcode, + parser->argc, parser->argv); + } + + /* Fail on read/parse error */ + if (guac_error != GUAC_STATUS_CLOSED) { + guacclip_log(GUAC_LOG_ERROR, "%s: %s", + path, guac_status_string(guac_error)); + guac_parser_free(parser); + return 1; + } + + /* Parse complete */ + guac_parser_free(parser); + return 0; + +} + +int guacclip_interpret(const char* path, const guacclip_options* options, + bool force) { + + /* Open input file */ + int fd = open(path, O_RDONLY); + if (fd < 0) { + guacclip_log(GUAC_LOG_ERROR, "%s: %s", path, strerror(errno)); + return 1; + } + + /* Lock entire input file for reading by the current process */ + struct flock file_lock = { + .l_type = F_RDLCK, + .l_whence = SEEK_SET, + .l_start = 0, + .l_len = 0, + .l_pid = getpid() + }; + + /* Abort if file cannot be locked for reading */ + if (!force && fcntl(fd, F_SETLK, &file_lock) == -1) { + + /* Warn if lock cannot be acquired */ + if (errno == EACCES || errno == EAGAIN) + guacclip_log(GUAC_LOG_WARNING, "Refusing to interpret recording of " + "in-progress session \"%s\" (specify the -f option to " + "override this behavior).", path); + + /* Log an error if locking fails in an unexpected way */ + else + guacclip_log(GUAC_LOG_ERROR, "Cannot lock \"%s\" for reading: %s", + path, strerror(errno)); + + close(fd); + return 1; + } + + /* Allocate interpreter state (creates output directory) */ + guacclip_state* state = guacclip_state_alloc(path, options); + if (state == NULL) { + close(fd); + return 1; + } + + /* Obtain guac_socket wrapping file descriptor */ + guac_socket* socket = guac_socket_open(fd); + if (socket == NULL) { + guacclip_log(GUAC_LOG_ERROR, "%s: %s", path, + guac_status_string(guac_error)); + close(fd); + guacclip_state_free(state); + return 1; + } + + guacclip_log(GUAC_LOG_INFO, "Extracting clipboard artifacts from \"%s\" " + "into \"%s\" ...", path, options->outdir); + + /* Attempt to read all instructions in the file */ + if (guacclip_read_instructions(state, path, socket)) { + guac_socket_free(socket); + guacclip_state_free(state); + return 1; + } + + /* Close input and finish interpreting process (writes manifest) */ + guac_socket_free(socket); + return guacclip_state_free(state); + +} diff --git a/src/guacclip/interpret.h b/src/guacclip/interpret.h new file mode 100644 index 0000000000..b9d7303620 --- /dev/null +++ b/src/guacclip/interpret.h @@ -0,0 +1,50 @@ +/* + * 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 GUACCLIP_INTERPRET_H +#define GUACCLIP_INTERPRET_H + +#include "state.h" + +#include + +/** + * Extracts all clipboard artifacts (text and images) from the given Guacamole + * protocol dump, writing per-item files and a manifest.json into the output + * directory specified by the given options. A read lock will be acquired on the + * input file to ensure that in-progress recordings are not interpreted. This + * behavior can be overridden by specifying true for the force parameter. + * + * @param path + * The path to the file containing the raw Guacamole protocol dump. + * + * @param options + * The options controlling extraction, including the output directory. + * + * @param force + * Interpret even if the input file appears to be an in-progress recording + * (has an associated lock). + * + * @return + * Zero on success, non-zero if an error prevented successful extraction. + */ +int guacclip_interpret(const char* path, const guacclip_options* options, + bool force); + +#endif diff --git a/src/guacclip/log.c b/src/guacclip/log.c new file mode 100644 index 0000000000..55e72017f8 --- /dev/null +++ b/src/guacclip/log.c @@ -0,0 +1,84 @@ +/* + * 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 "guacclip.h" +#include "log.h" + +#include +#include + +#include +#include + +int guacclip_log_level = GUACCLIP_DEFAULT_LOG_LEVEL; + +void vguacclip_log(guac_client_log_level level, const char* format, + va_list args) { + + const char* priority_name; + char message[2048]; + + /* Don't bother if the log level is too high */ + if (level > guacclip_log_level) + return; + + /* Copy log message into buffer */ + vsnprintf(message, sizeof(message), format, args); + + /* Convert log level to human-readable name */ + switch (level) { + + /* Error log level */ + case GUAC_LOG_ERROR: + priority_name = "ERROR"; + break; + + /* Warning log level */ + case GUAC_LOG_WARNING: + priority_name = "WARNING"; + break; + + /* Informational log level */ + case GUAC_LOG_INFO: + priority_name = "INFO"; + break; + + /* Debug log level */ + case GUAC_LOG_DEBUG: + priority_name = "DEBUG"; + break; + + /* Any unknown/undefined log level */ + default: + priority_name = "UNKNOWN"; + break; + } + + /* Log to STDERR */ + fprintf(stderr, GUACCLIP_LOG_NAME ": %s: %s\n", priority_name, message); + +} + +void guacclip_log(guac_client_log_level level, const char* format, ...) { + va_list args; + va_start(args, format); + vguacclip_log(level, format, args); + va_end(args); +} diff --git a/src/guacclip/log.h b/src/guacclip/log.h new file mode 100644 index 0000000000..66e8c7c0d5 --- /dev/null +++ b/src/guacclip/log.h @@ -0,0 +1,70 @@ +/* + * 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 GUACCLIP_LOG_H +#define GUACCLIP_LOG_H + +#include + +#include + +/** + * The maximum level at which to log messages. All other messages will be + * dropped. + */ +extern int guacclip_log_level; + +/** + * The string to prepend to all log messages. + */ +#define GUACCLIP_LOG_NAME "guacclip" + +/** + * Writes a message to guacclip's logs. This function takes a format and + * va_list, similar to vprintf. + * + * @param level + * The level at which to log this message. + * + * @param format + * A printf-style format string to log. + * + * @param args + * The va_list containing the arguments to be used when filling the format + * string for printing. + */ +void vguacclip_log(guac_client_log_level level, const char* format, + va_list args); + +/** + * Writes a message to guacclip's logs. This function accepts parameters + * identically to printf. + * + * @param level + * The level at which to log this message. + * + * @param format + * A printf-style format string to log. + * + * @param ... + * Arguments to use when filling the format string for printing. + */ +void guacclip_log(guac_client_log_level level, const char* format, ...); + +#endif diff --git a/src/guacclip/man/guacclip.1.in b/src/guacclip/man/guacclip.1.in new file mode 100644 index 0000000000..c16c668d19 --- /dev/null +++ b/src/guacclip/man/guacclip.1.in @@ -0,0 +1,123 @@ +.\" +.\" 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. +.\" +.TH guacclip 1 "07 Jul 2026" "version @PACKAGE_VERSION@" "Apache Guacamole" +. +.SH NAME +guacclip \- Guacamole clipboard artifact extractor +. +.SH SYNOPSIS +.B guacclip +[\fB-f\fR] +[\fB-o \fIOUTDIR\fR] +[\fB--direction \fIguest-to-client\fR|\fIclient-to-guest\fR] +[\fB--include \fIimage\fR|\fItext\fR|\fIall\fR] +[\fB--max-item-bytes \fIN\fR] +[\fIFILE\fR]... +. +.SH DESCRIPTION +.B guacclip +is a tool which accepts Guacamole protocol dumps, such as those saved as +Guacamole session recordings, and extracts all clipboard artifacts (both text +and images) transferred during the session. For each recording, the extracted +items are written as individual files alongside a +.B manifest.json +describing each item, including its transfer direction, mimetype, size, and +SHA-256 digest. +.P +.B guacclip +is essentially an implementation of a Guacamole client which accepts its input +from files instead of a network connection; however, unlike +.B guacenc +or +.BR guaclog , +it handles only the instructions related to clipboard transfers. +.P +Clipboard content is reassembled exclusively from streams opened by the +.B clipboard +instruction. Blobs belonging to other stream types (such as graphical image +updates, file transfers, or pipes) are ignored, ensuring that only genuine +clipboard artifacts are extracted. +.P +Guacamole acquires a write lock on recordings as they are being written. By +default, +.B guacclip +will check whether each input file is locked and will refuse to read and +interpret an input file if it appears to be an in-progress recording. This +behavior can be overridden by specifying the \fB-f\fR option. +. +.SH OPTIONS +.TP +\fB-f\fR +Overrides the default behavior of +.B guacclip +such that input files will be interpreted even if they appear to be recordings +of in-progress Guacamole sessions. +.TP +\fB-o \fIOUTDIR\fR +Writes all extracted items and the manifest into +.IR OUTDIR . +If not specified, each recording is extracted into a directory named +\fIFILE\fR.clipboard alongside the recording. The output directory and its +\fBitems\fR subdirectory are created if they do not already exist. +.TP +\fB--direction \fIguest-to-client\fR|\fIclient-to-guest\fR +Extracts only clipboard transfers in the given direction. By default, +transfers in all directions are extracted. +.TP +\fB--include \fIimage\fR|\fItext\fR|\fIall\fR +Restricts extraction to image content, textual content, or all content +respectively. The default is +.IR all . +.TP +\fB--max-item-bytes \fIN\fR +Caps the size of any single extracted item to +.I N +bytes. Items exceeding this cap are recorded in the manifest as incomplete and +oversized, and are not written to disk. If not specified, a default cap of +67108864 bytes (64 MiB) is applied to bound memory usage when processing +untrusted or malformed recordings. Specify \fB--max-item-bytes 0\fR to +explicitly disable the cap and allow items of any size. +. +.SH OUTPUT FORMAT +For each recording, +.B guacclip +creates an output directory containing an +.B items +subdirectory and a +.B manifest.json +file. Each extracted item is written into the +.B items +subdirectory using a filename which encodes its sequence number, offset, +direction, stream index, mimetype, and a short prefix of its SHA-256 digest. +.P +The +.B manifest.json +file is a JSON object listing every extracted item, along with its transfer +direction, mimetype, byte count, SHA-256 digest, and any warnings encountered +during extraction (for example, incomplete transfers or byte-count +mismatches). +.P +As a safeguard against unbounded memory growth when processing untrusted or +malformed recordings, at most 100000 items will be recorded in the manifest +for any single recording. If this limit is reached, a warning is logged and +any further clipboard items are silently dropped from the manifest. +. +.SH SEE ALSO +.BR guacenc (1), +.BR guaclog (1) diff --git a/src/guacclip/sha256.c b/src/guacclip/sha256.c new file mode 100644 index 0000000000..2b3ba7f5da --- /dev/null +++ b/src/guacclip/sha256.c @@ -0,0 +1,245 @@ +/* + * 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 "sha256.h" + +#include +#include +#include + +/** + * The SHA-256 round constants, as defined by FIPS 180-4. These are the first + * 32 bits of the fractional parts of the cube roots of the first 64 primes. + */ +static const uint32_t GUACCLIP_SHA256_K[64] = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, + 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, + 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, + 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 +}; + +/** + * Rotates the given 32-bit value right by the given number of bits. + * + * @param value + * The 32-bit value to rotate. + * + * @param bits + * The number of bits by which to rotate the value. + * + * @return + * The rotated 32-bit value. + */ +static uint32_t guacclip_sha256_rotr(uint32_t value, unsigned int bits) { + return (value >> bits) | (value << (32 - bits)); +} + +/** + * Processes a single 64-byte block of message data, updating the working hash + * values within the given context. + * + * @param context + * The SHA-256 context whose state should be updated. + * + * @param block + * A pointer to exactly 64 bytes of message data to process. + */ +static void guacclip_sha256_process(guacclip_sha256_context* context, + const uint8_t block[64]) { + + uint32_t w[64]; + int i; + + /* Prepare the first 16 words directly from the big-endian block */ + for (i = 0; i < 16; i++) { + w[i] = ((uint32_t) block[i * 4] << 24) + | ((uint32_t) block[i * 4 + 1] << 16) + | ((uint32_t) block[i * 4 + 2] << 8) + | ((uint32_t) block[i * 4 + 3]); + } + + /* Extend the first 16 words into the remaining 48 words */ + for (i = 16; i < 64; i++) { + uint32_t s0 = guacclip_sha256_rotr(w[i - 15], 7) + ^ guacclip_sha256_rotr(w[i - 15], 18) + ^ (w[i - 15] >> 3); + uint32_t s1 = guacclip_sha256_rotr(w[i - 2], 17) + ^ guacclip_sha256_rotr(w[i - 2], 19) + ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + + /* Initialize working variables from the current hash state */ + uint32_t a = context->state[0]; + uint32_t b = context->state[1]; + uint32_t c = context->state[2]; + uint32_t d = context->state[3]; + uint32_t e = context->state[4]; + uint32_t f = context->state[5]; + uint32_t g = context->state[6]; + uint32_t h = context->state[7]; + + /* Perform the main compression loop */ + for (i = 0; i < 64; i++) { + + uint32_t s1 = guacclip_sha256_rotr(e, 6) + ^ guacclip_sha256_rotr(e, 11) + ^ guacclip_sha256_rotr(e, 25); + uint32_t ch = (e & f) ^ ((~e) & g); + uint32_t temp1 = h + s1 + ch + GUACCLIP_SHA256_K[i] + w[i]; + uint32_t s0 = guacclip_sha256_rotr(a, 2) + ^ guacclip_sha256_rotr(a, 13) + ^ guacclip_sha256_rotr(a, 22); + uint32_t maj = (a & b) ^ (a & c) ^ (b & c); + uint32_t temp2 = s0 + maj; + + h = g; + g = f; + f = e; + e = d + temp1; + d = c; + c = b; + b = a; + a = temp1 + temp2; + + } + + /* Add the compressed chunk to the current hash value */ + context->state[0] += a; + context->state[1] += b; + context->state[2] += c; + context->state[3] += d; + context->state[4] += e; + context->state[5] += f; + context->state[6] += g; + context->state[7] += h; + +} + +void guacclip_sha256_init(guacclip_sha256_context* context) { + + /* Initial hash values: first 32 bits of the fractional parts of the + * square roots of the first 8 primes */ + context->state[0] = 0x6a09e667; + context->state[1] = 0xbb67ae85; + context->state[2] = 0x3c6ef372; + context->state[3] = 0xa54ff53a; + context->state[4] = 0x510e527f; + context->state[5] = 0x9b05688c; + context->state[6] = 0x1f83d9ab; + context->state[7] = 0x5be0cd19; + + context->bitcount = 0; + context->buffer_length = 0; + +} + +void guacclip_sha256_update(guacclip_sha256_context* context, + const void* data, size_t length) { + + const uint8_t* input = (const uint8_t*) data; + size_t i; + + for (i = 0; i < length; i++) { + + context->buffer[context->buffer_length++] = input[i]; + + /* Process each complete 64-byte block */ + if (context->buffer_length == 64) { + guacclip_sha256_process(context, context->buffer); + context->bitcount += 512; + context->buffer_length = 0; + } + + } + +} + +void guacclip_sha256_final(guacclip_sha256_context* context, + uint8_t digest[GUACCLIP_SHA256_DIGEST_LENGTH]) { + + int i; + + /* Total message length in bits, including the bytes still buffered */ + uint64_t total_bits = context->bitcount + (uint64_t) context->buffer_length * 8; + + /* Append the mandatory 0x80 padding byte */ + context->buffer[context->buffer_length++] = 0x80; + + /* If there is not enough room for the 8-byte length, pad and flush */ + if (context->buffer_length > 56) { + while (context->buffer_length < 64) + context->buffer[context->buffer_length++] = 0x00; + guacclip_sha256_process(context, context->buffer); + context->buffer_length = 0; + } + + /* Pad with zeroes up to the length field */ + while (context->buffer_length < 56) + context->buffer[context->buffer_length++] = 0x00; + + /* Append the message length as a big-endian 64-bit integer */ + for (i = 7; i >= 0; i--) + context->buffer[context->buffer_length++] = + (uint8_t) (total_bits >> (i * 8)); + + guacclip_sha256_process(context, context->buffer); + + /* Produce the final big-endian digest */ + for (i = 0; i < 8; i++) { + digest[i * 4] = (uint8_t) (context->state[i] >> 24); + digest[i * 4 + 1] = (uint8_t) (context->state[i] >> 16); + digest[i * 4 + 2] = (uint8_t) (context->state[i] >> 8); + digest[i * 4 + 3] = (uint8_t) (context->state[i]); + } + +} + +void guacclip_sha256_hex(const void* data, size_t length, + char output[GUACCLIP_SHA256_HEX_LENGTH]) { + + static const char hex[] = "0123456789abcdef"; + uint8_t digest[GUACCLIP_SHA256_DIGEST_LENGTH]; + guacclip_sha256_context context; + int i; + + guacclip_sha256_init(&context); + guacclip_sha256_update(&context, data, length); + guacclip_sha256_final(&context, digest); + + for (i = 0; i < GUACCLIP_SHA256_DIGEST_LENGTH; i++) { + output[i * 2] = hex[(digest[i] >> 4) & 0xF]; + output[i * 2 + 1] = hex[digest[i] & 0xF]; + } + output[GUACCLIP_SHA256_DIGEST_LENGTH * 2] = '\0'; + +} diff --git a/src/guacclip/sha256.h b/src/guacclip/sha256.h new file mode 100644 index 0000000000..d7eee4e48b --- /dev/null +++ b/src/guacclip/sha256.h @@ -0,0 +1,130 @@ +/* + * 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 GUACCLIP_SHA256_H +#define GUACCLIP_SHA256_H + +#include +#include + +/** + * The length, in bytes, of a raw (binary) SHA-256 digest. + */ +#define GUACCLIP_SHA256_DIGEST_LENGTH 32 + +/** + * The length, in bytes, of the buffer required to hold the null-terminated + * hexadecimal (lowercase) representation of a SHA-256 digest. + */ +#define GUACCLIP_SHA256_HEX_LENGTH 65 + +/** + * The internal state of an incremental SHA-256 computation. This structure + * should be initialized with guacclip_sha256_init(), updated incrementally + * with any number of guacclip_sha256_update() calls, and finalized with a + * single call to guacclip_sha256_final(). This is a self-contained + * implementation of the FIPS 180-4 SHA-256 algorithm requiring no external + * cryptographic libraries. + */ +typedef struct guacclip_sha256_context { + + /** + * The eight 32-bit working hash values (H0 through H7). + */ + uint32_t state[8]; + + /** + * The total number of message bytes processed so far. + */ + uint64_t bitcount; + + /** + * Buffer holding message bytes which have not yet been processed as part + * of a complete 64-byte block. + */ + uint8_t buffer[64]; + + /** + * The number of bytes currently pending within the buffer. + */ + size_t buffer_length; + +} guacclip_sha256_context; + +/** + * Initializes the given SHA-256 context, preparing it to receive message data + * via guacclip_sha256_update(). + * + * @param context + * The SHA-256 context to initialize. + */ +void guacclip_sha256_init(guacclip_sha256_context* context); + +/** + * Updates the given SHA-256 context with the given block of message data. This + * function may be called any number of times to incrementally hash a message + * of arbitrary length. The provided data is binary-safe and may contain null + * bytes. + * + * @param context + * The SHA-256 context to update. + * + * @param data + * A pointer to the message bytes to incorporate into the hash. + * + * @param length + * The number of bytes to read from the given data pointer. + */ +void guacclip_sha256_update(guacclip_sha256_context* context, + const void* data, size_t length); + +/** + * Finalizes the given SHA-256 context, producing the raw 32-byte digest of all + * message data provided via guacclip_sha256_update(). After this call, the + * context should no longer be used without being re-initialized. + * + * @param context + * The SHA-256 context to finalize. + * + * @param digest + * A buffer of at least GUACCLIP_SHA256_DIGEST_LENGTH bytes which will + * receive the raw binary digest. + */ +void guacclip_sha256_final(guacclip_sha256_context* context, + uint8_t digest[GUACCLIP_SHA256_DIGEST_LENGTH]); + +/** + * Computes the SHA-256 digest of the given data in a single call, writing the + * result as a null-terminated lowercase hexadecimal string. + * + * @param data + * A pointer to the message bytes to hash. May be NULL only if length is + * zero. + * + * @param length + * The number of bytes to read from the given data pointer. + * + * @param output + * A buffer of at least GUACCLIP_SHA256_HEX_LENGTH bytes which will receive + * the null-terminated hexadecimal digest. + */ +void guacclip_sha256_hex(const void* data, size_t length, + char output[GUACCLIP_SHA256_HEX_LENGTH]); + +#endif diff --git a/src/guacclip/state.c b/src/guacclip/state.c new file mode 100644 index 0000000000..84adfcd47b --- /dev/null +++ b/src/guacclip/state.c @@ -0,0 +1,988 @@ +/* + * 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 "log.h" +#include "sha256.h" +#include "state.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * The initial capacity, in bytes, allocated for a clipboard stream's + * accumulation buffer. + */ +#define GUACCLIP_INITIAL_BUFFER 4096 + +/** + * The initial capacity, in entries, of the manifest items array. + */ +#define GUACCLIP_INITIAL_ITEMS 16 + +/** + * Creates the given directory if it does not already exist, behaving like + * "mkdir -p" for a single path component depth (the parent is assumed to + * exist). An already-existing directory is not treated as an error. + * + * @param path + * The directory path to create. + * + * @return + * Zero on success (including when the directory already exists), non-zero + * on failure. + */ +static int guacclip_mkdir(const char* path) { + + if (mkdir(path, S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH) == 0) + return 0; + + /* Existing directory is acceptable */ + if (errno == EEXIST) + return 0; + + guacclip_log(GUAC_LOG_ERROR, "Failed to create directory \"%s\": %s", + path, strerror(errno)); + return 1; + +} + +/** + * Returns the file extension (including leading dot) appropriate for the given + * clipboard mimetype. + * + * @param mimetype + * The mimetype to map to an extension. + * + * @return + * A statically-allocated extension string, including the leading dot. + */ +static const char* guacclip_extension_for(const char* mimetype) { + + if (strcmp(mimetype, "text/plain") == 0) + return ".txt"; + if (strcmp(mimetype, "image/png") == 0) + return ".png"; + if (strcmp(mimetype, "image/jpeg") == 0) + return ".jpg"; + if (strcmp(mimetype, "image/bmp") == 0) + return ".bmp"; + if (strcmp(mimetype, "image/tiff") == 0) + return ".tiff"; + + return ".bin"; + +} + +/** + * Writes a filesystem-safe "slug" derived from the given mimetype into the + * given buffer. All characters other than ASCII alphanumerics are replaced + * with underscores. + * + * @param mimetype + * The mimetype to convert into a slug. + * + * @param out + * The buffer to receive the null-terminated slug. + * + * @param out_size + * The size of the output buffer, in bytes. + */ +static void guacclip_mime_slug(const char* mimetype, char* out, + size_t out_size) { + + size_t i; + for (i = 0; mimetype[i] != '\0' && i < out_size - 1; i++) { + char c = mimetype[i]; + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9')) + out[i] = c; + else + out[i] = '_'; + } + out[i] = '\0'; + +} + +/** + * Returns whether clipboard content having the given mimetype should be + * extracted, according to the configured include mode. + * + * @param options + * The extraction options. + * + * @param mimetype + * The mimetype to test. + * + * @return + * true if the content should be extracted, false otherwise. + */ +static bool guacclip_include_matches(const guacclip_options* options, + const char* mimetype) { + + switch (options->include) { + + case GUACCLIP_INCLUDE_ALL: + return true; + + case GUACCLIP_INCLUDE_IMAGE: + return strncmp(mimetype, "image/", 6) == 0; + + case GUACCLIP_INCLUDE_TEXT: + return strncmp(mimetype, "text/", 5) == 0; + + } + + return true; + +} + +/** + * Locates the active clipboard stream having the given stream index. + * + * @param state + * The interpreter state to search. + * + * @param index + * The stream index to locate. + * + * @return + * A pointer to the matching stream, or NULL if no clipboard stream is open + * on the given index. + */ +static guacclip_stream* guacclip_find_stream(guacclip_state* state, int index) { + + int i; + for (i = 0; i < GUACCLIP_MAX_STREAMS; i++) { + if (state->streams[i].in_use && state->streams[i].index == index) + return &state->streams[i]; + } + + return NULL; + +} + +/** + * Adds a warning message to the given manifest item, if space remains. + * + * @param item + * The manifest item to annotate. + * + * @param warning + * The warning message to add. A copy is stored. + */ +static void guacclip_item_warn(guacclip_item* item, const char* warning) { + + if (item->num_warnings >= GUACCLIP_MAX_WARNINGS) + return; + + char* copy = strdup(warning); + if (copy == NULL) + return; + + item->warnings[item->num_warnings++] = copy; + +} + +/** + * Duplicates the given string, falling back to duplicating the given + * fallback literal if either the input is NULL or the duplication of the + * input fails due to memory exhaustion. This function never returns NULL: if + * even the fallback cannot be duplicated, the process is treated as being + * fatally out of memory. + * + * @param value + * The string to duplicate, or NULL to duplicate the fallback directly. + * + * @param fallback + * The non-NULL fallback string to duplicate if value is NULL or if + * duplicating value fails. + * + * @return + * A newly-allocated, heap-owned copy of value or fallback. + */ +static char* guacclip_strdup_or(const char* value, const char* fallback) { + + char* copy = value != NULL ? strdup(value) : NULL; + if (copy != NULL) + return copy; + + copy = strdup(fallback); + if (copy != NULL) + return copy; + + guacclip_log(GUAC_LOG_ERROR, "Out of memory duplicating string."); + exit(1); + +} + +/** + * Creates and opens the given temporary file path for writing, exclusively. + * The file is created with O_EXCL and O_NOFOLLOW so that a pre-existing file + * or symlink already present at that path (as might be planted by another + * process with write access to the output directory) causes the open to + * fail rather than being silently followed or overwritten, closing a + * symlink-race window inherent to opening a deterministic temporary path. + * + * @param tmp_path + * The temporary file path to create and open. + * + * @return + * A FILE* opened for writing in binary mode, or NULL on failure (with an + * error already logged). + */ +static FILE* guacclip_create_tmp(const char* tmp_path) { + + int fd = open(tmp_path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, + S_IRUSR | S_IWUSR); + if (fd < 0) { + guacclip_log(GUAC_LOG_ERROR, "Failed to create \"%s\": %s", + tmp_path, strerror(errno)); + return NULL; + } + + FILE* file = fdopen(fd, "wb"); + if (file == NULL) { + guacclip_log(GUAC_LOG_ERROR, "Failed to allocate stream for \"%s\": " + "%s", tmp_path, strerror(errno)); + close(fd); + unlink(tmp_path); + return NULL; + } + + return file; + +} + +/** + * Writes the given bytes to a new file at the given path using a temporary + * file and atomic rename, ensuring that no partially-written file is ever + * visible at the destination path. + * + * @param path + * The final destination path. + * + * @param data + * The bytes to write. + * + * @param length + * The number of bytes to write. + * + * @return + * Zero on success, non-zero on failure. + */ +static int guacclip_write_file(const char* path, const char* data, + size_t length) { + + /* Build temporary path alongside the destination */ + char tmp_path[4096]; + int len = snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); + if (len < 0 || (size_t) len >= sizeof(tmp_path)) { + guacclip_log(GUAC_LOG_ERROR, "Output path too long: \"%s\"", path); + return 1; + } + + /* Open temporary file for writing, exclusively (guards against a + * pre-planted file or symlink at the deterministic temporary path) */ + FILE* file = guacclip_create_tmp(tmp_path); + if (file == NULL) + return 1; + + /* Write payload (binary-safe) */ + if (length > 0 && fwrite(data, 1, length, file) != length) { + guacclip_log(GUAC_LOG_ERROR, "Failed to write \"%s\": %s", + tmp_path, strerror(errno)); + fclose(file); + unlink(tmp_path); + return 1; + } + + if (fclose(file) != 0) { + guacclip_log(GUAC_LOG_ERROR, "Failed to flush \"%s\": %s", + tmp_path, strerror(errno)); + unlink(tmp_path); + return 1; + } + + /* Atomically move into place */ + if (rename(tmp_path, path) != 0) { + guacclip_log(GUAC_LOG_ERROR, "Failed to rename \"%s\" to \"%s\": %s", + tmp_path, path, strerror(errno)); + unlink(tmp_path); + return 1; + } + + return 0; + +} + +/** + * Ensures the manifest items array has room for at least one more entry, + * growing it if necessary. + * + * @param state + * The interpreter state whose items array should be grown. + * + * @return + * Zero on success, non-zero if reallocation failed. + */ +static int guacclip_ensure_item_capacity(guacclip_state* state) { + + if (state->num_items < state->items_capacity) + return 0; + + /* Enforce a hard cap on the total number of manifest items to bound + * memory growth against a hostile recording containing an extremely + * large number of clipboard open/end pairs */ + if (state->num_items >= GUACCLIP_MAX_ITEMS) { + if (!state->item_limit_warned) { + guacclip_log(GUAC_LOG_WARNING, "Maximum number of manifest items " + "(%d) reached; further clipboard items will be dropped " + "from the manifest.", GUACCLIP_MAX_ITEMS); + state->item_limit_warned = true; + } + return 1; + } + + int new_capacity = state->items_capacity == 0 + ? GUACCLIP_INITIAL_ITEMS : state->items_capacity * 2; + if (new_capacity > GUACCLIP_MAX_ITEMS) + new_capacity = GUACCLIP_MAX_ITEMS; + + guacclip_item* resized = realloc(state->items, + sizeof(guacclip_item) * new_capacity); + if (resized == NULL) { + guacclip_log(GUAC_LOG_ERROR, "Failed to grow manifest items array."); + return 1; + } + + state->items = resized; + state->items_capacity = new_capacity; + return 0; + +} + +/** + * Releases all memory associated with the given clipboard stream and marks its + * slot as free. + * + * @param stream + * The clipboard stream to release. + */ +static void guacclip_stream_release(guacclip_stream* stream) { + free(stream->mimetype); + free(stream->direction); + free(stream->buffer); + memset(stream, 0, sizeof(*stream)); +} + +/** + * Finalizes the given clipboard stream, computing its digest, writing its file + * (when appropriate), and appending a manifest entry. The stream is released + * before this function returns. + * + * @param state + * The interpreter state owning the stream. + * + * @param stream + * The clipboard stream to finalize. + * + * @param incomplete_reason + * NULL if finalization is occurring due to a normal "end" instruction, or + * a short warning string (added to the item's warnings and forcing + * complete=false) if finalization is occurring for some other reason, + * such as end-of-file being reached with the stream still open, or the + * stream being reopened before it was closed. + */ +static void guacclip_finalize_stream(guacclip_state* state, + guacclip_stream* stream, const char* incomplete_reason) { + + /* Streams tracked purely for correct blob/end consumption are dropped */ + if (stream->skipped) { + guacclip_stream_release(stream); + return; + } + + if (guacclip_ensure_item_capacity(state)) { + guacclip_stream_release(stream); + return; + } + + guacclip_item* item = &state->items[state->num_items]; + memset(item, 0, sizeof(*item)); + + item->sequence = stream->sequence; + item->stream = stream->index; + item->direction = guacclip_strdup_or(stream->direction, "unknown"); + item->mimetype = guacclip_strdup_or(stream->mimetype, + "application/octet-stream"); + item->offset_ms = stream->offset_ms; + item->sync_timestamp = stream->sync_timestamp; + item->bytes = stream->length; + item->expected_bytes = stream->expected_bytes; + item->complete = true; + item->filename = NULL; + item->sha256[0] = '\0'; + + /* An oversized item is never written to disk */ + if (stream->oversized) { + item->complete = false; + guacclip_item_warn(item, "oversized"); + } + + /* A stream finalized for any reason other than a normal "end" + * instruction (e.g. still open at EOF, or reopened before being closed) + * is incomplete */ + if (incomplete_reason != NULL) { + item->complete = false; + guacclip_item_warn(item, incomplete_reason); + } + + /* Detect a mismatch against the annotated byte count */ + if (stream->expected_bytes >= 0 + && (size_t) stream->expected_bytes != stream->length) { + item->complete = false; + guacclip_item_warn(item, "byte-count-mismatch"); + } + + /* Compute digest and write file only for non-oversized items */ + if (!stream->oversized) { + + guacclip_sha256_hex(stream->buffer, stream->length, item->sha256); + + /* Build slug and 8-char digest prefix for the filename */ + char slug[128]; + guacclip_mime_slug(item->mimetype, slug, sizeof(slug)); + + char prefix[9]; + memcpy(prefix, item->sha256, 8); + prefix[8] = '\0'; + + const char* ext = guacclip_extension_for(item->mimetype); + + /* Compose the relative item filename */ + char rel_name[1024]; + int rlen = snprintf(rel_name, sizeof(rel_name), + "%d_%" PRId64 "ms_%s_stream%d_%s_%s%s", + item->sequence, item->offset_ms, item->direction, + item->stream, slug, prefix, ext); + + if (rlen < 0 || (size_t) rlen >= sizeof(rel_name)) { + guacclip_log(GUAC_LOG_WARNING, + "Item filename too long for stream %d; not written.", + item->stream); + guacclip_item_warn(item, "filename-too-long"); + item->complete = false; + } + else { + + /* Build absolute path within the items subdirectory */ + char full_path[4096]; + int flen = snprintf(full_path, sizeof(full_path), "%s/items/%s", + state->outdir, rel_name); + + if (flen < 0 || (size_t) flen >= sizeof(full_path)) { + guacclip_log(GUAC_LOG_WARNING, + "Item path too long for stream %d; not written.", + item->stream); + guacclip_item_warn(item, "path-too-long"); + item->complete = false; + } + else if (guacclip_write_file(full_path, stream->buffer, + stream->length)) { + guacclip_item_warn(item, "write-failed"); + item->complete = false; + } + else { + /* Record path relative to the output directory */ + char rel_path[1200]; + snprintf(rel_path, sizeof(rel_path), "items/%s", rel_name); + item->filename = strdup(rel_path); + } + + } + + } + + state->num_items++; + + guacclip_log(GUAC_LOG_INFO, "Extracted clipboard item: seq=%d stream=%d " + "direction=%s mimetype=%s bytes=%zu complete=%s", + item->sequence, item->stream, item->direction, item->mimetype, + item->bytes, item->complete ? "true" : "false"); + + guacclip_stream_release(stream); + +} + +guacclip_state* guacclip_state_alloc(const char* recording_path, + const guacclip_options* options) { + + /* Create output directory and items subdirectory */ + if (guacclip_mkdir(options->outdir)) + return NULL; + + char items_dir[4096]; + int len = snprintf(items_dir, sizeof(items_dir), "%s/items", + options->outdir); + if (len < 0 || (size_t) len >= sizeof(items_dir)) { + guacclip_log(GUAC_LOG_ERROR, "Output directory path too long: \"%s\"", + options->outdir); + return NULL; + } + + if (guacclip_mkdir(items_dir)) + return NULL; + + /* Allocate zero-initialized state */ + guacclip_state* state = calloc(1, sizeof(guacclip_state)); + if (state == NULL) { + guacclip_log(GUAC_LOG_ERROR, "Failed to allocate interpreter state."); + return NULL; + } + + state->options = options; + state->recording_path = recording_path; + state->outdir = options->outdir; + state->next_sequence = 0; + + return state; + +} + +void guacclip_state_sync(guacclip_state* state, int64_t timestamp) { + + if (!state->has_sync) { + state->has_sync = true; + state->first_sync_timestamp = timestamp; + } + + state->last_sync_timestamp = timestamp; + +} + +void guacclip_state_buffer_direction(guacclip_state* state, int index, + const char* direction, int64_t expected_bytes) { + + int i; + + /* Update any existing pending annotation for this index */ + for (i = 0; i < GUACCLIP_MAX_PENDING; i++) { + if (state->pending[i].in_use && state->pending[i].index == index) { + free(state->pending[i].direction); + state->pending[i].direction = strdup(direction); + state->pending[i].expected_bytes = expected_bytes; + return; + } + } + + /* Otherwise, occupy a free slot */ + for (i = 0; i < GUACCLIP_MAX_PENDING; i++) { + if (!state->pending[i].in_use) { + state->pending[i].in_use = true; + state->pending[i].index = index; + state->pending[i].direction = strdup(direction); + state->pending[i].expected_bytes = expected_bytes; + return; + } + } + + guacclip_log(GUAC_LOG_WARNING, "Too many pending clipboard direction " + "annotations; dropping annotation for stream %d.", index); + +} + +/** + * Consumes and removes any buffered direction annotation for the given stream + * index. + * + * @param state + * The interpreter state to search. + * + * @param index + * The stream index whose annotation should be consumed. + * + * @param direction + * Receives a newly-allocated copy of the direction string, or NULL if no + * annotation was buffered. The caller owns the returned string. + * + * @param expected_bytes + * Receives the annotated byte count, or -1 if none was buffered. + */ +static void guacclip_consume_direction(guacclip_state* state, int index, + char** direction, int64_t* expected_bytes) { + + int i; + + *direction = NULL; + *expected_bytes = -1; + + for (i = 0; i < GUACCLIP_MAX_PENDING; i++) { + if (state->pending[i].in_use && state->pending[i].index == index) { + *direction = state->pending[i].direction; + *expected_bytes = state->pending[i].expected_bytes; + state->pending[i].in_use = false; + state->pending[i].direction = NULL; + state->pending[i].index = 0; + state->pending[i].expected_bytes = -1; + return; + } + } + +} + +int guacclip_state_open(guacclip_state* state, int index, + const char* mimetype) { + + /* A duplicate open on an in-use index likely indicates malformed input; + * finalize the previous stream (marked incomplete) so its partial + * contents are still recorded in the manifest, rather than silently + * discarding them */ + guacclip_stream* existing = guacclip_find_stream(state, index); + if (existing != NULL) { + guacclip_log(GUAC_LOG_WARNING, "Clipboard stream %d reopened before " + "being closed; finalizing previous (incomplete) contents.", + index); + guacclip_finalize_stream(state, existing, "stream-reopened-before-end"); + } + + /* Locate a free stream slot */ + guacclip_stream* stream = NULL; + int i; + for (i = 0; i < GUACCLIP_MAX_STREAMS; i++) { + if (!state->streams[i].in_use) { + stream = &state->streams[i]; + break; + } + } + + if (stream == NULL) { + guacclip_log(GUAC_LOG_WARNING, "Too many concurrent clipboard " + "streams; ignoring stream %d.", index); + return 1; + } + + /* Consume any buffered direction annotation for this stream */ + char* direction; + int64_t expected_bytes; + guacclip_consume_direction(state, index, &direction, &expected_bytes); + + memset(stream, 0, sizeof(*stream)); + stream->in_use = true; + stream->index = index; + stream->mimetype = strdup(mimetype); + stream->direction = direction != NULL ? direction : strdup("unknown"); + stream->expected_bytes = expected_bytes; + stream->sequence = state->next_sequence++; + stream->sync_timestamp = state->last_sync_timestamp; + stream->offset_ms = state->has_sync + ? state->last_sync_timestamp - state->first_sync_timestamp : 0; + stream->buffer = NULL; + stream->length = 0; + stream->capacity = 0; + stream->oversized = false; + + /* Determine whether this stream should actually be extracted */ + bool included = guacclip_include_matches(state->options, mimetype); + bool direction_ok = state->options->direction_filter == NULL + || strcmp(stream->direction, + state->options->direction_filter) == 0; + stream->skipped = !(included && direction_ok); + + return 0; + +} + +void guacclip_state_append(guacclip_state* state, int index, + const char* data, size_t length) { + + guacclip_stream* stream = guacclip_find_stream(state, index); + + /* Ignore blobs for non-clipboard streams (img, file, pipe, argv, ...) */ + if (stream == NULL) + return; + + /* Skipped streams and already-oversized streams accumulate nothing */ + if (stream->skipped || stream->oversized) + return; + + /* Nothing to accumulate for an empty (or fully-invalid) blob; returning + * early here avoids an undefined memcpy(NULL, ..., 0) below when the + * stream's buffer has not yet been allocated */ + if (length == 0) + return; + + /* Enforce the per-item size cap */ + size_t max = state->options->max_item_bytes; + if (max != 0 && stream->length + length > max) { + stream->oversized = true; + free(stream->buffer); + stream->buffer = NULL; + stream->capacity = 0; + guacclip_log(GUAC_LOG_WARNING, "Clipboard stream %d exceeded maximum " + "item size (%zu bytes); marking oversized.", index, max); + return; + } + + /* Grow the accumulation buffer as needed */ + if (stream->length + length > stream->capacity) { + size_t new_capacity = stream->capacity == 0 + ? GUACCLIP_INITIAL_BUFFER : stream->capacity; + while (stream->length + length > new_capacity) + new_capacity *= 2; + + char* resized = realloc(stream->buffer, new_capacity); + if (resized == NULL) { + guacclip_log(GUAC_LOG_ERROR, "Failed to grow buffer for clipboard " + "stream %d.", index); + return; + } + + stream->buffer = resized; + stream->capacity = new_capacity; + } + + /* Copy decoded bytes into the accumulation buffer (binary-safe) */ + memcpy(stream->buffer + stream->length, data, length); + stream->length += length; + +} + +void guacclip_state_end(guacclip_state* state, int index) { + + guacclip_stream* stream = guacclip_find_stream(state, index); + + /* Ignore end for non-clipboard streams */ + if (stream == NULL) + return; + + guacclip_finalize_stream(state, stream, NULL); + +} + +/** + * Writes the given string to the given file as a JSON string literal, + * including the surrounding quotes and escaping all characters as required by + * the JSON specification. + * + * @param file + * The output file to write to. + * + * @param value + * The string to write, or NULL to write a JSON null. + */ +static void guacclip_json_string(FILE* file, const char* value) { + + if (value == NULL) { + fprintf(file, "null"); + return; + } + + fputc('"', file); + + const unsigned char* c = (const unsigned char*) value; + for (; *c != '\0'; c++) { + switch (*c) { + case '"': fprintf(file, "\\\""); break; + case '\\': fprintf(file, "\\\\"); break; + case '\b': fprintf(file, "\\b"); break; + case '\f': fprintf(file, "\\f"); break; + case '\n': fprintf(file, "\\n"); break; + case '\r': fprintf(file, "\\r"); break; + case '\t': fprintf(file, "\\t"); break; + default: + /* Escape both control characters and any byte outside the + * printable ASCII range (treating it as a Latin-1 code + * point), ensuring the manifest remains valid, parseable + * JSON even when a mimetype or other attacker-controlled + * string contains non-UTF-8 byte sequences */ + if (*c < 0x20 || *c > 0x7F) + fprintf(file, "\\u%04x", *c); + else + fputc(*c, file); + break; + } + } + + fputc('"', file); + +} + +/** + * Writes the manifest.json file summarizing all extracted items into the + * output directory. + * + * @param state + * The interpreter state whose items should be serialized. + * + * @return + * Zero on success, non-zero on failure. + */ +static int guacclip_write_manifest(guacclip_state* state) { + + char path[4096]; + int len = snprintf(path, sizeof(path), "%s/manifest.json", state->outdir); + if (len < 0 || (size_t) len >= sizeof(path)) { + guacclip_log(GUAC_LOG_ERROR, "Manifest path too long."); + return 1; + } + + char tmp_path[4096]; + len = snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); + if (len < 0 || (size_t) len >= sizeof(tmp_path)) { + guacclip_log(GUAC_LOG_ERROR, "Manifest temp path too long."); + return 1; + } + + FILE* file = guacclip_create_tmp(tmp_path); + if (file == NULL) + return 1; + + fprintf(file, "{\n"); + fprintf(file, " \"recording\": "); + guacclip_json_string(file, state->recording_path); + fprintf(file, ",\n"); + fprintf(file, " \"generated_items\": %d,\n", state->num_items); + fprintf(file, " \"items\": ["); + + int i; + for (i = 0; i < state->num_items; i++) { + + guacclip_item* item = &state->items[i]; + + fprintf(file, "%s\n {\n", i == 0 ? "" : ","); + fprintf(file, " \"sequence\": %d,\n", item->sequence); + fprintf(file, " \"stream\": %d,\n", item->stream); + + fprintf(file, " \"direction\": "); + guacclip_json_string(file, item->direction); + fprintf(file, ",\n"); + + fprintf(file, " \"mimetype\": "); + guacclip_json_string(file, item->mimetype); + fprintf(file, ",\n"); + + fprintf(file, " \"filename\": "); + guacclip_json_string(file, item->filename); + fprintf(file, ",\n"); + + fprintf(file, " \"offset_ms\": %" PRId64 ",\n", item->offset_ms); + fprintf(file, " \"sync_timestamp\": %" PRId64 ",\n", + item->sync_timestamp); + fprintf(file, " \"bytes\": %zu,\n", item->bytes); + + fprintf(file, " \"expected_bytes\": "); + if (item->expected_bytes >= 0) + fprintf(file, "%" PRId64, item->expected_bytes); + else + fprintf(file, "null"); + fprintf(file, ",\n"); + + fprintf(file, " \"sha256\": "); + guacclip_json_string(file, item->sha256[0] != '\0' ? item->sha256 : NULL); + fprintf(file, ",\n"); + + fprintf(file, " \"complete\": %s,\n", + item->complete ? "true" : "false"); + + fprintf(file, " \"warnings\": ["); + int w; + for (w = 0; w < item->num_warnings; w++) { + if (w != 0) + fprintf(file, ", "); + guacclip_json_string(file, item->warnings[w]); + } + fprintf(file, "]\n"); + + fprintf(file, " }"); + + } + + if (state->num_items > 0) + fprintf(file, "\n ]\n"); + else + fprintf(file, "]\n"); + fprintf(file, "}\n"); + + if (fclose(file) != 0) { + guacclip_log(GUAC_LOG_ERROR, "Failed to flush manifest \"%s\": %s", + tmp_path, strerror(errno)); + unlink(tmp_path); + return 1; + } + + if (rename(tmp_path, path) != 0) { + guacclip_log(GUAC_LOG_ERROR, "Failed to rename manifest into place: %s", + strerror(errno)); + unlink(tmp_path); + return 1; + } + + guacclip_log(GUAC_LOG_INFO, "Wrote manifest \"%s\" (%d item(s)).", + path, state->num_items); + return 0; + +} + +int guacclip_state_free(guacclip_state* state) { + + int i; + + if (state == NULL) + return 0; + + /* Finalize any streams still open at end-of-file */ + for (i = 0; i < GUACCLIP_MAX_STREAMS; i++) { + if (state->streams[i].in_use) + guacclip_finalize_stream(state, &state->streams[i], + "stream-open-at-eof"); + } + + /* Write the manifest describing all extracted items */ + int result = guacclip_write_manifest(state); + + /* Free all manifest items */ + for (i = 0; i < state->num_items; i++) { + guacclip_item* item = &state->items[i]; + free(item->direction); + free(item->mimetype); + free(item->filename); + int w; + for (w = 0; w < item->num_warnings; w++) + free(item->warnings[w]); + } + free(state->items); + + /* Free any remaining pending direction annotations */ + for (i = 0; i < GUACCLIP_MAX_PENDING; i++) + free(state->pending[i].direction); + + free(state); + return result; + +} diff --git a/src/guacclip/state.h b/src/guacclip/state.h new file mode 100644 index 0000000000..c7a17043ef --- /dev/null +++ b/src/guacclip/state.h @@ -0,0 +1,494 @@ +/* + * 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 GUACCLIP_STATE_H +#define GUACCLIP_STATE_H + +#include +#include +#include + +/** + * The maximum number of clipboard streams which may be simultaneously open at + * any one time before additional streams are ignored. + */ +#define GUACCLIP_MAX_STREAMS 256 + +/** + * The maximum number of buffered direction annotations (from "log" + * instructions) which may be pending at any one time. + */ +#define GUACCLIP_MAX_PENDING 256 + +/** + * The maximum number of warnings which may be recorded for any single + * extracted clipboard item. + */ +#define GUACCLIP_MAX_WARNINGS 8 + +/** + * The maximum number of manifest items which may be recorded for a single + * recording. This serves as a safety cap against unbounded memory growth + * when processing a hostile or malformed recording containing an extremely + * large number of clipboard open/end pairs. Once this limit is reached, + * further clipboard items are dropped (with a single warning logged) rather + * than being added to the manifest. + */ +#define GUACCLIP_MAX_ITEMS 100000 + +/** + * Which categories of clipboard content should be extracted. + */ +typedef enum guacclip_include_mode { + + /** + * Extract only image clipboard content (image mimetypes). + */ + GUACCLIP_INCLUDE_IMAGE, + + /** + * Extract only textual clipboard content (text mimetypes). + */ + GUACCLIP_INCLUDE_TEXT, + + /** + * Extract all clipboard content regardless of mimetype. + */ + GUACCLIP_INCLUDE_ALL + +} guacclip_include_mode; + +/** + * The options controlling how a recording is processed. These are populated + * from the command line and remain constant for the lifetime of interpreting + * a single recording. + */ +typedef struct guacclip_options { + + /** + * The directory into which extracted items and the manifest should be + * written. This directory (and its "items" subdirectory) will be created + * if it does not already exist. + */ + const char* outdir; + + /** + * The only transfer direction to extract, or NULL to extract all + * directions. When non-NULL, this is either "guest-to-client" or + * "client-to-guest". + */ + const char* direction_filter; + + /** + * Which categories of clipboard content should be extracted. + */ + guacclip_include_mode include; + + /** + * The maximum number of bytes to accumulate for any single clipboard item, + * or zero if the caller has explicitly opted out of any limit (via + * "--max-item-bytes 0"). Items which exceed this limit are marked + * oversized and are not written to disk. Callers which have not + * explicitly specified a limit should use GUACCLIP_DEFAULT_MAX_ITEM_BYTES + * rather than zero, to avoid unbounded memory growth against hostile + * input. + */ + size_t max_item_bytes; + +} guacclip_options; + +/** + * A single in-progress clipboard stream being reassembled from "blob" + * instructions. + */ +typedef struct guacclip_stream { + + /** + * Whether this stream slot is currently in use. + */ + bool in_use; + + /** + * The Guacamole stream index which opened this clipboard stream. + */ + int index; + + /** + * The mimetype of the clipboard content, as declared by the "clipboard" + * instruction. This string is owned by the stream and freed when the + * stream is released. + */ + char* mimetype; + + /** + * The transfer direction of this clipboard content ("guest-to-client", + * "client-to-guest", or "unknown"). This string is owned by the stream and + * freed when the stream is released. + */ + char* direction; + + /** + * The sequence number assigned to this stream when it was opened. + */ + int sequence; + + /** + * The absolute sync timestamp (in milliseconds) most recently seen at the + * time this stream was opened. + */ + int64_t sync_timestamp; + + /** + * The offset, in milliseconds, of this stream relative to the first sync + * timestamp seen in the recording. + */ + int64_t offset_ms; + + /** + * The expected size of the clipboard content in bytes, as annotated by the + * recording's "log" instruction, or -1 if unknown. + */ + int64_t expected_bytes; + + /** + * Whether this stream is being tracked purely to correctly consume its + * "blob"/"end" instructions, but its content should not be extracted (due + * to direction or include filtering). + */ + bool skipped; + + /** + * Whether this stream has exceeded the configured maximum item size. + */ + bool oversized; + + /** + * Heap buffer accumulating the decoded clipboard bytes. + */ + char* buffer; + + /** + * The number of decoded bytes currently stored within the buffer. + */ + size_t length; + + /** + * The current allocated capacity of the buffer, in bytes. + */ + size_t capacity; + +} guacclip_stream; + +/** + * A buffered direction annotation parsed from a "log" instruction, awaiting + * the "clipboard" instruction it describes. + */ +typedef struct guacclip_pending_direction { + + /** + * Whether this pending-direction slot is currently in use. + */ + bool in_use; + + /** + * The stream index this annotation applies to. + */ + int index; + + /** + * The transfer direction ("guest-to-client" or "client-to-guest"). Owned + * by this slot and freed when consumed. + */ + char* direction; + + /** + * The annotated content size in bytes, or -1 if the annotation did not + * include a byte count. + */ + int64_t expected_bytes; + +} guacclip_pending_direction; + +/** + * A completed manifest entry describing a single extracted (or attempted) + * clipboard item. + */ +typedef struct guacclip_item { + + /** + * The sequence number of the item. + */ + int sequence; + + /** + * The Guacamole stream index the item was extracted from. + */ + int stream; + + /** + * The transfer direction of the item. Owned by the item. + */ + char* direction; + + /** + * The mimetype of the item. Owned by the item. + */ + char* mimetype; + + /** + * The relative path (within the output directory) of the written item + * file, or NULL if no file was written. Owned by the item. + */ + char* filename; + + /** + * The offset of the item in milliseconds relative to the recording start. + */ + int64_t offset_ms; + + /** + * The absolute sync timestamp at which the item's stream opened. + */ + int64_t sync_timestamp; + + /** + * The number of bytes reassembled for the item. + */ + size_t bytes; + + /** + * The expected size of the item in bytes as annotated by the recording, or + * -1 if unknown. + */ + int64_t expected_bytes; + + /** + * The lowercase hexadecimal SHA-256 digest of the item's bytes. Empty if + * no digest was computed. + */ + char sha256[65]; + + /** + * Whether the item was completely and consistently reassembled. + */ + bool complete; + + /** + * Human-readable warnings associated with the item. Each entry is owned by + * the item. + */ + char* warnings[GUACCLIP_MAX_WARNINGS]; + + /** + * The number of warnings recorded for the item. + */ + int num_warnings; + +} guacclip_item; + +/** + * The overall state of the guacclip interpreter while processing a single + * recording. + */ +typedef struct guacclip_state { + + /** + * The options controlling extraction. + */ + const guacclip_options* options; + + /** + * The path to the recording being interpreted (recorded in the manifest). + */ + const char* recording_path; + + /** + * The output directory into which items and the manifest are written. + */ + const char* outdir; + + /** + * Whether a sync timestamp has been observed yet. + */ + bool has_sync; + + /** + * The first sync timestamp observed within the recording. + */ + int64_t first_sync_timestamp; + + /** + * The most recent sync timestamp observed within the recording. + */ + int64_t last_sync_timestamp; + + /** + * The next sequence number to assign to an opened clipboard stream. + */ + int next_sequence; + + /** + * Active clipboard streams being reassembled. + */ + guacclip_stream streams[GUACCLIP_MAX_STREAMS]; + + /** + * Buffered direction annotations awaiting their "clipboard" instruction. + */ + guacclip_pending_direction pending[GUACCLIP_MAX_PENDING]; + + /** + * Dynamic array of completed manifest items. + */ + guacclip_item* items; + + /** + * The number of completed manifest items. + */ + int num_items; + + /** + * The allocated capacity of the items array. + */ + int items_capacity; + + /** + * Whether the GUACCLIP_MAX_ITEMS warning has already been logged. Used to + * ensure the warning is only logged once per recording, even if many + * further items are dropped. + */ + bool item_limit_warned; + +} guacclip_state; + +/** + * Allocates and initializes a new guacclip interpreter state for the given + * recording, creating the output directory and its "items" subdirectory. + * + * @param recording_path + * The path to the recording being interpreted. + * + * @param options + * The options controlling extraction. Must remain valid for the lifetime + * of the returned state. + * + * @return + * A newly-allocated guacclip_state, or NULL if allocation or output + * directory creation failed. + */ +guacclip_state* guacclip_state_alloc(const char* recording_path, + const guacclip_options* options); + +/** + * Frees all memory associated with the given guacclip interpreter state after + * finalizing any streams left open and writing the manifest. If the given + * state is NULL, this function has no effect. + * + * @param state + * The guacclip interpreter state to free, which may be NULL. + * + * @return + * Zero if finalization (including manifest writing) succeeded, non-zero + * otherwise. + */ +int guacclip_state_free(guacclip_state* state); + +/** + * Records the given sync timestamp within the interpreter state. + * + * @param state + * The interpreter state to update. + * + * @param timestamp + * The millisecond sync timestamp. + */ +void guacclip_state_sync(guacclip_state* state, int64_t timestamp); + +/** + * Buffers a direction annotation (parsed from a "log" instruction) for the + * given stream index, to be consumed when that clipboard stream opens. + * + * @param state + * The interpreter state to update. + * + * @param index + * The stream index the annotation applies to. + * + * @param direction + * The transfer direction ("guest-to-client" or "client-to-guest"). + * + * @param expected_bytes + * The annotated content size in bytes, or -1 if not present. + */ +void guacclip_state_buffer_direction(guacclip_state* state, int index, + const char* direction, int64_t expected_bytes); + +/** + * Opens a new clipboard stream on the given stream index with the given + * mimetype, consuming any buffered direction annotation. + * + * @param state + * The interpreter state to update. + * + * @param index + * The stream index being opened. + * + * @param mimetype + * The mimetype of the clipboard content. + * + * @return + * Zero on success, non-zero if the stream could not be opened. + */ +int guacclip_state_open(guacclip_state* state, int index, + const char* mimetype); + +/** + * Appends the given decoded bytes to the open clipboard stream having the given + * stream index. If no clipboard stream is open on that index, this is a no-op + * (the blob belongs to some other, non-clipboard stream). + * + * @param state + * The interpreter state to update. + * + * @param index + * The stream index the blob belongs to. + * + * @param data + * The decoded bytes to append. + * + * @param length + * The number of bytes to append. + */ +void guacclip_state_append(guacclip_state* state, int index, + const char* data, size_t length); + +/** + * Ends the clipboard stream having the given stream index, writing its + * extracted item to disk (if applicable) and appending a manifest entry. If no + * clipboard stream is open on that index, this is a no-op. + * + * @param state + * The interpreter state to update. + * + * @param index + * The stream index being ended. + */ +void guacclip_state_end(guacclip_state* state, int index); + +#endif From 79d366a03a15dd411fc243bb1fa959526ac6b755 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Wed, 8 Jul 2026 10:30:27 -0300 Subject: [PATCH 36/36] GUACAMOLE-261: guacclip --dedup flag and CUnit test suite (#27) Complete the guacclip Phase 1 code: add duplicate detection and a unit-test suite. --dedup none|skip (default none): duplicates are detected by SHA-256 of the reassembled item bytes. Every manifest item gains a duplicate_of field (the sequence number of the first identical item, or null). In skip mode a duplicate's file is not written (filename null) and a "duplicate" warning is added; none mode writes every item. Repeated copies are legitimate audit events, so none is the default. tests/: a CUnit suite (test_guacclip, wired into make check) covering SHA-256 FIPS 180-4 vectors and, via the guacclip_interpret() integration path, multi-blob reassembly, interleaved streams, direction correlation (and the unknown-direction fallback), the non-clipboard-stream guard, truncation, oversized capping, bad base64, non-numeric stream index, and both dedup modes. 14/14 pass; -Werror -Wall clean. Requires libcunit (already the test dep for the other modules). --- configure.ac | 1 + src/guacclip/Makefile.am | 2 + src/guacclip/guacclip.c | 30 +- src/guacclip/man/guacclip.1.in | 17 + src/guacclip/state.c | 55 +++ src/guacclip/state.h | 37 ++ src/guacclip/tests/.gitignore | 6 + src/guacclip/tests/Makefile.am | 77 ++++ src/guacclip/tests/test_interpret.c | 687 ++++++++++++++++++++++++++++ src/guacclip/tests/test_sha256.c | 103 +++++ 10 files changed, 1012 insertions(+), 3 deletions(-) create mode 100644 src/guacclip/tests/.gitignore create mode 100644 src/guacclip/tests/Makefile.am create mode 100644 src/guacclip/tests/test_interpret.c create mode 100644 src/guacclip/tests/test_sha256.c diff --git a/configure.ac b/configure.ac index 54441a1a56..295e3f6eab 100644 --- a/configure.ac +++ b/configure.ac @@ -1538,6 +1538,7 @@ AC_CONFIG_FILES([Makefile src/guaclog/Makefile src/guaclog/man/guaclog.1 src/guacclip/Makefile + src/guacclip/tests/Makefile src/guacclip/man/guacclip.1 src/pulse/Makefile src/protocols/kubernetes/Makefile diff --git a/src/guacclip/Makefile.am b/src/guacclip/Makefile.am index 25a1ae5ec6..d6b8eedd04 100644 --- a/src/guacclip/Makefile.am +++ b/src/guacclip/Makefile.am @@ -25,6 +25,8 @@ AUTOMAKE_OPTIONS = foreign +SUBDIRS = . tests + bin_PROGRAMS = guacclip man_MANS = \ diff --git a/src/guacclip/guacclip.c b/src/guacclip/guacclip.c index 62d9b635eb..291d31c59e 100644 --- a/src/guacclip/guacclip.c +++ b/src/guacclip/guacclip.c @@ -37,7 +37,8 @@ enum { GUACCLIP_OPT_DIRECTION = 256, GUACCLIP_OPT_INCLUDE, - GUACCLIP_OPT_MAX_ITEM_BYTES + GUACCLIP_OPT_MAX_ITEM_BYTES, + GUACCLIP_OPT_DEDUP }; int main(int argc, char* argv[]) { @@ -53,7 +54,8 @@ int main(int argc, char* argv[]) { .outdir = NULL, .direction_filter = NULL, .include = GUACCLIP_INCLUDE_ALL, - .max_item_bytes = GUACCLIP_DEFAULT_MAX_ITEM_BYTES + .max_item_bytes = GUACCLIP_DEFAULT_MAX_ITEM_BYTES, + .dedup = GUACCLIP_DEDUP_NONE }; /* Define long options */ @@ -61,6 +63,7 @@ int main(int argc, char* argv[]) { {"direction", required_argument, NULL, GUACCLIP_OPT_DIRECTION}, {"include", required_argument, NULL, GUACCLIP_OPT_INCLUDE}, {"max-item-bytes", required_argument, NULL, GUACCLIP_OPT_MAX_ITEM_BYTES}, + {"dedup", required_argument, NULL, GUACCLIP_OPT_DEDUP}, {NULL, 0, NULL, 0} }; @@ -122,6 +125,19 @@ int main(int argc, char* argv[]) { break; } + /* --dedup: Control handling of duplicate clipboard items */ + case GUACCLIP_OPT_DEDUP: + if (strcmp(optarg, "none") == 0) + options.dedup = GUACCLIP_DEDUP_NONE; + else if (strcmp(optarg, "skip") == 0) + options.dedup = GUACCLIP_DEDUP_SKIP; + else { + guacclip_log(GUAC_LOG_ERROR, "Invalid --dedup value " + "\"%s\" (expected none or skip).", optarg); + goto invalid_options; + } + break; + /* Invalid option */ default: goto invalid_options; @@ -207,13 +223,21 @@ int main(int argc, char* argv[]) { " [--direction guest-to-client|client-to-guest]" " [--include image|text|all]" " [--max-item-bytes N]" + " [--dedup none|skip]" " [FILE]...\n" "\n" " --max-item-bytes N Caps each extracted clipboard item to " "N bytes\n" " (default: %d, i.e. 64 MiB). Specify\n" " --max-item-bytes 0 to disable the " - "cap.\n", + "cap.\n" + " --dedup none|skip Handling of duplicate clipboard items " + "(identical\n" + " content). \"none\" (default) writes " + "every item;\n" + " \"skip\" omits duplicate content from " + "disk but\n" + " still lists it in the manifest.\n", argv[0], GUACCLIP_DEFAULT_MAX_ITEM_BYTES); return 1; diff --git a/src/guacclip/man/guacclip.1.in b/src/guacclip/man/guacclip.1.in index c16c668d19..9739457d9a 100644 --- a/src/guacclip/man/guacclip.1.in +++ b/src/guacclip/man/guacclip.1.in @@ -28,6 +28,7 @@ guacclip \- Guacamole clipboard artifact extractor [\fB--direction \fIguest-to-client\fR|\fIclient-to-guest\fR] [\fB--include \fIimage\fR|\fItext\fR|\fIall\fR] [\fB--max-item-bytes \fIN\fR] +[\fB--dedup \fInone\fR|\fIskip\fR] [\fIFILE\fR]... . .SH DESCRIPTION @@ -93,6 +94,22 @@ oversized, and are not written to disk. If not specified, a default cap of 67108864 bytes (64 MiB) is applied to bound memory usage when processing untrusted or malformed recordings. Specify \fB--max-item-bytes 0\fR to explicitly disable the cap and allow items of any size. +.TP +\fB--dedup \fInone\fR|\fIskip\fR +Controls how duplicate clipboard items (items whose reassembled content is +byte-for-byte identical to that of an earlier item, as determined by SHA-256) +are handled. With +.IR none , +the default, every item is written to disk, since repeated copies are +legitimate audit events. With +.IR skip , +the content of a duplicate item is not written to disk, though the item is +still listed in the manifest and annotated with a +.B duplicate +warning. In both modes, each duplicate item's +.B duplicate_of +field in the manifest records the sequence number of the first item having +identical content. . .SH OUTPUT FORMAT For each recording, diff --git a/src/guacclip/state.c b/src/guacclip/state.c index 84adfcd47b..d695318c0e 100644 --- a/src/guacclip/state.c +++ b/src/guacclip/state.c @@ -401,6 +401,38 @@ static void guacclip_stream_release(guacclip_stream* stream) { memset(stream, 0, sizeof(*stream)); } +/** + * Locates the earliest previously-finalized manifest item whose SHA-256 digest + * matches the given digest, returning its sequence number. Used to populate the + * "duplicate_of" field and to drive --dedup handling. Only items which had a + * digest computed (i.e. non-oversized items) are considered. + * + * @param state + * The interpreter state whose already-finalized items should be searched. + * + * @param sha256 + * The lowercase hexadecimal SHA-256 digest to search for. + * + * @return + * The sequence number of the first (lowest-sequence) earlier item having + * an identical digest, or -1 if no earlier item shares the digest. + */ +static int guacclip_find_duplicate(guacclip_state* state, const char* sha256) { + + int i; + int first = -1; + + for (i = 0; i < state->num_items; i++) { + guacclip_item* other = &state->items[i]; + if (other->sha256[0] != '\0' && strcmp(other->sha256, sha256) == 0 + && (first < 0 || other->sequence < first)) + first = other->sequence; + } + + return first; + +} + /** * Finalizes the given clipboard stream, computing its digest, writing its file * (when appropriate), and appending a manifest entry. The stream is released @@ -448,6 +480,7 @@ static void guacclip_finalize_stream(guacclip_state* state, item->complete = true; item->filename = NULL; item->sha256[0] = '\0'; + item->duplicate_of = -1; /* An oversized item is never written to disk */ if (stream->oversized) { @@ -475,6 +508,19 @@ static void guacclip_finalize_stream(guacclip_state* state, guacclip_sha256_hex(stream->buffer, stream->length, item->sha256); + /* Correlate against any earlier item having identical content */ + item->duplicate_of = guacclip_find_duplicate(state, item->sha256); + bool is_duplicate = item->duplicate_of >= 0; + + /* In "skip" dedup mode, a duplicate's content is not written to disk; + * the item is still recorded in the manifest (with duplicate_of set) + * and annotated with a "duplicate" warning */ + if (is_duplicate + && state->options->dedup == GUACCLIP_DEDUP_SKIP) { + guacclip_item_warn(item, "duplicate"); + } + else { + /* Build slug and 8-char digest prefix for the filename */ char slug[128]; guacclip_mime_slug(item->mimetype, slug, sizeof(slug)); @@ -527,6 +573,8 @@ static void guacclip_finalize_stream(guacclip_state* state, } + } + } state->num_items++; @@ -907,6 +955,13 @@ static int guacclip_write_manifest(guacclip_state* state) { guacclip_json_string(file, item->sha256[0] != '\0' ? item->sha256 : NULL); fprintf(file, ",\n"); + fprintf(file, " \"duplicate_of\": "); + if (item->duplicate_of >= 0) + fprintf(file, "%d", item->duplicate_of); + else + fprintf(file, "null"); + fprintf(file, ",\n"); + fprintf(file, " \"complete\": %s,\n", item->complete ? "true" : "false"); diff --git a/src/guacclip/state.h b/src/guacclip/state.h index c7a17043ef..873c26a39a 100644 --- a/src/guacclip/state.h +++ b/src/guacclip/state.h @@ -74,6 +74,28 @@ typedef enum guacclip_include_mode { } guacclip_include_mode; +/** + * How duplicate clipboard items (items whose reassembled bytes are identical to + * those of an earlier item, as determined by SHA-256) should be handled. + */ +typedef enum guacclip_dedup_mode { + + /** + * Keep every clipboard item, writing each to disk regardless of whether an + * identical item was seen earlier. Duplicates are still annotated in the + * manifest via the "duplicate_of" field. This is the default, as repeated + * copies are legitimate audit events. + */ + GUACCLIP_DEDUP_NONE, + + /** + * List duplicate items in the manifest (with "duplicate_of" set and a + * "duplicate" warning) but do not write their content to disk. + */ + GUACCLIP_DEDUP_SKIP + +} guacclip_dedup_mode; + /** * The options controlling how a recording is processed. These are populated * from the command line and remain constant for the lifetime of interpreting @@ -111,6 +133,12 @@ typedef struct guacclip_options { */ size_t max_item_bytes; + /** + * How duplicate clipboard items (identical reassembled bytes) should be + * handled. + */ + guacclip_dedup_mode dedup; + } guacclip_options; /** @@ -284,6 +312,15 @@ typedef struct guacclip_item { */ char sha256[65]; + /** + * The sequence number of the first earlier item having an identical + * SHA-256 digest, or -1 if this item is the first occurrence of its + * content (or no digest was computed). This is populated regardless of the + * configured dedup mode, and is serialized as "duplicate_of" in the + * manifest (null when -1). + */ + int duplicate_of; + /** * Whether the item was completely and consistently reassembled. */ diff --git a/src/guacclip/tests/.gitignore b/src/guacclip/tests/.gitignore new file mode 100644 index 0000000000..274543eb39 --- /dev/null +++ b/src/guacclip/tests/.gitignore @@ -0,0 +1,6 @@ + +# Compiled test runner +test_guacclip + +# Generated CUnit TAP runner +_generated_runner.c diff --git a/src/guacclip/tests/Makefile.am b/src/guacclip/tests/Makefile.am new file mode 100644 index 0000000000..2c678ed1fa --- /dev/null +++ b/src/guacclip/tests/Makefile.am @@ -0,0 +1,77 @@ +# +# 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 + +# +# Unit tests for guacclip +# + +check_PROGRAMS = test_guacclip +TESTS = $(check_PROGRAMS) + +# +# The test runner is generated from the test source files. The guacclip logic +# sources (everything except guacclip.c, which provides main()) are compiled +# directly into the test program so that the interpreter can be exercised +# end-to-end. +# + +test_guacclip_SOURCES = \ + test_sha256.c \ + test_interpret.c \ + ../instructions.c \ + ../interpret.c \ + ../log.c \ + ../sha256.c \ + ../state.c + +test_guacclip_CFLAGS = \ + -Werror -Wall \ + @LIBGUAC_INCLUDE@ \ + -I$(srcdir)/.. + +test_guacclip_LDADD = \ + @CUNIT_LIBS@ \ + @LIBGUAC_LTLIB@ + +# +# Autogenerate test runner +# + +GEN_RUNNER = $(top_srcdir)/util/generate-test-runner.pl +CLEANFILES = _generated_runner.c + +_generated_runner.c: $(test_guacclip_SOURCES) + $(AM_V_GEN) $(GEN_RUNNER) $(test_guacclip_SOURCES) > $@ + +nodist_test_guacclip_SOURCES = \ + _generated_runner.c + +# Use automake's TAP test driver for running any tests +LOG_DRIVER = \ + env AM_TAP_AWK='$(AWK)' \ + $(SHELL) $(top_srcdir)/build-aux/tap-driver.sh diff --git a/src/guacclip/tests/test_interpret.c b/src/guacclip/tests/test_interpret.c new file mode 100644 index 0000000000..f0e29a7bf0 --- /dev/null +++ b/src/guacclip/tests/test_interpret.c @@ -0,0 +1,687 @@ +/* + * 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 "interpret.h" +#include "log.h" +#include "state.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * A temporary workspace within which a single test recording is written and + * interpreted. All members are absolute paths within a freshly-created + * temporary directory. + */ +typedef struct workspace { + + /** The root temporary directory created by ws_init(). */ + char root[512]; + + /** The path of the recording file to be interpreted. */ + char rec[768]; + + /** The output directory into which guacclip should extract items. */ + char out[768]; + +} workspace; + +/** + * Encodes the given bytes as a null-terminated standard base64 string. The + * returned buffer is heap-allocated and must be freed by the caller. + */ +static char* b64_encode(const void* data, size_t len) { + + static const char tbl[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + const uint8_t* in = (const uint8_t*) data; + size_t out_len = 4 * ((len + 2) / 3); + char* out = malloc(out_len + 1); + + size_t i, o = 0; + for (i = 0; i < len; i += 3) { + uint32_t n = (uint32_t) in[i] << 16; + if (i + 1 < len) n |= (uint32_t) in[i + 1] << 8; + if (i + 2 < len) n |= (uint32_t) in[i + 2]; + out[o++] = tbl[(n >> 18) & 63]; + out[o++] = tbl[(n >> 12) & 63]; + out[o++] = (i + 1 < len) ? tbl[(n >> 6) & 63] : '='; + out[o++] = (i + 2 < len) ? tbl[n & 63] : '='; + } + out[o] = '\0'; + + return out; + +} + +/** + * Writes a single Guacamole instruction to the given file from the given array + * of string elements (the first of which is the opcode). + */ +static void emit_argv(FILE* f, int argc, const char* const* argv) { + + int i; + for (i = 0; i < argc; i++) { + if (i != 0) + fputc(',', f); + fprintf(f, "%zu.%s", strlen(argv[i]), argv[i]); + } + fputc(';', f); + +} + +/** + * Writes a single Guacamole instruction to the given file. The variadic + * arguments are the instruction's elements (opcode first) as const char*, and + * MUST be terminated by a NULL pointer. + */ +static void emit(FILE* f, ...) { + + const char* argv[32]; + int argc = 0; + + va_list ap; + va_start(ap, f); + + const char* element; + while ((element = va_arg(ap, const char*)) != NULL && argc < 32) + argv[argc++] = element; + + va_end(ap); + + emit_argv(f, argc, argv); + +} + +/** + * Writes a "blob" instruction to the given file for the given stream index, + * base64-encoding the given raw bytes as the blob payload. + */ +static void emit_blob(FILE* f, const char* index, const void* data, + size_t len) { + + char* b64 = b64_encode(data, len); + emit(f, "blob", index, b64, NULL); + free(b64); + +} + +/** + * Initializes the given workspace, creating a fresh temporary directory and + * deriving the recording and output paths within it. + */ +static void ws_init(workspace* ws) { + + char template[] = "/tmp/guacclip_test_XXXXXX"; + char* root = mkdtemp(template); + CU_ASSERT_PTR_NOT_NULL_FATAL(root); + + snprintf(ws->root, sizeof(ws->root), "%s", root); + snprintf(ws->rec, sizeof(ws->rec), "%s/recording.guac", root); + snprintf(ws->out, sizeof(ws->out), "%s/out", root); + +} + +/** + * Recursively removes the given workspace's temporary directory. + */ +static void ws_cleanup(workspace* ws) { + char cmd[1024]; + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", ws->root); + int result = system(cmd); + (void) result; +} + +/** + * Returns a guacclip_options structure populated with the same defaults used + * by the command-line tool. + */ +static guacclip_options default_opts(void) { + guacclip_options options = { + .outdir = NULL, + .direction_filter = NULL, + .include = GUACCLIP_INCLUDE_ALL, + .max_item_bytes = 64 * 1024 * 1024, + .dedup = GUACCLIP_DEDUP_NONE + }; + return options; +} + +/** + * Interprets the recording in the given workspace using the given options + * (whose outdir is overridden to the workspace output directory). Logging is + * suppressed to keep test output clean. Returns the result of + * guacclip_interpret(). + */ +static int run_interpret(workspace* ws, guacclip_options options) { + guacclip_log_level = GUAC_LOG_ERROR; + options.outdir = ws->out; + return guacclip_interpret(ws->rec, &options, true); +} + +/** + * Reads the entire contents of the given file into a newly-allocated, + * null-terminated buffer. The number of bytes read (excluding the null + * terminator) is stored in *len_out if len_out is non-NULL. Returns NULL on + * failure. + */ +static char* read_file(const char* path, size_t* len_out) { + + if (len_out != NULL) + *len_out = 0; + + FILE* f = fopen(path, "rb"); + if (f == NULL) + return NULL; + + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + + if (size < 0) { + fclose(f); + return NULL; + } + + char* buffer = malloc((size_t) size + 1); + size_t read = fread(buffer, 1, (size_t) size, f); + fclose(f); + + buffer[read] = '\0'; + if (len_out != NULL) + *len_out = read; + + return buffer; + +} + +/** + * Reads the manifest.json of the given output directory into a newly-allocated, + * null-terminated string. Returns NULL on failure. + */ +static char* read_manifest(const char* outdir) { + char path[900]; + snprintf(path, sizeof(path), "%s/manifest.json", outdir); + return read_file(path, NULL); +} + +/** + * Counts the number of regular entries within the "items" subdirectory of the + * given output directory. Returns -1 if the directory cannot be opened. + */ +static int count_items(const char* outdir) { + + char items[900]; + snprintf(items, sizeof(items), "%s/items", outdir); + + DIR* dir = opendir(items); + if (dir == NULL) + return -1; + + int count = 0; + struct dirent* entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") != 0 + && strcmp(entry->d_name, "..") != 0) + count++; + } + + closedir(dir); + return count; + +} + +/** + * Reads the single item file found within the given output directory's "items" + * subdirectory into a newly-allocated buffer, storing its length in *len_out. + * Intended for tests which expect exactly one item. Returns NULL if no item is + * found. + */ +static char* read_only_item(const char* outdir, size_t* len_out) { + + char items[900]; + snprintf(items, sizeof(items), "%s/items", outdir); + + DIR* dir = opendir(items); + if (dir == NULL) + return NULL; + + char* result = NULL; + struct dirent* entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 + || strcmp(entry->d_name, "..") == 0) + continue; + char path[1900]; + snprintf(path, sizeof(path), "%s/%s", items, entry->d_name); + result = read_file(path, len_out); + break; + } + + closedir(dir); + return result; + +} + +/** + * Returns whether some item file within the given output directory's "items" + * subdirectory has content exactly matching the given bytes. + */ +static bool item_content_exists(const char* outdir, const void* data, + size_t len) { + + char items[900]; + snprintf(items, sizeof(items), "%s/items", outdir); + + DIR* dir = opendir(items); + if (dir == NULL) + return false; + + bool found = false; + struct dirent* entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 + || strcmp(entry->d_name, "..") == 0) + continue; + char path[1900]; + snprintf(path, sizeof(path), "%s/%s", items, entry->d_name); + size_t item_len; + char* content = read_file(path, &item_len); + if (content != NULL && item_len == len + && memcmp(content, data, len) == 0) + found = true; + free(content); + if (found) + break; + } + + closedir(dir); + return found; + +} + +/** + * Test which verifies that a clipboard stream split across several blobs is + * reassembled into the exact original byte sequence. + */ +void test_interpret__multiblob(void) { + + workspace ws; + ws_init(&ws); + + FILE* f = fopen(ws.rec, "wb"); + CU_ASSERT_PTR_NOT_NULL_FATAL(f); + emit(f, "sync", "1000", NULL); + emit(f, "clipboard", "1", "text/plain", NULL); + emit_blob(f, "1", "Hello", 5); + emit_blob(f, "1", ", ", 2); + emit_blob(f, "1", "World!", 6); + emit(f, "end", "1", NULL); + fclose(f); + + CU_ASSERT_EQUAL(run_interpret(&ws, default_opts()), 0); + + CU_ASSERT_EQUAL(count_items(ws.out), 1); + + size_t len; + char* content = read_only_item(ws.out, &len); + CU_ASSERT_PTR_NOT_NULL_FATAL(content); + CU_ASSERT_EQUAL(len, 13); + CU_ASSERT_NSTRING_EQUAL(content, "Hello, World!", 13); + free(content); + + ws_cleanup(&ws); + +} + +/** + * Test which verifies that two interleaved clipboard streams are reassembled + * independently and correctly. + */ +void test_interpret__interleaved(void) { + + workspace ws; + ws_init(&ws); + + FILE* f = fopen(ws.rec, "wb"); + CU_ASSERT_PTR_NOT_NULL_FATAL(f); + emit(f, "sync", "1000", NULL); + emit(f, "clipboard", "1", "text/plain", NULL); + emit(f, "clipboard", "2", "text/plain", NULL); + emit_blob(f, "1", "AA", 2); + emit_blob(f, "2", "BBB", 3); + emit_blob(f, "1", "AA", 2); + emit_blob(f, "2", "BBB", 3); + emit(f, "end", "1", NULL); + emit(f, "end", "2", NULL); + fclose(f); + + CU_ASSERT_EQUAL(run_interpret(&ws, default_opts()), 0); + + CU_ASSERT_EQUAL(count_items(ws.out), 2); + CU_ASSERT_TRUE(item_content_exists(ws.out, "AAAA", 4)); + CU_ASSERT_TRUE(item_content_exists(ws.out, "BBBBBB", 6)); + + ws_cleanup(&ws); + +} + +/** + * Test which verifies that a preceding "log" direction annotation is correlated + * with the clipboard stream it describes. + */ +void test_interpret__direction(void) { + + workspace ws; + ws_init(&ws); + + FILE* f = fopen(ws.rec, "wb"); + CU_ASSERT_PTR_NOT_NULL_FATAL(f); + emit(f, "sync", "1000", NULL); + emit(f, "log", + "clipboard stream=5 direction=guest-to-client mimetype=text/plain", + NULL); + emit(f, "clipboard", "5", "text/plain", NULL); + emit_blob(f, "5", "secret", 6); + emit(f, "end", "5", NULL); + fclose(f); + + CU_ASSERT_EQUAL(run_interpret(&ws, default_opts()), 0); + + char* manifest = read_manifest(ws.out); + CU_ASSERT_PTR_NOT_NULL_FATAL(manifest); + CU_ASSERT_PTR_NOT_NULL( + strstr(manifest, "\"direction\": \"guest-to-client\"")); + free(manifest); + + ws_cleanup(&ws); + +} + +/** + * Test which verifies that a clipboard stream lacking any direction annotation + * is reported with direction "unknown". + */ +void test_interpret__unknown_direction(void) { + + workspace ws; + ws_init(&ws); + + FILE* f = fopen(ws.rec, "wb"); + CU_ASSERT_PTR_NOT_NULL_FATAL(f); + emit(f, "sync", "1000", NULL); + emit(f, "clipboard", "1", "text/plain", NULL); + emit_blob(f, "1", "data", 4); + emit(f, "end", "1", NULL); + fclose(f); + + CU_ASSERT_EQUAL(run_interpret(&ws, default_opts()), 0); + + char* manifest = read_manifest(ws.out); + CU_ASSERT_PTR_NOT_NULL_FATAL(manifest); + CU_ASSERT_PTR_NOT_NULL(strstr(manifest, "\"direction\": \"unknown\"")); + free(manifest); + + ws_cleanup(&ws); + +} + +/** + * Test which verifies the core guard: blob/end instructions on a stream index + * that was never opened by a "clipboard" instruction (e.g. a graphical image + * stream) produce no clipboard item. + */ +void test_interpret__non_clipboard_stream(void) { + + workspace ws; + ws_init(&ws); + + FILE* f = fopen(ws.rec, "wb"); + CU_ASSERT_PTR_NOT_NULL_FATAL(f); + emit(f, "sync", "1000", NULL); + /* Stream 7 is never opened by a clipboard instruction */ + emit_blob(f, "7", "\x89PNG\r\n", 6); + emit(f, "end", "7", NULL); + fclose(f); + + CU_ASSERT_EQUAL(run_interpret(&ws, default_opts()), 0); + + CU_ASSERT_EQUAL(count_items(ws.out), 0); + + char* manifest = read_manifest(ws.out); + CU_ASSERT_PTR_NOT_NULL_FATAL(manifest); + CU_ASSERT_PTR_NOT_NULL(strstr(manifest, "\"generated_items\": 0")); + free(manifest); + + ws_cleanup(&ws); + +} + +/** + * Test which verifies that a stream still open at end-of-file yields an item + * marked incomplete. + */ +void test_interpret__truncation(void) { + + workspace ws; + ws_init(&ws); + + FILE* f = fopen(ws.rec, "wb"); + CU_ASSERT_PTR_NOT_NULL_FATAL(f); + emit(f, "sync", "1000", NULL); + emit(f, "clipboard", "1", "text/plain", NULL); + emit_blob(f, "1", "partial", 7); + /* No "end" instruction: the stream is truncated at EOF */ + fclose(f); + + CU_ASSERT_EQUAL(run_interpret(&ws, default_opts()), 0); + + /* The partial content is still written */ + CU_ASSERT_EQUAL(count_items(ws.out), 1); + + char* manifest = read_manifest(ws.out); + CU_ASSERT_PTR_NOT_NULL_FATAL(manifest); + CU_ASSERT_PTR_NOT_NULL(strstr(manifest, "\"complete\": false")); + CU_ASSERT_PTR_NOT_NULL(strstr(manifest, "stream-open-at-eof")); + free(manifest); + + ws_cleanup(&ws); + +} + +/** + * Test which verifies that a clipboard item exceeding the configured + * --max-item-bytes is reported as oversized and is not written to disk. + */ +void test_interpret__oversized(void) { + + workspace ws; + ws_init(&ws); + + FILE* f = fopen(ws.rec, "wb"); + CU_ASSERT_PTR_NOT_NULL_FATAL(f); + emit(f, "sync", "1000", NULL); + emit(f, "clipboard", "1", "text/plain", NULL); + emit_blob(f, "1", "abcdefgh", 8); + emit(f, "end", "1", NULL); + fclose(f); + + guacclip_options options = default_opts(); + options.max_item_bytes = 4; + CU_ASSERT_EQUAL(run_interpret(&ws, options), 0); + + /* Oversized content is never written */ + CU_ASSERT_EQUAL(count_items(ws.out), 0); + + char* manifest = read_manifest(ws.out); + CU_ASSERT_PTR_NOT_NULL_FATAL(manifest); + CU_ASSERT_PTR_NOT_NULL(strstr(manifest, "oversized")); + CU_ASSERT_PTR_NOT_NULL(strstr(manifest, "\"filename\": null")); + free(manifest); + + ws_cleanup(&ws); + +} + +/** + * Test which verifies that a malformed base64 blob payload is handled without + * crashing. + */ +void test_interpret__bad_base64(void) { + + workspace ws; + ws_init(&ws); + + FILE* f = fopen(ws.rec, "wb"); + CU_ASSERT_PTR_NOT_NULL_FATAL(f); + emit(f, "sync", "1000", NULL); + emit(f, "clipboard", "1", "text/plain", NULL); + /* Emit a raw, deliberately-malformed base64 payload */ + emit(f, "blob", "1", "!!!!not-valid-base64", NULL); + emit(f, "end", "1", NULL); + fclose(f); + + /* The recording is interpreted without crashing */ + CU_ASSERT_EQUAL(run_interpret(&ws, default_opts()), 0); + + char* manifest = read_manifest(ws.out); + CU_ASSERT_PTR_NOT_NULL_FATAL(manifest); + free(manifest); + + ws_cleanup(&ws); + +} + +/** + * Test which verifies that a clipboard instruction bearing a non-numeric stream + * index is ignored, producing no item. + */ +void test_interpret__non_numeric_stream(void) { + + workspace ws; + ws_init(&ws); + + FILE* f = fopen(ws.rec, "wb"); + CU_ASSERT_PTR_NOT_NULL_FATAL(f); + emit(f, "sync", "1000", NULL); + emit(f, "clipboard", "x", "text/plain", NULL); + emit(f, "blob", "x", "aGk=", NULL); + emit(f, "end", "x", NULL); + fclose(f); + + CU_ASSERT_EQUAL(run_interpret(&ws, default_opts()), 0); + + CU_ASSERT_EQUAL(count_items(ws.out), 0); + + char* manifest = read_manifest(ws.out); + CU_ASSERT_PTR_NOT_NULL_FATAL(manifest); + CU_ASSERT_PTR_NOT_NULL(strstr(manifest, "\"generated_items\": 0")); + free(manifest); + + ws_cleanup(&ws); + +} + +/** + * Test which verifies that, under --dedup none, two identical clipboard items + * are both written to disk, with the second annotated with duplicate_of set to + * the first item's sequence number. + */ +void test_interpret__dedup_none(void) { + + workspace ws; + ws_init(&ws); + + FILE* f = fopen(ws.rec, "wb"); + CU_ASSERT_PTR_NOT_NULL_FATAL(f); + emit(f, "sync", "1000", NULL); + emit(f, "clipboard", "1", "text/plain", NULL); + emit_blob(f, "1", "duplicate", 9); + emit(f, "end", "1", NULL); + emit(f, "clipboard", "2", "text/plain", NULL); + emit_blob(f, "2", "duplicate", 9); + emit(f, "end", "2", NULL); + fclose(f); + + CU_ASSERT_EQUAL(run_interpret(&ws, default_opts()), 0); + + /* Both copies are written */ + CU_ASSERT_EQUAL(count_items(ws.out), 2); + + char* manifest = read_manifest(ws.out); + CU_ASSERT_PTR_NOT_NULL_FATAL(manifest); + CU_ASSERT_PTR_NOT_NULL(strstr(manifest, "\"duplicate_of\": 0")); + /* No "duplicate" warning is emitted in none mode */ + CU_ASSERT_PTR_NULL(strstr(manifest, "\"duplicate\"")); + free(manifest); + + ws_cleanup(&ws); + +} + +/** + * Test which verifies that, under --dedup skip, the second of two identical + * clipboard items is not written to disk (filename null), is annotated with a + * "duplicate" warning, and still carries duplicate_of in the manifest. + */ +void test_interpret__dedup_skip(void) { + + workspace ws; + ws_init(&ws); + + FILE* f = fopen(ws.rec, "wb"); + CU_ASSERT_PTR_NOT_NULL_FATAL(f); + emit(f, "sync", "1000", NULL); + emit(f, "clipboard", "1", "text/plain", NULL); + emit_blob(f, "1", "duplicate", 9); + emit(f, "end", "1", NULL); + emit(f, "clipboard", "2", "text/plain", NULL); + emit_blob(f, "2", "duplicate", 9); + emit(f, "end", "2", NULL); + fclose(f); + + guacclip_options options = default_opts(); + options.dedup = GUACCLIP_DEDUP_SKIP; + CU_ASSERT_EQUAL(run_interpret(&ws, options), 0); + + /* Only the first copy is written to disk */ + CU_ASSERT_EQUAL(count_items(ws.out), 1); + + char* manifest = read_manifest(ws.out); + CU_ASSERT_PTR_NOT_NULL_FATAL(manifest); + CU_ASSERT_PTR_NOT_NULL(strstr(manifest, "\"duplicate_of\": 0")); + CU_ASSERT_PTR_NOT_NULL(strstr(manifest, "\"filename\": null")); + CU_ASSERT_PTR_NOT_NULL(strstr(manifest, "\"duplicate\"")); + free(manifest); + + ws_cleanup(&ws); + +} diff --git a/src/guacclip/tests/test_sha256.c b/src/guacclip/tests/test_sha256.c new file mode 100644 index 0000000000..1ef9358657 --- /dev/null +++ b/src/guacclip/tests/test_sha256.c @@ -0,0 +1,103 @@ +/* + * 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 "sha256.h" + +#include + +#include +#include + +/** + * Converts the given raw SHA-256 digest into a null-terminated lowercase + * hexadecimal string. + * + * @param digest + * The raw 32-byte digest to convert. + * + * @param out + * A buffer of at least GUACCLIP_SHA256_HEX_LENGTH bytes which will receive + * the null-terminated hexadecimal representation. + */ +static void hexlify(const uint8_t digest[GUACCLIP_SHA256_DIGEST_LENGTH], + char out[GUACCLIP_SHA256_HEX_LENGTH]) { + + static const char hex[] = "0123456789abcdef"; + int i; + + for (i = 0; i < GUACCLIP_SHA256_DIGEST_LENGTH; i++) { + out[i * 2] = hex[(digest[i] >> 4) & 0xF]; + out[i * 2 + 1] = hex[digest[i] & 0xF]; + } + out[GUACCLIP_SHA256_DIGEST_LENGTH * 2] = '\0'; + +} + +/** + * Test which verifies that the SHA-256 of the empty string matches the known + * FIPS 180-4 digest. + */ +void test_sha256__empty(void) { + + char out[GUACCLIP_SHA256_HEX_LENGTH]; + guacclip_sha256_hex("", 0, out); + + CU_ASSERT_STRING_EQUAL(out, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + +} + +/** + * Test which verifies that the SHA-256 of "abc" matches the known FIPS 180-4 + * digest. + */ +void test_sha256__abc(void) { + + char out[GUACCLIP_SHA256_HEX_LENGTH]; + guacclip_sha256_hex("abc", 3, out); + + CU_ASSERT_STRING_EQUAL(out, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + +} + +/** + * Test which verifies that incrementally hashing "abc" across several + * guacclip_sha256_update() calls yields the same digest as hashing it in one + * pass. + */ +void test_sha256__multi_update(void) { + + guacclip_sha256_context context; + uint8_t digest[GUACCLIP_SHA256_DIGEST_LENGTH]; + char out[GUACCLIP_SHA256_HEX_LENGTH]; + + guacclip_sha256_init(&context); + guacclip_sha256_update(&context, "a", 1); + guacclip_sha256_update(&context, "b", 1); + guacclip_sha256_update(&context, "c", 1); + guacclip_sha256_final(&context, digest); + + hexlify(digest, out); + + CU_ASSERT_STRING_EQUAL(out, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + +}