Codex:“陈仓·暗渡”与“风中的微光”

一、引子

虽然真正开始对“匿名”的研究大概只是在前几年,但回忆起来,20年前我就已经做过“匿名”有关的社会实践。彼时单位的电脑还不能上外网,但有一个内部论坛。我把论坛服务器黑掉之后将其“据为己有”,用它来学习ASP,同时也免不了在上面动点手脚。发现单位睁一只眼闭一只眼之后,我胆子愈发大起来,于是为论坛开发各种道具插件,其中有两样是“匿名器”与“高级匿名器”。顾名思义,“匿名器”可以隐匿发帖用户的ID,但还不彻底,别人可以购买“远视之镜”道具来看到真实ID。但“高级匿名器”就非常高级了,数据库不再保存ID与IP,即使是服务器管理员也无从查知。

自从有了“高级匿名器”,论坛变得非常热闹,各种吐槽公司、吐槽同事、吐槽收入的大实话层出不穷。在我看来,论坛在有了“高级匿名器”之后迎来了新生。有一个朋友对此有一段精辟的描述:“高级匿名器,看似是戴上了面具,实则是卸下了面具”。

熟悉我博客的人可能有印象,在我开始学习 Linux 最初的一段时间里,我首先尝试的自部署项目就是 Anonymous Chatroom (至今还在运行:点此进入)。后来了解了非对称加密 RSA,也提供了一个纯前端的 RSA 工具(点此进入)。在那之后我就有了一个念头:理论上完全可以编写一个既匿名、又支持端到端的聊天室。然而,即使有了AI的帮助,想要从零开始手撸一个这样的网站,工作量还是太大了——直到 Codex 的出现,于是理想照进现实。

在这篇博客中,我不仅会介绍“陈仓·暗渡”、“风中的微光”两个网站的设想、细节和改进过程,也会一并介绍 Codex 如何使用。它几乎没有门槛,任何人都可以用它实现自己的创意。

二、安装与配置 Codex

我们假设用户使用的是 Windows 。

1、ChatGPT 与科学上网

首先,必须具备科学上网环境,且拥有一个 ChatGPT 账号,至少是 Plus。这一步比较复杂,对很多人来说是一个较难跨越的门槛,但这部分不是本文的范围,后面有机会我可能会单独写一篇。

2、安装 node.js

安装 codex 需要使用 npm 命令,因此首先需要安装 node.js。它是一个用来解释 JavaScript 的运行时环境(Runtime),除了用来安装 codex 之外,它也可以为你后续的网站项目提供编译环境。

node.js 的中文官网下载地址:https://nodejs.org/zh-cn/download,它带有一个清晰易懂的筛选器,选择 Windows - 使用 docker 和 npm ,以及LTS(长生命周期支持)的版本就行了。

下载之后直接安装,全部默认即可,它默认会将自己的命令行添加到系统环境变量(PATH),这样你在任何路径下都可以执行诸如 npm 这样的命令。

3、安装 powershell

这一步可能有人会觉得很奇怪:powershell 不是 windows 已经自带了吗?为什么还要安装?其实很多人不清楚,windows 自带的 powershell 版本是 5.0 ,而它的最新版本已经到 7.5 啦。为了充分发挥 codex 的能力,我们当然要尽量搭建一个“最新的”环境(虽然这个环境与 Linux、MacOS 相比还是磕碜了一点)。

打开已有的 powershell ,执行:

winget install --id Microsoft.PowerShell --source winget

最新版本的 powershell 很快就安装好了。需要注意的是,7.x 版本的 powershell 的可执行程序不再是 powershell.exe,而是 pwsh.exe,这可能是为了与 5.0 版本的 powershell 和谐共存。

记住:在任何地方,想要运行新版本的 powershell,请执行 pwsh。

4、安装 codex

接下来,打开 pwsh,先设置好代理环境(注意这是 powershell 写法,并注意将代理端口改成你自己的):

$env:HTTP_PROXY = "http://127.0.0.1:7890"
$env:HTTPS_PROXY = "http://127.0.0.1:7890"

然后执行 codex 的安装命令:

npm i -g @openai/codex

稍等片刻, codex 就安装成功了。建议先别急着运行它,为了让它工作得更好,避免出错,还需要一些其他的设置。

5、安装 php (可选)

如果你的目标是创建网站,那么采用 php 语言仍然是一个不错的选择,它无需单独编译,支持数据库,社区活跃、经常更新。要知道大名鼎鼎的 WordPress 至今仍然是基于 php 的(包括本站在内)。

php 官方下载网站: https://www.php.net/downloads.php ,建议下载最新版本的 NTS 版本(Non Thread Safe)。

它无需安装,直接解压缩即可使用。通常建议建压缩到: c:\php 。

手动将 php.ini-production 文件复制一份,重命名为 php.ini ,同时定位到大约900多行的位置,可以看到一堆的 ;extension ,这是扩展定义,默认情况下是都没有打开的(;前缀表示注释行)。建议打开以下常用扩展,打开的方式就是删掉前面的“;” 。

extension=curl
extension=fileinfo
extension=gd
extension=intl
extension=mbstring
extension=exif
extension=mysqli
extension=pdo_sqlite
extension=sqlite3
extension=php_zip.dll

改完之后保存。之后,将 c:\php 路径加入到系统PATH或用户PATH(windows中叫“环境变量”)。这样,在任意地方都可以执行 php 命令了。

6、在指定工作文件夹中运行 codex

首先我们要为 codex 将来工作的“地方”作一个安排。例如,我在D盘创建了 codex 文件夹,下面创建了“prj01”子目录。那么将来需要先在 powershell 中定位到该目录,再执行 codex,这样 codex 就会把工作目录设置为当前文件夹了。

对于不熟悉命令行的人来说,如何在 powershell 中进入指定的项目文件夹呢?

第1种方法:在资源管理器中定位到该文件夹,然后在地址栏直接输入 pwsh ,回车。

第2种方法:先打开 pwsh ,然后输入命令:

cd /d d:\codex\prj-01

注意,如果路径带有空格,需要在路径上加上一对英文双引号。譬如:

cd /d "d:\codex\my prj01"

命令行的提示符会给出当前路径。然后执行 codex 就可以了。

7、首次执行 codex 的登录操作

首次执行 codex,它会自动弹出浏览器,打开登录页面。登录你的 ChatGPT 账号,登录信息就会保存在本地,以后再执行 codex 时就不会再要求登录了。

起初 codex 只支持使用 API,后来也支持了订阅用户,确实是 OpenAI 的善举,也大大加快了 codex 的普及。

8、Python环境(可选)

Python 在 AI 时代是最流行的编程语言,各种各样的库如汪洋大海取之不尽。同时Python无需专门编译,直接用python.exe就可以执行,使用起来也非常方便。

Python:官方下载页面: https://www.python.org/downloads/windows/ ,目前建议安装3.12系列,有一些项目还没有完全兼容3.13(当下是2025年12月)。

Python 装完之后,在 Windows 上使用还需要两步设置:

1)在 “设置” - “应用” - “高级应用设置” - “应用执行别名” 中,关掉已有的 Python。它会将你的 Python 命令莫名导向 Microsoft Store(真是让人超级无语的设置)。

2)在系统环境变量和用户环境变量中,删除 “C:\Users\[USERNAME]\AppData\Local\Microsoft\WindowsApps”。

