OpenLess 是一个开源的跨平台听写/输入法应用(Windows/macOS/Linux 桌面端 + Android),客户端代码在 github.com/Open-Less/openless。它自带一套端到端加密(E2EE)多设备同步功能,客户端 Rust 代码位于 openless-all/app/crates/openless-core/src/cloud_sync_e2ee*。
现状:这套同步机制默认依赖官方服务器 apic.openless.top:9443,而该服务器的登录认证走的是 GitHub OAuth Device Flow——客户端必须能连上 github.com/api.github.com 才能建立同步会话。在中国大陆网络环境下,这一步经常不稳定。
本次任务目标:实现一个自托管的替代服务器,具备与官方服务器完全相同的同步数据接口(客户端的加密/合并逻辑完全不用改),但认证方式换成服务器管理员手动签发的静态 Token,彻底不依赖 GitHub。
重要前提:本服务器只需要做"存储 + 认证 + 并发控制",不需要理解、不需要解密任何业务数据——所有同步内容在到达服务器之前已经是客户端加密好的密文(E2EE),服务器只是一个带版本控制的密文 blob 存储 + token 校验网关。这是本项目能做得"轻量"的根本原因,请不要引入任何业务数据解析逻辑。
服务器的完整 HTTP 接口规格来自对客户端 Rust 源码(cloud_sync_e2ee_protocol/transport.rs、types.rs)的逆向整理,下面第 3 节的每一条都是从真实客户端代码里核对过的硬性要求,不是建议,是必须——客户端有大量 assert/validate 逻辑,字段名、字段值、HTTP 状态码但凡有一处不符,客户端会直接拒绝连接或同步失败。
| 项目 | 选型 | 理由 |
|---|---|---|
| 语言/框架 | Python 3.12 + FastAPI | 原生异步、Pydantic 做严格 JSON schema 校验(本协议大量字段要求 camelCase 命名 + 拒绝未知字段),单进程即可生产可用 |
| ASGI server | uvicorn | 与 FastAPI 官方推荐搭配,单容器单进程即可 |
| 数据库 | SQLite(Python 内置 sqlite3 模块,无需额外依赖) |
个人/小团队多设备同步场景,并发量极低,SQLite 完全够用,且免去额外容器 |
| 容器化 | Docker + docker-compose,单容器 | 见第 5 节 |
| HTTPS | 不在容器内处理,交给宿主机反向代理(Caddy/Nginx)做 TLS termination,容器内只监听纯 HTTP | 简化容器逻辑;客户端强制要求 https:// 开头的地址,但这可以由反代提供 |
openless-sync-server/
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── .env.example
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app 入口,挂载路由
│ ├── config.py # 从环境变量读配置
│ ├── db.py # SQLite 连接、建表(启动时自动建表,若不存在)
│ ├── models.py # Pydantic 请求/响应模型(camelCase 别名)
│ ├── auth.py # token 校验、session 签发/校验中间件
│ ├── routes/
│ │ ├── capabilities.py # GET /v1/capabilities
│ │ ├── auth.py # POST /v1/auth/token, DELETE /v1/auth/session
│ │ └── vault.py # GET/PUT/DELETE /v1/me/vault*, GET /v1/me/operations/{id}
│ └── admin_cli.py # 命令行小工具:创建账户 + 生成静态 token(不走 HTTP,本地执行)
├── data/ # volume 挂载点:sqlite 文件 + 密文快照存这里
└── tests/
└── test_protocol.py # 见第 7 节验收测试,用 pytest + httpx 写
所有 JSON 字段名一律 camelCase。所有时间用 ISO8601 字符串。所有响应
Content-Type: application/json(除非另有说明)。
- 认证:无需认证,任何人可访问。
- 响应:
200 OK,body 必须逐字节精确匹配以下结构(客户端硬编码校验,任意一个数值错误都会导致客户端报UnsupportedProtocol并拒绝继续):
{
"protocolVersion": 1,
"cryptoProfile": "argon2id-xchacha20poly1305-v1",
"githubClientId": "Ov23liyv3nEucG7oMHNE",
"maxHttpBodyBytes": 25165824,
"maxCiphertextBytes": 16777232,
"maxPlaintextJsonBytes": 15728640,
"idempotencyRetentionSeconds": 604800,
"maxBackupRetentionDays": 30
}⚠️ githubClientId不是随便填的占位值,客户端会严格校验它:客户端建立连接时会先请求这个接口,把返回的githubClientId跟自己编译时写死的 GitHub OAuth App Client ID 做逐字节比较,不相等就直接报OAuth application mismatch并拒绝继续,根本走不到后面的/v1/auth/token。当前客户端源码(crates/openless-core/src/marketplace.rs)里写死的默认值是"Ov23liyv3nEucG7oMHNE"(可以被编译时的GITHUB_OAUTH_CLIENT_ID环境变量覆盖——如果你知道自己那份客户端编译时设置了别的值,改用那个值)。这个字段名字带 "github" 只是历史遗留,跟真实 GitHub 账号无关,纯粹是客户端用来确认"这是我认识的那个 OAuth 应用"的一个固定校验值,服务器原样抄成上面这个字符串即可,不需要自己生成或校验。idempotencyRetentionSeconds:客户端要求 ≥ 604800(即至少 7 天),写死 604800 即可。maxBackupRetentionDays:客户端要求 ≤ 30,写死 30 即可。- 其余四个数值(
protocolVersion/cryptoProfile/maxHttpBodyBytes/maxCiphertextBytes/maxPlaintextJsonBytes)必须与上面示例完全相等,不可修改。
- 认证:请求头
Authorization: Bearer <静态token>。 - 服务器逻辑:
- 从
accounts表中查找与该 token 哈希匹配的账户记录。 - 未找到或 token 已被吊销 →
401 Unauthorized,body:{"code": "unauthenticated", "message": "invalid token"}。 - 找到 → 在
sessions表插入一条新记录:生成一个新的随机access_token(建议 32 字节随机数做 base64url 编码),过期时间 = 当前时间 + 900 秒。
- 从
- 响应(
200 OK):
{
"protocolVersion": 1,
"accessToken": "<刚生成的随机字符串>",
"tokenType": "Bearer",
"expiresIn": 900,
"account": { "githubId": "<account_id 字符串>", "login": "<账户显示名>" }
}⚠️ protocolVersion和tokenType是必填字段,不能省略:客户端用#[serde(deny_unknown_fields)]严格解析这个响应,少了任何一个字段会直接反序列化失败(报json schema错误,在换取会话这一步就整个失败,根本走不到后面)。protocolVersion必须恰好等于1(整数,不是字符串),tokenType必须恰好是字符串"Bearer"(大小写、拼写都要精确匹配,随便写别的值客户端会拒绝)。这两个字段在 3.1 的/v1/capabilities里也出现过,是同一套协议版本/令牌类型约定,/v1/auth/token的响应必须原样带上,不是/v1/auth/github独有的。expiresIn必须是 1 到 900 之间的整数(客户端硬性校验区间,不能超过 900)。⚠️ account.githubId(以及下面所有接口里出现的ownerGithubId)格式有硬性限制,不是任意字符串:客户端把这个字段按 GitHub 数字账号 ID 的格式解析——必须是纯十进制数字组成的字符串,长度 1~20 位,且不能有前导零("0"本身也不合法,必须 ≥ 1)。传一个 UUID 或用户名字符串进去,客户端会在收到响应的第一时间就解析失败,整个登录直接报错。最简单的做法:accounts表的主键用数据库自增整数(如 SQLite 的INTEGER PRIMARY KEY AUTOINCREMENT,天然从 1 开始、无前导零),JSON 里输出时转成字符串(str(account_id))即可,不需要额外校验格式。
- 认证:
Authorization: Bearer <access_token>(不是静态 token,是 3.2 换回来的那个)。 - 逻辑:从
sessions表删除该 access_token 对应记录。 - 响应:
204 No Content(无 body)。客户端专门校验这个状态码,返回 200 会被当成异常。 - 若 access_token 无效/已过期 →
401,客户端会将其当作"已经登出"处理,不视为错误。
- 从
Authorization: Bearer <access_token>取值,查sessions表。 - 查不到,或
expires_at已过去 → 一律返回401 Unauthorized,body:(客户端会识别{"code": "unauthenticated", "message": "session expired or invalid"}code字段里的unauthenticated/session_expired这两个值,做出不同的重试逻辑,两个值都可以用,实现上统一用unauthenticated即可。)
revision/baseRevision/committedRevision 这几个字段,跟 3.2 里 githubId/ownerGithubId 是同一个底层类型(Revision,本质是数字,但在 JSON 里必须写成字符串,例如 "0"、"3",不能写成 JSON 数字 0、3;同样不能有前导零)。下面所有示例已经按这个规则写成字符串,照抄即可,不要"优化"成数字类型。
- 认证:见 3.4。
- 条件请求支持:若请求头带
If-None-Match: <etag>且与当前vault_metadata.etag相等 → 返回304 Not Modified,无 body。 - 否则:返回
200 OK,响应头必须带ETag: <当前etag>,body 必须包含下面全部 11 个字段,一个都不能少(客户端用#[serde(deny_unknown_fields)]严格解析,多字段、少字段都会直接失败)。empty状态(还没上传过任何快照)示例:
{
"protocolVersion": 1,
"state": "empty",
"ownerGithubId": "2",
"revision": "0",
"vaultId": null,
"keyId": null,
"updatedAt": null,
"payloadSchemaVersion": null,
"ciphertextBytes": 0,
"ciphertextSha256": null,
"lastOperationId": null
}已经上传过快照的 active 状态示例(vaultId/keyId/updatedAt/payloadSchemaVersion/ciphertextSha256/lastOperationId 这 6 个字段从 null 变成实际值,其余字段名不变):
{
"protocolVersion": 1,
"state": "active",
"ownerGithubId": "2",
"revision": "3",
"vaultId": "be406ca5-37fa-4b26-9a9a-74c1aad48eae",
"keyId": "2b1889ab-7278-48e7-8d2f-284195de2484",
"updatedAt": "2026-10-01T12:00:00Z",
"payloadSchemaVersion": 1,
"ciphertextBytes": 123456,
"ciphertextSha256": "aaaa...(64 位小写十六进制 sha256)",
"lastOperationId": "f1d8c32e-d209-4e56-8735-bc66b8684c71"
}protocolVersion固定是1(整数,不是字符串——只有 revision 系的字段才是字符串)。state取值只能是"empty"、"active"或"deleted"(v1 里基本只会用到前两种,deleted是删除流程中间态,正常不会持久停留在这个状态——见 3.8)。ciphertextBytes是普通整数(不是Revision),empty状态下写0。etag的生成方式:建议直接用revision的字符串形式,或sha256(account_id + ":" + revision),只要每次 revision 变化时 etag 也跟着变、revision 不变时 etag 不变即可,客户端不关心具体算法。
- 认证:见 3.4。
- 要求请求头
If-Match: <etag>:若与当前vault_metadata.etag不一致 → 返回412 Precondition Failed(客户端收到后会重新走 3.5 再重试)。 - 若 vault 状态是
empty(还没有快照) → 返回404 Not Found。 - 否则 返回
200 OK,body 就是 3.7 里客户端 PUT 上来的那个 body 原封不动地返回(ciphertext是 base64 编码的密文,服务器不需要、也不能解析其内容)——这条接口不需要自己理解或重新拼装字段,存的是什么 JSON 字节,原样吐出来就行,字段名、字段数量、类型全部跟着客户端走,不要凭空增删或改动大小写。
-
认证:见 3.4。
-
必须携带的请求头:
If-Match: <etag>——必须等于当前vault_metadata.etag,否则整个写入基于过期状态,需要拒绝。Idempotency-Key: <operation_id>——同一个请求 id 的重复提交,服务器必须能识别出来。Content-Type: application/json。
-
请求 body:客户端发来的 JSON,服务器原样存下整个 body(不需要理解每个字段的业务含义),但需要单独抽取以下几个字段用于并发控制判断:
revision(新版本号,字符串)、baseRevision(基于哪个旧版本号写的,字符串)、operationId、kind("create"/"snapshot"/"passwordChange")、vaultId。 -
处理逻辑(严格按此顺序):
- 幂等检查:查
idempotency_log表,(account_id, operation_id)是否已存在。- 存在 → 直接返回上次记录的响应 body 和状态码,额外加响应头
Idempotency-Replayed: true。不要重新执行任何写入逻辑。 - 不存在 → 继续下一步。
- 存在 → 直接返回上次记录的响应 body 和状态码,额外加响应头
- If-Match 校验:请求头
If-Match必须等于当前vault_metadata.etag,不相等 →412 Precondition Failed,不记入 idempotency_log(因为根本没写成功)。 - 乐观并发校验:body 里的
baseRevision必须等于当前vault_metadata.revision;如果不相等,说明有别的设备已经抢先写过了 → 返回409 Conflict,body:{"code": "revision_conflict", "message": "..."},同样不记入 idempotency_log。 - 两个校验都通过:
vault_snapshot表整条覆盖为新的密文 body。vault_metadata.revision=baseRevision + 1(也就是客户端传来的新revision值,两者应该相等,可以再做一次断言)。vault_metadata.state置为"active"。vault_metadata.lastOperationId= 本次operation_id。- 生成新的
etag。 - 把本次的响应结果写入
idempotency_log。
- 响应:
200 OK(⚠️ 不是 204),响应头Idempotency-Replayed: false,body 必须是完整的 "操作回执" 结构,下面 7 个字段一个不能少(跟 3.9 轮询接口、3.8 删除接口返回的是同一种结构):
{ "operationId": "<本次 operation_id>", "status": "committed", "kind": "snapshot", "committedRevision": "<新版本号,字符串,同上面的 revision>", "committedAt": "2026-10-01T12:00:00Z", "vaultId": "<本次写入的 vault_id>", "ciphertextSha256": "<64 位小写十六进制,sha256(请求体里 ciphertext 字段解码后的原始字节)>" }status固定字符串"committed"(v1 版本所有写操作都同步完成,不会有别的取值)。kind原样回显请求体里的kind字段。ciphertextSha256必须真的计算(不能瞎填):客户端会用它跟本地重新计算的哈希比对,对不上直接报错。
- 幂等检查:查
DELETE、请求体和响应体的字段形状不同。照抄下面的规格,不要按普通 REST 删除接口的直觉去实现(普通直觉是"204 No Content",这里客户端明确要求 200 + 一个 JSON body,204 会被客户端拒绝)。
- 认证:见 3.4。
- 必须携带的请求头:跟 3.7 完全一样——
If-Match、Idempotency-Key、Content-Type: application/json。 - 请求 body(客户端发来的 JSON,4 个字段):
{
"protocolVersion": 1,
"baseRevision": "<当前 revision,字符串>",
"operationId": "<operation_id>",
"expectedVaultId": "<当前 vault_id>"
}-
处理逻辑(跟 3.7 的第 1~3 步完全一样,第 4 步不同):
- 幂等检查(同 3.7)。
- If-Match 校验(同 3.7)。
- 乐观并发校验:body 里的
baseRevision必须等于当前vault_metadata.revision,不等 →409 Conflict(同 3.7)。 - 全部通过后:
- 清空该账户的
vault_snapshot记录。 vault_metadata.state重置为"empty"。vault_metadata.revision=baseRevision + 1。vault_metadata.vaultId/keyId/updatedAt/payloadSchemaVersion/ciphertextSha256全部置回null,ciphertextBytes置回0。vault_metadata.lastOperationId= 本次operation_id。- 生成新的
etag。 - 把本次的响应结果写入
idempotency_log。
- 清空该账户的
- 响应:
200 OK(同样不是 204),响应头Idempotency-Replayed: false,body 是跟 3.7 一样的 "操作回执" 结构,kind固定"delete",ciphertextSha256固定null,vaultId是被删除前的那个 vault_id(不是 null——客户端会拿它跟删除前的 vault_id 比对):
{ "operationId": "<本次 operation_id>", "status": "committed", "kind": "delete", "committedRevision": "<新版本号,字符串>", "committedAt": "2026-10-01T12:00:00Z", "vaultId": "<被删除前的 vault_id>", "ciphertextSha256": null }
- 认证:见 3.4。
- 逻辑:查
idempotency_log表是否有该operation_id的记录(跨账户查不到算未找到)。- 找到 →
200 OK,body 是跟 3.7/3.8 完全一样的 "操作回执" 结构(不是简化版的{"operationId":"...","status":"committed"}——同样是那 7 个字段一个不能少)。 - 没找到 →
404 Not Found,body:{"code": "operation_not_found", "message": "..."}。
- 找到 →
- 本项目 v1 版本所有写操作都是同步完成的,这个接口只是配合客户端的轮询逻辑做个简单查询(比如客户端在提交请求后掉线、没收到 3.7/3.8 的响应,重新连上后靠这个接口确认到底有没有写成功),从
idempotency_log里把上次记录的同一份回执原样吐出来就行,不需要真正的异步任务队列。
CREATE TABLE IF NOT EXISTS accounts (
account_id INTEGER PRIMARY KEY AUTOINCREMENT, -- 必须是自增整数:JSON 里转成字符串后要满足 GithubId 的格式限制(见 3.2 的⚠️说明)
login_name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE, -- sha256(静态token),不存明文
revoked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
access_token TEXT PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(account_id),
expires_at TEXT NOT NULL -- ISO8601
);
CREATE TABLE IF NOT EXISTS vault_metadata (
account_id INTEGER PRIMARY KEY REFERENCES accounts(account_id),
vault_id TEXT, -- NULL when state='empty'
key_id TEXT, -- NULL when state='empty'
state TEXT NOT NULL DEFAULT 'empty',
revision INTEGER NOT NULL DEFAULT 0, -- store as integer; serialize to string in JSON (see 3.5's ⚠️ note)
payload_schema_version INTEGER, -- NULL when state='empty'
ciphertext_bytes INTEGER NOT NULL DEFAULT 0,
ciphertext_sha256 TEXT, -- NULL when state='empty'; 64-char lowercase hex
last_operation_id TEXT,
etag TEXT NOT NULL,
updated_at TEXT -- NULL when state='empty'; ISO8601 when active
);
CREATE TABLE IF NOT EXISTS vault_snapshot (
account_id INTEGER PRIMARY KEY REFERENCES accounts(account_id),
body_json TEXT NOT NULL, -- 原样存客户端上传的整个 JSON body(含 ciphertext 字段)
revision INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS idempotency_log (
account_id INTEGER NOT NULL,
operation_id TEXT NOT NULL,
response_status INTEGER NOT NULL,
response_body TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (account_id, operation_id)
);所有接口把 account_id 写进 JSON 响应时(account.githubId、ownerGithubId 等字段),一律用 str(account_id) 转成十进制字符串 —— 数据库里是整数,线上协议里是字符串,这是两回事,不要弄混。
账户/token 的管理不走 HTTP 接口(避免暴露一个"创建账户"的公开端点带来的安全风险),而是通过 app/admin_cli.py 命令行工具在服务器上本地执行,例如:
docker compose exec sync-server python -m app.admin_cli create-account --login "我的账户"
# 输出:account_id=<自增整数,例如 1>, token=<明文token,只显示这一次,自己抄下来配到客户端里>FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
# 非 root 用户运行
RUN useradd -m appuser
USER appuser
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]services:
sync-server:
build: .
ports:
- "127.0.0.1:8080:8080" # 只监听本机,对外暴露交给宿主机反代做 TLS
volumes:
- ./data:/app/data
environment:
- DB_PATH=/app/data/sync.db
- STORAGE_PATH=/app/data
restart: unless-stoppedfastapi
uvicorn[standard]
pydantic
(刻意不引入 ORM/额外框架,保持依赖最小。)
-
docker compose up -d --build一条命令启动成功,无需任何手动初始化步骤(建表逻辑在应用启动时自动跑)。 - 镜像体积 < 150MB(
docker images查看)。 -
docker compose down后docker compose up -d重新启动,之前创建的账户和同步数据仍然存在(验证 volume 挂载生效)。 - 容器内进程不是以 root 运行(
docker compose exec sync-server whoami应输出非 root 用户)。 - 容器日志中不出现明文 token、密文内容的打印(检查所有
log/print语句,确保不泄露敏感字段)。
- 静态 token 在数据库中只存
sha256哈希,任何日志/错误信息都不得回显明文 token。 - 所有
/v1/me/*接口在未通过 3.4 认证中间件时一律 401,不允许有遗漏的未鉴权路径。 -
access_token严格执行 15 分钟过期,过期后的 token 即使值本身没变也必须被拒绝。 - 服务器代码里不出现任何解密/解析
ciphertext字段内容的逻辑——这个字段对服务器来说应该完全是不透明的字节流。 -
admin_cli.py创建账户时生成的静态 token 用密码学安全随机数生成器(Pythonsecrets模块,不要用random)。 - 所有 SQL 查询使用参数化查询,禁止字符串拼接 SQL(防注入,虽然是自用工具也要养成习惯)。
GET /v1/capabilities返回值与第 3.1 节 JSON 逐字段比对相等。- 用不存在的 token 调
POST /v1/auth/token→ 401。 - 用
admin_cli建好的账户 token 调POST /v1/auth/token→ 200,且expiresIn≤ 900。 - 用拿到的
access_token调GET /v1/me/vault→ 200,响应体逐字段比对 3.5 节empty状态的完整 11 字段示例(尤其revision必须是字符串"0"不是数字0,protocolVersion/ciphertextBytes等 6 个非 null 字段也必须都在),响应头有ETag。 - 带上一步拿到的
etag做If-None-Match再调一次GET /v1/me/vault→ 304。 - 用错误/过期的
access_token调任意/v1/me/*接口 → 401。 - 首次
PUT /v1/me/vault/snapshot(baseRevision="0")→ 200(不是 204),响应体是 3.7 节完整的 7 字段操作回执,committedRevision变成字符串"1"。 - 用同一个
Idempotency-Key把上一步的请求原样再发一次 → 返回跟上次完全相同的 body,且响应头Idempotency-Replayed: true;数据库里revision不能变成 2。 - 换一个新的
Idempotency-Key,但故意把baseRevision填成过时的值(比如还是"0",而实际已经是"1")→ 409 冲突,revision不变。 - 正确递增
baseRevision后再次上传 → 成功,committedRevision变成"2"。 GET /v1/me/vault/snapshot带正确If-Match→ 200,拿到的 body 与上传时的请求 body 逐字节比对完全一致(验证服务器只是原样存取,没有改动任何字段,包括ciphertext)。GET /v1/me/vault/snapshot带过时的If-Match→ 412。DELETE /v1/auth/session→ 204,之后用同一个access_token再请求任意接口 → 401。DELETE /v1/me/vault(带正确的If-Match/Idempotency-Key/3.8 节的 4 字段请求 body)→ 200(不是 204!),响应体是 3.8 节的操作回执,kind == "delete",ciphertextSha256 == null;之后GET /v1/me/vault的state变回"empty",revision变回"0",其余可空字段全部变回null。- 重新走一遍第 7 条(对同一账户再次
PUT /v1/me/vault/snapshot,baseRevision用第 14 条之后的新 revision)→ 验证删除后依然可以重新创建,不会被之前的vault_id/key_id卡住。 GET /v1/me/operations/{operation_id}传一个第 7~10 条里真实提交过的operation_id→ 200,body 跟当时 PUT 返回的操作回执完全一致;传一个随便编的 UUID → 404,body 是{"code": "operation_not_found", ...}。
全部 16 条通过,即视为服务器端开发完成,可以对接客户端联调。
- 不做多快照历史版本(v1 只保留最新一份密文,旧版本不需要留档)。
- 不做真正的异步任务队列(所有写操作都是同步完成的)。
- 不做用户自助注册/Web 管理界面(账户管理走命令行
admin_cli.py)。 - 不做 TLS/证书处理(交给外层反向代理)。
- 不做除本文档列出的 8 个接口之外的任何额外接口。