跳转到内容

TLS 与证书

中继自行终止 TLS,并且只使用 TLS 1.3:没有 TLS 1.2,没有 0-RTT,普通连接上没有 ALPN,版本不可配置。

TLS 必须在中继处终止。 协议第 2 版把每个客户端的密钥证明以及中继的身份签名,都绑定到客户端与中继之间的 TLS 会话上。在中继前终止 TLS 的反向代理、负载均衡器或 CDN 会让所有连接失败。支持以下三种部署方式:

部署方式证书说明位置
A. 直连,自动证书(推荐)内置 Let’s Encrypt本页、快速入门
B. 直连,静态证书您自己的文件,收到 SIGHUP 时重新加载本页
C. 位于带 PROXY 协议 v2 的 TCP(第 4 层)透传之后中继自己的(A 或 B)TCP 前端部署

您需要配置且只配置一个证书来源:

来源配置节80 端口续期
内置 Let’s Encrypt(推荐)[server.acme]不使用自动,无需重启
磁盘上的静态证书[server.tls]仅当您的证书工具需要时由您续期,然后执行 systemctl reload

以下情况下服务器拒绝启动:

  • 配置文件缺失、不可读或无效;
  • 既没有配置 [server.acme](且 enabled = true)也没有配置 [server.tls],并且没有使用 --no-tls;
  • 两者都配置了;
  • 无法加载静态证书或私钥,或私钥与证书不匹配;
  • ACME 设置无效:没有域名、通配符或格式错误的域名、无效的联系人、相对路径的 cache_dir、cache_dir 是符号链接,或无法创建缓存目录或设置其权限;
  • tls_handshake_timeout_secs 不在 1–120 之间;
  • 使用 --no-tls 但没有设置 [server] dev_origin;
  • --no-tls 与非回环地址同时使用,且没有 --insecure-public-plaintext。

绝不会静默回退到明文。

中继自行申请和续期证书,在正常监听端口上应答 TLS-ALPN-01 质询。

  • 每个域名都有指向本主机的公网 DNS A 或 AAAA 记录。
  • 公网 TCP 443 端口直达中继。Let’s Encrypt 只在 443 端口验证 TLS-ALPN-01。普通端口转发(iptables DNAT、pf rdr、TCP 模式的负载均衡器)可以;任何在中继前终止 TLS 的东西都会导致验证失败。
  • 一个持久、可写的缓存目录。

如果 bind_addr 不在 443 端口,中继仍会启动,但会警告:

ACME: listening on port 8443, but Let's Encrypt validates TLS-ALPN-01 on port 443 only. Make sure public :443 is forwarded to this listener without TLS termination in between.
[server]
bind_addr = "0.0.0.0:443"
[server.acme]
enabled = true
domains = ["chat.example.com"]
contacts = ["mailto:[email protected]"]
cache_dir = "/var/lib/rchat/acme"
staging = false
配置项类型默认值含义
enabled布尔false启用 ACME。为 false 时忽略整个配置节。
domains字符串列表[]证书上的主机名,启用时必填。必须是小写的完整域名(字母、数字、连字符,最长 253 个字符),不能是通配符或 IP 地址,也不能带协议、端口或路径。
contacts字符串列表[]可选,ACME 账户的电子邮件地址。缺少 mailto: 时会自动添加,其他协议会被拒绝。
cache_dir路径/var/lib/rchat/acme存放 ACME 账户密钥和证书私钥。必须是绝对路径,不能是符号链接,并且必须在重启后保留。
staging布尔false使用 Let’s Encrypt 测试环境:证书不受信任,但速率限制宽松。首次部署时使用。

[server.acme] 中的未知配置项会在启动时被拒绝,因此像 email = ... 这样的拼写错误会让服务器停止,而不是被忽略。该配置节也可以写成顶层的 [acme]。