如果 Python 在安装时忘记勾选添加到 PATH,那么在系统环境变量中,将你的 Python 安装路径中 Python.exe 所在目录添加到 PATH。

9、C/C++编译器(可选)

如果你也对C语言和C++语言感兴趣,那么建议安装相对轻量级的msys2环境。官方下载页面:https://www.msys2.org/  ,安装之后还没完,打开 MSYS2 MINGW64 终端,输入:

pacman -Syu

这一步是执行更新。可以执行2-3次,确认已经全部更新。

然后安装库与依赖:

pacman -S mingw-w64-x86_64-clang mingw-w64-x86_64-toolchain

这将安装一揽子clang、clang+、gc、gc++、windows头文件、c/c++标准库。这些基本覆盖了日常需求。

之后,在系统环境变量 PATH 中添加:C:\msys64\mingw64\bin (如果你将msys2装在C盘的话),这样就可以在任意位置执行 clang 或 clang+ ,来对你的 .c 文件、 .cpp 文件进行编译了。

10、启动 codex 脚本

好了,经过上述配置,我们已经有了 codex ,并具备了常见的网站、编程场景下的各种环境。由于 codex 需要连接 OpenAI 的服务器,显然需要“科学上网”,而每次手动输入 $env 未免有点麻烦。所以再做一个脚本 codex.bat,将它放到 codex 目录下。既然有了脚本,可以在每次启动 codex 之前顺便升级一下( codex 的更新也是蛮频繁的)。

@echo off
set HTTP_PROXY=http://127.0.0.1:7890
set HTTPS_PROXY=http://127.0.0.1:7890
set "NO_PROXY=localhost,127.0.0.1,::1"
call npm upgrade -g @openai/codex
echo.
echo npm upgrade finished. 
echo.
pwsh.exe -NoExit

为啥这个脚本不直接启动 codex ?很简单,你需要先进入你的项目文件夹,然后再手动执行 codex 。codex 的工作文件夹就是通过这种方式指定的。

三、使用 codex

1、开始干活

对于一个新的项目文件夹,codex 首次执行时会问你一个问题:

You are running Codex in D:\Codex\prj-test

Since this folder is not version controlled, we recommend requiring approval of all edits and commands.

1. Allow Codex to work in this folder without asking for approval
› 2. Require approval of edits and commands

建议选择第一项:允许 codex 进行各种操作而无需申请批准。

真正进入 codex 后,它顶部会显示当前的工作文件夹。请确保位于正确的工作文件夹,否则 codex 对文件的编辑操作可能会造成意料之外的后果。

它提示你有几个预设的命令:/init、/status、/approvals、/model、/review,我觉得比较常用的就是 /model ,用来切换模型;/approvals ,用来切换授权模式。

倒数第二行是一个提示符,这里是输入 prompt 的地方。其实你已经可以给 codex 提要求啦。

最后一行是一个状态显示,一开始你会看到一个“100% context left”,这表示当前进程的上下文使用情况。注意,这一点现在已经不用太担心,一方面, OpenAI  提供了一种上下文压实技术,可以在过程中不断精简 tokens ,从而使的上下文窗口的剩余值经常得到恢复;另一方面,你也可以使用 ExecPlan 方法,让 codex 一边干活一边记笔记,防止忘记自己干过什么、要干什么。

如果你的 prompt 非常长,不想在黑乎乎的命令行中输入,那么可以单独编辑一个文本文件,比如 prompt.txt ,将它放到项目文件夹中,然后跟 codex 说:

请阅读 prompt.txt ,然后按要求完成任务。

就可以了(不需要完全一致,意思表达准确,文件名准确就可以了)。

2、AGENTS.md 与 PLANS.md

这一部分并不是必须的,但对于执行复杂任务来说,却极大影响 codex 的工作效率与质量。因此,除非项目特别简单,建议每个项目都进行配置。

简单来说,AGENTS.md (注意,文件名大小写敏感) 是一个 markdown 格式的文本文件,它会被 codex 自动读取。编好 AGENTS.md,就相当于每次给 codex 一个固定的提示,有点像是 system prompt。

那么,PLANS.md 又是什么呢?它是一个 OpenAI 官方发布的 “记笔记指南”,里面详细约定了 codex 应该如何规划任务、记录步骤、提示要点等等。因此,我们可以在 AGENTS.md 中,明确要求 codex 按照 PLANS.md 的指示,认真做好笔记。

AGENTS.md 和 PLANS.md 究竟如何编辑, OpenAI 官方已经给出了说明:https://cookbook.openai.com/articles/codex_exec_plans ,由于我的 codex 运行在 windows 环境,所以我还在 AGENTS.md 中添加了一段,提醒它,你在 windows 11 环境中运行,所以别老想着执行那些 Linux 、 MacOS 命令:

# ExecPlans
When writing complex features or significant refactors, use an ExecPlan (as described in ai/PLANS.md) from design to implementation.

# Execution Environment (MANDATORY)

Codex is running on a **Windows 11** machine.

All commands executed by the agent — such as file operations, directory creation, reading/writing files, or patching code — **must use Windows-compatible syntax**
(PowerShell or CMD).

The agent must NOT assume Linux or macOS. Never use bash syntax, POSIX paths,
or Unix-specific commands such as `ls`, `rm`, `chmod`, `touch`, `sudo`,
`systemctl`, `brew`, or `apt`.

All local filesystem paths must use Windows-style paths:
`C:\path\to\project\file.ext`.

This rule applies only to Codex's own execution environment and does NOT restrict
the target runtime environment of the project.

我也修改了 PLANS.md 的路径,改成了位于 ai 子目录中。

将 AGENTS.md 和包含了 PLANS.md 的 ai 目录一起 copy 到项目文件夹中,今后 codex 在干活的时候,就会记笔记了,默认情况下是英文,你可以修改 PLANS.md 要求它用中文(不过我觉得还是保持英文比较好)。每次干完活,你可以检查它生成的 execplan 文件(文件名不固定,但一定是 md 后缀,也就是 markdown 格式),检阅它的成果,并查看还有哪些任务没完成( codex 并不总是一次性完成所有任务 )。

你甚至可以编辑 execplan 文件,给它新的提示,新的要求,然后让 codex 根据 execplan_xxx.md 的记录继续工作。

3、进程的中断与恢复

有时候你执行了一个很长时间的任务,以为任务已经完成,于是退出了 codex ,但随后发现,还有很多需要修改的地方。这时候怎么办?不用担心, codex 带有一个 resume 命令:

codex resume --last

即可恢复上一个进程,继续工作。

那么如果要恢复几天前的另一个进程呢?或者,当一个进程中断,想要在另一台电脑上继续,该如何呢?也不用慌,因为 codex 的所有记录,均保存在本地这个文件夹中:c:\users\[USERNAME]\.codex\sessions

找到对应的月份,就能看到一系列以 rollout 开头的 json 文件。举一个例子:

rollout-2025-12-14T12-04-13-019b1b07-c8f7-7301-865e-f50a1f49237e.json

注意,黑色部分是rollout-前缀和时间戳,红色部分是session ID。因此,我们如果要恢复指定的进程:

codex resume 019b1b07-c8f7-7301-865e-f50a1f49237e

就可以了。如果需要跨机器继续,也很简单,将这个 json 文件复制到目标机器上,再执行 resume 命令即可。

四、陈仓 · 暗渡

绕了这么大一圈,终于回归主题。但在真正动用 codex 之前,还是应该先和 ChatGPT 聊一聊,因为自己的想法未必成熟,可能会有逻辑上或技术栈局限性方面的硬伤。

