Changkun's Blog

Science and art, life in between.


  • Home

  • Ideas

  • Archives

  • Tags

  • Bio

Guacamole 源码分析与 VNC 中 RFB 协议的坑

Published at: 2016-08-21   |   Reading: 2928 words ~6min   |   PV/UV: /

今天折腾了一整天 Guacamole,遇到了臭名昭著的坑,且听我一一道来。

简单来说 Guacamole 提供了浏览器端访问的桌面系统的解决方案。Guacamole 提供的解决方案主要由两部分组成:

  1. 浏览器端基于 HTML5,Canvas 技术: Guacamole Client 的 Guacamole-Common-JS 组件
  2. Guacamole Client 的 Guacamole Web 组件,
  3. Guacamole Server 仍然分为两个部分:
    • Guacamole Web 服务容器
    • guacd 守护进程与 RDP/VNC/TELNET 等其他服务进行通信。

下面这张图很好的解释了 guacamole 的架构,出自官网手册:

部署 Guacamole Server

部署 Guacamole 分两步:

  1. 部署 guacamole-server
  2. 部署 guacamole-web-service

部署 guacamole-server

1
2
3
4
5
6
7
8
9
# 装依赖
sudo apt-get install libpng12-dev libjpeg-dev libcairo2-dev libossp-uuid-dev libpulse-dev libvncserver-dev libcairo2-dev freerdp-x11 libfreerdp-dev libvorbis-dev libssh-dev libpulse-dev tomcat7 tomcat7-admin libpango1.0-dev autoconf libossp-uuid-dev libtelnet-dev libvncserver-dev build-essential default-jre default-jdk  maven vnc4server

# 然后去官网把源码下下来, 进到 /src 内:
./configure
./configure --with-init-dir=/etc/init.d
sudo make
sudo make install
sudo ldconfig

部署 guacamole-client:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
sudo mkdir -p /var/lib/guacamole/classpath
sudo wget -q --span-hosts http://sourceforge.net/projects/guacamole/files/current/binary/guacamole-0.9.9.war -P /var/lib/guacamole
cd /var/lib/tomcat7/webapps
sudo rm -rf ROOT
sudo ln -s /var/lib/guacamole/guacamole-0.9.9.war ./ROOT.war
sudo mkdir /etc/guacamole
sudo ln -s /etc/guacamole /usr/share/tomcat7/.guacamole
sudo vim /etc/guacamole/guacamole.properties

## 配置内容如下
#tname and port of guacamole proxy
guacd-hostname: 127.0.0.1
guacd-port:     4822
enable-websocket: true

# Authentication provider class
# auth-provider: net.sourceforge.guacamole.net.basic.BasicFileAuthenticationProvider
lib-drectory: /var/lib/guacamole/classpath
auth-provider: com.stephensugden.guacamole.net.hmac.HmacAuthenticationProvider
secret-key: for-test
timestamp-age-limit: 42000000

再在 /etc/guacamole/ 下创建 user-mapping.xml:

<user-mapping><authorize username="changkun" password="123"><protocol>vnc</protocol><param name="hostname">localhost</param><param name="port">5900</param><param name="password">VNCPASS</param></authorize></user-mapping>

最后启动 guacamole/tomcat7/vnc 服务即可:

1
2
3
sudo /etc/init.d/guacd start
sudo /etc/init.d/tomcat7 start
vnc4server :1

关闭 vnc 服务用 vnc4server -kill :1。

这时便可以在 8080 端口访问 guacamole 服务了。

Guacamole 对 UTF-8 的支持

关于 UTF-8 和 Unicode 之间的区别,简单来说 Unicode 是一种规范标准,规定了字符集的编码;而 UTF-8 是 Unicode 的一个具体的实现,解决了 Unicode 的存储问题。