缓存目录。 中继以 0700 权限创建 cache_dir,并通过临时文件加原子重命名,以 0600 权限写入每个文件。如果目录或文件已经存在且权限较宽,会将其收紧,并记录 ACME: tightening permissions on ... to 0700。文件包括 account-<hash>.der(账户密钥)和 cert-<hash>.pem(证书私钥与证书链)。测试环境与生产环境的文件名不同,切换时不会互相覆盖。丢失缓存意味着要重新申请,会计入速率限制。

缓存为空时,中继会记录:

WARN ACME: no certificate cached yet. TLS handshakes will FAIL until Let's Encrypt issues one (usually under a minute). Issuance needs public DNS for ["chat.example.com"] pointing here and port 443 reaching this listener directly.

签发完成之前,所有 TLS 握手都会失败。 不存在临时证书或自签名证书。证书到达时:

INFO ACME: certificate issued and deployed; TLS is ready

之后启动时,会从 cache_dir 加载已缓存的证书:

INFO ACME: cached certificate loaded; TLS is ready
  • 测试环境(staging = true)签发的证书不受客户端信任,但基本不用担心速率限制。中继会警告 ACME: staging certificates are NOT trusted by clients; switch staging off once issuance works。失败后按 ACME 库自带的快速指数退避重试。
  • 生产环境(staging = false)。缓存为空的首次生产运行时,中继会提醒 Let’s Encrypt 每个主机名每小时只允许 5 次验证失败。失败重试的间隔至少 5 分钟,一次配置错误不会在几秒内耗尽这个额度。

请先使用测试环境。证书签发成功(颁发者包含 STAGING)后,设置 staging = false 并重启。

失败永远不会让服务器停止。每次失败记录一行日志,然后重试:

WARN ACME: certificate issuance failed, will retry with backoff (not before 300 s): <error from Let's Encrypt>

续期失败时这一行写的是 renewal 而不是 issuance。测试环境下没有 (not before …) 部分。

第一次遇到速率限制时记录一次,重复的速率限制错误在下次成功之前都记录在 debug 级别:

WARN ACME: rate-limited by Let's Encrypt (retry after <time>). Retrying automatically with backoff; further rate-limit errors are logged at debug. See https://letsencrypt.org/docs/rate-limits/

证书有效期过去三分之二后,ACME 客户端会自动续期。新证书在内存中替换:新的握手立即使用它,已有连接不受影响,不需要重启或重新加载。

INFO ACME: certificate renewed and deployed (no restart needed)

在 ACME 模式下执行 systemctl reload 是无害的,中继会记录 SIGHUP: nothing to reload (no static certificate in use)。

正在使用的证书的到期时间会在加载时、每次部署或重新加载时以及每 6 小时检查一次,ACME 证书和静态证书都适用。

  • 距到期不足 7 天:WARN TLS certificate expires in N day(s). ...。对 ACME 来说,这意味着续期已经失败了数周。
  • 已过期:ERROR TLS certificate being served has EXPIRED; clients will reject it. ...

这个值也以 Prometheus 指标 rchat_tls_cert_not_after_seconds 导出。可以对 rchat_tls_cert_not_after_seconds - time() < 7 * 86400 设置告警(参见指标)。

如果缓存文件损坏,失败日志后面会跟一条提示:

  • ACME: the cached ACME account key looks corrupt. Stop the server, delete account-*.der in cache_dir and start again; a new account is registered automatically.
  • ACME: the cached certificate looks corrupt. Stop the server, delete cert-*.pem in cache_dir and start again; a new certificate is ordered (mind production rate limits).

中继会先读取每个 TLS ClientHello,再决定如何应答。只提供一个 ALPN 协议 acme-tls/1(RFC 8737)的握手来自 Let’s Encrypt 的验证,中继用质询证书应答后关闭连接。其他所有握手(来自应用或浏览器)都使用正常的 TLS 1.3 配置。两条路径都只使用 TLS 1.3。

