这篇记录一个特殊整改方案:Matrix Synapse 的客户端访问地址不是标准 443,而是类似:
public_baseurl: "https://matrix.example.com:12335"在这种部署里,不打算依赖 .well-known 或客户端侧配置来约束房间加密,而是直接在 Synapse 服务端禁止用户创建加密房间,也禁止用户在普通房间里手动开启端到端加密。
这个方案能做到“服务端强制”,但它不是一个无副作用的通用推荐方案。Element、Element X、Element Classic 在添加好友或发起私聊时,可能会默认夹带 m.room.encryption 初始状态。服务端如果强制拒绝这类请求,部分客户端会出现建房失败、一直转圈、显示空房间等兼容性问题。
所以这篇更适合做故障记录和方案备忘:知道怎么做,也知道什么时候不该做。
目标
目标是让 Synapse 服务端满足以下规则:
- 新房间默认不启用加密。
- 客户端创建房间时如果携带
m.room.encryption,服务端拒绝。 - 客户端在已有房间里发送
m.room.encryption状态事件,服务端拒绝。 - 已经加密的旧房间不尝试转换,因为 Matrix 加密房间不能原地变回非加密房间。
最终表现是:只要请求试图把房间变成加密房间,Synapse 返回 403 Forbidden。
为什么不能只改客户端
Element 客户端里可以设置默认是否创建加密房间,但这不是强制边界。用户换客户端、换版本,或者客户端在创建私聊时自动携带初始加密状态,都可能绕过“默认不加密”的 UI 设置。
真正需要拦截的是 Matrix 事件本身:
m.room.encryption只要这个状态事件成功进入房间,房间就成为加密房间,后续无法原地关闭。
因此服务端要拦两条路径:
createRoom请求里的initial_state携带m.room.encryption。- 已存在房间里后续发送
m.room.encryption状态事件。
路径约定
本文以 Docker 部署为例:
/opt/synapse/相关文件:
/opt/synapse/docker-compose.yml
/opt/synapse/data/homeserver.yaml
/opt/synapse/data/modules/no_room_encryption.pySynapse 容器名:
matrix-synapse如果你的路径或容器名不同,照着替换即可。
第一步:备份配置
先进入部署目录:
cd /opt/synapse备份 Compose 和 Synapse 配置:
cp docker-compose.yml docker-compose.yml.before-no-room-encryption-$(date +%Y%m%d%H%M%S)
cp data/homeserver.yaml data/homeserver.yaml.before-no-room-encryption-$(date +%Y%m%d%H%M%S)第二步:关闭默认加密
编辑 homeserver.yaml:
vi /opt/synapse/data/homeserver.yaml加入顶层配置:
encryption_enabled_by_default_for_room_type: off这一步只控制默认行为,不是强制禁止。真正的强制禁止靠后面的模块。
第三步:让容器加载自定义模块
编辑 Compose:
vi /opt/synapse/docker-compose.yml在 Synapse 服务的 environment 下加入:
environment:
- "PYTHONPATH=/data/modules"如果原来已经有 environment,只加这一行即可,例如:
services:
synapse:
image: matrixdotorg/synapse:latest
container_name: matrix-synapse
environment:
- "TZ=Asia/Shanghai"
- "PYTHONPATH=/data/modules"这里 /data/modules 是容器内路径。宿主机对应目录一般是:
/opt/synapse/data/modules/创建模块目录:
mkdir -p /opt/synapse/data/modules第四步:创建禁止加密模块
创建模块文件:
vi /opt/synapse/data/modules/no_room_encryption.py写入下面完整内容:
from synapse.api.errors import Codes, SynapseError
from synapse.module_api import NOT_SPAM
DENY_MESSAGE = "Room encryption is disabled on this homeserver. Please create/use an unencrypted room."
class NoRoomEncryption:
def __init__(self, config, api):
self.api = api
api.register_spam_checker_callbacks(
check_event_for_spam=self.check_event_for_spam,
)
api.register_third_party_rules_callbacks(
check_event_allowed=self.check_event_allowed,
on_create_room=self.on_create_room,
)
@staticmethod
def parse_config(config):
return config or {}
async def check_event_for_spam(self, event):
if self._is_encryption_event(event):
return DENY_MESSAGE
return NOT_SPAM
async def check_event_allowed(self, event, state_events):
if self._is_encryption_event(event):
raise SynapseError(403, DENY_MESSAGE, Codes.FORBIDDEN)
return True, None
async def on_create_room(self, requester, config, is_requester_admin):
initial_state = config.get("initial_state")
if not isinstance(initial_state, list):
return
for state in initial_state:
if state.get("type") == "m.room.encryption":
raise SynapseError(403, DENY_MESSAGE, Codes.FORBIDDEN)
@staticmethod
def _is_encryption_event(event):
return getattr(event, "type", None) == "m.room.encryption"这个模块做两层拦截:
on_create_room:拒绝创建房间时夹带m.room.encryption。check_event_allowed:拒绝房间创建后再发送m.room.encryption。
check_event_for_spam 是额外保险,避免部分事件发送路径漏过去。
第五步:在 homeserver.yaml 加载模块
继续编辑:
vi /opt/synapse/data/homeserver.yaml加入:
modules:
- module: no_room_encryption.NoRoomEncryption
config: {}如果文件里已经有 modules:,不要重复写第二个 modules:,把这一项合并到原列表里。
最终相关配置类似:
public_baseurl: "https://matrix.example.com:12335"
encryption_enabled_by_default_for_room_type: off
modules:
- module: no_room_encryption.NoRoomEncryption
config: {}第六步:编译检查
先检查 Python 模块语法:
docker exec -e PYTHONPATH=/data/modules matrix-synapse \
python -m py_compile /data/modules/no_room_encryption.py没有输出一般就是通过。
再检查 Compose:
cd /opt/synapse
docker compose config >/tmp/synapse-compose-check.yml第七步:重启 Synapse
cd /opt/synapse
docker compose restart synapse等待接口恢复:
for i in 1 2 3 4 5 6 7 8 9 10; do
code=$(curl -sS -o /tmp/synapse_versions.json -w '%{http_code}' \
http://127.0.0.1:8008/_matrix/client/versions || true)
echo "$code"
[ "$code" = 200 ] && break
sleep 2
done确认容器健康:
docker compose ps检查模块是否加载:
docker logs --since 3m matrix-synapse 2>&1 \
| grep -E 'Loaded module|ERROR|CRITICAL|ModuleFailed|no_room_encryption|Traceback' \
| tail -80正常应看到类似:
Loaded module <no_room_encryption.NoRoomEncryption object ...>第八步:准备测试 Token
测试需要一个本服用户 access token。可以使用管理员 token:
TOKEN=$(cat /path/to/admin-access-token)如果没有这个文件,就换成任意本服用户的 access token。
第九步:验证普通建房仍可用
ROOM_JSON=$(curl -sS -X POST http://127.0.0.1:8008/_matrix/client/v3/createRoom \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"no-encryption-test","preset":"private_chat"}')
echo "$ROOM_JSON"
ROOM_ID=$(printf '%s' "$ROOM_JSON" | sed -n 's/.*"room_id":"\([^"]*\)".*/\1/p')
echo "$ROOM_ID"返回 room_id 就说明普通非加密房间可以创建。
第十步:验证创建时夹带加密会被拒绝
curl -i -X POST http://127.0.0.1:8008/_matrix/client/v3/createRoom \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "encrypted-create-should-fail",
"preset": "private_chat",
"initial_state": [
{
"type": "m.room.encryption",
"state_key": "",
"content": {
"algorithm": "m.megolm.v1.aes-sha2"
}
}
]
}'预期:
HTTP/1.1 403 Forbidden响应类似:
{
"errcode": "M_FORBIDDEN",
"error": "Room encryption is disabled on this homeserver. Please create/use an unencrypted room."
}第十一步:验证创建后开启加密会被拒绝
对前面成功创建的普通房间发送加密状态事件:
curl -i -X PUT \
"http://127.0.0.1:8008/_matrix/client/v3/rooms/$ROOM_ID/state/m.room.encryption" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"algorithm":"m.megolm.v1.aes-sha2"}'预期同样是:
HTTP/1.1 403 ForbiddenElement 客户端兼容性
这个方案服务端约束很强,但客户端兼容性不一定好。
实际测试里,部分 Element/Element Classic/Element X 在添加好友或发起私聊时,会自动把 m.room.encryption 放进 createRoom.initial_state。如果服务端直接返回 403,客户端可能出现:
- 添加好友后私聊房间创建失败。
- 界面一直停在创建房间或转圈。
- 房间列表出现无名空房间。
- 老客户端和新客户端表现不一致。
所以如果你的目标是“用户体验优先”,不建议直接启用这个强拒绝版本。更温和的变体是:创建房间时剔除 initial_state 里的 m.room.encryption,让房间正常创建为非加密房间;创建后再手动开启加密时仍然返回 403。
温和版的 on_create_room 可以改成:
async def on_create_room(self, requester, config, is_requester_admin):
initial_state = config.get("initial_state")
if not isinstance(initial_state, list):
return
config["initial_state"] = [
state
for state in initial_state
if state.get("type") != "m.room.encryption"
]但即便是温和版,也可能和某些客户端的私聊/验证流程有兼容性问题。启用前最好先用测试账号验证添加好友、私聊、退出房间、重新登录这些流程。
回滚方式
如果客户端出现兼容性问题,可以按下面方式回滚。
编辑 Compose,删除:
- "PYTHONPATH=/data/modules"编辑 homeserver.yaml,删除:
encryption_enabled_by_default_for_room_type: off再删除或注释模块配置:
modules:
- module: no_room_encryption.NoRoomEncryption
config: {}停用模块文件:
mv /opt/synapse/data/modules/no_room_encryption.py \
/opt/synapse/data/modules/no_room_encryption.py.disabled-$(date +%Y%m%d%H%M%S)如果模块目录只放了这个文件,也可以清理:
find /opt/synapse/data/modules/__pycache__ \
-maxdepth 1 -type f -name 'no_room_encryption*' -delete 2>/dev/null || true
rmdir /opt/synapse/data/modules/__pycache__ 2>/dev/null || true
rmdir /opt/synapse/data/modules 2>/dev/null || true重启:
cd /opt/synapse
docker compose config >/tmp/synapse-compose-rollback-check.yml
docker compose restart synapse确认恢复:
curl -sS -o /tmp/synapse_versions.json -w '%{http_code}\n' \
http://127.0.0.1:8008/_matrix/client/versions
docker logs --since 90s matrix-synapse 2>&1 \
| grep -E 'Loaded module|no_room_encryption|ERROR|CRITICAL|ModuleFailed|Traceback' \
| tail -80回滚后日志里不应再出现 no_room_encryption 模块加载。
结论
服务端禁止加密房间这件事可以做,而且拦截点也很明确:拦住 m.room.encryption 状态事件即可。
但在真实 Element 客户端里,私聊和添加好友流程经常默认带加密初始状态。强行拒绝会带来兼容性问题,所以这个方案适合有明确运维目标、能接受客户端限制的场景。
如果只是为了控制服务端媒体存储增长,更稳妥的长期方案通常是:保留客户端默认加密能力,同时用清晰的媒体保留规则控制服务端硬盘,并通过服务器通知告知用户大文件会定期清理。