浏览器内 P2P 文件传输 Web App(React + WebRTC + Node.js Signaling)。
这个项目是一个「零注册、低门槛、强隐私」的跨设备文件传输实验:
用户只需输入节点编号与 6 位通行码,即可在浏览器内完成设备间直连传输。
设计灵感来自《某科学的超电磁炮》中的“妹妹网络”设定,采用和风海报风格视觉表达。
核心目标:
- 文件本体端到端直传(WebRTC DataChannel)
- 服务器仅负责信令,不存储文件
- 支持断线重连与断点续传
- 信令服务器自动下发 Cloudflare TURN 短时效凭证(含防滥用 + 1T 月度熔断);保留用户手工 TURN
- 支持强制 relay 测试
当前状态:
- v1/v2 核心功能完成
- PWA 基础能力完成(SW + Manifest + 安装提示)
- 性能项已完成 desktop Lighthouse 实测(首页 Performance 99)
- 测试基线已加固:服务端集成脚本统一
runTest退出守卫,CI 不再因悬挂句柄被卡住 - 剩余主要工作为实网验证闭环(ICE 三场景、TURN 公网可达)
- 节点注册与接入(nodeId + passcode)
- QR 邀请加入(含链接接入)
- 多 peer 同时在线与群发
- 文件传输(加密、暂停/恢复/取消)
- 大文件接收写盘(File System Access / OPFS / IndexedDB 降级)
- 会话消息与文件卡片下载
- 网络诊断(信道类型、ICE 路径与时间戳、诊断一键复制)
- 前端:React 18 + TypeScript + Vite + Tailwind + Zustand
- 传输:WebRTC DataChannel + 应用层 AES-GCM
- 后端:Node.js + Express + ws(信令)
- 部署:静态前端 + Docker 化信令 + 可选 coturn
misaka-network/
├── client/ # 前端
├── server/ # 信令服务
├── deploy/ # 生产部署模板(Caddy / coturn)
├── docs/ # 精简文档入口
│ └── archive/ # 历史/低频文档归档
└── README.md
- 启动信令服务
cd server
npm install
cp .env.example .env # 可选:不建也能跑,全部走默认值
npm run dev默认:http://localhost:9080,WS:ws://localhost:9080/ws
npm run dev、npm start 和 Docker 镜像 CMD 共用同一个入口 src/bootstrap.ts,
按下列顺序加载环境文件(先命中者生效;已存在于进程环境中的变量始终优先):
$MISAKA_ENV_FILEserver/.env- 仓库根
.env(与docker-compose.yml的env_file同一份)
全部可配项见 server/.env.example。集成测试与 Playwright
故意直接启动 dist/index.js(npm run start:raw 是同一入口),不读任何 .env,
避免开发者的本地文件污染测试。
- 启动前端
cd client
npm install
npm run dev默认:http://localhost:5173(开发代理 /api /ws 到 9080)
- 设备 A 打开首页,输入节点编号和通行码接入网络
- 设备 B 扫描 A 的 QR(或打开复制链接)
- 在网络页选择目标节点并发送文件
- 接收端确认并下载文件
根目录执行,干净检出即可直接跑(根 .env 是可选的)。快速栈明确以
NODE_ENV=development 运行,默认 TURN_AUTO_ENABLED=false:
docker compose up -d --build需要 Cloudflare 自动 TURN、或需要身份派生跨重启保持稳定时,先建密钥文件:
cp .env.example .env
# 至少填:SERVER_SECRET(openssl rand -hex 32)
# TURN_CF_KEY_ID / TURN_CF_API_TOKEN / TURN_CF_ACCOUNT_TAG(启用自动 TURN 时)
# TURN_AUTO_ENABLED=true
docker compose up -d --build查看状态:
docker compose ps
docker compose logs -f signaling这套栈把 9080 直接发布到宿主机,前面没有反向代理,因此
TRUST_PROXY=0: 服务端忽略X-Forwarded-For,否则每条 per-IP 防线都能被伪造头绕过。
cd deploy
cp Caddyfile.example Caddyfile
# 修改域名与邮箱
cp .env.example .env
# 必填 SERVER_SECRET;启用自动 TURN 时还要填 TURN_CF_* 三件套
docker compose -f docker-compose.prod.yml up -d --buildSERVER_SECRET 缺失时 docker compose ... up 会直接报错退出,而不是静默用随机值启动
(随机值会让 Cloudflare customIdentifier 每次重启都变,revoke 重试永久失效)。
密钥必须是 openssl rand -hex 32 生成的 64 位十六进制值。轮换会同时改变通行码身份
HMAC 与 TURN identifier,必须作为协同失效事件执行:先排空活动会话和凭据,所有信令
实例同时换钥,再统一重启;不能滚动混用新旧密钥。
健康检查:
curl -s https://signal.example.com/api/health拓扑固定为恰好一跳:internet → caddy → signaling:9080。signaling 容器只 expose
不 ports,唯一入口是 Caddy。对应两处配置必须成对存在:
| 位置 | 作用 |
|---|---|
deploy/Caddyfile.example |
先 request_header -X-Forwarded-For / -X-Real-IP / -Forwarded 丢弃客户端自带的转发头,再 header_up X-Forwarded-For {http.request.remote.host} 写入真实 TCP 对端;HTTP 与 /ws 升级共用同一个 reverse_proxy 块 |
deploy/docker-compose.prod.yml |
TRUST_PROXY: "1" —— 恰好信任这一跳 |
任何一边缺失都会出问题:只设 TRUST_PROXY 不清洗头 → 客户端可伪造 IP;只清洗头不设
TRUST_PROXY → 全部用户折叠成 Caddy 容器的 IP,共享 API 限流、节点上限与 TURN 配额。
在 Caddy 前面增加 CDN 时,Caddy 只信任 CDN 发布的 CIDR,从验证后的 {client_ip}
覆盖上游转发头;signaling 仍保持 TRUST_PROXY=1,因为它只接收 Caddy 这一跳。
上线后验证:
cd deploy
./verify-proxy-trust.sh https://signal.example.com # 伪造 XFF 不应换来新配额 → 期望 429
./verify-proxy-trust.sh https://signal.example.com --fresh # 换一个网络执行 → 期望 200
docker compose -f docker-compose.prod.yml logs -f caddy # 访问日志里的 remote_ip 应是真实客户端 IP若 Caddy 访问日志里每条请求都是 172.x 网关地址,说明宿主机多了一层 NAT,
per-IP 防护在修好之前都不可信。
信令服务器可自动按 session 申请 Cloudflare Realtime TURN 短时效凭证下发给客户端,所有防滥用、配额、熔断都在服务端 enforce,客户端为纯消费方。
-
拿凭证:Cloudflare Dashboard → Realtime → TURN,建一个 TURN Key,记下:
- Key ID
- API Token(权限至少
TURN:Edit+Account Analytics:Read) - Account Tag(侧栏 Account ID)
-
配置
.env(仓库已 gitignore;根栈用根.env.example,Caddy 生产栈用deploy/.env.example):SERVER_SECRET=... # openssl rand -hex 32,必须跨重启稳定 TURN_CF_KEY_ID=... TURN_CF_API_TOKEN=... TURN_CF_ACCOUNT_TAG=...
-
运行(
docker-compose.yml已内置全部可调阈值):docker compose up -d --build
验证:
curl -s http://localhost:9080/api/turn-status | jq curl -s -H "Authorization: Bearer $TURN_OPERATOR_TOKEN" \ http://localhost:9080/api/turn-status | jq
第一个响应只给出粗粒度可用性;月度用量、熔断和撤销队列等详细状态仅在配置
TURN_OPERATOR_TOKEN并提供对应 Bearer token 时返回。
| 变量 | 默认 | 含义 |
|---|---|---|
TURN_AUTO_ENABLED |
根快速栈 false;生产栈 true |
总开关 |
TURN_CREDENTIAL_TTL_SEC |
300 |
凭证有效期(短=即使 revoke 失败也兜底) |
TURN_MAX_BYTES_PER_SESSION |
1 GiB | 单 session 累计字节超额 → revoke |
TURN_MAX_BYTES_PER_HOUR_PER_IP |
10 GiB | 单 IP 一小时悲观字节上限 |
TURN_MAX_ISSUE_PER_HOUR_PER_IP |
60 | 单 IP 一小时签发次数 |
TURN_GLOBAL_MONTHLY_BYTES_LIMIT |
1 TiB | CF 免费额度上限(按 UTC 月份重置) |
TURN_GLOBAL_THRESHOLD_PCT |
90 |
达到全局阈值 % 即熔断停止下发 |
TURN_REVOKE_ALL_ON_KILL |
false |
熔断时是否批量 revoke 所有活动凭证 |
TURN_ABUSE_POLL_SEC |
30 |
按 customIdentifier 查 CF Analytics 周期 |
TURN_GLOBAL_POLL_SEC |
120 |
全局月度用量查询周期 |
TURN_REVOKE_RETRY_INTERVAL_MS |
60000 |
失败的 CF revoke 调用重试间隔 |
TURN_CF_TIMEOUT_MS |
8000 |
单次 Cloudflare API 调用墙钟超时 |
TURN_ANALYTICS_PAGE_LIMIT |
1000 |
Analytics 单页行数 |
TURN_ANALYTICS_MAX_PAGES |
20 |
Analytics 最大页数,触顶时进入 degraded |
TURN_IP_BAN_STRIKES |
3 |
同 IP 触发多少个滥用 session 后封禁 |
TURN_BAN_DURATION_SEC |
86400 |
IP 封禁秒数;0 禁用 |
TURN_OPERATOR_TOKEN |
空 | 详细 TURN 状态接口的 Bearer token |
TURN_AUTO_ENABLED=true 时,Cloudflare Key ID、API Token 和 Account Tag 均为必填;
缺少任一项,服务会在绑定端口前失败。
服务端把 TURN 状态(月度字节、活动凭证、签发历史)与暴破锁/冻结快照原子写到
TURN_PERSIST_DIR,重启不丢。其余数据(节点 / 会话 / 上报)维持现有内存策略。
| 栈 | /app/data 落点 |
|---|---|
根 docker-compose.yml |
绑定挂载 ./data(已 gitignore,方便直接查看/备份) |
deploy/docker-compose.prod.yml |
命名卷 misaka_data(不是匿名卷,down/重建镜像都不丢) |
命名卷备份 / 恢复:
docker run --rm -v misaka_data:/data -v "$PWD:/backup" \
alpine tar czf /backup/misaka-data-$(date +%F).tar.gz -C /data .
docker run --rm -v misaka_data:/data -v "$PWD:/backup" \
alpine sh -c 'cd /data && tar xzf /backup/misaka-data-YYYY-MM-DD.tar.gz'从旧版 Dockerfile 的匿名 /app/data 卷升级时,先按
生产数据卷迁移指南 把旧快照复制到
misaka_data;直接重建会让月度额度、撤销队列和安全锁从空状态开始。
保留策略:这些文件不含任何用户内容,只有计数器、不可逆派生的 TURN identifier 和 按 IP 归档的滥用状态。按 IP 的记录属于个人数据,建议每日快照 + 7~30 天保留, 存放位置不要对外可读;丢失备份的后果是月度熔断计数与封锁状态归零,不是数据损坏。
设置 → 中继 tab 展示自动 TURN 的粗粒度可用性;下方经过地址校验的手工 TURN 在总开关启用时与自动下发并存。运维详细额度只通过带 operator token 的 API 查看。
cd deploy
cp turnserver.conf.example turnserver.conf
# 修改 external-ip / realm / user
docker compose -f docker-compose.turn.yml up -d防火墙需放行:
3478/tcp+udp5349/tcp49160-49200/udp(与模板一致)
cd client
npm install
npm run build将 client/dist/ 部署到静态托管(GitHub Pages / Nginx / Cloudflare Pages)。
「应用挂在哪个路径」只有一个来源:VITE_BASE。client/vite.config.ts 把它交给
Vite 的 base,随后三处全部由它派生,不再各写各的:
| 派生物 | 来源 |
|---|---|
dist/index.html 里的 asset / manifest / favicon URL |
Vite base |
React Router basename 与所有对外绝对链接(QR 邀请、扫码跳转) |
import.meta.env.BASE_URL → client/src/lib/appBase.ts |
dist/404.html 的 SPA 回退跳转目标 |
构建期把 %BASE_URL% 替换为同一个 base(vite.config.ts 的 misaka-base-aware-404 插件) |
- 不设 →
./:相对 asset,路由按站点根处理。适合自定义域名或自托管根路径。 VITE_BASE=/my-repo/→ GitHub Pages 项目站点子路径。
GitHub Pages 工作流不需要手填:.github/workflows/deploy.yml 先跑
actions/configure-pages,用它输出的真实 base_path(仓库名,自定义域名时为 /)
生成 VITE_BASE,构建后再断言 404 跳转目标与 asset 前缀一致。仓库改名或 fork 都不用改代码。
本地验证子路径构建:
cd client
VITE_BASE=/subpath-smoke/ npm run build:subpath
grep "var base" dist-subpath-smoke/404.html # → /subpath-smoke/
grep -o '"/subpath-smoke/assets/[^"]*"' dist-subpath-smoke/index.html | headCI 每个 PR 都跑同一套断言(.github/workflows/test.yml 的 client-build job)。
dist/config.json 运行时配置示例:
{
"API_BASE": "https://signal.example.com",
"WS_URL": "wss://signal.example.com/ws"
}部署 base 只有一个来源:构建时的 Vite base(本仓库由 VITE_BASE 设置)。
修改挂载子路径必须重新构建,确保静态资源、router、Service Worker 与 404 回退一致;
运行时 config.json 只覆盖后端 API_BASE / WS_URL。
# 安装所有依赖
npm run install:all
# 运行全部测试(服务端集成 + 客户端单元/契约)
npm test
# 仅服务端
npm --prefix server test
# 仅客户端
npm --prefix client test
# 端到端测试(Playwright,自动启动 server + Vite)
npm run test:e2e
# 人工 Playwright 调试脚本(需自行先跑起 server + Vite)
npm --prefix client run test:manual├── server/tests/ # 服务端集成测试 (Node.js spawn + fetch + WS)
│ ├── _harness.mjs # runTest / killChild:强制显式退出,防止悬挂句柄
│ ├── register-edge.test.mjs # /api/register schema 校验、多设备、Bearer 保护
│ ├── brute-force.test.mjs # 通行码穷举锁定、IP 上限、速率限制
│ ├── ws-auth.test.mjs # WS AUTH 4001/4002 关闭码
│ ├── signaling-end.test.mjs # SIGNAL_ICE_END 转发 + 跨 channel 隔离
│ ├── turn-policy.test.mjs # TURN 颁发门控 (IP cap / 熔断)
│ ├── turn-lifecycle.test.mjs # 凭证过期清理、月度滚月
│ ├── turn-http.test.mjs # /api/turn-status / /api/turn-credentials HTTP 形态
│ └── stress-1gb.test.mjs # 1GB 文件内存压测 (sender 流式 / 接收写盘)
├── client/tests/unit/ # 客户端 Vitest 单测 (jsdom)
│ ├── nat-classify.test.ts # NAT 分类纯函数
│ ├── authedFetch.test.ts # authedFetch 401 自愈状态机
│ ├── transfer-frame.test.ts # chunk 帧编码 / IV 派生 / AES-GCM round-trip
│ └── network-cleanup.test.ts # peer 离线清理、重连不残留监听器
├── client/tests/e2e/ # Playwright 端到端 (真实 server + 真实 WebRTC)
│ ├── transfer.spec.ts # 两 peer 单文件 / 多文件传输 + LAN 重协商抑制
│ └── auth-recovery.spec.ts # authedFetch 401 自愈 (QR 路径)
├── client/tests/ui-contract.test.mjs # 前端关键行为源码契约
└── client/tests/manual-test.mjs # 人工 Playwright 调试入口 (npm run test:manual)
上面只列出代表性文件,实际用例数以目录为准。
cd client
npm run test:unit -- --coverage
# 打开 coverage/index.htmlPR 必须通过 .github/workflows/test.yml 中全部 job 才能合并:
| Job | 内容 |
|---|---|
server-tests |
服务端构建 + 集成 + TURN 脚本 |
client-tests |
typecheck(src)+ typecheck:config(配置)+ typecheck:tests(单测/E2E)+ 单测/契约 |
client-build |
生产 bundle 构建 + 子路径部署 smoke check(asset / router / 404 base 一致性) |
e2e-tests |
Playwright 双 peer 真实 WebRTC |
tests-touched-guard |
改了 src/ 却没改 tests/ 的 PR 会被拦截([skip-test-guard] 可放行) |
client-build 是 2026-07 补上的:此前 PR 只做 typecheck 与单测,Vite/Rollup/PostCSS/Tailwind
的构建期回归要合并后才在部署工作流暴露。
GitHub Actions job 本身不会自动成为合并门禁。仓库管理员还必须在 main 的分支保护或
ruleset 中把上表显示的检查名称设为 required;修改 job 名称时同步更新 ruleset。
详细的测试纪律 / PR 描述要求 / 测试分层见 CONTRIBUTING.md 与 CLAUDE.md。要点:
- 改动前先跑
npm test建立绿基线,再开工 - 修 bug 必须先写复现失败用例,再改代码
- 服务端集成脚本必须用
server/tests/_harness.mjs的runTest包裹main,避免悬挂句柄拖死 CI
- 总览:
docs/00-overview.md - 当前进度:
docs/PROGRESS.md - 会话模板:
docs/PROMPTS.md - 归档文档:
docs/archive/
- 项目维护与版权:© Master Huang · Misaka Network
- 本项目为同人风格技术作品,非商业用途,向原作及相关创作者致敬。