如果您已经在管理证书(企业 CA、certbot,或用于测试的私有 CA),使用这种方式。

[server]
bind_addr = "0.0.0.0:443"
[server.tls]
cert_path = "/etc/rchat/tls/cert.pem" # PEM,完整证书链,叶证书在前
key_path = "/etc/rchat/tls/key.pem" # PEM 私钥:PKCS#8、PKCS#1 或 SEC1

[server.tls] 只接受 cert_path 和 key_path。旧模板中的 min_version 和 tls13_only 从未被读取,现在会在启动时被拒绝:

[server.tls] `tls13_only` is not a setting: TLS 1.3 is mandatory and cannot be changed. Remove `tls13_only` from the config file.

这两个文件必须能被 rchat 用户读取。启动时中继会记录 TLS 1.3 enabled with static certificate (SIGHUP reloads it)。

中继收到 SIGHUP 时重新读取这两个文件,不会断开连接。附带的单元定义了 ExecReload=/bin/kill -HUP $MAINPID:

Terminal window
sudo systemctl reload rchat-server

成功时记录 SIGHUP: TLS certificate reloaded。如果新文件不可读、格式错误或私钥不匹配,会继续使用旧证书,并记录:

ERROR SIGHUP: TLS certificate reload failed, keeping the old one: ...

如果更想用 certbot 而不是内置 ACME 客户端,可用 standalone 插件签发证书(签发期间需要 80 端口空闲),再复制到 rchat 用户可读的位置。请复制而不是建立符号链接,因为 /etc/letsencrypt/live 只有 root 能读:

Terminal window
sudo certbot certonly --standalone -d chat.example.com --email [email protected] --agree-tos
sudo install -d -m 0750 -o root -g rchat /etc/rchat/tls
sudo install -m 0644 /etc/letsencrypt/live/chat.example.com/fullchain.pem /etc/rchat/tls/fullchain.pem
sudo install -m 0640 -g rchat /etc/letsencrypt/live/chat.example.com/privkey.pem /etc/rchat/tls/privkey.pem

将 [server.tls] 指向 /etc/rchat/tls/fullchain.pem 和 /etc/rchat/tls/privkey.pem。续期时,添加部署钩子 /etc/letsencrypt/renewal-hooks/deploy/rchat-reload.sh(权限 0755),把新证书复制到位并重新加载中继:

#!/bin/sh
set -e
umask 077
install -m 0644 "$RENEWED_LINEAGE/fullchain.pem" /etc/rchat/tls/.fullchain.pem.new
install -m 0640 -g rchat "$RENEWED_LINEAGE/privkey.pem" /etc/rchat/tls/.privkey.pem.new
mv -f /etc/rchat/tls/.fullchain.pem.new /etc/rchat/tls/fullchain.pem
mv -f /etc/rchat/tls/.privkey.pem.new /etc/rchat/tls/privkey.pem
systemctl reload rchat-server

用 sudo certbot renew --dry-run 检查。

对于独占 443 端口的中继,内置 ACME 客户端更简单:不需要 80 端口、certbot 或钩子。

在测试和内部网络中,可以使用自建私有 CA 签发的证书。不要把 CA 私钥放在中继上,并让每台客户端设备信任该 CA 证书。

本地开发用的明文模式(--no-tls)

