五年前,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(); // 启动连接
service
,username
,password
分别是服务器的地址(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
属性,用来区分不同的请求。
此外,from
和to
用来告知发送和接受者的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.set
和client.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 传送数据分为几个步骤:
- 首先是建立数据流。通过发送一个
iq
请求,携带一个open
类型的节点,告诉对方希望的传输方式、数据包节点的大小、数据流的ID等。 - 在对方同意之后,发送数据。数据可以通过
iq
或者message
类型的节点携带,使用Base64编码。每个数据包上有一个顺序编号。对方接收到数据后,会发送<iq type="result" />
,反馈收到数据包。 - 在数据传输完毕后,发送
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
对象可以产生open
和denied
事件,分别表示数据流已经成功建立(并被对方接受),或者被对方拒绝的情况。