1、打磨 prompt

我先和 ChatGPT 简单聊了聊,初步确定采用 TypeScript + React 架构来实现它;但一开始没有想清楚应该用 Web Socket 还是 WebRTC。思考一番后,我发给 ChatGPT 第一个版本的 prompt:

沟通版 Prompt
我的目标:创建一个网页,实现实时点对点传输文件和聊天的功能。
具体而言:
1、网页的顶部是一个code输入框,用户A打开网页,自定义一个code(不少于8位),点击右侧的“连接”按钮。
2、系统会判断当前code有没有一个listen进程,如果没有,那就创建一个listen,连接按钮变成橙色的“等待接入...”
3、用户B打开同一个网页,输入同一个code,点击右侧的“连接”按钮,系统发现该code已经有一个listen进程,于是按钮首先显示“加入中...”,连接建立后显示绿色的“已连接”。同时,用户A那边的按钮也变成绿色的“已连接”。
4、这种连接基于websocket、webrtc等技术。
5、一旦连接建立,用户A、用户B双方的网页,code输入框都锁定,右侧“已连接”按钮的边上会多出一个按钮“断开连接”。
6、一旦连接建立,用户A、用户B双方的网页,code输入框的下方,会出现两个区域。左边的区域是文件区域,该区域的上方是一个当前活动框,A、B均可以将本地文件拖动到这个框内(或点击按钮选择本地文件),则该文件会出现在当前活动框内,对方可以接收。注意这不是上传、下载,而是点对点的直接传输。最好能显示传输进度。文件区域的下方是一个历史记录框,文件传输完成后,该文件就移出当前活动框,转而显示在历史记录框,表明文件已经传输成功。
7、右边的区域是一个聊天框,用户A、用户B可以在这里聊天、沟通,仅限纯文本。聊天框下面当然要有一个输入框。聊天框中,“对方发送的内容”在左侧,“自己说的内容”在右侧,就像常规的聊天软件那样的布局。这个聊天框也可以用于验证双方的身份,确保文件发送的是正确的对象。
8、当任意一方点击“断开连接”按钮,则code输入框的下方全部消失,网页回归到初始状态。 以上,是我的初步想法,你帮我看看有无考虑不周之处。先不要给任何代码,我现在是完善prompt,稍后我会让codex来做具体的代码工作。

和 ChatGPT 继续交流,很快它指出我的想法中的一些问题:

1)code不应该由用户来自定义,而应该随机生成;
2)WebRTC需要点对点连接,基于复杂的网络环境,几乎可以肯定需要在服务器上搭建 TURN 服务,非常复杂。由于我的设计意图是所有数据通过服务器 S 中转(这样显然在复杂网络环境下更强韧),所以 WebRTC 是没有必要的,改用 Web Socket 是更好的方案。

我让 ChatGPT 给我一个重新整理的 prompt。它给出了用于 codex 的 第1版 prompt:

codex 第1版 prompt
目标:
创建一个网页,允许两位用户通过一个 随机生成的 code 来建立连接,并在连接成功后进行 文件的直接传输 和 实时聊天。所有的数据传输(文件和聊天消息)都通过 同一台服务器 中转,客户端之间并不直接连接。

基本要求:
1. 用户界面:
网页顶部有一个 code 输入框,初始时没有显示 code 字符串。
页面上有两个按钮:创建连接 和 加入连接。
点击 创建连接 后,系统生成一个 随机的 code 字符串,code 输入框显示该随机生成的字符串。两个按钮变成 等待接入...,并允许用户 A 复制该随机生成的 code 字符串并发送给用户 B。
用户 B 打开同一网页,在 code 输入框中粘贴字符串,点击 加入连接 按钮,系统会验证 code 是否有效。如果有效,系统建立连接。
页面包含两个区域:左边区域为 文件传输区,右边区域为 聊天框。

2. 连接流程:
用户 A:
用户 A 点击 创建连接,系统生成并显示一个随机 code。然后,按钮变为 等待接入...,用户 A 可以复制该 code 并发送给用户 B。
当用户 A 输入并点击 连接 按钮时,系统通过 WebSocket 进行连接管理,并为后续的文件传输通道提供支持。
用户 B:
用户 B 打开同一个网页,输入用户 A 提供的 code,点击 加入连接 按钮,系统检查 code 是否已存在正在监听的进程。如果存在,系统会尝试连接,按钮显示 加入中...。成功连接后,按钮变为 已连接,并且双方都能看到该状态。

3. 文件传输:
数据中转:
文件传输通过 WebSocket 实现,数据通过 同一台服务器 中转(而非 WebRTC 数据通道)。
当用户 A 或用户 B 将文件拖动到活动框时,该文件的传输请求会发送给对方,对方必须同意接收该文件。只有对方确认接收后,文件才会通过 WebSocket 通道发送。
在文件传输过程中,显示传输进度条,以展示当前传输进度。
文件传输完成后,文件从活动框移到历史记录框,表明文件传输成功。

4. 聊天功能:
WebSocket:
聊天功能使用 WebSocket 来保证实时性。当用户 A 发送消息时,用户 B 会立即收到消息。
聊天框下方有一个输入框,允许用户发送文本消息。
聊天框的布局是左边显示对方发送的消息,右边显示自己发送的消息。
连接与断开:
当任意一方点击 断开连接 按钮时,code 输入框和所有功能区域(文件区、聊天框)会恢复初始状态,网页回归到连接前的界面。
WebSocket 连接应当被清理,文件传输和聊天也会结束。

5. 信令服务器与数据中转功能:
需要实现一个 内置信令服务器和数据中转服务,用于管理用户 A 和 B 之间的 WebSocket 信令交换,并中转文件数据和实时消息。
同一台服务器 负责接收客户端1和客户端2的连接请求,存储它们的连接信息,接收文件数据并将其转发给目标客户端。

6. 技术栈:
前端:
使用 Vue 或 React 来构建网页界面。
用 HTML5 和 CSS3 来实现基本布局和样式。
使用 WebSocket 来处理实时聊天消息和文件传输。
后端:
使用 Node.js 和 WebSocket 来搭建信令服务器和数据中转服务。
使用 TypeScript 来编写后端代码,确保类型安全和可维护性。
文件传输:
使用 WebSocket 来实现文件传输,数据通过 同一台服务器 中转。
聊天:
使用 WebSocket 来实现实时文本聊天。
服务器:
使用 Node.js 的 WebSocket 库来构建信令服务器,同时支持实时消息传递和 WebSocket 连接。

7. 功能细节:
连接过程:
在用户 A 点击 创建连接 时,客户端会向 信令服务器 发送请求,告知要监听某个 code。
用户 B 输入相同的 code,客户端向信令服务器请求建立连接。
信令服务器 处理并协调 WebSocket 信令过程,使得两者能够通过 同一台服务器 建立连接。
WebSocket 信令交换:
信令流程:
用户 A 初始化一个连接,发送请求消息到信令服务器。
用户 B 收到请求后,创建连接并返回确认信息。
信令服务器 将确认信息发送给用户 A,从而完成 WebSocket 信令交换,建立连接。
文件传输:
使用 WebSocket 将文件数据通过 同一台服务器 中转传输。
传输过程中实时更新进度条,传输完成后显示在历史记录框中。
聊天:
使用 WebSocket 发送和接收消息,确保聊天的实时性。
聊天框中,发送方和接收方的消息样式有所不同,便于用户区分。