关于 UTF-8 的具体实现细节可以归为两点:

  1. 对于单字节的符号,字节的第一位设为0,后面7位为这个符号的unicode码。因此对于英语字母,UTF-8编码和ASCII码是相同的。
  2. 对于n字节的符号(n>1),第一个字节的前n位都设为1,第n+1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的unicode码。

这里说的比较精炼,可以参考阮一峰的一篇关于字符编码的文章。

Guacamole Client 对 UTF-8 的编码

对于剪切板之间的传输不支持中文的情况,自然最先想到从客户端查起。首先查到官方文档中对于剪贴板的描述,实现剪贴板之间的传输主要依赖 setClipboard 方法和 onclipboard 事件。

对于 setClipboard 来说:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
/**
 * Sets the clipboard of the remote client to the given text data.
 *
 * @deprecated Use createClipboardStream() instead. 
 * @param {String} data The data to send as the clipboard contents.
 */
this.setClipboard = function(data) {
    // Do not send requests if not connected
    if (!isConnected())
        return;
    // Open stream
    var stream = guac_client.createClipboardStream("text/plain");
    var writer = new Guacamole.StringWriter(stream);
    // Send text chunks
    for (var i=0; i<data.length; i += 4096)
        writer.sendText(data.substring(i, i+4096));
    // Close stream
    writer.sendEnd();
};

看到这里发现对于字符的处理每次传输4096长度的字符串,知道传输完成才关闭写入流。所以关于编码的部分取决于 Guacamole.StringWriter对于 .sendText()的实现。

在这个实现中,.sendText() 会将字符串先编码为 UTF-8 然后通过 .sendData 方法传输:

1
2
3
4
5
6
7
8
9
/**
 * Sends the given text.
 * 
 * @param {String} text The text to send.
 */
this.sendText = function(text) {
    if (text.length)
        array_writer.sendData(__encode_utf8(text));
};

那么问题就落在了关于对 UTF-8 的编码上了,下面是 guacamole-client 关于 UTF-8 编码的关键实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// Guacamole Client 字符转码

var buffer = new Uint8Array(8192);
var length = 0;

function __expand(bytes) {

    // Resize buffer if more space needed
    if (length+bytes >= buffer.length) {
        var new_buffer = new Uint8Array((length+bytes)*2);
        new_buffer.set(buffer);
        buffer = new_buffer;
    }

    length += bytes;

}

function __append_utf8(codepoint) {

    var mask;
    var bytes;

    // 1 byte
    if (codepoint <= 0x7F) {
        mask = 0x00;
        bytes = 1;
    }
    // 2 byte
    else if (codepoint <= 0x7FF) {
        mask = 0xC0;
        bytes = 2;
    }
    // 3 byte
    else if (codepoint <= 0xFFFF) {
        mask = 0xE0;
        bytes = 3;
    }
    // 4 byte
    else if (codepoint <= 0x1FFFFF) {
        mask = 0xF0;
        bytes = 4;
    }
    // If invalid codepoint, append replacement character
    else {
        __append_utf8(0xFFFD);
        return;
    }
    // Offset buffer by size
    __expand(bytes);
    var offset = length - 1;
    // Add trailing bytes, if any
    for (var i=1; i<bytes; i++) {
        buffer[offset--] = 0x80 | (codepoint & 0x3F);
        codepoint >>= 6;
    }
    // Set initial byte
    buffer[offset] = mask | codepoint;
}

function __encode_utf8(text) {

    // Fill buffer with UTF-8
    // 使用 UTF-8 填充缓冲区
    for (var i=0; i<text.length; i++) {
        var codepoint = text.charCodeAt(i);
        __append_utf8(codepoint);
    }
    // Flush buffer
    if (length > 0) {
        var out_buffer = buffer.subarray(0, length);
        length = 0;
        return out_buffer;
    }

}

// 『你好』的 UTF-8 编码
// E4 BD A0
// 1110 0100 1011 1101 1010 0000
// E5 A5 BD
// 1110 0101 1010 0101 1011 1101
console.log(__encode_utf8("你好"));

