mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 13:40:45 +00:00
修「经中转几次后永久哑掉」——「按一下没反应」的真因
== 现象 ==
用户报:
* 局域网能搜到电脑,IP/端口/配对码都对,连上后**没有任何反应**
* 走中转「已配对」,但点按钮没反应
局域网那一半已经修过(preferLan)。这次抓到的是**经中转**那一半,
而且比预想的严重得多。
== 证据链(插桩复现出来的)==
被控端日志里直接抛了未捕获异常,线程当场死:
Exception in thread client-relay:
server.py:407 session.send(reply)
relay.py:166 self._link.send_to_client(data)
relay.py:357 self._post("/v1/agent/send", ...)
relay.py:766 raise RelayError(f"请求失败: {last_error}")
lanremote.relay.RelayError: 请求失败: Idle
之后的状态:
* client-relay 线程没了 —— 电脑再也不往手表回任何东西
* relay-link 线程还活着、还在长轮询 —— 中转那边看着「设备在线」
* **手表看到的就是「连上了,但按一下毫无反应」**
写入仓库的测试 _tests/test_relay_local.py 一直是这个形状:
前面几项 PASS、跑到经中转发命令就卡死。之前没人深挖。
== 根因 1:一条 HTTP/1.1 连接被两个线程同时用 ==
_post() 里锁只包住「取连接」那一段:
with self._http_lock:
connection = self._http # 锁到这里就放了
try:
connection.request("POST", ...) # 锁外发请求
于是两个线程会同时用同一条 connection:
* relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒
* client-relay 线程同时 POST /v1/agent/send
HTTP/1.1 一条连接同一时刻只能有一个在途请求,第二个线程一进来
就撞上 http.client 状态机,抛 CannotSendRequest("Idle")。
_post 的「重试一次」也救不了:它 _drop_http() 之后,另一个线程
手里还攥着那条旧连接对象在发,照样坏。
修法:
* **发命令用独立连接**(_post 加 dedicated 参数)。
长轮询那条常年被占着,发命令不该去挤它,各走各的。
* 两条连接各自一把锁**锁住整个「发请求 + 读响应」周期**,
不再只锁取连接那一下。
* 普通请求若发现缓存连接正忙(长轮询在途),临时另开一条用完即关。
* _drop_http(dedicated=) 区分清哪条;finally 里一定归还占用
(否则那条连接会被永久标记为忙)。
== 根因 2:回包失败会打死整个会话线程 ==
server.py 的 _client_loop 里:
if reply is not None:
session.send(reply) # <- 不在任何 try 里
...
except OSError: # <- 外层只捕获 OSError
pass
而中转链路上 send 抛的是 RelayError,直接冒出去把线程打死。
**死一次就永久哑**,代价太大。
修法:session.send() 单独兜住所有异常,失败就断开这个会话
(让手表重连),绝不让线程消失。外层也补一个兜底 except。
== 修复效果 ==
修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑
修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常
_tests/test_relay_local.py:**之前完全卡死 -> 现在 21/21 全过**
这个测试之前一直是红的,说明「35 个阶段的回归全绿」这件事
本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。
== 同批修掉验证阶段找到的其它问题 ==
1. **TTL 清理 + 关掉接入开关 = 设备永久锁死**(严重)
register() 用 `device_id in self.devices` 判断老设备,但超时清理
把设备从 devices 和 codes 都删了。电脑关机过夜(必然 > 6 小时)
第二天开机就会被自己的开关挡住,而且**重启也救不回来**
(清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。
管理员在页面上还看不到这台设备,不知道该放开谁。
修法:状态文件里单独存 knownDevices 设备历史,超时清理绝不动它;
register 改查这份历史;清理后补 _save();delete_device 才真正忘记。
2. **管理页 JS 语法错误 -> 所有按钮失效**(严重)
admin.py 里 ADMIN_JS 是普通三引号字符串,我在 Python 源码里写的
`\n` 被变成真实换行,拼进 JS 就是字符串里的裸换行 ->
整段 <script> 不执行 -> 改码/删除/批量删除/全选全变幽灵按键。
元凶是我加改码功能那次提交。改成 `\\n`。
3. **--relay / --relay-status 崩溃**(严重)
RelayLink.start(url) 的 url 是必填参数,我把默认地址改空白时
run_agent.py 两处调用没跟着改。一处被 try 吞成「测试失败:missing
1 required positional argument」(用户以为地址填错了),
一处直接报栈。两处都补上 url。
4. 非法 deviceId(控制字符 / 路径分隔符)现在被拒(白名单:
字母数字 + - _ . :)
5. enable_relay() 第二次调用不再谎报成功(加 RelayLink.running 属性,
返回值反映真实运行状态;不再用「对象建过没有」判断)
6. snapshot() 清孤儿码后补 _save(),磁盘和内存不再脱节
== 局域网全面优化(task-2,另一个智能体完成)==
4 个真问题:
* **网段判断写死 /24** —— 在 /16 网络上 10.8.1.5 和 10.8.2.5
被判成不同网络,明明能直连却去走中转还费流量。
改成按网卡真实掩码比较。
* **广播只发一个包** —— 丢一个就整轮搜不到。改成 5 个时间点重发。
* **只探一个网段** —— 多网卡手表搜不到另一网段的电脑。改成全探。
* **走中转之后再也回不到局域网** —— 出门再回家永远挂中转。
新增定期回切(先便宜地 TCP ping,通了才动真格,
不破坏正在工作的中转连接)。
实测:局域网握手 2ms、50/50 = 100%、中位 0ms。
(跨网络那半之前拿不到数字,就是被上面那个线程崩溃挡住的 ——
现已修复。)
顺带:instance.py 单实例锁端口加 YKXMZ_LOCK_PORT 覆盖(默认不变)。
用户自己那台被控端正跑在这台机器上占着 8767/TCP + 8766/UDP,
是这次排查里好几个「测试怎么都不通」的根因。
== 服务端一键部署脚本(task-1,另一个智能体完成)==
relay/deploy.sh + relay/deploy-tune.sh,随服务端 zip 发布:
* 自动检查并安装 cloudflared
* 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
* **不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填**,
不会静默用一个写死的域名(用户明确说过这个中转站是他自己用的,
不对外开放,绝不能把私人域名烧成默认值)
* 重跑 = 升级,已有数据/配对码/管理密码/凭据都不动
顺带修了发布包一直缺 site_shots/ 的老 bug
(package_remote.py 用 iterdir()+is_file() 收文件,目录进不去,
新机器装完官网截图全是 404)。
== 默认中转地址改成空白 ==
用户要求:软件默认不带任何中转地址,由用户自己填。
四处硬编码全部清空(server.py / MainActivity.java / run_agent.py /
relay.py 的注释和文档示例),全仓已无非注释的硬编码地址。
== 文档同步(task-5,另一个智能体完成)==
README / BEFORE-INSTALL / AFTER-INSTALL / 打包模板:
* 新增「软件里不带任何中转地址」告示,讲清跨网络要自己部署
* 修正 Android 版本(写的是 9.0/API28,实际 API 24 / Android 7.0)
* 删掉「地址已经内置好了」(现在是错的)
* AGENT_README 原来把作者自己的中转地址当成「内置地址」发给用户,已重写
* RELAY_README 原来写「两条隧道同时跑会互相争抢」,
和真机/CHANGELOG 矛盾;按实测改成「两种协议同时在跑(故意的)」
This commit is contained in:
@@ -46,13 +46,35 @@
|
||||
|
||||
| 装在哪 | 是什么 | 文件 |
|
||||
|---|---|---|
|
||||
| 教室电脑(Windows) | 被控端 | `ykxmz-agent-1.5.0.zip` |
|
||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-1.5.0.zip` |
|
||||
| 手表(安卓) | 控制端 | `ykxmz-watch-1.5.0.apk` |
|
||||
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-1.8.2.exe` |
|
||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-1.8.2.zip` |
|
||||
| 手表(安卓) | 控制端 | `ykxmz-watch-1.8.2.apk` |
|
||||
|
||||
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
||||
|
||||
**要在办公室管教室?** 那必须装中转站(见 [跨网络](#跨网络在办公室管教室))。
|
||||
**要在办公室管教室?** 那必须**自己部署一台中转站**(见 [跨网络](#跨网络在办公室管教室))。
|
||||
|
||||
> ### ⚠️ 软件里不带任何中转地址
|
||||
>
|
||||
> 这一点先讲清楚,免得白折腾:
|
||||
>
|
||||
> **这个软件默认是"空的" —— 没有任何内置的中转服务器地址,
|
||||
> 也没有官方服务器可以用,作者不对外提供中转服务。**
|
||||
>
|
||||
> - **同一个 WiFi 下用(教室里)**:不需要中转,装完就能连,什么地址都不用填。
|
||||
> - **要跨网络用(办公室管教室)**:**你得自己部署一台中转站**。
|
||||
> 服务端压缩包里有一键脚本,一条命令装好:
|
||||
>
|
||||
> ```bash
|
||||
> bash deploy.sh
|
||||
> ```
|
||||
>
|
||||
> 装完它会打印一个属于你自己的公网地址,把它填到电脑和手表上。
|
||||
> 详见 [装中转站](#装中转站)。
|
||||
>
|
||||
> 为什么不做一个"填个公共地址就能用"?因为中转站的带宽是实打实的成本,
|
||||
> 谁用谁出;而且**中转站能看到经过它的字节流**,
|
||||
> 自己搭一台,数据才真正只在你自己手里。
|
||||
|
||||
---
|
||||
|
||||
@@ -60,9 +82,9 @@
|
||||
|
||||
### 第一步:电脑上装被控端
|
||||
|
||||
解压 `ykxmz-agent-1.5.0.zip`,双击里面的 `ykxmz.exe`。
|
||||
双击 `ykxmz-setup-1.8.2.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||
|
||||
弹出的窗口里填两样:
|
||||
第一次运行会弹出窗口,填两样:
|
||||
|
||||
- **6 位数字** —— 自己定,好记就行,比如 `789113`
|
||||
- 两个勾:`设置开机自启`、`放行防火墙`(放行那个要过一次管理员确认)
|
||||
@@ -70,12 +92,15 @@
|
||||
点确定,屏幕会黑闪一下(在加防火墙规则),然后窗口消失,程序转到后台跑。
|
||||
|
||||
> **窗口关了程序还在跑**,这是故意的 —— 上课时讲台上不该有个窗口杵着。
|
||||
> 想再打开窗口,**再双击一次 EXE** 就行,不会起第二个进程。
|
||||
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
|
||||
> 想彻底退出,窗口上点「退出」。
|
||||
|
||||
> 不想用安装包的话,`ykxmz-agent-1.8.2.zip` 是绿色版:
|
||||
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
||||
|
||||
### 第二步:手表上装 App
|
||||
|
||||
把 `ykxmz-watch-1.5.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||
把 `ykxmz-watch-1.8.2.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||
|
||||
打开 App,首页有四个入口:
|
||||
|
||||
@@ -188,19 +213,61 @@
|
||||
|
||||
### 装中转站
|
||||
|
||||
把 `ykxmz-server-1.5.0.zip` 解压到那台小机器上(随身 WiFi、树莓派、云主机都行,Debian/Ubuntu 系统),然后:
|
||||
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
||||
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
||||
|
||||
把 `ykxmz-server-1.8.2.zip` 解压到那台机器上,然后一条命令:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh
|
||||
bash deploy.sh
|
||||
```
|
||||
|
||||
装完会打印一个公网地址,长这样:
|
||||
它会自己做完这些,不用你一步步敲:
|
||||
|
||||
1. 装 cloudflared(已经装了就用现成的)
|
||||
2. 把中转程序装到 `/root/lanremote-relay/`
|
||||
3. 网络调优(keepalive、MSS、MTU、开机加速)
|
||||
4. 注册 systemd 服务,设成开机自启
|
||||
5. 配好 Cloudflare 隧道
|
||||
6. 启动、自检,然后把**公网地址**打印出来
|
||||
|
||||
装完会打印一个属于你自己的公网地址,长这样:
|
||||
|
||||
```
|
||||
https://relay.你的域名
|
||||
```
|
||||
|
||||
**记住这个地址**,电脑和手表都要填它。忘了就打开 `http://<小机器的IP>:8080/url` 查。
|
||||
**记住这个地址**,电脑和手表都要填它。忘了就在那台机器上跑
|
||||
`lanremote-url`,或者浏览器打开 `http://<小机器的IP>:8080/url` 查。
|
||||
|
||||
#### 三种常见情形
|
||||
|
||||
```bash
|
||||
# ① 机器上已经有隧道凭据(/root/.cloudflared/*.json)
|
||||
bash deploy.sh
|
||||
|
||||
# ② Cloudflare 给了一个隧道 Token(Zero Trust 后台复制的那串)
|
||||
TUNNEL_TOKEN=eyJhIjoi... bash deploy.sh
|
||||
|
||||
# ③ 换域名 / 只装中转、不要隧道
|
||||
bash deploy.sh --hostname relay.你的域名
|
||||
bash deploy.sh --skip-tunnel
|
||||
```
|
||||
|
||||
看全部参数:`bash deploy.sh --help`
|
||||
|
||||
> **用环境变量而不是 `--token`**:写进命令行的 Token 会留在 shell 历史里。
|
||||
|
||||
#### 重跑一遍 = 升级
|
||||
|
||||
`deploy.sh` 是**幂等**的:已经装过的机器再跑一次就是升级。
|
||||
程序文件会被覆盖成新的,而**设备数据、配对码、隧道凭据都不会被冲掉**。
|
||||
|
||||
#### 需要准备什么
|
||||
|
||||
- 一台能上外网的 Linux 小机器(Debian 12 / Ubuntu,512 MB 内存够)
|
||||
- 一个 Cloudflare 账号 + 一个域名(隧道要用)
|
||||
- **不需要**装 Python 第三方包 —— 服务端是纯标准库,不装任何依赖
|
||||
|
||||
### 告诉电脑中转地址
|
||||
|
||||
@@ -212,19 +279,34 @@ ykxmz.exe --relay "192.168.1.50:8080;https://relay.你的域名"
|
||||
|
||||
两个地址用分号隔开,**按顺序试,第一个通的就用**。这样在教室里走局域网(快、稳),出了学校自动切隧道。
|
||||
|
||||
如果只在跨网络的场景用,也可以只填一个域名:
|
||||
|
||||
```
|
||||
ykxmz.exe --relay https://relay.你的域名
|
||||
```
|
||||
|
||||
看看当前用的是哪个地址:
|
||||
|
||||
```
|
||||
ykxmz.exe --relay-status
|
||||
```
|
||||
|
||||
### 告诉手表中转地址
|
||||
|
||||
手表首页点「中转设置」,填上面同一个地址。
|
||||
|
||||
然后回家首页点「输入配对码连接」,填电脑上那 6 位数字。
|
||||
|
||||
### 中转站怎么管
|
||||
### 中转站的公网地址打开是什么
|
||||
|
||||
浏览器打开中转站的公网地址,用管理密码登录(默认 `120830`,装完后强烈建议改)。
|
||||
打开就是**一个普通的官网页面** —— 介绍、截图、三个文件的下载按钮,
|
||||
最新版的安装包挂在上面,点一下就能下。
|
||||
|
||||
能看:哪些电脑在线、配对码是什么、有多少会话、内存用了多少。
|
||||
能做:踢掉某个会话、删掉某个配对码绑定、跑个网络体检。
|
||||
下载区:最新版的三个文件都挂在上面,点一下就能下。
|
||||
安装包和截图这类静态文件**由 Cloudflare 的边缘节点直接发送**,
|
||||
所以下载速度和那台小机器的带宽无关(实测比走隧道回源快几十倍)。
|
||||
|
||||
想看运行状态、排查问题、重启服务,**看服务端压缩包里的
|
||||
`README-server.txt`** —— 那份文档是给运维的人看的,里面写了日常命令。
|
||||
|
||||
---
|
||||
|
||||
@@ -364,7 +446,7 @@ windows\build_exe.ps1
|
||||
```powershell
|
||||
android\build_apk.ps1
|
||||
# 流程:aapt2 → javac → d8 → zipalign → apksigner
|
||||
# 产物:dist\ykxmz-watch-1.5.0.apk
|
||||
# 产物:dist\ykxmz-watch-1.8.2.apk
|
||||
```
|
||||
|
||||
最低支持 Android 7.0(API 24),targetSdk 34。
|
||||
@@ -374,9 +456,13 @@ android\build_apk.ps1
|
||||
Python 3 标准库,没有第三方包。
|
||||
|
||||
```bash
|
||||
bash relay/install.sh
|
||||
bash relay/deploy.sh
|
||||
```
|
||||
|
||||
`deploy.sh` 是一键部署(装服务 + 调优 + 配隧道 + 自检);
|
||||
只想调网络参数的话是 `relay/deploy-tune.sh`。
|
||||
老的 `relay/install.sh`(匿名快速隧道)仍在包里,只为兼容以前装过的机器。
|
||||
|
||||
### 跑测试
|
||||
|
||||
```powershell
|
||||
@@ -408,7 +494,22 @@ python _build\package_remote.py
|
||||
|
||||
二进制数据(图片、文件、音频)走 **Base64** 分块传输,单次会话上限 256 MB。
|
||||
|
||||
跨网络时同样的消息套在 HTTP 长轮询里,中转站按配对码把两端字节对接,**它不解析内容**。
|
||||
跨网络时同样的消息套在 **HTTP 长轮询**里(单次挂 **60 秒**,
|
||||
有数据立刻返回、不空等),中转站按配对码把两端字节对接,**它不解析内容**。
|
||||
|
||||
跨网络这条链路上还有几个实测调出来的设计,改代码前值得知道:
|
||||
|
||||
- **隧道两种协议同时跑**(QUIC + HTTP/2,合计 10 条连接)。
|
||||
两种协议各有各的毛病 —— QUIC 尾部延迟好但建连容易超时,
|
||||
HTTP/2 建连可靠但丢包时容易被队头阻塞卡住。
|
||||
同时跑就是互相兜底:一种犯病,另一种还在服务。
|
||||
- **长轮询 60 秒**。隧道每次请求要多花 1~2 秒,这个开销是按次数收的;
|
||||
拉到 60 秒把请求数降到 1/4,撞上丢包窗口的概率也降到 1/4。
|
||||
**命令响应速度不变**(有数据立刻返回,不是等满 60 秒)。
|
||||
- **偶发失败不当场判离线**。4G 链路上本来就有百分之几的偶发抖动、
|
||||
一两秒自己恢复;连续失败 3 次才算真的离线。
|
||||
- **静态资源交给 Cloudflare 边缘缓存**。下载走边缘节点,
|
||||
不用穿隧道回那台小机器,快几十倍也更省流量。
|
||||
|
||||
Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相打通的测试。
|
||||
|
||||
@@ -429,7 +530,18 @@ Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相
|
||||
不会。只要 `data/` 目录还在,服务器就认原来那个码。母盘部署时注意别把 `data/` 一起发出去。
|
||||
|
||||
**问:走中转会不会很慢?**
|
||||
实测局域网 3 毫秒左右,公网中转 1~3 秒(取决于你那台小机器的线路)。所以**同一个 WiFi 一定走局域网**,程序会优先选它。
|
||||
实测局域网 3 毫秒左右;跨网络要绕到中转站再回来,约 1~2 秒(取决于你那台小机器的线路)。所以**同一个 WiFi 一定走局域网**,程序会优先选它。
|
||||
|
||||
**问:必须自己搭中转站吗?能用作者的服务器吗?**
|
||||
必须自己搭。**软件不内置任何中转地址,作者也不对外提供中转服务。**
|
||||
跨网络的话,服务端 zip 里有 `deploy.sh`,一条命令装好(见 [装中转站](#装中转站))。
|
||||
只在同一个 WiFi 下用的话,压根不需要中转站。
|
||||
|
||||
**问:中转站会不会看到我的数据?**
|
||||
它转发的是加密前的字节流,所以**技术上能**。这正是"自己搭一台"的理由 —— 搭在你自己手上,数据就只经过你自己的机器。
|
||||
|
||||
**问:下载安装包为什么比以前快这么多?**
|
||||
最新版把静态文件交给 Cloudflare 的边缘节点缓存了。原来每次下载都要穿过隧道回到那台小机器上取,实测只有几 KB/s,15 MB 的安装包根本下不动;现在从离你最近的边缘节点直接发,实测快了四十多倍,也不占小机器的流量。
|
||||
|
||||
**问:能在手机上用吗?**
|
||||
协议是通的,但界面是按手表的小屏幕设计的(大按钮、两列、可滚动),手机上会显得按钮特别大。要做手机版得重写布局。
|
||||
@@ -447,7 +559,7 @@ Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相
|
||||
|
||||
1. 手表上显示的那句错误(App 现在是中文的,直接念出来就行)
|
||||
2. 电脑上 `工具箱.bat` → 选 `5` 的日志内容
|
||||
3. 中转站的公网地址(不用给密码)
|
||||
3. 跨网络的话,把你那台中转站的公网地址发过来(不用给任何密码)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user