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。想随时在手机上用它,面对的是三个硬约束:

  1. 大概率不能直连。WSL2 在 NAT 后面(私有网段,手机无论在家还是出门都路由不到),家里也没有公网 IP,不想为此开端口映射。
  2. 不想为”手机访问”单独开一套服务。网关已经有完整的 Web UI,重新实现一套移动协议意味着把 3MB 的 SPA 用 Kotlin 重写,并且此后每次 Raven 升级都要追着 RPC 面跑。
  3. 安全模型不让步。”家里不开公网端口”这条不破;暴露到公网的入口必须有多层认证,不能裸奔。

手上有一台阿里云 ECS(有 443、有证书签发实践),于是方案自然成形:ECS 做公网边缘,WSL 主动向外建隧道。隧道是从 WSL 主动出去的,所以家里只需要能访问 ECS 的 22 端口——和平时 SSH 登录没有区别。


二、架构总览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌─────────────┐     https://raven.bloodysky.top
│ 手机 APK │ ──────────────────────────────────┐
│ (WebView 壳)│ │
└─────────────┘ ▼
┌──────────────────────────┐
│ ECS nginx :443 │
│ · Basic Auth(第一层) │
│ · limit_req 防爆破 │
│ · Origin 改写(见 §4.1) │
└────────────┬─────────────┘
│ proxy_pass
▼
┌──────────────────────────┐
│ ECS 127.0.0.1:18792 │
│ (SSH 反向隧道入口, │
│ 只绑回环,公网不可达) │
└────────────┬─────────────┘
│ ssh -R
▼
┌──────────────────────────┐
│ WSL Raven 网关 │
│ 127.0.0.1:18792 │
│ · Web UI SPA │
│ · /rpc WebSocket │
│ · /auth /file │
└──────────────────────────┘

组件清单:

组件 位置 职责
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)。

桌面浏览器直接打开公网地址的完整界面:侧栏是定时任务与最近会话,底部输入框带模型选择——这一屏已经过了两层认证、穿过了 SSH 隧道

架构固有边界: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
2
3
4
5
6
7
8
9
10
11
12
13
14
# ~/.config/systemd/user/raven-tunnel.service
[Unit]
After=network-online.target

[Service]
ExecStart=/usr/bin/ssh -F /home/twrp/.ssh/config -N -T \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-R 18792:127.0.0.1:18792 ecs-blog
Restart=always
RestartSec=5

[Install]
WantedBy=default.target

三个理由:

  • 零新端口。复用 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 侧回环绑定、双层认证:

WSL 侧:systemd 用户服务 active (running),ssh -R 把 ECS 回环的 18792 映射回本地网关,断线由 systemd 重启

ECS 侧:ss -tlnp 显示 18792 只绑 127.0.0.1 与 [::1](公网扫不到);同一 URL 无凭据 401、带 Basic Auth 200

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
2
3
$ curl -H 'Origin: https://raven.bloodysky.top' ... http://127.0.0.1:18792/rpc
HTTP/1.1 403 Forbidden
bad origin

修法在代理层,不动网关: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
2
3
4
5
6
7
8
9
10
1) POST /auth/nonce
头:X-Raven-Token: <64 位 token>
→ {"nonce":"..."} (一次性,约 30 分钟有效)

2) POST /auth/exchange
体:{"nonce":"<上一步的 nonce>"}
→ 200 + Set-Cookie: raven_session_18792=<值>;
HttpOnly; Max-Age=2592000; Path=/; SameSite=Strict

3) cookie 落 WebView 的 jar → 之后加载首页即完整可用

没有会话 cookie 时,网关给的是这样一个页面——没有 token 输入框,只有一条红条提示去拿 fresh link,这正是”必须走 nonce 流程”的直观证据:

未登录态:网关自己的页面,红条提示 sign-in expired、让用 raven web 拿新链接——第二层认证没有 token 输入框

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
2
3
4
5
6
7
8
9
10
11
12
val uri = Uri.parse(pending.url)
val request = DownloadManager.Request(uri).apply {
addRequestHeader("User-Agent", pending.userAgent)
CookieManager.getInstance().getCookie(pending.url)?.let {
addRequestHeader("Cookie", it)
}
// DownloadManager 不会协商 Basic Auth,不带这个头必被 401
if (isSameSite(uri)) {
basicAuthHeader()?.let { addRequestHeader("Authorization", it) }
}
...
}

凭据从 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 视口):

真机实拍:手机端首页,布局 1:1 用满屏幕,输入框与模型选择器完整可达 窄屏点开侧栏浮层:新任务、连接、定时任务、最近会话全部可达,浮层覆盖主内容而非把它挤成 151px

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
2
3
4
5
6
7
8
// app/build.gradle.kts
val gatewayUrl = localProperties.getProperty("gateway.url")
?: "https://gateway.example.com"
defaultConfig {
manifestPlaceholders["gatewayHost"] = gatewayHost
resValue("string", "home_url", gatewayUrl.trimEnd('/'))
resValue("string", "home_host", gatewayHost)
}

发布前的审计与压平:orphan 分支把历史压成单提交(原历史里有真实域名),fresh clone 后全历史 git grep 零命中,工作树零命中;本地 local.properties 保留真实地址,自己构建照常预填。

真机验收(公开前用户实测):1.1.0 登录进聊天界面可用;把网关地址改成不存在的域名,报「连不上 Raven 网关」错误页而不是白屏——后者同时证明运行时输入的地址真正生效,不是硬编码覆盖。

一次完整的手机端往返(415px WebView 实拍):发问 → 流式输出 → 8 秒完成,请求路径是公网 443 → ECS 回环 → SSH 隧道 → WSL 网关:

手机端一次完整对话往返:问句气泡、流式回复、「已完成 8s」,全程经公网与隧道

六、成果、代码与遗留

  • 公开仓库: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 不会。