8. 项目结构:
project/
├── backend/ # Node.js 后端代码
│ ├── server.ts # TypeScript 后端主文件
│ ├── signalingServer.ts # 信令服务器
│ ├── dataRelayServer.ts # 数据中转服务器 (WebSocket 传输)
│ ├── controllers/ # 后端路由、数据库操作等
├── frontend/ # React/Vue 前端代码
│ ├── src/ # 前端源码
│ │ ├── components/ # 组件(如文件上传、聊天框等)
│ │ ├── App.vue / App.tsx # 根组件
│ │ └── index.ts # 前端入口文件
│ ├── public/ # 静态文件(如图片、字体等)
│ └── dist/ # 编译后的静态文件
├── package.json # 项目依赖和启动脚本
├── tsconfig.json # TypeScript 配置
├── vite.config.ts # Vite 配置
└── nginx.conf # Nginx 配置文件(用于生产环境)

9. 同一台服务器的 WebSocket 中转功能:
在同一台服务器中,WebSocket 服务器会接收来自客户端1和客户端2的连接请求,并将消息(包括文件数据和聊天消息)从一个客户端转发到另一个客户端。

我把上述 prompt 保存为了 prompt.txt 文件,然后要求 codex “根据 prompt.txt,完成任务”。

2、调试过程

在此之前,我从来没有接触过 TypeScript 和 React。但是在 ChatGPT 的指导下,还是逐渐摸清楚了如何编译和调试。调试会用到 vite ,这个需要手动安装一下,然后可以通过执行这个命令来在本地运行:

npm run dev

默认情况下,前端会在端口 5173 监听,后端根据代码设置,监听在 3001 端口。

其实 codex 在第一次工作之后就完成了基本功能,但由于项目带有后端服务,且在调试环境下后端服务的地址会与实际部署不太一样,所以我很快就将项目直接部署到服务器上,在“准生产环境”下直接测试了。

简单来说,当 codex 完成工作时,后端 backend 文件夹中会有一系列的 ts 文件,而前端 frontend 文件夹中会有一些 tsx 文件。codex 同时会在项目文件夹创建  package.json 和 tsconfig.json 文件,其中说明了项目需要哪些依赖,入口在哪里,以及需要编译哪些内容。在初次编译之前,需要先安装必要的依赖。在项目文件夹路径的命令行中,执行:

npm install

这会在项目文件夹中增加一个目录 node_modules。有了这些依赖,就可以对项目源代码进行编译了。编译很简单,在项目文件夹下执行命令即可。后端(根据 tsconfig.json):

npx tsc

编译前端(根据 package.json):

npm run build

根据  package.json 和 tsconfig.json 文件的配置,相关产出物会位于指定的文件夹。比如,我的后端产出位于:dist-ts\backend\,前端产出位于:frontend\dist\ 。

将它们部署到服务器(ubuntu),并配置好域名和 nginx,设置好 ws 反代:

server {
    server_name cc.lucyqin.cn;
    location / {
        root /var/www/ccv/frontend;
        try_files $uri $uri/ /index.html;
    }
    location /ws/ {
        proxy_pass http://localhost:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
	proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        keepalive_timeout 3600s;
    }

    listen 443 ssl;
    #SSL部分略
}

然后让 codex 根据 nginx 配置修改相关的 backend 代码,将 Web Socket 连接改为通过 域名/ws 的方式连接。

服务器上同样需要安装node.js,并在项目文件夹执行 npm install ,因为它需要单独运行后端的 server.js,如果缺少依赖,则无法成功执行。同时建议安装 pm2,用来方便地管理后端服务。

首先安装 nvm:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

然后用 nvm 安装 node.js 最新的 LTS 版本:

nvm install --lts

此时 node.js 已经安装了最新的 LTS 版本。再安装 pm2:

npm install -g pm2

假设服务器上的项目文件夹位于:/var/www/ccv/ ,那么 server.js 就位于: /var/www/ccv/backend/。用 pm2 启动服务:

pm2 start /var/www/ccv/backend/server.js --name my-ccv-service

可以用以下命令查看服务启动是否出现错误:

pm2 logs my-ccv-service

如果有错误,多半是因为依赖没有正确安装。确保在项目文件夹中执行 npm install。

好,重启 nginx 确保相关网站配置生效:

nginx -s reload

不出意外的话,这时候访问:cc.lucyqin.cn 就可以打开了。以上步骤均只需执行一次。将项目成功跑起来之后,剩下的调试过程就简单多了,前端文件只需要替换即可;后端文件在替换后需要重启 pm2 服务:

pm2 restart my-ccv-service

3、语音、贴图与功能取舍

第一个版本上线之后,很快收到一些内测用户的反馈:为什么不能发语音?为什么不能发图片?

思索良久,基于项目的定位,我决定上线语音功能,但不打算在聊天窗口增加图片功能。主要考虑如下:

1)语音功能在手机端非常有优势,符合用户的使用习惯;有利于快速识别对方的身份;同时不会带来布局上的太大负担。
2)语音功能当然有泄密的风险,因为对方即便不知道如何从网页上将语音另存为文件,但并不妨碍他用第三方独立设备对语音进行转录。但为了平衡便利性,还是提供这一功能,对安全更敏感的用户完全可以选择不使用语音功能。
3)图片无需在聊天窗口展示,因为基于网页浏览器操作,用户并不能直接“转发”图片,而只能像文件一样从设备中找到图片文件发送,这个过程与发送“文件”是一样的。从读取的一方来看,图片缩略图仍然需要点开,那么这与点开文件下载操作类似。同时,图片功能会显著增加布局上的压力,也会大幅降低网站“匿名”的调性。

其实在既有的架构下增加一个语音功能并没有想象中的那么难,数据仍然通过浏览器的 blob 数据包发送,在接收端重新组装起来。无非增加一种信息类型,前端页面中将它显示在聊天窗口中而已。

比较折腾的反而是这个发送语音的图标,最终我把微信界面上的语音图标截图给 codex ,要求它参考 voice.jpg 用 svg 画一个类似的,这才终于画了一个基本满意的(没错,codex 支持多模态,可以读取并理解项目文件夹中的图片文件)。

当然,用户在最终使用时,需要在系统中授予浏览器的录音权限。有一些国产手机在这方面权限控制得比较严。

4、界面交互优化

1)动态高度输入框

我们平时在使用一些大众软件时,常常会忽略一些“耳熟能详”的细节,直到你自己做一个类似项目时才会意识到它。陈仓 · 暗渡 的右侧是一个聊天框,下面有一个输入框,输入框右侧是一个“发送”按钮和一个“语音”按钮,这是自然而然的事情,但真到用起来就发现有一个问题:输入框的高度应该是多少?

显然只留一行是不合适的,用户可能会输入大段的文字,只有一行不方便 review ;但预留好几行也不妥,界面会变得非常丑。我不禁想到,微信是怎么解决这个问题的?一看才发现,原来微信的输入框是动态高度!当用户输入文字超过1行时,输入框自动变成2行;内容更多时,输入框的行数也会相应增加,最多可以到9行。如果超过9行,那就需要用户自行滚动内容了。

于是依葫芦画瓢,好在现代浏览器里也都已经支持了 field-sizing: content 这种显示模式。经过亿点点调试,终于实现了与微信类似的效果。不得不说,用户体验这方面,微信还是非常值得借鉴的。

