0. 结论先行
- 架构一句话:手机 APK →
https://raven.bloodysky.top(ECS nginx,两层认证)→ ECS 回环127.0.0.1:18792→ SSH 反向隧道 → 家里 WSL 的 Raven 网关。家里不开任何公网端口,ECS 不开任何新端口。 - 客户端没有重写任何 Raven 协议:网关自带一个 3MB 的单文件 Web UI SPA(移动端 viewport、560px 断点、WebSocket 流式输出、审批与文件交付俱全),APK 只是个全屏 WebView 壳,只做容器层适配。
- 隧道用
ssh -R而不是 frp:复用 22 端口,零新端口、零安全组改动、零新软件;-R默认只绑 ECS 回环,只有 ECS 本机的 nginx 够得着,这是有意的安全设计。 - 六个坑全部有实测证据:Origin 白名单(公网访问全 403)、一次性 nonce 登录流程、DownloadManager 静默 401、
body{min-width:780px}的”桌面缩小版”、React #185 无限更新循环、z-index:999压掉三层。每一个都给了判定性证据和修法。 - 代码已公开:
github.com/luckyboybs/raven-android,发布前做过单提交历史压平与全库凭据审计;网关地址构建期可配、运行时可输。
一、需求与约束
Raven TUI 跑在家里一台 WSL 上,网关的 Web UI 监听在 127.0.0.1:18792。想随时在手机上用它,面对的是三个硬约束:
- 大概率不能直连。WSL2 在 NAT 后面(私有网段,手机无论在家还是出门都路由不到),家里也没有公网 IP,不想为此开端口映射。
- 不想为”手机访问”单独开一套服务。网关已经有完整的 Web UI,重新实现一套移动协议意味着把 3MB 的 SPA 用 Kotlin 重写,并且此后每次 Raven 升级都要追着 RPC 面跑。
- 安全模型不让步。”家里不开公网端口”这条不破;暴露到公网的入口必须有多层认证,不能裸奔。
手上有一台阿里云 ECS(有 443、有证书签发实践),于是方案自然成形:ECS 做公网边缘,WSL 主动向外建隧道。隧道是从 WSL 主动出去的,所以家里只需要能访问 ECS 的 22 端口——和平时 SSH 登录没有区别。
二、架构总览
1 | ┌─────────────┐ https://raven.bloodysky.top |
组件清单:
| 组件 | 位置 | 职责 |
|---|---|---|
raven-tunnel.service |
WSL,systemd 用户服务 | ssh -N -R 18792:127.0.0.1:18792,Restart=always 保活 |
| nginx vhost | ECS /etc/nginx/conf.d/raven.bloodysky.top.ssl.conf |
TLS 终止、Basic Auth、限流、Origin 改写、反代 |
raven-mobile.css/js |
ECS /var/www/raven/,nginx sub_filter 注入 |
窄屏布局修复、侧栏折叠、刷新钮、诊断面板 |
| APK 工程 | github.com/luckyboybs/raven-android |
WebView 壳:登录、cookie、文件选择、下载、错误页 |
一次完整请求的路径:手机 WebView 发请求 → ECS nginx 校验 Basic Auth → 改写 Origin 后 proxy_pass 到回环 18792 → SSH 隧道原样送到 WSL 网关 → 网关校验会话 cookie → 响应原路返回。WebSocket 升级走同一条路(nginx 配了 Upgrade/Connection 头,实测 101)。
架构固有边界:WSL/Windows 关机则整体不可用——隧道是从 WSL 主动外出的,WSL 不运行就没有出口。恢复靠 systemd:Linger=yes + WantedBy=default.target,开机未登录也起,断线 5 秒重连。
三、三个关键设计决策
3.1 为什么是 WebView 壳,而不是重写协议
先看清了网关给的是什么:raven serve 在 18792 上挂着一个完整的 Web UI——同源 SPA,/rpc 走 WebSocket 流式输出,RPC 面里有 approval.pending(审批弹窗)、文件交付、cron/dag 控制台,页面自带移动端 viewport 和 560px 断点。也就是说”手机端”需要的几乎一切,网关已经有了。
于是 APK 的定位收缩为容器层:全屏 WebView、登录态管理、<input type=file> 接管、下载接管、错误页、返回键、深色适配。不实现任何 Raven 协议,升级 Raven 不影响 APK。这个决定把工作量从”重写一个客户端并长期追协议”降为”写一个壳”,也是整个项目一天内能跑完的原因。
3.2 为什么是 SSH 反向隧道,而不是 frp
内网穿透的常规答案是 frp,但这里有个更轻的选择:ssh -R。
1 | # ~/.config/systemd/user/raven-tunnel.service |
三个理由:
- 零新端口。复用 22,ECS 安全组不用动,不用装 frps,WSL 不用装 frpc。
- 回环绑定是默认且正是所需。
-R不加GatewayPorts时只绑 ECS 的127.0.0.1——实测ss -tlnp确认只有回环监听,公网扫不到这个口,只有 ECS 本机的 nginx 够得着。 - 保活是 systemd 的强项。
Restart=always+RestartSec=5+ExitOnForwardFailure(建隧失败就退,让 systemd 重启,而不是挂个半死连接)+ServerAliveInterval 30(NAT 会话保活)。
两侧的实测证据——WSL 侧服务保活与 ECS 侧回环绑定、双层认证:
3.3 两层认证怎么摆
公网暴露的是一个能在家里执行 shell 的 agent,认证不能只靠一层:
- 第一层,nginx Basic Auth:整个 vhost
auth_basic,htpasswd 文件在 ECS 本地;配limit_req(20r/s、突发 50)防爆破。 - 第二层,网关自己的 token/cookie:见 §4.2 的 nonce 流程,一次性换 30 天 cookie。
两层各自独立:nginx 挡掉的是”知道网址就进来”,网关挡掉的是”有网址也拿不到会话”。后面 §4.3 会看到,这个”两层都要过”的设计在下载链路上埋了一个坑。
四、踩坑实录
4.1 Origin 白名单:公网访问全 403
第一个阻塞级问题。网关 ws.py 的 _origin_ok 有一份白名单,只认 http://127.0.0.1:18792 和 http://localhost:18792。浏览器从公网域名打开页面时,WebSocket 握手和 /auth/exchange 必带 Origin: https://raven.bloodysky.top——不在白名单,直接 403 bad origin。也就是说:不修这条,远程登录和实时通道全灭,页面能打开但什么都做不了。
判定性证据(本地直连网关,带公网 Origin 打 /rpc):
1 | $ curl -H 'Origin: https://raven.bloodysky.top' ... http://127.0.0.1:18792/rpc |
修法在代理层,不动网关:nginx 把 Origin 改写为白名单值。
1 | proxy_set_header Origin "http://127.0.0.1:18792"; |
代理本身已有 Basic Auth 门禁,网关的 token/cookie 认证不变,浏览器同源 CORS 保护不受影响。改完实测:/auth/exchange 200 + Set-Cookie,带 cookie 的 WebSocket 升级 101。
4.2 登录:一次性 nonce 链接流程
第二个意外:网关 Web UI 没有 token 输入框。远程浏览器拿到会话的唯一途径,是网关自己的一次性 nonce 链接流程:
1 | 1) POST /auth/nonce |
没有会话 cookie 时,网关给的是这样一个页面——没有 token 输入框,只有一条红条提示去拿 fresh link,这正是”必须走 nonce 流程”的直观证据:
APK 里用 HttpURLConnection 实现(没引 OkHttp),凭据进 EncryptedSharedPreferences(AES256-GCM)。两个实现细节值得说:
- cookie 名从
Set-Cookie响应头解析,只匹配raven_session前缀,不硬编码端口——端口会变。 - 冷启动自动跑完整流程,顶部进度条 10%→45%→80%;网关重启后 cookie 失效,点刷新钮重走一遍即可,不用杀 App。
4.3 DownloadManager 不协商 Basic Auth:静默 401
这个坑藏得最深。用户反馈”下载文件失败”,而 toast 明明提示”已开始下载”。
逐层实测把范围钉死(同一个交付 token,八种组合):
| 场景 | 结果 |
|---|---|
| 本地网关 + cookie | 200,完整字节 |
| ECS 经隧道 + cookie | 200,完整字节 |
| 公网 + Basic Auth + cookie | 200,完整字节 |
| 公网 + Basic Auth,无 cookie | 401(网关会话 guard) |
| 公网 + 仅 cookie,无 Basic Auth | 401(nginx Basic Auth 层) |
根因:/files/download 要同时过两层认证,而 DownloadManager 是独立的系统服务——它不弹对话框协商 Basic Auth,也不继承 WebView 的凭据。原代码只带了 User-Agent 和 Cookie,于是每次下载都被 nginx 401 挡死;而 enqueue() 本身是成功的,toast 照弹,失败发生在它内部,用户只看到通知栏一个失败条目。
修法很小,同站 URL 补一个头:
1 | val uri = Uri.parse(pending.url) |
凭据从 EncryptedSharedPreferences 读(登录时无条件写入,与”不保存 token”无关),外部站点的下载不带自家凭据。修复后用 curl 精确复现 DownloadManager 的请求面(UA + Cookie + Authorization,不发 Origin/Referer):200 且字节完整;去掉 Authorization 立刻回 401——证明就是这个头,不是别的改动顺带修好的。
4.4 “桌面缩小版”:body 的 min-width:780px
真机第一版验收,用户一加 13T 上的观感是”功能全通但桌面缩小版”。截图量不出根因,直接从页面 CSS 挖:body 有一条硬性 min-width: 780px——手机视口只有 415~660px,装不下,WebView 只能把整页缩到约 65% 适配。侧栏还常驻 264px,在 415px 视口里占掉 64%,主内容只剩 151px。
修法是一层nginx sub_filter 注入的移动优化层(raven-mobile.css + raven-mobile.js),源副本在 work/raven-mobile/inject/,改完 scp 到 ECS 即生效:
1 | sub_filter '<head>' '<head><link rel="stylesheet" href="/raven-mobile.css"><script src="/raven-mobile.js" defer></script>'; |
为什么走注入层而不是改网关页面:升级免疫。Raven 升级会覆盖 site-packages 里的页面文件,注入层在代理层,与网关版本解耦。窄屏下做的事:解除 780px 地板让布局 1:1 用满屏幕;侧栏浮层化(复用页面自带的 data-rail 状态机,不是自己造抽屉);触控目标 44px;输入框 16px(防 WebView 聚焦缩放)。
修复后的真机效果(415px 视口):
4.5 S8 桌面组件崩溃:React #185 与自愈限频
最贵的一个坑。用户反馈”点几下工作区按钮就消失”,并且”不断弹出恢复工作区提示,应用无法使用”。
根因不在注入层——用对照实验证明的:同一条点击路径,一遍带全部注入、一遍把注入全拦掉跑裸页面,两边都在第 3 轮崩溃。从页面 bundle 里挖出了真因:调色板组件的 useEffect 没有依赖数组,每次渲染都跑;回调里无条件 store.set({...}) 造出新引用;useSyncExternalStore 认为快照变了 → 重渲染 → effect 再跑——无限循环,React 抛 Minified React error #185(Maximum update depth exceeded),而该组件是独立 root、没有错误边界,整个 root 被卸载,desk-ready 类消失,入口按钮从 DOM 里蒸发。大会话(几百个产物)触发,新对话不崩。
注入层只能事后拉起,不能预防——这点必须对用户明说。而”拉起”本身也踩了坑:第一版自愈在 S8 恢复后立刻撤守卫,结果”重载→挂载→再崩→再自愈”无限循环,用户看到的就是那句”不断有恢复工作区提示”。修法是把限频做成铁律:
- 时间戳存
localStorage(跨重载持久),10 分钟最多自愈一次;sessionStorage 不够,重载后可能丢。 - 限频等待期给一个可见的手动恢复钮,别让用户干等。
- 触发条件查输入框
value不查focus——页面会自动聚焦输入框,查焦点会把自愈永久堵死。 - 自愈定时器用独立
setInterval,挂在会clearInterval自停的启动轮询里永远跑不到。
4.6 z-index:999,一个数字压掉三层
另一个”截图看不出来”的问题。用户反馈”点更多,弹出的菜单被遮挡”,只剩右侧一条空白白带。
第一反应是菜单被屏幕裁切,但量了 rect 发现不是:菜单 x=[179,357]、视口 360,没有被裁切。用 elementFromPoint 做判定性测试,在菜单区域 x=245 处返回的是 DIV.sess、x=259 处返回 #railGrip——全是侧栏子元素。真因:注入层把侧栏浮层设了 z-index: 999,而页面自己的层级阶梯是 rail-handle:30 < menu:42 < tip:90 < toast:115,999 一举压掉三层——菜单、tooltip(那个黑色”更多”药丸,被不透明白色侧栏盖住,截图像素分析都看不出来,亮度网格在那个位置读到 255 白)、toast(归档/删除的反馈提示)。
修法一行:降回页面自己的设计令牌。
1 | .app[data-rail="on"] .rail { z-index: var(--z-rail-handle); } /* 30,不是 999 */ |
教训有两条:不要造大数,用页面自己的 token;当时若按”把菜单 z-index 提上去”修,菜单能好,但 tooltip 和 toast 仍然压着——两种改法都是一行,但只有降回 token 一次修三层。
五、公开化:构建期参数化 + 运行时输入
项目收尾时把客户端推上了公开仓库。直接公开有一个问题:代码里硬编码着真实域名。最终方案是两层解耦:
- 构建期预填(可选):
local.properties(已被.gitignore,永不入库)里配gateway.url/gateway.host,由 Gradle 注入resValue home_url和 Manifest 的 intent-filter host;公开 clone 没有这两行,默认占位地址gateway.example.com。 - 运行时输入(始终可用):登录界面第一个输入框「网关地址」,规范化(trim、去尾斜杠、必须 https)后与凭据一起加密保存,之后启动自动使用。
1 | // app/build.gradle.kts |
发布前的审计与压平:orphan 分支把历史压成单提交(原历史里有真实域名),fresh clone 后全历史 git grep 零命中,工作树零命中;本地 local.properties 保留真实地址,自己构建照常预填。
真机验收(公开前用户实测):1.1.0 登录进聊天界面可用;把网关地址改成不存在的域名,报「连不上 Raven 网关」错误页而不是白屏——后者同时证明运行时输入的地址真正生效,不是硬编码覆盖。
一次完整的手机端往返(415px WebView 实拍):发问 → 流式输出 → 8 秒完成,请求路径是公网 443 → ECS 回环 → SSH 隧道 → WSL 网关:
六、成果、代码与遗留
- 公开仓库:
github.com/luckyboybs/raven-android(Kotlin,AGP 8.1.4,minSdk 24)。clone 后建个local.properties指向自己的网关即可构建;不配也能用,首启手输地址。 - 自己的部署不受影响:本地构建仍预填真实地址,深链(点开
/auth#<nonce>一次性链接直接进 App)只对构建期配置的 host 生效——这是 intent-filter 的固有限制,README 里写明了。 - 遗留三项:
swiperefreshlayout未用依赖还在 dex 里(代码已无引用);APK 目前只有 debug 签名;S8 崩溃的根因在页面代码与 WebView 的交互,注入层只能事后拉起——要根治得改页面本身(给那个useEffect加依赖数组,或让 store set 在浅相等时不再造新引用)。
最后一条经验,与语言和框架无关:这个项目里每一个”想当然”都被实测打过脸——菜单以为被屏幕裁切、实际是层级遮挡;tooltip 以为不存在、实际被白浮层盖住;下载以为没触发、实际 enqueue() 成功而失败在它内部。截图和日志会说谎,elementFromPoint 和逐层 curl 不会。


;同一 URL 无凭据 401、带 Basic Auth 200](/images/2026-10-18/raven-android-term-ecs.png)
