Synapse 非 443 端口下禁止用户创建加密房间

记录 Matrix Synapse 在 public_baseurl 使用非 443 端口时,如何通过服务端模块禁止新建加密房间和手动开启加密,并说明兼容性风险与回滚方式。

这篇记录一个特殊整改方案: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.py

Synapse 容器名:

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 Forbidden

Element 客户端兼容性

这个方案服务端约束很强,但客户端兼容性不一定好。

实际测试里,部分 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 客户端里,私聊和添加好友流程经常默认带加密初始状态。强行拒绝会带来兼容性问题,所以这个方案适合有明确运维目标、能接受客户端限制的场景。

如果只是为了控制服务端媒体存储增长,更稳妥的长期方案通常是:保留客户端默认加密能力,同时用清晰的媒体保留规则控制服务端硬盘,并通过服务器通知告知用户大文件会定期清理。