2)Enter 与 Shift+Enter

默认情况下,用户在 PC 端浏览器上,会习惯于 Shift+Enter 换行,然后回车提交。但这种操作在手机上显然是不一样的。手机上无法输入 Shift+Enter,因此我们需要重新定义,手机上输入法按下 Enter 时表示换行,而只当点击“发送”时才是回车。

我要求 codex 作了这个巧妙的修改之后得意洋洋,再回到微信一看,原来微信(安卓版)早就已经是这样了,平时用着觉得天经地义,根本没有注意到这个细节……

5、文件传输优化

文件传输作为一个重头戏功能,显然是需要反复测试和优化的。

1)反馈与反压

在最初的版本中,文件发送端将文件分片(chunks)之后,会粗暴地将它们全部堆积到浏览器的发送队列,导致浏览器占用内存暴涨,文件稍微大一点(超过1G)时,浏览器就会崩溃。与此同时,接收端一旦开始接收,进度信息也不能有效地反馈给发送端,发送端对文件传输进度一无所知,甚至对当前是否在传输都不掌握。这显然是不合理的。

因此,增加反馈(ack信令)与反压机制(backpressure),接收端会不断地通过服务器向发送端发送反馈信令,发送端不再无脑堆积分片(chunks),而是发送有限数量的分片在缓存区,当确认接收端已经接收了若干分片后,再发送新的分片。反馈信令也可以用来计算文件传输进度,这样就可以从容地为文件传输增加一个进度条了。

与此同时,发送端的内存占用问题也得到了解决。

无论如何,接收端在接收完全部的 blob 后,还是需要将它们组装成一个完整文件,这个过程仍然会占用不小于文件大小的内存。但实践表明,接收端的这个动作压力会小很多,即便是3个G大小的文件都可以轻松应对。

2)分片大小与缓冲区

接下来就是文件传输速度的优化。 codex 默认设置的分片(chunks)大小为 64KB ,分片等待队列为 2,缓存区也很小( 128KB )。试验下来发现,这种方式只能用到最大约 13Mbps 的带宽,也即是 1.5MB/s 的速度。我的家庭宽带上传带宽是 50Mbps, 服务器带宽是 200Mbps,显然还有很大的发挥空间。

经过一番测试,将分片大小改为 1MB ,分片等待数量改为 12, 缓存区大小设置为 4MB ,可以最大用足 100Mbps 带宽。再大似乎不太合适,毕竟服务器还要留一点余量的。

五、风中的微光

有了“陈仓 · 暗渡”这个1对1连接的版本,自然想到做一个支持多人同时在线的版本(ccm.lucyqin.cn)。只需要稍微思考一下就会发现,与1对1版本相比,多人版本的复杂性是指数上升的。

1、多用户识别逻辑

在多人场景下,需要对不同用户进行区分,因此需要一个用户名机制。但在匿名的基本属性下,用户名只能是由系统随机赋值,这就需要预先准备好一个随机用户名库:aliases.json。这个好办,让 ChatGPT 出马,分分钟生成了100个两个汉字、互不重复的用户名。

但如果只是一个随机分配的用户名,人多了之后显然还是无法分清楚谁是谁,特别是在强调安全性的场合下。因此,需要提供一个用户名自定义功能,用户可以用,也可以不用,也可以用一种团队间一看就清楚、彼此心照不宣的方式来用。所以,界面上就需要设计一个自定义显示名称的地方。

进一步地,为了防止用户恶意重命名,导致出现另一种混乱,我们需要对用户名再增加一个识别机制:一个固定的16进制后缀。它不用太长,也不能太短,综合考虑下来我保留了4位。这样一来,房间内的初始用户名通常是:

炎雀-4D2F

用户可以自定义前缀部分,但后缀部分保持不变,比如改名后变成:

lucyqin-4D2F

这样就既兼顾了自定义的便利性,也保持了可追溯的严谨性。

这时候就会想到,如果用户比较多,又频繁修改用户名,仅凭大家“记住”这个后缀是不太现实的。这就需要在用户自定义名称时,有一个“动态信息栏”机制,提示大家:

炎雀-4D2F 将名称改为了 lucyqin-4D2F

这样用户就可以通过查看动态信息栏,完成对具体用户身份的追溯。

2、房管与 “code+验证” 模式

我们知道,系统的设计逻辑是凭 code 进入。“陈仓  · 暗渡”的1对1模式下,只要其中1人断开,另1人也会立即断开。但多人模式下,房间本身是一种“持久”连接,只要房间还留有最后1人,这个房间的 code 就始终有效,别人仍然可以加入这个房间。这就带来两个问题:code 泄漏问题、房间管理问题。

多人模式下,某个成员分享的文件,后面新进入房间的人也可以看到(这是设计要求),因此如果有非法用户获知了 code 毫无阻拦地接入房间,后果不堪设想。

由于 code 本身是在不可信的信道(例如微信群)分享的,无法排除有非法用户获知 code 的可能性。因此需要为房间额外增加一个“验证”模式,即使新成员输入了正确的 code ,但仍然需要发送一段验证留言,通过验证后方可进入房间。这样就给了房间管理员一个把控房间安全的最后抓手。

这也就意味着,需要一个“房间管理员”的角色。显然,房间的创建者就是天然的“房管”。考虑到房间创建者可能会先退出房间(断开连接),那么“房管”的角色就自动转移至第二个进入房间的用户。同时在“动态消息栏”中予以公告。

3、聊天窗口设计

与1对1不同的是,多人聊天窗口显然要增加“发言”用户标识。我们无需像微信一样为每个用户设置头像,肯定是以气泡方式显示用户名,但仍然有优化的地方。

系统为每个新进入房间的用户随机分配两个对比色,一旦分配之后就固定下来。这样一来,不同的用户在聊天窗口中的“文字版头像”就会显示成不同的颜色,这就非常友好了。

同时,将用户名的前缀与后缀分成两行显示,以优化聊天窗口布局。为了限制恶意用户,设置用户名前缀最长不超过6个汉字。

4、“用户列表”与“文件挂板”整合显示

在“陈仓 · 暗渡”1对1的模式下,不需要展示用户列表。但多人模式下,显然需要一个区域展示当前在线的用户列表。起初的设想是,将页面分成左、中、右三个部分,左边仍然是文件分享区,中间是聊天窗口,右边是在线用户列表——似乎流行的IM软件都是这样做的。

但仔细想想就知道,一旦有用户分享文件,肯定是需要在文件显示上标注“分享者”的。同时,这个用户列表只是用来显示,并没有其他功能(除了被房管“踢出”),所以两者完全可以合二为一。

于是调整思路,左侧区域显示在线用户列表,并标注两种特殊身份:“我”、“房管”。“我”永远出现在列表最上方。如果当前用户为“房管”,则在每个用户名右侧还会出现一个“踢出”按钮。当任何用户分享文件时,该文件出现在该用户名的下方区域,同时用户名右侧显示分享文件数量。

实践下来,结合不同用户名的显示颜色,这种方式是非常清晰的。

5、codex prompt

想清楚之后,多人版本在1对1版本的基础上改造。我将“陈仓 · 暗渡”项目复制了一份,交给 codex ,prompt 如下:

升级多人版 Prompt
这是一个匿名的、仅浏览器的1对1文件传输和聊天工具。它使用web socket,通过服务器进行中转实现文件传输和消息发送,用户在创建连接时,自动生成一个随机code,发给第二个人作为加入连接的凭证。
现在我需要对该系统做一个较大的改造:支持多人场景。不需要保留既有的1对1模式(那个模式我单独已经复制出去了,已经作为独立站点运行了)。