总的来说 __encode_utf8() 这个方法最终实现了对 UTF-8 字符串的编码转换,上面给出的例子中,『你好』这两个汉字最终被编码为 3-bytes 的 UTF-8,分别是 E4 BD A0 和 E5 A5 BD。

看起来客户端这边没什么问题,那么我们再来查一查服务端的代码。

Guacamole Server 对 UTF-8 的解码

先来看对 UTF-8 字符的处理是怎么实现的,由于 guacd 底层由 C 语言实现,就不再粘贴代码了,我们可以单独把 /src/libguac/unicode.c 和 /src/libguac/guacamole/unicode.h 这两个文件单独拿出来,注释掉 unicode.c 里面的 #include "config.h",然后编写下面的 main.cpp:

1
2
3
4
5
6
7
#include "unicode.h"
int main() {
    // 这里保存了『你好』的 UTF-8 编码
    char str[] = {0xE4, 0xBD, 0xA0, 0xE5, 0xA5, 0xBD, 0};
    guac_utf8_write(str[0], str, 6);
    printf("%s", str);
}

我们把三个文件一起编译 gcc main.c unicode.c,容易发现最后输出的确实是『你好』两个汉字,那么,究竟为什么最后还是没办法传递中文?

guacd 与 VNC 交互

首先我们需要定位到src/common/guac_clipboard.c:

这个函数用于设置剪切板所粘贴文字的类型:

1
2
3
4
void guac_common_clipboard_reset(guac_common_clipboard* clipboard, const char* mimetype) {
    clipboard->length = 0;
    strncpy(clipboard->mimetype, mimetype, sizeof(clipboard->mimetype)-1);
}

说明 guacd 在处理剪切板本身是没有问题的。

那么它在 VNC 上究竟是怎么处理剪切板的呢?/src/protocols/vnc/clipboard.c揭示了一切:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
int guac_vnc_clipboard_handler(guac_user* user, guac_stream* stream,
        char* mimetype) {

    /* Clear clipboard and prepare for new data */
    guac_vnc_client* vnc_client = (guac_vnc_client*) user->client->data;
    // 设置剪切板的类型
    guac_common_clipboard_reset(vnc_client->clipboard, mimetype);

    // 设置剪切板内容的处理方法
    /* Set handlers for clipboard stream */
    stream->blob_handler = guac_vnc_clipboard_blob_handler;
    stream->end_handler = guac_vnc_clipboard_end_handler;

    return 0;
}
int guac_vnc_clipboard_blob_handler(guac_user* user, guac_stream* stream,
        void* data, int length) {

    // 将数据拼接到剪切板中
    /* Append new data */
    guac_vnc_client* vnc_client = (guac_vnc_client*) user->client->data;
    guac_common_clipboard_append(vnc_client->clipboard, (char*) data, length);

    return 0;
}
void guac_vnc_cut_text(rfbClient* client, const char* text, int textlen) {

    guac_client* gc = rfbClientGetClientData(client, GUAC_VNC_CLIENT_KEY);
    guac_vnc_client* vnc_client = (guac_vnc_client*) gc->data;

    char received_data[GUAC_VNC_CLIPBOARD_MAX_LENGTH];

    const char* input = text;
    char* output = received_data;
    guac_iconv_read* reader = vnc_client->clipboard_reader;

    /* Convert clipboard contents */
    guac_iconv(reader, &input, textlen,
               GUAC_WRITE_UTF8, &output, sizeof(received_data));

    // 在这里设置剪切板内容
    /* Send converted data */
    guac_common_clipboard_reset(vnc_client->clipboard, "text/plain");
    guac_common_clipboard_append(vnc_client->clipboard, received_data, output - received_data);
    guac_common_clipboard_send(vnc_client->clipboard, gc);

}

