五年前,NeoAtlantis应用科学和神秘学实验室的负责人初步接触XMPP协议,发现它不仅仅是一种聊天协议, 实际上可以作为点对点的数据包通信方式。从这个角度看,XMPP适合传输程序之间的数据, 是物联网时代颇有潜力的技术方案。

最近重新捡起了XMPP协议,发现最近这方面的库也是多有改进。 之前在书上出现的SleekXMPP(Python)库已经重写成了slixmpp,在NodeJS上用的xmpp.js已经更加方便使用了。 而且相应的XEP标准也已经形成。 比如XEP-0047(2021年1月)是带内数据流的传输,就是可以在两个XMPP客户端之间形成双向的连接。适合发送图片等文件,也适合构建TCP通道。

据此,本实验室开发了TCP-over-XMPP这个项目,用于验证这个设想。 TCP-over-XMPP 是一个端口转发程序。本地计算机上的端口数据会转换成XMPP的带内数据流,发送到远端计算机的另一个目标端口上。 这个程序可以作为通用的代理服务器,也可以用于在各种电脑间建立一般的数据信道,实现进程间的通信、协作等,用途可以非常广泛。

本文是记录一些在开发这个程序过程中的经验。

1 xmpp.js 的基本用法

xmpp.js是一个模块化的库,也自带很多XEP的实现。

导出库中的client,可以新建一个XMPP客户端:

const { xml, client } = require("@xmpp/client");

const xmpp = client({
    service: config.xmpp.service,
    username: config.xmpp.username,
    password: config.xmpp.password,
    resource: "tmpp",
});

xmpp.start(); // 启动连接

serviceusernamepassword分别是服务器的地址(xmpp.js支持通过WebSocket、BOSH和直接连接三种方式,要在地址中写明协议),用户名和密码。这里不再赘述。

resource需要特别说明一下。 一个XMPP的用户ID(“JID”),形同 neoatlantis@im.neoatlantis.org/resource,其中neoatlantis为用户名,im.neoatlantis.org是服务器地址,而斜线后面的resource称作资源名。 因为我们的通信是使用<iq />节点在客户端与客户端之间通信,我们需要具体指定是哪个客户端。XMPP允许多个客户端同时登录,借助资源名区分。 如果在后面使用iq通信时,对方的地址只写成neoatlantis@im.neoatlantis.org(无资源名)的形式,这一消息发送出去后,服务器会代表对方用户进行回复,而不会真正将消息转发给客户端。 因为服务器可能并不支持XEP-0047标准(这一标准本来就是应当由客户端实现的,无需服务器参与),将会回复<service-unavailable />错误。 因此,在实现这个程序的过程中,指明资源名是很关键的细节。

1.1 监听client的一般事件

xmpp这个实例通过如上方法建立成功,就可以按照一般的事件监听方式,响应XMPP连接上发生的各种事情,比如:

xmpp.on("online", async function(){
    // 登录成功,需要发送 <presence /> 通知服务器我方已经上线
    // 如果不进行这一操作,后续通信可能无法正常进行
    await xmpp.send(xml("presence"));
});

如果监听message类型的事件,可以建立类似聊天机器人之类的应用。由于和本例无关,这里略去细节。

1.2 处理和我们的例子相关的IQ节点通信

XMPP协议通过发送<iq />类别的节点,实现各个客户端之间的查询和响应。 在XEP-0047中,数据流就完全以这种节点传送(而不推荐使用<message />类型)。

iq查询有多种类型。<iq type="set" /><iq type="get" />用于发出请求,类似HTTP中的GET和POST。 请求的结果由<iq type="result" />传送。如果这个请求存在错误,会使用<iq type="error" />节点回复。

由于一个客户端可以发出多个请求,而不同的请求处理次序或需要的时间都有所不同,所以<iq />节点必须会带有一个id属性,用来区分不同的请求。 此外,fromto用来告知发送和接受者的JID,也是不可或缺的。

xmpp.js提供了很方便的方式,可以略去iq节点互相通信的细节,将整个流程与js的async/await功能结合起来。 我们可以省略节点的id,让xmpp.js给我们自动分配一个。请求发出后,可以使用await等待对方客户端反馈结果(或在超时情况下自动返回错误)。 如果我们收到了请求,可以返回给xmpp.js一个Promise,xmpp.js会等待这个Promise产生结果,然后按照规则返回给对方客户端。

为了实现这些功能,xmpp.js会按照<iq />节点所承载的子节点的命名空间(xmlns)区分一个iq请求属于哪些XEP标准,并送给对应的处理函数。

响应对方的set类型的请求

为了定义一个新的处理函数,负责在收到iq承载的<open />消息时新建数据流,可以使用类似下文的方式:

const bytestreams = {};
client.iqCallee.set("http://jabber.org/protocol/ibb", "open", async (ctx)=>{
    const sid = ctx.stanza.attrs.id;
    if(!sid || bytestreams[sid] !== undefined){
        // 如果已经存在一个bytestream,就拒绝新的连接
        return xml(
            "error",
            { type: "cancel" },
            xml("not-acceptable", { xmlns: "urn:ietf:params:xml:ns:xmpp-stanzas" })
        );
    }
    // 新建一个数据流
    await new_bytestream(ctx);
    // ...
    return true; // 会导致一个空的<iq type="result />发送出去,表明结果成功
});

client.iqCallee.setclient.iqCallee.get的用法很像express库,可以在这里指定要处理的、分属某个命名空间的iq请求。

发起一个iq请求并等待回应

使用client.iqCaller.request,可以发出一个完整的iq请求,然后用await等待收到回复。例如,根据XEP-0047,发送一个open请求,新建数据流,可以使用如下代码。

async function request_open(peer, sid){
    const stanza = xml(
        "iq",
        { to: this.peer, type: "set" },
        xml(
            "open",
            {
                xmlns: "http://jabber.org/protocol/ibb",
                "block-size": "65535",
                sid: sid,       // sid 为数据流的id,见XEP-0047
                stanza: "iq",
            }
        )
    );
    return await client.iqCaller.request(stanza);
}

1.3 使用xml()函数构建XMPP节点(XMPP Stanza)

xmpp.js内置了一种简明的xml()函数,用来构建新的XMPP节点。这个函数可以接受最多3个参数,分别是节点的名字,节点的属性(Attributes)和子节点:

> const { xml } = require("@xmpp/client");

> xml("open").toString()
'<open/>'

> xml("open", { xmlns: "a" }).toString()
'<open xmlns="a"/>'

> xml("open", { xmlns: "a" }, xml("b")).toString()
'<open xmlns="a"><b/></open>'

> xml("open", { xmlns: "a" }, [xml("b"), xml("c")]).toString()
'<open xmlns="a"><b/><c/></open>'

2 XEP-0047简介

XEP-0047 是一个已经正式公布的XEP标准。 XMPP的全称是Extensible Messaging and Presence Protocol,可扩展消息与存在协议。实际上,XMPP的通信以XML格式为基础,因此具有丰富的扩展性。 诸多XEP标准就是在这一基础上、面对各种不同的需求,作出的规定。

XEP-0047: In-Band Bytestreams 称作带内数据流。In-Band 翻译成带内,是指数据流和其他的通信(比如消息)采用同一个信道。这是XMPP传送二进制数据的几个可选的方式之一。 这一方式不是最优先的方案,而是各种带外数据流(不经过XMPP连接,只通过XMPP控制,单独建立其他的信道)都失效之后的最后手段。

XEP-0047 传送数据分为几个步骤:

  1. 首先是建立数据流。通过发送一个iq请求,携带一个open类型的节点,告诉对方希望的传输方式、数据包节点的大小、数据流的ID等。
  2. 在对方同意之后,发送数据。数据可以通过iq或者message类型的节点携带,使用Base64编码。每个数据包上有一个顺序编号。对方接收到数据后,会发送<iq type="result" />,反馈收到数据包。
  3. 在数据传输完毕后,发送close类型的节点,关闭数据流。

XEP-0047 建立的数据信道是双向的(duplex),任何一端都可以是发送方和接收方。因此,可以方便地在这种信道上传送TCP数据连接。

2.1 本实验室为xmpp.js编写的XEP-0047扩展库

https://github.com/neoatlantis/tcp-over-xmpp/tree/master/xep0047_inband_bytestream 可以看到这个扩展库的目录。

要在自己的NodeJS项目中使用这个扩展库,首先仍然需要安装xmpp.js这个库,并按照范例给client传送合适的参数,建立XMPP连接。

然后,将client实例传给扩展库,得到一个数据流的管理实例:

const { xml, client } = require("@xmpp/client");
const xmpp = client({ /* ... */ });

const XMPPInbandBytestreamEndpoint = require("./xep0047_inband_bytestream");
const ibb_endpoint = new XMPPInbandBytestreamEndpoint(xmpp);

这个ibb_endpoint实例的用法,思路类似NodeJS的各种http server。它有两种用法:

(a) 通过bytestream事件获得新生成的(来自对方)的数据流
ibb_endpoint.on("bytestream", function(bytestream){
    if(/* 判断何时可以接受这个数据流 */){
        bytestream.accept();
        // 处理数据流,比如将它转发给其他的程序
        forward_bytestream(bytestream);
    } else {
        bytestream.reject();
    }
});

需要注意的是,新的bytestream如果来自对方,一定要在处理的时候首先调用accept()或者reject(),来表明是否接受这个数据流。否则,数据流并不会建立成功。

(b) 我方主动新建一个数据流
const bytestream = await ibb_endpoint.create(peer);

ibb_endpoint抛出的bytestream对象,是一个stream.Duplex,因此可以和其他的stream对象,比如socket,直接连接。 bytestream对象可以产生opendenied事件,分别表示数据流已经成功建立(并被对方接受),或者被对方拒绝的情况。