1、code
为了更好的安全性,code从6位数字升级到8位字符,包括大小写字母。

2、房间管理者
创建code,也就是创建房间的人,自动成为房间的管理者。管理者有义务对加入房间的人的身份进行甄别,并具有将某人踢出房间的权限。

3、加入房间的人,系统自动分配alias。请预先准备好100个alias,格式为两个汉字,比如蓝鲸、银狐、柳絮等等,这些预设的alias保存为一个配置文件,系统可以从中随机读取。给用户分配的alias为随机读取的两个汉字+一个随机的16进制的4位后缀:例如银狐-A2EB、柳絮-1F68等。

4、房间管理者可以设置房间的模式:
模式一:只要知道code就可以直接进入。
模式二:知道code点击加入后,仍然需要房间管理者批准。在该模式下,加入的用户需要发送一段消息,仅房间管理者能收到。管理者根据该段消息确认加入者身份,批准后用户方可加入房间。注意,用户是先分配alias,再执行验证动作。这样管理者可以将验证的对象和alias匹配起来。

5、用户可以修改自己的alias,但只能修改前缀部分,4位16进制后缀保持不变,这样既满足了个性化定制的需求,也解决的身份识别的问题。用户修改alias的记录将显示在页面下方的“动态消息栏”,这样也方便从中查询、核对用户的身份确定对应关系。

6、整个界面分为4个大的部分:
顶部,code栏、创建连接、加入连接、断开的按钮,这个不变。第二行显示当前用户的alias,并提供一个编辑alias功能。编辑功能尽量轻量化,在页面内完成,不要做弹出窗口。如果当前用户为房间管理者,则第二行还提供模式切换功能,单选:Code模式/Code+验证模式。
左侧,是当前在线用户列表和文件分享区。设想中,左侧分为上下两个区域,上面这个区域是一个文件拖拽区,当前用户可以将文件拖拽进去,或点击该区域选择本地文件。下面区域是当前房间所有在线用户的alias列表,当前用户排在首位。由于要兼顾文件的相关操作,左栏的宽度不宜太小,建议占整个界面的1/3宽度。
右侧,是一个尺寸较大的聊天窗口,下面是输入框和发送按钮,这部分与现在设计相同,区别在于大一些,因为要支持多人聊天。聊天窗口中,别人的发言要增加alias以区分是谁发言。
底部,是动态消息栏。主要显示房间人员的进出情况,当前总人数情况,用户修改alias情况,用户分享文件的情况,用户下载文件的情况。

7、文件的挂板与传输:
一旦当前用户分享某个文件,该文件就会出现在该用户的alias下面,包括文件名、文件大小、分享时间,文件名的前面用一个图标来表示文件状态。文件状态分为:未下载、已下载、正在下载。当文件正在下载时,显示进度条,实现逻辑与当前方式一致。下载完成后变成已下载状态,同时文件名变成绿色。用户分享多个文件时,则这些文件依次挂在该用户的alias下面。注意,文件挂板操作只是登记文件分享,并没有真正开始文件的传输。只有当房间内另一个人下载该文件时,才实时创建web socket连接开始传输文件。
对于分享文件的用户来说,他的界面上对某个文件还需要提供一个取消按钮,以取消分享。即使该文件已经处于传输状态,他也可以随时取消。
对于下载别人分享的文件的用户来说,也提供一个取消按钮,他可以取消正在传输的文件的传输进程。
对于已经分享的文件,其他用户下载时无需分享者同意。

8、由于整个页面内容比较多了,在前端设计上稍微紧凑一些。

9、如果新的版本需要新的npm依赖,请同步修改package.json。

10、后端监听端口从3001改成3002。

7、后台连接逻辑

在多人模式下,实际上每个用户都与房间内的其他在线用户创建了一对连接(经过服务器),所以,房间内如果有10个人,那么理论连接数就是10*9/2。但在最初的版本中,由于没有考虑加密,服务器采用的是一种类“中心化”结构,每个用户发送的文字消息、语音消息、文件分享消息,会广播给房间内其他在线的用户,仅当发生文件请求与传输时,才会搭建一条“点对点”(经过服务器)的连接。

这个架构后续在考虑加密时几乎完全重构。

六、端到端加密(E2EE)

1、算法选择

接下来,就是终极目标,端到端加密(E2EE,End-to-end encryption)了。为什么这么执着?一方面这是早已有之的想法,另一方面基于现实的原因:现在只有国内的腾讯云、阿里云提供了200Mbps带宽、流量不限的 VPS 服务,而我用的是腾讯云。海外 VPS 通常都有流量上限,用来干这个显然不合适。而既然用着国内的服务器,当然会有一些细思极恐的念头。虽然从架构上看,服务器不保留任何临时文件,只是“倒个手”,但如果有人在服务器上开一个 Tcpdump监听一切流量 ,那所有明文传输的信息都将无所遁形。所以,端到端加密还是必须要实现的(申明:这是从保护用户信息安全的角度,并非有意对抗监管)。

在动手之前还是一样,先和 ChatGPT 充分交流,了解目前主流端到端加密实现的思路与算法。

我原本以为,端到端加密不就是 RSA 吗?双方交换一个公钥,对信息或文件加密,对方收到后在本地用私钥解密不就完了?实际情况比这个还要复杂一些。

RSA 虽然好,但它对性能要求高。如果连传文件都用 RSA 野蛮加密,那么两边的浏览器都会吃不消的。所以通用的做法是混合加密:双方先用非对称算法协商出一个第三方不可能知道的对称密钥,然后都用这个对称密钥对信息进行加密,这样就轻松多了。

而非对称算法如果只是用来交换密钥,那么除了 RSA 还有另一个性能更好的选择:ECDH。这是一种基于椭圆曲线离散对数难题的加密机制,它不能直接加密消息,但却可以很方便地与对方安全地交换一个共用密钥。它的性能更好,P-384 已经是超级强度(相当于 RSA 7680 位),但速度却比 RSA 快的多。

EDCH的局限性是缺少身份校验,无法防御中间人攻击(MITM)。什么是中间人攻击?举例说明,如果A、B通过服务器S建立连接,但S其实已经被恶意人士掌握,那么S完全可以伪装出一个Bs与A通信,再伪装出一个As与B通信。A、B以为自己的信息是端到端加密,但S实际上是先解密,再加密发给另一端,所有信息在S节点暴露。因此如果采用 ECDH ,那就需要增加一个安全码机制,连接双方可以基于共同的密钥计算一个短字符串,然后通过另一个信道核对这个字符串是否一致。如果不一致,说明中间人攻击已经发生。

我原本以为中间人攻击是一个传说中的事物,我做了安全码机制后,应该不会真的遇到才对……结果是我天真了。后文会详细解释在 Web Socket 连接场景 + 内网 环境下,“中间人攻击”是多么的普遍。(2025.12.15更新:进一步研究发现,并不是真正意义上的中间人攻击)

对于双方协商的对称密钥,当然也要有足够的强度。与 ChatGPT 沟通后,明确采用 AES-256-GCM。其中GCM模式提供了对“解密是否成功”的认证,即让接收方能判断“收到的数据是否已经成功解密”,这一点很重要。

2、1对1升级E2EE prompt