Section titled “本地开发用的明文模式(--no-tls)”
dev.toml
[server]
bind_addr = "127.0.0.1:18443"
dev_origin = "ws://127.0.0.1:18443"
Terminal window
rchat-server --config dev.toml --init-relay-identity # 只需一次
rchat-server --config dev.toml --no-tls
  • 它会忽略 [server.tls] 和 [server.acme] 并给出警告,同时记录醒目的 TLS DISABLED 横幅。

  • 它要求设置 [server] dev_origin,即客户端拨号使用的小写 scheme://host:port。没有 TLS 就没有通道绑定,因此客户端的证明改为绑定到该源的哈希,这只能防止证明被重放到源不同的中继。

  • 使用 --no-tls 且没有 --config 时,如果 /etc/rchat/server.toml 不存在,本可使用内置默认值,但默认值中没有 dev_origin,因此中继会拒绝启动。显式指定的 --config 文件不存在时总是报错。

  • 它在非回环地址(127.0.0.0/8 和 ::1 之外的任何地址)上拒绝启动:

    Error: --no-tls with non-loopback bind address 0.0.0.0:8443: refusing to serve plaintext (including circuit tokens) on the network. ...

    --insecure-public-plaintext 可以覆盖这一点,但会把所有流量暴露在网络上,并在启动时记录错误。它没有任何正当的生产用途。

--no-tls 没有生产用途。位于终止 TLS 的代理之后的中继无法使用协议第 2 版;位于 TCP 透传之后时,中继保留自己的 TLS(参见 TCP 前端部署)。iOS 应用的发布版本拒绝 ws://,同一设备上的中继除外。

tls_handshake_timeout_secs(默认 10,允许 1–120)限制客户端完成 TLS 握手的时间,超时的握手会被断开。

Terminal window
# TLS 1.3 成功
openssl s_client -connect chat.example.com:443 -tls1_3 </dev/null 2>&1 | grep -E '^ *Protocol|New, TLS'
# TLS 1.2 被拒绝,提示 "alert protocol version"
openssl s_client -connect chat.example.com:443 -tls1_2 </dev/null 2>&1 | grep -i alert
# 颁发者与有效期
openssl s_client -connect chat.example.com:443 -servername chat.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -dates
日志消息处理方法
no TLS configured. Add [server.acme] ... or [server.tls] ...添加一个证书来源,或在本地开发时使用 --no-tls。
both [server.tls] (static certificate) and [server.acme] (automatic certificates) are configured只保留一个。在 [server.acme] 中设置 enabled = false 也可以。
[server.acme] is enabled but `domains` is empty至少列出一个主机名。
[server.acme] domain "..." is invalid: ...使用小写 DNS 名称,不带通配符、协议、端口或路径。
[server.acme] contact "..." is not an email address使用 [email protected] 或 mailto:[email protected]。
[server.acme] cache_dir ... must be an absolute path例如使用 /var/lib/rchat/acme。
ACME cache_dir ... is a symlink; refusing to follow it将 cache_dir 指向真实目录。
cannot create ACME cache directory / cannot set 0700 on ACME cache directory目录属于其他用户,或不在单元允许写入的路径(/var/lib/rchat)内。
[server.tls] `tls13_only` is not a setting删除 min_version 和 tls13_only。
failed to load TLS certificate chain / failed to load TLS private key / TLS key ... does not match certificate ...检查路径、PEM 内容,以及 rchat 用户能否读取这两个文件。
--no-tls with non-loopback bind address--no-tls 仅用于本地开发:绑定 127.0.0.1,或配置 TLS。
--no-tls requires [server] dev_origin在 [server] 下添加 dev_origin = "ws://127.0.0.1:<port>"(仅限开发)。
应用提示 “This server couldn’t prove its identity”中继前面有东西在终止 TLS。请使用 TCP 透传,或让中继自己占用 443 端口。
绑定 443 端口时 Permission denied使用附带的单元运行,它授予 CAP_NET_BIND_SERVICE。
握手失败,日志显示 no certificate cached yet首次签发尚未完成,请关注 ACME: 日志。
ACME: certificate issuance failed, will retry ...通常是 DNS 未指向本主机,或 443 端口没有直达中继(防火墙、代理、转发错误)。冒号后面是 Let’s Encrypt 返回的错误。
ACME: rate-limited by Let's Encrypt等待重试时间。用 staging = true 测试,并保持 cache_dir 持久。
TLS certificate expires in N day(s) / ... has EXPIREDACME:查看更早的 ACME: 警告。静态证书:续期后执行 systemctl reload。