"text/plain" 看起来完全没有问题,Excuse me????????,问题在哪儿?最后,终于查到了大坑原来在 VNC 协议本身身上。

VNC 的大坑

最后的最后,我们终于把坑锁定在了 VNC 这个协议本身上,我们能够查到 RFP, Remote Framebuffer Protocol 这个协议本身的描述,在 7.5.6 ClientCutText 和 7.6.4 ServerCutText 中:

7.5.6. ClientCutText

RFB provides limited support for synchronizing the “cut buffer” of selected text between client and server. This message tells the server that the client has new ISO 8859-1 (Latin-1) text in its cut buffer. Ends of lines are represented by the newline character (hex 0a) alone. No carriage-return (hex 0d) is used. There is no way to transfer text outside the Latin-1 character set.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
+--------------+--------------+--------------+
| No. of bytes | Type [Value] | Description  |
+--------------+--------------+--------------+
| 1            | U8 [6]       | message-type |
| 3            |              | padding      |
| 4            | U32          | length       |
| length       | U8 array     | text         |
+--------------+--------------+--------------+

```> **7.6.4.  ServerCutText**
>
> The server has new ISO 8859-1 (Latin-1) text in its cut buffer.  Ends of lines are represented by the newline character (hex 0a) alone.  No carriage-return (hex 0d) is used.  There is no way to transfer text outside the Latin-1 character set.

+————–+————–+————–+ | No. of bytes | Type [Value] | Description | +————–+————–+————–+ | 1 | U8 [3] | message-type | | 3 | | padding | | 4 | U32 | length | | length | U8 array | text | +————–+————–+————–+

1
2
3
4
5
6
7
8

在这两个消息的设计中,所有的内容均按照 `text/plain` 的方式进行传输,彻底忽略了剪切板中的 `minetype`,最终导致了无法传输除了 `ISO 8859-1` 标准规定以外的字符。## 一些扩展的思考

VNC 是个烂协议,这么烂的协议居然活到了 2016 年,那么我们有什么办法可以解决它呢?要知道,guacamole 本身实现的服务端 `guacd` 就可以将其称之为 `guacamole` 协议了。从最开始的架构图就可以看到,`guacd`本身并不仅仅只和 VNC 打交道,它还支持 `RDP` 这种远比 `VNC` 复杂得多也好得多的协议。但是,为什么我们还是希望用 `VNC`?因为 `VNC` 支持会话共享,而这正是  `RDP` 所做不到的事情。理论上看,我们可以在 `guacd` 底层动刀,复制出一个数据流,从而间接的支持会话共享,当然,这都是后话了。

关于会话共享,我们以后有时间再研究,这里有一个 `issue`:

- [Screen sharing support within Guacamole core](https://glyptodon.org/jira/browse/GUAC-844)
#guacamole# #VNC# #Linux# #DevOps#
  • Author: Changkun Ou
  • Link: https://changkun.de/blog/posts/guacamole-%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90%E4%B8%8E-vnc-%E4%B8%AD-rfb-%E5%8D%8F%E8%AE%AE%E7%9A%84%E5%9D%91/
  • License: All articles in this blog are licensed under CC BY-NC-ND 4.0 unless stating additionally.
给博客做了一个桌面客户端…
《高速上手 C++11/14》正式发布
  • TOC
  • Overview
Changkun Ou

Changkun Ou

Stop Talking. Just Coding.

276 Blogs
165 Tags
Homepage GitHub Email YouTube Twitter Zhihu
Friends
    Frimin ZZZero march1993 qcrao maiyang Xargin Muniao
  • 部署 Guacamole Server
    • 部署 guacamole-server
    • 部署 guacamole-client:
  • Guacamole 对 UTF-8 的支持
    • Guacamole Client 对 UTF-8 的编码
    • Guacamole Server 对 UTF-8 的解码
  • VNC 的大坑
© 2008 - 2024 Changkun Ou. All rights reserved. | PV/UV: /
0%