首先在“陈仓 · 暗渡”上尝试升级。我请 ChatGPT 给出升级 prompt:

1对1模式升级端到端加密 prompt
这是一个 1 对 1 的匿名临时连接网站(“陈仓·暗渡”):
用户 A 点击“创建连接”,获得一个连接码;
用户 B 输入连接码“加入连接”;
A 和 B 之间通过 WebSocket / WebRTC 发送消息和文件(包括语音包)。

现在需要在前端实现端到端混合加密,要求如下:

密钥交换(ECDH P-384)
不做长期账号体系,每次连接都是匿名、临时的。
A 创建连接时,在浏览器中生成一对临时 ECDH(P-384) 密钥对 (a_priv, a_pub);
B 加入连接时,生成 (b_priv, b_pub);
通过服务器中转,双方互相拿到对方的公钥。
在前端使用 WebCrypto API 计算共享秘密:
shared = ECDH(a_priv, b_pub) 或 ECDH(b_priv, a_pub)。

密钥派生(HKDF):
使用 HKDF-SHA-384 从 shared 派生一个或多个密钥:
例如:
session_key_msg:消息加密用 AES-256-GCM;
session_key_file:文件加密用 AES-256-GCM;
session_key_voice:语音包加密用 AES-256-GCM;
HKDF 的 salt 可以随机生成并在握手阶段一起传给对方;
info 字段要区分用途,例如 "ccad-msg"、"ccad-file"、"ccad-voice"。

消息加密(AES-256-GCM):
所有聊天内容、信令消息,都必须在前端用 AES-256-GCM 加密后再发给服务器。
每条消息使用一个唯一的 nonce,例如:
会话 ID + 方向(A→B、B→A)+ 自增消息序号 组合成 96bit nonce。
把 messageType、sequenceNumber 等放进 GCM 的 additionalData 里,保证元数据也被认证。
解密失败(authTag 校验失败)时,直接丢弃消息。
文件加密:
每个文件单独生成一个 fileId,再从 session_key_file 通过 HKDF 派生该文件的 file_key。
文件在前端按块加密:
AES-256-GCM(file_key, nonce = fileId || chunkIndex)。
文件名、大小、类型等元数据,也用 session_key_msg 再加密一层,让服务器无法看到明文文件名。
语音加密:
实现原理参考文件加密。

端到端特性:
所有加密、解密都必须在浏览器前端完成。
服务器只负责中转加密后的消息和文件块,永远不持有任何解密密钥。
连接结束时,前端清除与本次连接相关的所有密钥和状态。

实现要求:
使用 WebCrypto API 实现 ECDH、HKDF、AES-256-GCM,不要手写底层密码算法。
用 TypeScript 定义清晰的类型:握手消息、加密消息、加密文件块的结构。

保留一个可以扩展的“安全码”接口:
对 shared 做 SHA-256 后取前几位,显示为6位数字串,用于双方人工比对防止中间人攻击。
一旦双方连接建立,则在code栏这一行(含按钮)的下方,显示一行字:连接校验码:xxx xxx
例如:
安全码:378 210 (请确认对方显示的与此一致)

由于1对1的场景很简单,结果非常顺利,大约只调试了1个小时,便完成了升级。

3、多人版升级E2EE

多人版的 prompt 大部分内容与 1对1 版雷同,区别在于安全码的界面设计。经过思考,我将在线用户列表的用户名做成可以点击的:点击后显示安全码。这里 prompt 就不再全部放出了。

codex 在工作的时候很快发现了架构上的问题。由于端到端加密本质上是1对1的,所以要在多人模式下实现端到端加密,就不能再使用之前的“广播”模式。此外,多人模式下,信息的类型骤然增加,每条聊天文字和语音消息需要带有发送人信息;文件传输也需要带有发送人、接收人信息;房间处于 code+验证 模式时还有申请留言信息;所有用户修改用户名时需要通知到每个人;每个人的进入、退出信息;房管变更信息……等等。

以 codex 之能,一次也干不完,每次干个40-50分钟,就老老实实在 ExecPlan 中写,我已经完成了 1、2、3,还有4、5、6没完成……于是我每次跟它说:请根据 execplan 继续完成剩下的任务。

多人版的调试过程比较漫长,过程不再赘述。最终,设计目标基本实现,现在系统明文传输的只剩下一些元数据:例如某个IP在某个时间加入了房间、退出了房间;每个用户初始随机分配的用户名;房间当前处于 code 模式还是 code+验证 模式;每个用户的心跳(ping/pong)信令等。这些都属于无法与用户身份和信息内容挂钩的低敏感数据,所以就不再继续折磨 codex 了。

为了防止 codex 在改造项目的时候偷懒,遗漏的重要信息传递过程中的加密,另开 codex 进程对所有代码进行审计。审计结果令人满意,所有敏感信息均实现了端到端加密。

七、防篡改机制

1、基本思路

如果所有的服务均在一台服务器上提供,且将“服务器被非法控制”的因素考虑在内,那么会存在一种最恶劣的情形:整个端到端加密的网站被全部替换成明文交换信息,而所有前端表现完全不变,用户对此毫不知情。

如何解决这个问题呢?显然,不可能要求这台服务器S1“自证清白”,因为它的自证本身就不可信。所以,需要在另一台安全服务器S2上发布某种验证信息,并使用户有能力对当前服务器S1和安全服务器S2上的信息作比对。最好的比对对象当然是前端页面使用的JS脚本,它包含了大部分前端加密逻辑。

当然,如果S1服务器本身只是唯一的入口和通知平台,那么这种通知本身也可以被篡改,将验证方案指向另一个恶意服务器S3。但这是无可奈何的事情,我们只能假设,在S1服务器尚处于安全阶段时,少部分对安全有高度敏感性的用户已经提前得知了S2安全服务器的存在。

对于“陈仓 · 暗渡”和“风中的微光”,我在 check.lucyqin.net (也就是S2安全服务器)上设置了防篡改验证服务,并在两个网站的 footer 位置增加了显眼的防篡改验证标识。用户只需要点击链接打开 https://check.lucyqin.net/cchttps://check.lucyqin.net/ccm,即可一键验证S1服务器上网站脚本是否与正确版本一致。验证使用 SHA-256 方式。

2、实现细节

注意,受制于跨域访问限制,用户并不能在 S2安全服务器上直接用前端脚本访问 S1服务器 的内容(因为这样做访问者是用户),因此还是需要在 S2 上安装一个后端服务。当前端按下一个按钮时,向后端服务 Server.js 发送一个指令, Server.js 立即对 S1 服务器上指定路径的 JS 文件进行抓取(访问者变成了S2,不存在跨域问题),并进行 SHA-256 hash,然后与 Server.js 中预设的 hash 进行比对,并返回比对结果。

index.html 与 Server.js 均可做成易于扩展的格式,此处不再赘述。

八、Service worker 与 PWA

这次的部署也加深了对 Service worker 与 PWA 的理解。PWA 全称是 Progressive Web App ,中文一般叫“渐进式 Web 应用”。设置 PWA 的目的是为了让手机用户能够将网页安装成一个桌面应用,从而下次更方便地进入。

为了实现 PWA 的完整目标(离线可用、秒开),就需要注册一个 Service worker,它通过项目项目的主 JS 加载,决定了项目执行时的缓存应用方式。

注意,以两个网站的特性来说,其实“离线可用”是一个伪命题。我只是在部署是顺便研究了一下 Service worker,并不代表必须要使用它。理论上无需 Service worker,只要一个 manifest.json 就可以实现最基本的 PWA 了。

