之前公司一直用Matrix作为即时通讯软件,但我一直不是很喜欢这个协议,总觉得它太臃肿了,而且依赖客户端同步状态,稍有不慎就会出现无法正常使用的情况。而与此同时,我一直对XMPP很感兴趣。在收拾服务器的过程中,我删掉了一直没用上的Hermes Agent,节省出了不少内存,于是决定自己部署一个XMPP服务器试试看。
什么是XMPP
和一些煞有介事的开源项目一样,XMPP本身不代表任何代码或实现,而只是一种标准。让事情变得更糟的是,XMPP是一系列标准。例如RFC 6120定义了XMPP的核心标准,例如XML流要怎么协商建立和关闭,流有哪些属性,干什么用的,遇到错误要怎么处理,TLS要如何协商,还有Stanza是怎么定义的,诸如此类。想要聊天功能?不好意思,还得再来个RFC 6121定义一下花名册(roster)、联系人地址格式(JID)、如何订阅消息、怎么交换信息。按道理来说,到这一步应该就能实现最基础的消息交换(聊天)了,但如果你没在线,就收不到消息,因为这只是消息交换,不隐含任何存储功能。此外,目前这些消息还全都是明文的,一个服务器内还好办,如果涉及跨服务器沟通,那你最好祈祷服务器互联没有用明文,并且两边的站长不会偷看你的信息。如果想要安全,那还得考虑加密。而且这些RFC只说了1对1交换信息,没有考虑到群聊。那么你想吧,群聊、离线消息存储、多人聊天,这些东西加在一起,基本上是所有现代即时通讯软件的门槛了,尤其是端到端加密+群聊,目前谁也没有想出一个万能的好办法,各种协议采用的方法都不同,进而有不同的优点和缺点。总之,互联网发明这么多年了,人类还没想明白要怎么样安全且优雅的聊天。
于是便有了RFC之外的XMPP拓展协议,即XEP。比如XEP-0313 MAM,这个拓展协议允许服务器帮助用户中心化地暂存消息,这样用户的客户端就可以同步消息,而不必担心某个客户端离线之后收不到消息,导致消息历史和其他客户端脱节。还有XEP-0045 MUC,这个扩展协议定义了多人聊天需要由服务器提供一个类似IRC room的东西,然后转发给群聊的参与者。至于端到端加密,我们有两种尝试,一个是XEP-0373利用OpenPGP来加密,类似发邮件的PGP加密和签名;另一个是XEP-0384,创造了一个基于Signal的OMEMO协议,后者似乎更广泛一些。至于发送大文件,使用XML流传输二进制肯定是想不开了,于是人们又搞了XEP-0363,用HTTP协议传文件。。。。。。
总之,XMPP的核心很轻量化,虽然没有很完美,但在我看来,要比Matrix优雅不少:没有复杂的状态同步,没有沉重的协议。大部分消息都是XML(或者说Stanza),扩展按需开启,如果你只想写一个Echo Bot,那只要实现最核心的XMPP就可以了。想要加密,就实现OMEMO(虽然目前实现不多,Java上的实现比较废物,Python上的omemo库倒是还行,但因为python这个语言本身非常糟糕,因此拖累的这个库的使用体验),想要上传文件,那就实现一下XEP 0363。就像乐高一样,你需要什么就装上什么,不需要就不用装。但话又说回来,这也就意味着不同的客户端支持的标准不一样,能力也就不一样,你可以用XEP 0363通过HTTP发文件,但对方的客户端不一定能明白。虽然比较广泛使用的协议被大多数客户端支持,但不同的客户端有自己独特的功能,可能换一个客户端就不能用了。
选型
由于标准是开放的,所以任何人都可以按照标准自行实现客户端和服务端。但有时候百花齐放并不是好事,很有可能出现A客户端123功能比较好,而B客户端456功能比较好。想全都要?不好意思,A和B换着用吧。因此选型其实是早期最让人摸不着头脑的事情。
关于服务器,最初我有三个候选:
- ejabberd
- Openfire
- Prosody IM
由于Openfire是Java写的,而我的服务器没多少资源,因此率先排除。而ejabberd看起来非常企业级,和RabbitMQ一样是Erlang写的,因此在性能和可拓展性上没的说,并且主打开箱即用,包含了XMPP服务器本身和用于音视频聊天的SIP服务,适合企业。但Erlang的一个问题在于,它的起步价很高,虽然天花板也很高,但我的小服务器确实承受不住。虽然从来没有跑过ejabberd,但我之前跑过RabbitMQ,起手4G内存漱漱口,我用不起。
于是我将选择放在了Prosody上。当然,社区中也有一些让XMPP更加易用的尝试,例如Snikket,他们试图将Prosody和一些客户端包装起来。但我部署完prosody之后才知道他们,所以没有尝试。对我而言,我只需要文字聊天+传文件,以及端到端加密即可,因此Prosody能满足我的需求。
关于客户端,虽然有一些正在开发中的客户端看起来赏心悦目,但大部分功能不全。因此最终我选择桌面端用Gajim(支持Linux和Windows),安卓端用Monocles Chat。
部署Prosody
配置DNS
由于XMPP协议比较古早,因此除了比较依赖XML之外,还比较依赖DNS。例如我想将服务器地址定为xmpp.skyblond.info,那么在开启MUC和文件上传功能之后,我们需要给如下域名配置DNS:
xmpp.skyblond.info,A/AAAA记录指向服务器IPconference.xmpp.skyblond.info,MUC多人聊天用的虚拟域名,但因为要TLS证书,因此A/AAAA记录也需要指向服务器IPupload.xmpp.skyblond.info,文件传输用的域名,理论上可以不用,但Prosody检查的时候会不高兴,因此也要A/AAAA记录指向服务器IP
除了以上A/AAAA记录之外,还要配置一堆SRV记录,用来告诉客户端和其他服务端该联系谁。比如_xmpp-server._tcp.conference.xmpp.skyblond.info这条SRV记录会告诉其他XMPP服务器,如果别的服务器上有用户要加入我服务器上的群聊,该联系谁。这种情况下,可以将其指向xmpp.skyblond.info:5269,即Prosody服务器。总共需要配置如下组合:
_xmpp和_xmpps,前者是TCP协商后升级成STARTTLS,后者的是直接使用TLS,跳过明文协商的步骤- 在上述组合的基础上,搭配
-client和-server,分别对应客户端和其他服务端。例如_xmpp-client就是客户端明文连接用的,而_xmpps-server是其他客户端S2S直接使用TLS用的。因为不知道客户端和其他服务端都各自支持什么,因此得全都配。 - 对于
conference域名,因为没有客户端直连,只需要配置两个服务器的就行 - 对于
upload域名,因为没有S2S,只是给自家人提供服务,并且主要是HTTP请求,因此可以不配置SRV
也就是说,总共需要3个域名的A和AAAA记录指向服务器,然后6个SRV记录(4个xmpp,2个conference.xmpp)指向主域名。难怪XMPP部署的人少呢。
容器编排
欸,别急,还没完。域名的DNS配置完了,接下来该想证书的事儿了。Prosody需要获取证书,对于STARTTLS和直连TLS都需要在TCP流上进行加密,因此不能让Caddy这一类网关代劳。但我的服务器上部署了一堆Docker服务,他们是需要Caddy代劳处理TLS,并将HTTP明文转发给容器的。此外,基于运维考量,我希望所有东西都跑在docker里,又不想让数据乱串,那怎么办呢?经过我的深度思考,得出了如下结论:
- Caddy仍然作为Gateway,并且自己维护一份主域名
xmpp.skyblond.info的TLS证书。Prodosy可以将HTTP映射到这个主域名,并只接受HTTP流量,对于HTTP文件上传和下来在说,是否由Prodosy进行TLS加密,影响不大。 - Prosody的docker compose启动一个certbot sidecar容器,该容器常住并定期运行证书更新任务,更新后重启prosody容器。这个certbot自己维护
xmpp.skyblond.info、conference.xmpp.skyblond.info和upload.xmpp.skyblond.info的证书,由于C2S和S2S端口和HTTP端口不一样,因此不冲突。
配置文件
Caddyfile
基于以上设计,我的Caddyfile部分如下:
xmpp.skyblond.info {
encode gzip zstd
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
}
# Proxy Let's Encrypt HTTP-01 challenge to certbot sidecar
handle /.well-known/acme-challenge/* {
reverse_proxy prosody-certbot:80
}
# Proxy HTTP file share and other Prosody HTTP services
handle {
reverse_proxy prosody:5280
}
}
# xmpp components certs via certbot
http://conference.xmpp.skyblond.info, http://upload.xmpp.skyblond.info {
# Proxy Let's Encrypt HTTP-01 challenge to certbot sidecar
handle /.well-known/acme-challenge/* {
reverse_proxy prosody-certbot:80
}
}所有XMPP相关域名的acme challenge路径都要转发给certbot容器,不然它无法获得自己的TLS证书。对于主域名,我没区分那么细,理论上只要把/share转发给prosody即可支持HTTP文件。我比较懒,直接把剩下的都转给Prosody了。
docker compose
Prosody官方有docker镜像,所以直接用就好了:
services:
prosody:
image: prosodyim/prosody:13.0
# name must be fixed so certbot sidecar can issue a reload when cert changes
container_name: prosody
pull_policy: always
restart: unless-stopped
# XMPP ports (cannot be reverse-proxied by Caddy — raw XMPP, not HTTP)
ports:
# Client connections (STARTTLS)
- "5222:5222"
# Client connections (direct TLS, for _xmpps-client SRV record)
- "5223:5223"
# Server-to-server federation (STARTTLS)
- "5269:5269"
# Server-to-server federation (direct TLS, for _xmpps-server SRV record)
- "5270:5270"
networks:
shared:
aliases:
- prosody
volumes:
# Persistent data (accounts, MAM archive, uploaded files)
# Backed up via Kopia — accounts are worth preserving
- /....../prosody/data:/var/lib/prosody
# Custom config snippet (direct TLS ports, components)
- /....../etc/conf.d:/etc/prosody/conf.d:ro
# TLS certificates managed by certbot sidecar
- prosody-certs:/etc/prosody/certs
environment:
# Virtual host (JID domain)
PROSODY_VIRTUAL_HOSTS: xmpp.skyblond.info
# Admin account (account itself must be created via prosodyctl)
PROSODY_ADMINS: ......@xmpp.skyblond.info
# Message archive (MAM) retention — auto-enables mod_mam
# Also serves as fallback for file share expiry in the docker template
# (we override file share expiry explicitly in conf.d)
PROSODY_RETENTION_DAYS: "3"
# Additional modules (built-in in Prosody 13.0)
# cloud_notify: push notifications for mobile clients (XEP-0357)
# websocket: XMPP over WebSocket for browser-based clients
PROSODY_ENABLE_MODULES: "cloud_notify,websocket"
deploy:
resources:
limits:
memory: 1G
cpus: 1
certbot:
image: certbot/certbot
pull_policy: always
restart: unless-stopped
networks:
shared:
aliases:
- prosody-certbot
volumes:
# Shared volume: certbot writes certs here, prosody reads them
- prosody-certs:/certs
# Certbot state (certs, account, renewal config)
- certbot-data:/etc/letsencrypt
# Deploy hook: copies certs + restarts prosody after issuance/renewal
- /....../deploy-hook.sh:/etc/letsencrypt/renewal-hooks/deploy/prosody.sh:ro
# Docker socket: deploy hook restarts the prosody container
- /var/run/docker.sock:/var/run/docker.sock
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
certbot certonly --standalone -n \
--email ......--agree-tos \
--expand \
-d xmpp.skyblond.info \
-d conference.xmpp.skyblond.info \
-d upload.xmpp.skyblond.info
/etc/letsencrypt/renewal-hooks/deploy/prosody.sh
while true; do certbot renew --quiet; sleep 12h; done
deploy:
resources:
limits:
memory: 256M
cpus: 0.5
volumes:
prosody-certs:
certbot-data:
networks:
# this is the globally shared network on this server
shared:
name: app-shared-network
external: true这里需要特别注意,prosody容器的名字必须是固定的,理论上也可以让docker compose生成,但certbot等一下要在证书更新后重启容器,固定名字更不容易出错。
Certbot
Certbot容器不需要配置文件,只需要一个常住的sh脚本即可:
#!/bin/sh
set -e
DOMAIN=xmpp.skyblond.info
SRC=/etc/letsencrypt/live/$DOMAIN
DST=/certs
# Copy certs to the shared volume where prosody reads them
# Prosody auto-discovers certs by hostname: xmpp.skyblond.info.crt/.key
# Note: certbot should generate a all-in-one cert for all these domains
for host in xmpp.skyblond.info conference.xmpp.skyblond.info upload.xmpp.skyblond.info; do
cp "$SRC/fullchain.pem" "$DST/$host.crt"
cp "$SRC/privkey.pem" "$DST/$host.key"
chmod 644 "$DST/$host.crt" "$DST/$host.key"
done
# Restart prosody container via Docker API to pick up renewed certs
python3 -c "
import socket, http.client
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect('/var/run/docker.sock')
conn = http.client.HTTPConnection('localhost')
conn.sock = s
conn.request('POST', '/containers/prosody/restart')
resp = conn.getresponse()
print(resp.status, resp.reason)
"
Certbot可以在一个证书中包含3个域名,因此deploy hook只需要把这一个证书文件复制到对应位置三份即可。然后给docker说一下要重启prosody这个容器就好了。
Prosody
由于容器中的Prodosy本身自带一份配置文件,我们这份配置文件只是作为补充,因此当不使用docker部署时,这份配置文件大概率不能用。
-- Direct TLS ports (for _xmpps-* SRV records)
c2s_direct_tls_ports = { 5223 }
s2s_direct_tls_ports = { 5270 }
-- Prosody is behind Caddy reverse proxy for HTTP services (file share)
-- Caddy handles TLS termination, so Prosody only needs plain HTTP on 5280
-- Bind on all interfaces so Caddy (in a separate container) can reach it
http_external_url = "https://xmpp.skyblond.info/"
http_ports = { 5280 }
https_ports = { }
http_interfaces = { "*", "::" }
-- Tell Prosody its public IPs (silences Docker NAT warning in prosodyctl check dns)
external_addresses = { "...ipv4...", "...ipv6..." }
-- Trust Caddy as reverse proxy (for X-Forwarded-For, X-Forwarded-Proto)
-- Allows WebSocket connections to be considered secure behind the proxy
trusted_proxies = { "172.16.0.0/12" }
-- Enable mod_register
modules_enabled:append{ "register" };
-- Do NOT allow users to register new accounts,
-- but should allow user to change password
allow_registration = false
-- MUC (group chat) component
Component "conference.xmpp.skyblond.info" "muc"
modules_enabled = { "muc_mam" }
muc_log_expires_after = "3d"
-- HTTP file share component
-- Defined manually because the docker image's default config has a typo:
-- it uses http_file_share_expire_after (no 's') but the correct option is
-- http_file_share_expires_after (with 's'). See:
-- https://prosody.im/doc/modules/mod_http_file_share
--
-- http_host is set to the main domain so Prosody generates upload URLs
-- like https://xmpp.skyblond.info/share/... (proxied by Caddy) instead of
-- https://upload.xmpp.skyblond.info/share/... (which has no DNS/cert).
-- See: https://prosody.im/doc/modules/mod_http_file_share#component_and_http_domains
Component "upload.xmpp.skyblond.info" "http_file_share"
http_host = "xmpp.skyblond.info"
http_file_share_expires_after = "3d"
http_file_share_size_limit = 10 * 1024 * 1024 * 1024
http_file_share_global_quota = 30 * 1024 * 1024 * 1024
http_paths = { file_share = "/share" }
-- only offer upload service to local user, not remote user
modules_disabled = { "s2s" }这里我配置了外部IP,并信任Caddy传来的代理信息。考虑到我的服务器本身硬盘不大,目前Prosody似乎没有什么好的第三方存储,因此我就用服务器的硬盘做暂存了。MAM历史信息保存3天,包括多人聊天。文件同样保留3天,单文件限制10GB,总共限制30GB,反正目前我的服务器就我自己用,足够了。
测试
在Linux上使用Gajim测试,发图发文件的功能都正常,多人聊天能够加入别的服务器的room,也能和其他服务器上的人发消息,应该是没问题了。
至于资源占用嘛,目前已经跑了大约一个月了,观察到prosody容器只用了不到20MB的内存,加上certbot也才不到30MB,非常好使。
后记
以上就是这次部署XMPP服务器的全过程了。虽然上一篇文章说是要写ESP32的内容,但最近在PCB设计上遇到了一些问题。如果你之前在b站刷到过我的视频,你就知道之前是拿胶条把东西都粘在一起的,很不美观,也不方便。在8月中旬不小心焊死一个GNSS模块之后,我痛定思痛,决定设计一个正经的PCB,于是下载了KiCAD并开始画图。很快啊,4个小时从下载软件到嘉立创付钱。但第一次设计,我以为没什么难度,到手之后才发现,5mm的三色LED因为针脚太密,并不好焊,经常导致连锡,或者加热时间太长把LED烧坏了。因此目前还在迭代设计,此外代码也要根据新的pcb板子做修改。因此我打算等PCB和代码都尘埃落定之后再写文章。
关于XMPP,在朋友间推行比较困难,因此我已经将我的JID更新到“关于我”页面中了。欢迎同好来发消息。
-全文完-

【歪门邪道】从零开始部署自己的XMPP服务器 由 天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。