diff --git a/Makefile.am b/Makefile.am index 6ce3814a28..1f07120056 100644 --- a/Makefile.am +++ b/Makefile.am @@ -34,12 +34,14 @@ DIST_SUBDIRS = \ src/guacd \ src/guacenc \ src/guaclog \ + src/guacclip \ src/pulse \ src/protocols/kubernetes \ src/protocols/rdp \ src/protocols/ssh \ src/protocols/telnet \ - src/protocols/vnc + src/protocols/vnc \ + src/protocols/spice SUBDIRS = \ src/libguac \ @@ -77,6 +79,10 @@ if ENABLE_VNC SUBDIRS += src/protocols/vnc endif +if ENABLE_SPICE +SUBDIRS += src/protocols/spice +endif + if ENABLE_GUACD SUBDIRS += src/guacd endif @@ -89,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 2f2560dd8d..295e3f6eab 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)') @@ -558,6 +562,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 # @@ -1460,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 # @@ -1482,6 +1537,9 @@ AC_CONFIG_FILES([Makefile src/guacenc/man/guacenc.1 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 src/protocols/kubernetes/tests/Makefile @@ -1489,7 +1547,9 @@ 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 + src/protocols/spice/tests/Makefile]) AC_OUTPUT # @@ -1501,6 +1561,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 @@ -1509,6 +1570,7 @@ AM_COND_IF([ENABLE_VNC], [build_vnc=yes], [build_vnc=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 @@ -1550,6 +1612,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,12 +1627,14 @@ $PACKAGE_NAME version $PACKAGE_VERSION SSH ........... ${build_ssh} Telnet ........ ${build_telnet} VNC ........... ${build_vnc} + SPICE ......... ${build_spice} Services / tools: 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..d6b8eedd04 --- /dev/null +++ b/src/guacclip/Makefile.am @@ -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. +# +# 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 + +SUBDIRS = . tests + +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..291d31c59e --- /dev/null +++ b/src/guacclip/guacclip.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 "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, + GUACCLIP_OPT_DEDUP +}; + +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, + .dedup = GUACCLIP_DEDUP_NONE + }; + + /* 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}, + {"dedup", required_argument, NULL, GUACCLIP_OPT_DEDUP}, + {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; + } + + /* --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; + + } + + } + + /* 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]" + " [--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" + " --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/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..9739457d9a --- /dev/null +++ b/src/guacclip/man/guacclip.1.in @@ -0,0 +1,140 @@ +.\" +.\" 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] +[\fB--dedup \fInone\fR|\fIskip\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. +.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, +.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..d695318c0e --- /dev/null +++ b/src/guacclip/state.c @@ -0,0 +1,1043 @@ +/* + * 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)); +} + +/** + * 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 + * 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'; + item->duplicate_of = -1; + + /* 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); + + /* 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)); + + 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, " \"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"); + + 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..873c26a39a --- /dev/null +++ b/src/guacclip/state.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 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; + +/** + * 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 + * 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; + + /** + * How duplicate clipboard items (identical reassembled bytes) should be + * handled. + */ + guacclip_dedup_mode dedup; + +} 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]; + + /** + * 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. + */ + 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 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"); + +} 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/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 new file mode 100644 index 0000000000..24eea7d0e6 --- /dev/null +++ b/src/protocols/spice/Makefile.am @@ -0,0 +1,157 @@ +# +# 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 + +SUBDIRS = . tests + +lib_LTLIBRARIES = libguac-client-spice.la + +nodist_libguac_client_spice_la_SOURCES = \ + _generated_keymaps.c + +libguac_client_spice_la_SOURCES = \ + argv.c \ + 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 \ + decompose.c \ + display.c \ + input.c \ + keyboard.c \ + keymap.c \ + log.c \ + settings.c \ + spice.c \ + user.c + +noinst_HEADERS = \ + argv.h \ + 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 \ + decompose.h \ + display.h \ + input.h \ + keyboard.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 \ + -I$(srcdir) \ + @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). + +# +# 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/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..1f9e641f83 --- /dev/null +++ b/src/protocols/spice/audio.c @@ -0,0 +1,481 @@ +/* + * 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 +#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). + */ +#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 + * 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; + + /* 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, + (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); + +} + +/** + * 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) { + + 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); + + /* Track mute state reported by the SPICE server */ + g_signal_connect(channel, "notify::mute", + G_CALLBACK(guac_spice_playback_mute_changed), 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; + + /* 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; + return 0; + +} + +/** + * 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 || out_frames > GUAC_SPICE_AUDIO_MAX_FRAMES) + return NULL; + + /* 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; + + 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, 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) + 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; + +} + +/** + * 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; + } + + /* 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; + + 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_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); +} + +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 new file mode 100644 index 0000000000..00af8af5f9 --- /dev/null +++ b/src/protocols/spice/audio.h @@ -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. + */ + +#ifndef GUAC_SPICE_AUDIO_H +#define GUAC_SPICE_AUDIO_H + +#include +#include +#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); + +/** + * 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 new file mode 100644 index 0000000000..e6d19481ce --- /dev/null +++ b/src/protocols/spice/auth.c @@ -0,0 +1,162 @@ +/* + * 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 + +#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); + + /* 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) { + + 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); + + /* 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. */ + SpiceSessionVerify verify; + if (settings->ignore_cert) + 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; + + 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); + + /* 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); + + /* 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 + * 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); + 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, + 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..c18e4d0e16 --- /dev/null +++ b/src/protocols/spice/channels.c @@ -0,0 +1,270 @@ +/* + * 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 "input.h" +#include "spice.h" + +#include +#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; + 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), 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); + } + + } + + } + 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); + + /* Track agent readiness (connection + monitors-config support) so a + * 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)); + } + + /* 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); + + /* 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 */ + 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); + } + + /* 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)) { + 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; + else if (SPICE_IS_RECORD_CHANNEL(channel)) + spice_client->record_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/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..a69590b236 --- /dev/null +++ b/src/protocols/spice/channels/file-upload.c @@ -0,0 +1,658 @@ +/* + * 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 +#include +#include +#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. + * + * @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; +} + +/** + * 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; + } + + /* 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) { + + 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 new file mode 100644 index 0000000000..615e9ce0c5 --- /dev/null +++ b/src/protocols/spice/channels/file-upload.h @@ -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. + */ + +#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; + +/** + * 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/channels/file.c b/src/protocols/spice/channels/file.c new file mode 100644 index 0000000000..f9417efbbc --- /dev/null +++ b/src/protocols/spice/channels/file.c @@ -0,0 +1,704 @@ +/* + * 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)); + } + + } + + /* 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). */ + + } + + 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 new file mode 100644 index 0000000000..4e661723c7 --- /dev/null +++ b/src/protocols/spice/client.c @@ -0,0 +1,218 @@ +/* + * 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 + +#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); + + /* 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); + 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); + + /* 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; + + /* 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 + + /* 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); + + /* 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 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 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); + + 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..ea2dd69f22 --- /dev/null +++ b/src/protocols/spice/clipboard.c @@ -0,0 +1,435 @@ +/* + * 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 + +#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 + +/** + * 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; + + } + +} + +/** + * 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. + */ +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. 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) { + + 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 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 (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; + } + } + } + +} + +/** + * 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. 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, + 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 no clipboard + * structure is available */ + if (spice_client->settings->disable_copy + || spice_client->clipboard == NULL) + return; + + /* 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); + + /* 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); + } + +} + +/** + * 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, provided the requested type matches the type Guacamole currently holds. + * + * @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 or no + * clipboard data is available */ + guac_common_clipboard* clipboard = spice_client->clipboard; + if (spice_client->settings->disable_paste || clipboard == NULL) + return FALSE; + + /* 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 handled; + +} + +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, 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; + +} + +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 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 + && 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] = type; + + 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/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..32e7ca3cef --- /dev/null +++ b/src/protocols/spice/cursor.c @@ -0,0 +1,156 @@ +/* + * 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; + + /* 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); + 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/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/display.c b/src/protocols/spice/display.c new file mode 100644 index 0000000000..c6d44d65ff --- /dev/null +++ b/src/protocols/spice/display.c @@ -0,0 +1,444 @@ +/* + * 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 "input.h" +#include "spice.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#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 + * 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. + * + * 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, + SpiceChannel* channel) { + + guac_spice_client* spice_client = (guac_spice_client*) client->data; + + /* Only relevant when multi-monitor support is enabled for this connection */ + if (spice_client->settings->max_secondary_monitors <= 0 || channel == NULL) + return; + + /* 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++] = '{'; + + /* 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; + + /* 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; + + if (gx >= (guint) surface_width || gy >= (guint) surface_height) + continue; + + 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; + + /* 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 */ + 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); + + guac_client_log(client, GUAC_LOG_DEBUG, + "multimon-layout published (%s source, %d monitor(s)): %s", + from_guest ? "guest-actual" : "client-requested", + written, json); + +} + +/** + * 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, 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); + + /* 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); + + /* 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, channel); + +} + +/** + * 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; + + /* 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); + +} + +/** + * 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; + + /* 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 */ + 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); + +} + +/** + * 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) { + + 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); + + /* 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); + +} 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..dd3b7e03f8 --- /dev/null +++ b/src/protocols/spice/input.c @@ -0,0 +1,521 @@ +/* + * 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 "keyboard.h" +#include "spice.h" + +#include +#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. + */ +/* 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) { + 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) { + + 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) + guac_spice_defer_motion(inputs, + x - spice_client->last_mouse_x, + y - spice_client->last_mouse_y, spice_mask); + else + 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)) + 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)) + 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)) + guac_spice_defer_button(inputs, SPICE_MOUSE_BUTTON_RIGHT, spice_mask, + spice_mask & SPICE_MOUSE_BUTTON_MASK_RIGHT); + + } + + /* 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; + int retval = 0; + + /* Report key state within recording */ + if (spice_client->recording != NULL) + guac_recording_report_key(spice_client->recording, keysym, pressed); + + pthread_rwlock_rdlock(&(spice_client->lock)); + + /* 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); + + pthread_rwlock_unlock(&(spice_client->lock)); + + return retval; + +} + +/** + * 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; + } + +} + +/** + * 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). + */ +#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; + + /* The scheduled timer has now fired and is no longer pending */ + spice_client->resize_timer_source = 0; + + /* 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 G_SOURCE_REMOVE; + + /* 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); + 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 + * 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); + + /* 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 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); + +} + +/** + * 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; + + gboolean connected = FALSE; + g_object_get(channel, "agent-connected", &connected, NULL); + + int ready = (connected + && spice_main_channel_agent_test_capability(channel, + VD_AGENT_CAP_MONITORS_CONFIG)); + + /* 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, 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; + + 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 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 */ + if (spice_client->main_channel == NULL) + return 0; + + /* 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). + * 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_queue_resize; + 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/input.h b/src/protocols/spice/input.h new file mode 100644 index 0000000000..3d19eb6a5a --- /dev/null +++ b/src/protocols/spice/input.h @@ -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. + */ + +#ifndef GUAC_SPICE_INPUT_H +#define GUAC_SPICE_INPUT_H + +#include +#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; + +/** + * 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; + +/** + * 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); + +/** + * 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 diff --git a/src/protocols/spice/keyboard.c b/src/protocols/spice/keyboard.c new file mode 100644 index 0000000000..e24847d4f1 --- /dev/null +++ b/src/protocols/spice/keyboard.c @@ -0,0 +1,696 @@ +/* + * 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. + */ +/** + * 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) { + + /* 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); + +} + +/** + * 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. + */ +/** + * 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) { + + /* Skip if inputs channel is not connected */ + if (spice_client->inputs_channel == NULL) + return; + + /* 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); + +} + +/** + * 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. 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 */ + 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 new file mode 100644 index 0000000000..51b75a780f --- /dev/null +++ b/src/protocols/spice/keymap.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 "keymap.h" + +#include + +const guac_spice_keymap* guac_spice_keymap_find(const char* name) { + + /* For each keymap */ + const guac_spice_keymap** current = GUAC_SPICE_KEYMAPS; + while (*current != NULL) { + + /* If name matches, done */ + if (strcmp((*current)->name, name) == 0) + return *current; + + current++; + } + + /* Failure */ + return NULL; + +} + diff --git a/src/protocols/spice/keymap.h b/src/protocols/spice/keymap.h new file mode 100644 index 0000000000..69a02df256 --- /dev/null +++ b/src/protocols/spice/keymap.h @@ -0,0 +1,202 @@ +/* + * 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 + +/** + * 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 name + * The name of the keymap to find. + * + * @return + * The keymap having the given name, or NULL if no such keymap exists. + */ +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/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..c2600f4fee --- /dev/null +++ b/src/protocols/spice/settings.c @@ -0,0 +1,836 @@ +/* + * 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 "keymap.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", + "pubkey", + "proxy", + "color-depth", + "swap-red-blue", + "preferred-compression", + "server-layout", + "read-only", + "disable-display-resize", + "disable-copy", + "disable-paste", + "disable-clipboard", + "clipboard-buffer-size", + "enable-audio", + "enable-audio-input", + "disable-audio-opus", + "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", + "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", + "recording-include-clipboard", + "create-recording-path", + "recording-write-existing", + "wol-send-packet", + "wol-mac-addr", + "wol-broadcast-addr", + "wol-udp-port", + "wol-wait-time", + "preferred-video-codec", + "secondary-monitors", + "file-transfer-mode", + + 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 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 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"). + */ + IDX_SERVER_LAYOUT, + + /** + * "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 audio input (e.g. microphone) should be enabled, "false" or + * blank otherwise. + */ + IDX_ENABLE_AUDIO_INPUT, + + /** + * "true" if the Opus audio codec should be disabled, causing the SPICE + * 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, + + /** + * "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, + + /** + * "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. + */ + 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 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. + */ + 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, + + /** + * 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, + + /** + * 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, + + /** + * 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 +}; + +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->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->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); + + 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->audio_input_enabled = + 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->preferred_video_codec = + 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); + + 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); + + 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); + + /* 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) { + 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->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); + + 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->preferred_video_codec); + 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->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); + 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..8c3018d4e2 --- /dev/null +++ b/src/protocols/spice/settings.h @@ -0,0 +1,516 @@ +/* + * 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 + +/** + * 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. + */ +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 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 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"). + */ + char* server_layout; + + /** + * 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 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. + */ + 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 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 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; + + /** + * 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; + + /** + * 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. + */ + 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; + + /** + * 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. + */ + 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 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. + */ + 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..5e13f305ec --- /dev/null +++ b/src/protocols/spice/spice.c @@ -0,0 +1,420 @@ +/* + * 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 +#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 + +/** + * 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; + + /* 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); + +} + +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 + * 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); + } + } + + /* 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); + + /* 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) + 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); + + /* 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.", + 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) */ + 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); + 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, + settings->recording_include_clipboard); + } + +#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, NULL); + + /* 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); + + } + + /* 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); + + /* Stop the render loop */ + if (spice_client->render_thread != NULL) { + guac_display_render_thread_destroy(spice_client->render_thread); + spice_client->render_thread = NULL; + } + + /* 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..0a2387d519 --- /dev/null +++ b/src/protocols/spice/spice.h @@ -0,0 +1,458 @@ +/* + * 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 "channels/file.h" +#include "common/clipboard.h" +#include "keyboard.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 + +/** + * 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. + */ +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 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. + */ + 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; + + /** + * Dynamic display-resize state. All fields are accessed only from the SPICE + * 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; + + /** + * 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; + + /** + * 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. + */ + 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; + + /** + * 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 + * 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; + + /** + * 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 + * 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. + */ + 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; + +/** + * 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 + * (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. + */ + 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 + * 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/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); + +} diff --git a/src/protocols/spice/user.c b/src/protocols/spice/user.c new file mode 100644 index 0000000000..1e2ea232b8 --- /dev/null +++ b/src/protocols/spice/user.c @@ -0,0 +1,155 @@ +/* + * 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 "audio.h" +#include "channels/file-upload.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; + + /* 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; + + /* 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) + user->file_handler = guac_spice_sftp_file_handler; +#endif + + /* 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; + + } + } + + } + + /* 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 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."); 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) {