由于项目在生产环境下调试,所以每次覆盖前端代码时,浏览器访问经常还会使用旧的缓存 JS ,导致各种奇怪的事情发生,一度让我非常头疼。实际上,只需要两步即可解决这个烦恼。

首先在 nginx 配置文件中强行令 sw.js 不缓存:

    location = /sw.js {
        root /var/www/ccv/frontend;
        add_header Cache-Control "no-store";
    }

然后,在 sw.js 中的 CACHE_NAME 中加上版本号,并设置为一旦版本号发生变化,则重建缓存:

const CACHE_NAME = "pwa-cc-v3.03";
const NAV_TIMEOUT_MS = 1200;

const HTML_URL = new URL("index.html", self.registration.scope).toString();

function fetchWithTimeout(urlOrReq, ms) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), ms);
  return fetch(urlOrReq, { signal: ctrl.signal }).finally(() => clearTimeout(t));
}

async function updateIndexHtml(cache) {
  const res = await fetch(HTML_URL, { cache: "reload" });
  const ct = res.headers.get("content-type") || "";
  if (res.ok && ct.includes("text/html")) {
    await cache.put(HTML_URL, res.clone());
  }
}

self.addEventListener("install", (event) => {
  self.skipWaiting();
  event.waitUntil((async () => {
    const cache = await caches.open(CACHE_NAME);
    try {
      await updateIndexHtml(cache);
    } catch {
    }
  })());
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys()
      .then((keys) => Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))))
      .then(() => self.clients.claim())
  );
});

self.addEventListener("fetch", (event) => {
  if (event.request.method !== "GET") return;

  const req = event.request;
  const accept = req.headers.get("accept") || "";
  const isNavigate = req.mode === "navigate" || accept.includes("text/html");

  if (isNavigate) {
    event.respondWith((async () => {
      const cache = await caches.open(CACHE_NAME);
      const cached = await cache.match(HTML_URL);

      if (cached) {
        event.waitUntil(updateIndexHtml(cache));
        return cached;
      }

      try {
        const res = await fetchWithTimeout(req, NAV_TIMEOUT_MS);
        const ct = res.headers.get("content-type") || "";
        if (res.ok && ct.includes("text/html")) {
          await cache.put(HTML_URL, res.clone());
        }
        return res;
      } catch {
        const fallback = await cache.match(HTML_URL);
        return fallback || new Response("Offline", { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } });
      }
    })());
    return;
  }

  event.respondWith((async () => {
    const cached = await caches.match(req);
    if (cached) return cached;

    const res = await fetch(req);
    if (res && res.ok) {
      const copy = res.clone();
      event.waitUntil((async () => {
        const cache = await caches.open(CACHE_NAME);
        await cache.put(req, copy);
      })());
    }
    return res;
  })());
});

这样一来,未来只需要每次升版本(v3 -> v3.01),用户浏览器就会自动清空旧缓存,重建新缓存了。

九、服务条款

基于网站的匿名、无痕、端到端加密的特性,为网站增加一个服务条款免除相关法律责任是非常必要的。

根据《网络安全法》第二十一条第三款,相关技术日志保存不少于六个月;但第四条也写了:鼓励对重要数据进行加密。

《网络安全法》第二十一条
(三)采取监测、记录网络运行状态、网络安全事件的技术措施,并按照规定留存相关的网络日志不少于六个月;

(四)采取数据分类、重要数据备份和加密等措施;

根据《密码法》第八条,可以依法使用商用密码保护网络与信息安全。

《密码法》
第八条 商用密码用于保护不属于国家秘密的信息。
公民、法人和其他组织可以依法使用商用密码保护网络与信息安全。

综上可知,法律是鼓励使用商业密码保护信息安全的,而技术日志并不包括信息的明文内容,对于一台服务器来说,IP访问日志、连接日志这些元数据日志保存不少于六个月,即可满足《网络安全法》的要求。

但与此同时,必须严正申明,网站创建的目的不是为了对抗监管,或为违法活动提供便利;同时,应配合相关部门的各种监管要求(在服务器端能掌握的信息范围内尽量提供)。所以,服务条款中对此作了特别说明。

十、系统的便利性与局限性

1、浏览器的局限性

“陈仓 · 暗渡”与“风中的微光”两个网站,其设计目的就是为了在仅有浏览器、能联网的情况下进行通信。因此,它在提供了无与伦比的便利性的同时,也不可避免地具有浏览器(而非应用)本身固有的局限性,主要体现在手机端。手机端即使安装了 PWA ,但一旦进入锁屏状态,大部分手机都会在有限的时间内掐断 Web Socket 连接。这不是 bug ,而是 feature 。只有纯正的 App 应用才能有办法在后台强行保持唤醒状态,浏览器没有这个权限。

类似地,在 PC 端的浏览器上,如果对页面进行刷新,也会掐断 Web Socket 连接。这是由浏览器本身的特性决定的。

如果将系统设计成断连之后,在某个时间段(比如5分钟或30分钟内)还能恢复连接,这将从根本上影响系统的安全性,我暂时还没有这个打算。

2、Web Socket 连接的局限性

试验表明,在用户的终端设备位于被严密管控的内网(例如国企大厂内网、金融业公司内网)时,它的端到端加密无法正常完成协商。进一步调研发现,自从 2011 年 Web Socket 问世以来,其实有我这样想法的人太多了。内网只需要与外网建立一个 Web Socket 长连接,之后管道内究竟传递的是什么就完全不可控了,这对于企业网络的安全审计来说是不可接受的。因此,很多主流防火墙都会对 Web Socket 连接进行限制,要么禁止双向连接,要么禁止单向(由内向外)的连接。

阻止的方式也有多种,有的是直接阻断,有的则是第三者插足,用企业颁发的根证书强行插入一个 https “中介”,或者偷偷摸摸地将信息重新分片、排序再发送,假装自己是一个“透明”第三人。但这些方法都会破坏我的两个网站的端到端密钥协商机制,即使网站显示连接成功,安全码也会不一样,从而暴露了“中间人”的存在。在这种情况下,由于两边的实际密钥不一致,所以解密必然失败,通信也就无法进行,具体表现是“看上去连接了,但实际上没连上,所有信息都不通”。

幸好,这种严密管控的情况还不是太多。

2025.12.15补充:在小米手机、华为手机上经过多次测试后发现,1对1连接模式下之前存在一个bug,这个bug只会在国产手机自定义 webview 的情况下复现,导致房建创建者会向服务器一下子发送两个创建房间的信号,从而造成冲突。奇怪的是在三星(港版系统)、苹果上都不会出现,初步分析是因为自定义 webview 让两次信号间隔时间变长了,从而变成不可忽略,最终导致加入端的密钥实际上是与创建者第二次发送的一致,与第一次发送的不一致。

解决方案也有,那就是老老实实地用客户端轮询的方式,一个 Request 返回一个 Response,这也是普通用户正常浏览网页时的信息交换方式。用这种方式当然也可以实现端到端加密,但一方面它不再是一个“实时”的连接,另一方面,它每次“一问一答”都会受到企业防火墙的策略影响:封包、限流、限速都可以轻松实现,而且可以将连接日志的颗粒度细化到每一次问答。这也是如今多数 IM 软件特别是企业通信软件的官方做法。

要不要做?还没想好。似乎有趣程度会降低。