mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-19 09:30:44 +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:
+8
-6
@@ -6,12 +6,14 @@
|
||||
现在改走边缘缓存:同一个文件实测从 **17 KB/s 提到 695 KB/s**,
|
||||
APK 从 3 KB/s 提到 101 KB/s,并且不再消耗 4G 流量。
|
||||
|
||||
- **跨网络连接更稳**:隧道改成**两种协议同时跑**
|
||||
(QUIC 5 条 + HTTP/2 5 条,共 10 条连接)。
|
||||
两种协议各有各的毛病 —— QUIC 尾部延迟好但建连容易超时,
|
||||
HTTP/2 建连可靠但丢包时容易卡住。同时跑就是互相兜底:
|
||||
一边犯病另一边还在服务。
|
||||
实测 25 分钟连续可用率 **50/50 = 100%**(改之前是 96~98%)。
|
||||
- **跨网络连接更稳**:隧道改成**两种协议同时跑**,一共 10 条连接。
|
||||
两种协议各有各的毛病 —— 实测这条线路上运营商对 UDP 有限制,
|
||||
纯 QUIC 的拨号成功率只有 72%;而纯 HTTP/2 一旦丢包就容易卡住
|
||||
(一条 TCP 上丢一个包,所有请求一起等)。
|
||||
两个一起跑就是互相兜底:QUIC 拨号失败的那部分由 HTTP/2 顶着,
|
||||
HTTP/2 卡住的时候 QUIC 的恢复更快。
|
||||
实测 25 分钟连续可用率 **50/50 = 100%**(改之前是 96~98%),
|
||||
尾部延迟 P99 从 3295ms 降到 2632ms。
|
||||
|
||||
- 修**网卡 MTU 和 QUIC 不匹配**:之前只给 TCP 做了 MSS 钳制,
|
||||
而 QUIC 走 UDP 完全不受保护,包一大就被中间设备悄悄丢掉。
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
passed=34 failed=0
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
passed=18 failed=0
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
═══ 1. 默认中转地址确实是空 ═══
|
||||
[PASS] DEFAULT_RELAY_URL 是空白
|
||||
|
||||
═══ 2. RelayLink.start('') 的行为 ═══
|
||||
[PASS] start('') 返回 False
|
||||
[PASS] start('') 没有新建线程
|
||||
[PASS] start('') 之后 candidates 是空的
|
||||
|
||||
═══ 3. enable_relay 连续调用两次的返回值 ═══
|
||||
第 1 次 enable_relay() -> False
|
||||
第 2 次 enable_relay() -> False
|
||||
日志: ['没配中转地址,只走局域网(需要跨网络时在设置里填中转地址)', '没配置中转地址,跳过', '没配中转地址,只走局域网(需要跨网络时在设置里填中转地址)']
|
||||
[PASS] 第 1 次调用返回 False(没有中转可连)
|
||||
[PASS] 第 2 次调用也应返回 False
|
||||
|
||||
═══ 4. 这个返回值会影响谁 ═══
|
||||
server.py 里调用 enable_relay 的地方:
|
||||
160: # 顺序很重要:enable_relay() 会挨个试中转地址,
|
||||
185: self.enable_relay()
|
||||
|
||||
全仓库搜索 enable_relay 的调用点:
|
||||
windows\lanremote\relay.py:317: 对外给 server.enable_relay() 判断用 ——
|
||||
windows\lanremote\server.py:160: # 顺序很重要:enable_relay() 会挨个试中转地址,
|
||||
windows\lanremote\server.py:185: self.enable_relay()
|
||||
windows\lanremote\server.py:308: def enable_relay(self, url: str = "") -> bool:
|
||||
|
||||
═══ 5. 空白默认下局域网发现仍可用吗 ═══
|
||||
[PASS] discovery 模块能导入(不依赖 DEFAULT_RELAY_URL)
|
||||
[PASS] discover() 能跑完不抛异常
|
||||
[PASS] discover() 返回的是列表
|
||||
· 发现到 0 台设备(本机没有 agent 在跑,0 台是正常的)
|
||||
|
||||
==============================================================
|
||||
通过 9 项,失败 0 项
|
||||
==============================================================
|
||||
@@ -1,47 +0,0 @@
|
||||
管理页新增:允许/禁止新设备接入 + 修改设备配对码
|
||||
|
||||
== 功能一:允许新设备接入开关 ==
|
||||
|
||||
关掉之后只有已经登记过的电脑能继续上线,没见过的新设备一律拒绝。
|
||||
状态存在 relay_state.json 里,重启不丢。
|
||||
|
||||
判断新设备用的是 deviceId,不是配对码 ——
|
||||
这一点很关键:管理员给老设备改过码之后,
|
||||
那台设备必须仍然能进来;按码判断的话它会被自己的开关挡在外面。
|
||||
测试里专门有一条 A3b 守这个行为。
|
||||
|
||||
被拒时回 blocked=True,让客户端能区分被管理员挡了
|
||||
和配对码冲突,而不是去匹配中文错误文案(文案一改就坏)。
|
||||
|
||||
== 功能二:修改任意设备的配对码 ==
|
||||
|
||||
管理页每台设备一个「改码」按钮,填 6 位数字即生效。
|
||||
要点:
|
||||
* 新旧码冲突检查
|
||||
* 旧码映射必须删掉,否则变成孤儿条目,
|
||||
管理页配对码占用的数字会虚高
|
||||
* 改码踢掉该设备当前会话(会话绑在码上)
|
||||
* 立刻持久化
|
||||
|
||||
== 顺手修掉两个真 bug ==
|
||||
|
||||
1. 改码功能一上线就 500:Session 用了 __slots__,
|
||||
根本没有 session_id 属性(会话是以 token 为 key 存在 dict 里的),
|
||||
写 s.session_id 直接 AttributeError。
|
||||
改成遍历 sessions.items() 拿 key。
|
||||
|
||||
2. snapshot() 里有同样的错(清理超时设备那段):
|
||||
平时 stale 列表是空的所以从没炸过,一旦真有设备超过 6 小时
|
||||
没上报就会 500。这次一起修了。
|
||||
|
||||
== 管理页 UI ==
|
||||
* 设备小节加了「允许新设备接入」勾选框
|
||||
* 每行加「改码」按钮(走 prompt,不引入弹窗层)
|
||||
* 改码按钮用 data-recode 而不是 data-device ——
|
||||
现有单台删除按 button.danger[data-device] 绑,
|
||||
带上 data-device 会被当成删除按钮,点改码变删设备。
|
||||
测试 C6 专门断言这一点。
|
||||
|
||||
== 测试 ==
|
||||
新增 _tests/test_admin_device_control.py(23 项),
|
||||
已注册进 _tests/verify_all.ps1 第 3e 阶段。
|
||||
@@ -0,0 +1,164 @@
|
||||
修「经中转几次后永久哑掉」——「按一下没反应」的真因
|
||||
|
||||
== 现象 ==
|
||||
|
||||
用户报:
|
||||
* 局域网能搜到电脑,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 矛盾;按实测改成「两种协议同时在跑(故意的)」
|
||||
@@ -0,0 +1,55 @@
|
||||
[PASS] 真中转起来了
|
||||
|
||||
═══ 场景 1:DEVICE_TTL 清理 + allow_new=False 的死亡组合 ═══
|
||||
[PASS] 设备首次注册成功
|
||||
[PASS] 关掉「允许新设备接入」成功
|
||||
[PASS] 【对照】设备还在 devices 里时,老设备能通过开关
|
||||
等 5 秒让设备超时…
|
||||
[PASS] 【前提】超时设备已被 snapshot 清出 devices
|
||||
[PASS] 【前提】该设备的码绑定也已被清(codes 成对清理)
|
||||
[PASS] 超时清理后老设备仍能重新上线
|
||||
[PASS] 【归因】打开开关后同一 deviceId 立刻能注册(证明是被开关挡的)
|
||||
|
||||
═══ 场景 2:改码 A->B->C 后 codes 里有没有留孤儿 ═══
|
||||
[PASS] 设备注册(码 A)
|
||||
[PASS] 改码 A->B 成功
|
||||
[PASS] 改码 B->C 成功
|
||||
[PASS] 一台设备只占一个码(codeCount 与设备数一致)
|
||||
[PASS] 设备当前码是 C
|
||||
[PASS] 旧码 A 已被释放(另一台设备能占用)
|
||||
|
||||
═══ 场景 3:改码之后重启,codes 是否还原成自洽状态 ═══
|
||||
[PASS] 再改成 B
|
||||
[PASS] state.json 里该设备只有一条码绑定
|
||||
|
||||
═══ 场景 4:deviceId 塞奇怪字符 ═══
|
||||
[PASS] 非法 deviceId(超长 200 字符)被拒绝而不是崩溃
|
||||
[PASS] 非法 deviceId(控制字符)被拒绝而不是崩溃
|
||||
[PASS] 非法 deviceId(路径分隔符)被拒绝而不是崩溃
|
||||
[PASS] 非法 deviceId(Unicode)被拒绝而不是崩溃
|
||||
[PASS] 非法 deviceId(只有空格)被拒绝而不是崩溃
|
||||
|
||||
═══ 场景 5:并发改码(两个线程同时改同一台设备)═══
|
||||
[PASS] 并发改码没抛异常
|
||||
[PASS] 并发后设备还在
|
||||
[PASS] 并发后设备当前码是 X 或 Y 之一
|
||||
[PASS] 并发改码后输掉的那个码没有变成孤儿(别的设备能占)
|
||||
|
||||
═══ 场景 6:管理接口未鉴权访问 ═══
|
||||
[PASS] 未鉴权 POST /v1/admin/allow_new 被拒(401/403)
|
||||
[PASS] 未鉴权 POST /v1/admin/set_code 被拒(401/403)
|
||||
[PASS] 未鉴权 POST /v1/admin/delete 被拒(401/403)
|
||||
[PASS] 未鉴权 POST /v1/admin/delete_all 被拒(401/403)
|
||||
|
||||
═══ 场景 7:设备列表为空时管理页是否 500 ═══
|
||||
[PASS] 设备列表为空时管理页仍正常渲染
|
||||
[PASS] 空列表页面没有 traceback
|
||||
|
||||
═══ 场景 8:关掉开关后重启中转,开关状态是否还在 ═══
|
||||
[PASS] 关掉开关
|
||||
[PASS] 重启后中转起来了
|
||||
[PASS] 重启后 allowNew 仍为 False(持久化生效)
|
||||
|
||||
==============================================================
|
||||
通过 34 项,失败 0 项
|
||||
==============================================================
|
||||
@@ -0,0 +1,55 @@
|
||||
[PASS] 真中转起来了
|
||||
|
||||
═══ 场景 1:DEVICE_TTL 清理 + allow_new=False 的死亡组合 ═══
|
||||
[PASS] 设备首次注册成功
|
||||
[PASS] 关掉「允许新设备接入」成功
|
||||
[PASS] 【对照】设备还在 devices 里时,老设备能通过开关
|
||||
等 5 秒让设备超时…
|
||||
[PASS] 【前提】超时设备已被 snapshot 清出 devices
|
||||
[PASS] 【前提】该设备的码绑定也已被清(codes 成对清理)
|
||||
[PASS] 超时清理后老设备仍能重新上线
|
||||
[PASS] 【归因】打开开关后同一 deviceId 立刻能注册(证明是被开关挡的)
|
||||
|
||||
═══ 场景 2:改码 A->B->C 后 codes 里有没有留孤儿 ═══
|
||||
[PASS] 设备注册(码 A)
|
||||
[PASS] 改码 A->B 成功
|
||||
[PASS] 改码 B->C 成功
|
||||
[PASS] 一台设备只占一个码(codeCount 与设备数一致)
|
||||
[PASS] 设备当前码是 C
|
||||
[PASS] 旧码 A 已被释放(另一台设备能占用)
|
||||
|
||||
═══ 场景 3:改码之后重启,codes 是否还原成自洽状态 ═══
|
||||
[PASS] 再改成 B
|
||||
[PASS] state.json 里该设备只有一条码绑定
|
||||
|
||||
═══ 场景 4:deviceId 塞奇怪字符 ═══
|
||||
[PASS] 非法 deviceId(超长 200 字符)被拒绝而不是崩溃
|
||||
[PASS] 非法 deviceId(控制字符)被拒绝而不是崩溃
|
||||
[FAIL] 非法 deviceId(路径分隔符)被拒绝而不是崩溃 —— {'ok': True, 'token': '64c472759d8098f2402a87a5f6d8abd19ec617c6a44ddb93', 'code': '067393', 'note': '', 'pollSeconds': 2.0}
|
||||
[PASS] 非法 deviceId(Unicode)被拒绝而不是崩溃
|
||||
[PASS] 非法 deviceId(只有空格)被拒绝而不是崩溃
|
||||
|
||||
═══ 场景 5:并发改码(两个线程同时改同一台设备)═══
|
||||
[PASS] 并发改码没抛异常
|
||||
[PASS] 并发后设备还在
|
||||
[PASS] 并发后设备当前码是 X 或 Y 之一
|
||||
[PASS] 并发改码后输掉的那个码没有变成孤儿(别的设备能占)
|
||||
|
||||
═══ 场景 6:管理接口未鉴权访问 ═══
|
||||
[PASS] 未鉴权 POST /v1/admin/allow_new 被拒(401/403)
|
||||
[PASS] 未鉴权 POST /v1/admin/set_code 被拒(401/403)
|
||||
[PASS] 未鉴权 POST /v1/admin/delete 被拒(401/403)
|
||||
[PASS] 未鉴权 POST /v1/admin/delete_all 被拒(401/403)
|
||||
|
||||
═══ 场景 7:设备列表为空时管理页是否 500 ═══
|
||||
[PASS] 设备列表为空时管理页仍正常渲染
|
||||
[PASS] 空列表页面没有 traceback
|
||||
|
||||
═══ 场景 8:关掉开关后重启中转,开关状态是否还在 ═══
|
||||
[PASS] 关掉开关
|
||||
[PASS] 重启后中转起来了
|
||||
[PASS] 重启后 allowNew 仍为 False(持久化生效)
|
||||
|
||||
==============================================================
|
||||
通过 33 项,失败 1 项
|
||||
==============================================================
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
============================================================
|
||||
deploy.sh 实测
|
||||
============================================================
|
||||
仓库: /c/Users/shu/Desktop/deepseekagent/LanRemote
|
||||
脚本: /c/Users/shu/Desktop/deepseekagent/LanRemote/relay/deploy.sh
|
||||
|
||||
── 1. 语法检查(bash -n)──
|
||||
[PASS] deploy.sh 语法正确
|
||||
[PASS] deploy-tune.sh 语法正确
|
||||
|
||||
── 2. 换行符 / BOM(.sh 必须 LF + 无 BOM)──
|
||||
[PASS] deploy.sh 是 LF 且无 BOM
|
||||
[PASS] deploy-tune.sh 是 LF 且无 BOM
|
||||
|
||||
── 3. --help ──
|
||||
[PASS] --help 退出码为 0
|
||||
[PASS] --help 有「用法」段
|
||||
[PASS] --help 列出了 --prefix
|
||||
[PASS] --help 提到了 TUNNEL_TOKEN
|
||||
[PASS] --help 没有副作用
|
||||
|
||||
── 3b. 非法参数要报错退出 ──
|
||||
[PASS] 未知参数退出码为 2
|
||||
[PASS] 非法协议退出码为 2
|
||||
|
||||
── 4. 搭沙箱(桩命令 + 临时安装根)──
|
||||
沙箱: /tmp/tmp.jb8lwoeXgw
|
||||
安装根: /tmp/tmp.jb8lwoeXgw/root
|
||||
桩命令: cloudflared curl free getent ip iptables nmcli python3 systemctl
|
||||
|
||||
── 5. 第一次部署(--skip-tunnel,不碰隧道)──
|
||||
[PASS] 第一次退出码为 0
|
||||
[PASS] relay.py 已部署
|
||||
[PASS] admin.py 已部署
|
||||
[PASS] landing.py 已部署
|
||||
[PASS] showcase.py 已部署
|
||||
[PASS] favicon.ico 已部署
|
||||
[PASS] deploy-tune.sh 已部署
|
||||
[PASS] watchdog.sh 已部署
|
||||
[PASS] site_shots 已部署
|
||||
[PASS] data 目录已建
|
||||
[PASS] downloads 目录已建
|
||||
[PASS] lanremote-relay.service 已生成
|
||||
[PASS] lanremote-nettune.service 已生成
|
||||
[PASS] lanremote-tunnel.service 已生成
|
||||
[PASS] 备用 http2 单元已生成
|
||||
[PASS] 看门狗 timer 已生成
|
||||
|
||||
生成的 relay unit 里应该有的关键项:
|
||||
[PASS] relay unit 含 RELAY_POLL=60
|
||||
[PASS] relay unit 含 RELAY_PORT=8080
|
||||
[PASS] relay unit 含 python3
|
||||
|
||||
两个隧道单元的内容(默认主 quic+8 / 副本 http2+2):
|
||||
[PASS] 主隧道单元用 quic
|
||||
[PASS] 主隧道 8 条连接(实测最优配比)
|
||||
[PASS] 副本隧道单元用 http2
|
||||
[PASS] 副本隧道 2 条连接(实测最优配比)
|
||||
[PASS] 主隧道 metrics 钉死 20241
|
||||
[PASS] 副本隧道 metrics 钉死 20242
|
||||
[PASS] nettune 只有一个入口脚本
|
||||
[PASS] unit 里没有明文 Token
|
||||
[PASS] 便捷命令 lanremote-url 已生成
|
||||
[PASS] 便捷命令 lanremote-key 已生成
|
||||
[PASS] 便捷命令 lanremote-status 已生成
|
||||
|
||||
── 6. 幂等性:放几个「不许被冲掉」的东西,再跑第二遍 ──
|
||||
[PASS] 第二次退出码为 0
|
||||
[PASS] 设备数据没被冲掉
|
||||
[PASS] 管理钥匙没被冲掉
|
||||
[PASS] 管理密码没被冲掉
|
||||
[PASS] 公网地址没被冲掉
|
||||
[PASS] relay_state.json 没被改(allowNew 保住了)
|
||||
[PASS] allowNew 仍然是 false(没被拨回 true)
|
||||
[PASS] 手工 config.yml 没被覆盖
|
||||
[PASS] relay.py 内容稳定
|
||||
[PASS] 第二遍没有新增 ✗ 错误
|
||||
|
||||
── 6b. 没变化时不该重启服务(别打断在线连接)──
|
||||
[PASS] 第二遍没有重启中转(无变化)
|
||||
[PASS] 第二遍没有重启隧道(无变化)
|
||||
[PASS] 第二遍没有多余的 daemon-reload
|
||||
[PASS] 输出里明确说了「内容没变、保持运行」
|
||||
|
||||
── 7. 凭据模式:给出域名时要能生成 config.yml ──
|
||||
[PASS] 凭据模式退出码为 0
|
||||
[PASS] config.yml 已生成
|
||||
[PASS] config.yml 里的隧道 ID 取自凭据文件名
|
||||
[PASS] config.yml 写入了域名
|
||||
[PASS] config.yml 有兜底 404 规则(没有它 cloudflared 起不来)
|
||||
[PASS] config.yml 指向本地中转端口
|
||||
|
||||
── 7c. 默认:两个隧道单元都要启用(协议级故障隔离)──
|
||||
[PASS] 默认(不传 --protocol)退出码为 0
|
||||
[PASS] 默认启用了主隧道
|
||||
[PASS] 默认启用了副本隧道
|
||||
[PASS] 默认没有关掉任何一个隧道
|
||||
|
||||
── 7d. --protocol quic:只跑主隧道 ──
|
||||
[PASS] --protocol quic 退出码为 0
|
||||
[PASS] quic 模式启用了主隧道
|
||||
[PASS] quic 模式停用了副本隧道
|
||||
[PASS] 输出说明了为什么停用副本
|
||||
|
||||
── 7e. --protocol http2:只跑副本隧道 ──
|
||||
[PASS] --protocol http2 退出码为 0
|
||||
[PASS] http2 模式启用了副本隧道
|
||||
[PASS] http2 模式停用了主隧道
|
||||
|
||||
── 7f. --protocol 的非法值要报错 ──
|
||||
[PASS] 非法协议退出码为 2
|
||||
[PASS] both 是合法值
|
||||
|
||||
── 7b. 再跑一次(不给 --hostname),config.yml 不该被改 ──
|
||||
[PASS] 再次运行退出码为 0
|
||||
[PASS] 没给 --hostname 时 config.yml 保持不变
|
||||
|
||||
── 8. Token 模式 ──
|
||||
[PASS] Token 模式退出码为 0
|
||||
[PASS] Token 写进了 tunnel.env
|
||||
[PASS] tunnel.env 内容正确
|
||||
[PASS] unit 里没有明文 Token(只有 EnvironmentFile 引用)
|
||||
[PASS] unit 用了 EnvironmentFile 读 Token
|
||||
[PASS] unit 的 ExecStart 里用 --token ${TUNNEL_TOKEN}
|
||||
|
||||
── 8b. tunnel.env 权限应该是 600 ──
|
||||
tunnel.env 权限: 644(Linux 上应为 600)
|
||||
|
||||
── 9. 缺文件时要明确报错,不能静默 ──
|
||||
[PASS] 文件不全时明确失败(退出码 1)
|
||||
[PASS] 报错信息说清了是「程序文件不全」
|
||||
|
||||
── 10. 桩命令确实被调用过(证明流程真的走了)──
|
||||
[PASS] 调用过 systemctl enable
|
||||
[PASS] 调用过 iptables
|
||||
[PASS] 第一次安装做了 daemon-reload
|
||||
|
||||
── 11. 脚本里不许有写死的域名 ──
|
||||
[PASS] deploy.sh 里没有写死的域名
|
||||
[PASS] deploy-tune.sh 里没有写死的域名
|
||||
[PASS] net-tune.sh 里没有写死的域名
|
||||
[PASS] 示例用的是「你的域名」占位符
|
||||
|
||||
── 12. 有凭据但没给域名 → 不许乱猜,必须明确提示 ──
|
||||
[调试] TMP=/tmp/tmp.jb8lwoeXgw
|
||||
[调试] 凭据目录内容: deadbeef-0000-1111-2222-333344445555.json
|
||||
[PASS] 没域名时退出码为 0(中转照装,只是隧道不启)
|
||||
[PASS] 没域名时没有生成 config.yml(没有乱猜)
|
||||
[PASS] 明确提示了「需要你提供公网域名」
|
||||
[PASS] 提示里给了怎么补域名
|
||||
[PASS] 提示里给了 Token 模式这个替代办法
|
||||
[PASS] 隧道没配好时没有去启动它
|
||||
[PASS] 中转仍然正常安装并启用
|
||||
|
||||
============================================================
|
||||
结果: 通过 98 项,失败 0 项
|
||||
============================================================
|
||||
沙箱目录(想自己看就留着): /tmp/tmp.jb8lwoeXgw
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=LanRemote Cloudflare 具名隧道
|
||||
After=network-online.target lanremote-relay.service
|
||||
Wants=network-online.target lanremote-relay.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --protocol http2 --edge-ip-version 4 --ha-connections 1 --retries 5 --config /root/.cloudflared/config.yml run lanremote
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
MemoryMax=128M
|
||||
StandardOutput=append:/root/lanremote-relay/tunnel.log
|
||||
StandardError=append:/root/lanremote-relay/tunnel.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
═══ 复现:管理员关掉「允许新设备接入」,电脑隔夜再开机 ═══
|
||||
[PASS] ① 电脑首次登记成功
|
||||
[PASS] ② 管理员关掉「允许新设备接入」
|
||||
[PASS] ③ 电脑当场重连仍然可以(开关只挡新设备)
|
||||
… 电脑关机了。等 4 秒模拟“隔夜”(真实是 6 小时)
|
||||
[PASS] ④ 中转把超时设备清掉了(设计中如此)
|
||||
磁盘 relay_state.json 里还有这台设备的码绑定: False
|
||||
内存里 codeCount=0
|
||||
[PASS] ⑤ 电脑第二天开机 —— 应该能连上
|
||||
|
||||
═══ 追问:重启中转能不能救回来? ═══
|
||||
重启后磁盘里这台设备的码绑定: False
|
||||
重启后内存 codeCount=0
|
||||
重启后 allowNew=False
|
||||
[PASS] ⑥ 重启中转后电脑能连上(说明重启能救回来)
|
||||
|
||||
═══ 追问:有没有绕过口子? ═══
|
||||
[PASS] ⑦ 换新 deviceId 重装 —— 同样被挡(符合开关语义)
|
||||
[PASS] ⑧ 新 deviceId + 老配对码 —— 也被挡
|
||||
|
||||
═══ 对照:打开开关后是否一切恢复 ═══
|
||||
[PASS] ⑨ 打开开关后原设备恢复
|
||||
|
||||
==============================================================
|
||||
通过 9 项,失败 0 项
|
||||
==============================================================
|
||||
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''查「默认中转服务器」在源码和已构建成品里各是什么值。
|
||||
|
||||
用户报告:软件默认中转服务器应该是**空白**、让用户自己填。
|
||||
但给我的版本是填好的,上传的版本是清空的 —— 两者不一致。
|
||||
|
||||
先把所有源头找出来,看清楚再改。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
|
||||
SOURCES = [
|
||||
("windows/lanremote/server.py", r"DEFAULT_RELAY_URL\s*=\s*(.+)"),
|
||||
("windows/lanremote/relay.py", r"192\.168\.\d+\.\d+:8080"),
|
||||
("windows/run_agent.py", r"or\s+\"(192\.168[^\"]*|[^\"]*xxmxxs[^\"]*)\""),
|
||||
("android/src/com/lanremote/watch/MainActivity.java",
|
||||
r"DEFAULT_RELAY_URL\s*=\s*(.+);"),
|
||||
]
|
||||
|
||||
print("=" * 70)
|
||||
print(" 一、源码里的默认中转地址")
|
||||
print("=" * 70)
|
||||
for rel, pattern in SOURCES:
|
||||
path = ROOT / rel
|
||||
if not path.is_file():
|
||||
print(" [不存在] %s" % rel)
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
hits = re.findall(pattern, text, re.M)
|
||||
print(" %s" % rel)
|
||||
if hits:
|
||||
for h in hits[:4]:
|
||||
h = h if isinstance(h, str) else h[0]
|
||||
print(" %s" % h.strip()[:100])
|
||||
else:
|
||||
print(" (没匹配到)")
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" 二、已构建成品里到底是什么")
|
||||
print("=" * 70)
|
||||
|
||||
# APK
|
||||
apk = ROOT / "dist" / "ykxmz-watch-1.8.2.apk"
|
||||
if apk.is_file():
|
||||
print(" %s" % apk.name)
|
||||
try:
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
dex = [n for n in z.namelist() if n.endswith(".dex")]
|
||||
found = []
|
||||
for name in dex:
|
||||
blob = z.read(name)
|
||||
for needle in (b"https://relay.xxmxxs.top",
|
||||
b"192.168.2.156",
|
||||
b"192.168.10.105"):
|
||||
if needle in blob:
|
||||
found.append((name, needle.decode()))
|
||||
if found:
|
||||
for name, needle in sorted(set(found)):
|
||||
print(" %s -> 含 %s" % (name, needle))
|
||||
else:
|
||||
print(" dex 里没有硬编码的中转地址(默认是空白)")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(" 读 APK 失败: %s" % exc)
|
||||
else:
|
||||
print(" dist/ykxmz-watch-1.8.2.apk 不存在")
|
||||
|
||||
# exe / onedir
|
||||
exe_dir = ROOT / "dist_exe" / "ykxmz"
|
||||
print()
|
||||
print(" %s" % exe_dir)
|
||||
if exe_dir.is_dir():
|
||||
hits = []
|
||||
for f in exe_dir.rglob("*"):
|
||||
if not f.is_file() or f.suffix.lower() not in (".py", ".pyc"):
|
||||
continue
|
||||
try:
|
||||
blob = f.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
for needle in (b"192.168.2.156", b"192.168.10.105",
|
||||
b"https://relay.xxmxxs.top"):
|
||||
if needle in blob:
|
||||
hits.append((f.relative_to(exe_dir).as_posix(),
|
||||
needle.decode()))
|
||||
if hits:
|
||||
for name, needle in sorted(set(hits))[:10]:
|
||||
print(" %-50s 含 %s" % (name, needle))
|
||||
else:
|
||||
print(" 没有硬编码的中转地址")
|
||||
else:
|
||||
print(" 不存在")
|
||||
|
||||
# 服务端 zip
|
||||
print()
|
||||
srv = ROOT / "dist" / "ykxmz-server-1.8.2.zip"
|
||||
if srv.is_file():
|
||||
print(" %s" % srv.name)
|
||||
try:
|
||||
with zipfile.ZipFile(srv) as z:
|
||||
for name in z.namelist():
|
||||
if not name.endswith((".py", ".txt", ".sh")):
|
||||
continue
|
||||
try:
|
||||
text = z.read(name).decode("utf-8", "replace")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for line in text.splitlines():
|
||||
if ("192.168.2.156" in line or "192.168.10.105" in line
|
||||
or "relay.xxmxxs.top" in line):
|
||||
print(" %s: %s" % (name, line.strip()[:90]))
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(" 读 zip 失败: %s" % exc)
|
||||
else:
|
||||
print(" dist/ykxmz-server-1.8.2.zip 不存在")
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" 三、结论")
|
||||
print("=" * 70)
|
||||
print(" 期望:默认中转服务器为**空白**,由用户自己填。")
|
||||
print(" 实际:见上面。凡是出现硬编码地址的地方都要改成空字符串。")
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
# 安全清理随身 WiFi 上的垃圾。
|
||||
#
|
||||
# 原则:先看清楚再删,删之前把「为什么它一定是垃圾」写下来。
|
||||
# 中转相关的目录(/root/lanremote-relay、/root/.cloudflared)一律不碰。
|
||||
set -u
|
||||
|
||||
BEFORE=$(df -m / | awk 'NR==2 {print $4}')
|
||||
echo "════════ 清理前:可用 ${BEFORE} MB ════════"
|
||||
echo
|
||||
|
||||
echo "════════ 0. 先看 nginx 到底在提供什么(决定动不动它)════════"
|
||||
echo " 在跑: $(systemctl is-active nginx)"
|
||||
echo " 根目录配置:"
|
||||
grep -hE '^\s*(root|server_name|listen)' /etc/nginx/sites-enabled/* /etc/nginx/conf.d/*.conf 2>/dev/null | head -10 | sed 's/^/ /'
|
||||
echo " /var/www 内容:"
|
||||
ls -1 /var/www 2>/dev/null | sed 's/^/ /'
|
||||
echo " 首页大小: $(stat -c%s /var/www/html/index.nginx-debian.html 2>/dev/null || echo 0) 字节"
|
||||
echo " → 这是 Debian 装 nginx 的默认欢迎页,和中转无关。"
|
||||
echo " **但为了安全我不删 nginx**(万一有人拿它做别的),只报告。"
|
||||
|
||||
echo
|
||||
echo "════════ 1. 删 /var/lib/mysql(113 MB)════════"
|
||||
echo " 依据:"
|
||||
echo " * dpkg 里没有任何 mysql/mariadb 包(0 个已安装)"
|
||||
echo " * 没有任何 mysqld 进程"
|
||||
echo " * 没有 mariadb/mysql 的 systemd 服务"
|
||||
echo " → 这是历史遗留的数据目录,包都卸了,目录没删干净。"
|
||||
if [ -d /var/lib/mysql ]; then
|
||||
echo " 删除前确认没有进程占用: $(fuser -m /var/lib/mysql 2>/dev/null | wc -l) 个"
|
||||
if [ "$(pgrep -c -f 'mysqld|mariadb' 2>/dev/null || echo 0)" -eq 0 ] \
|
||||
&& [ "$(dpkg -l 2>/dev/null | grep -cE '^ii +(mysql-server|mariadb-server)')" -eq 0 ]; then
|
||||
rm -rf /var/lib/mysql
|
||||
echo " ✓ 已删除 /var/lib/mysql"
|
||||
else
|
||||
echo " ! 检测到 mysql 相关包或进程,**跳过**(安全第一)"
|
||||
fi
|
||||
else
|
||||
echo " (不存在,跳过)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "════════ 2. 清 apt 缓存和索引(约 162 MB)════════"
|
||||
echo " 依据:/var/cache/apt 是已下载的 .deb 缓存,随时可重下;"
|
||||
echo " /var/lib/apt/lists 是索引,apt update 会重建。"
|
||||
apt-get clean 2>/dev/null
|
||||
rm -rf /var/lib/apt/lists/* 2>/dev/null
|
||||
echo " ✓ 已清理"
|
||||
|
||||
echo
|
||||
echo "════════ 3. 压缩 journald 日志(约 10 MB)════════"
|
||||
journalctl --vacuum-size=8M 2>&1 | tail -2 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. 清理我遗留的调试脚本(173 个)════════"
|
||||
echo " 这些是我这几次调试时上传到 /root 的临时脚本(ab*.sh、bench.sh 等),"
|
||||
echo " 中转本身不需要它们。"
|
||||
echo " ⚠ 保留:中转目录、.cloudflared、以及任何看起来像正式运维脚本的"
|
||||
n=$(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l)
|
||||
echo " 清理前: $n 个"
|
||||
mkdir -p /root/.trash-debug
|
||||
# 只移动我确定是调试用的(a开头/ab开头/bench/check开头等),保留其他
|
||||
for f in /root/a*.sh /root/ab*.sh /root/bench*.sh /root/check_*.sh /root/diag_*.sh \
|
||||
/root/dl*.sh /root/final*.sh /root/post_*.sh /root/probe*.py /root/smart_*.sh \
|
||||
/root/setup_*.sh /root/test_*.sh /root/why_*.sh /root/fa*.sh /root/tc*.sh \
|
||||
/root/cc*.sh /root/sa*.sh /root/sq.sh /root/dc.sh /root/vc*.sh; do
|
||||
[ -f "$f" ] && mv "$f" /root/.trash-debug/ 2>/dev/null
|
||||
done
|
||||
moved=$(ls -1 /root/.trash-debug 2>/dev/null | wc -l)
|
||||
echo " 已移走 $moved 个到 /root/.trash-debug(先不真删,确认没问题再删)"
|
||||
echo " /root 下剩余: $(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l) 个"
|
||||
|
||||
echo
|
||||
echo "════════ 5. 中转目录(绝对不动)════════"
|
||||
du -sh /root/lanremote-relay /root/.cloudflared 2>/dev/null | sed 's/^/ /'
|
||||
echo " → 这两个是命根子,一个字节都没碰"
|
||||
|
||||
echo
|
||||
AFTER=$(df -m / | awk 'NR==2 {print $4}')
|
||||
echo "════════ 结果 ════════"
|
||||
echo " 可用空间: ${BEFORE} MB -> ${AFTER} MB (腾出 $((AFTER - BEFORE)) MB)"
|
||||
df -h / | sed 's/^/ /'
|
||||
echo
|
||||
echo " 磁盘总览:"
|
||||
du -xhd1 / 2>/dev/null | sort -rh | head -8 | sed 's/^/ /'
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# 补删 /var/lib/mysql(上一条脚本的整数判断写错了,误跳过)
|
||||
set -u
|
||||
|
||||
echo "════════ 删除前的三重确认 ════════"
|
||||
echo " 1. 有没有 mysqld/mariadb 进程:"
|
||||
n=$(pgrep -f 'mysqld|mariadb' 2>/dev/null | wc -l)
|
||||
echo " $n 个"
|
||||
echo " 2. dpkg 里有没有 mysql/mariadb 包:"
|
||||
p=$(dpkg -l 2>/dev/null | grep -cE '^ii +(mysql|mariadb)' || true)
|
||||
echo " $p 个"
|
||||
echo " 3. 有没有相关 systemd 服务:"
|
||||
s=$(systemctl list-unit-files 2>/dev/null | grep -cE '^(mysql|mariadb)' || true)
|
||||
echo " $s 个"
|
||||
echo " 4. 目录大小:$(du -sh /var/lib/mysql 2>/dev/null | awk '{print $1}')"
|
||||
echo " 5. 有没有别的进程打开着里面的文件:"
|
||||
fuser -m /var/lib/mysql 2>/dev/null | wc -l | sed 's/^/ /'
|
||||
|
||||
if [ "$n" -eq 0 ] && [ "$p" -eq 0 ] && [ "$s" -eq 0 ]; then
|
||||
echo
|
||||
echo " → 三项都是 0,确认是卸载残留的数据目录,删除"
|
||||
rm -rf /var/lib/mysql
|
||||
echo " ✓ 已删除"
|
||||
else
|
||||
echo
|
||||
echo " ! 还有引用,**不删**"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "════════ 把剩余的调试脚本也移走 ════════"
|
||||
# 上一条只移走了一部分(通配符没覆盖全)。
|
||||
# 现在把 /root 下**所有顶层** .sh/.py 都移进回收目录 ——
|
||||
# 中转的运行时代码在 /root/lanremote-relay/ 里,不受影响。
|
||||
before=$(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l)
|
||||
mkdir -p /root/.trash-debug
|
||||
for f in /root/*.sh /root/*.py; do
|
||||
[ -f "$f" ] || continue
|
||||
mv "$f" /root/.trash-debug/ 2>/dev/null
|
||||
done
|
||||
after=$(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l)
|
||||
echo " /root 顶层脚本: $before -> $after"
|
||||
echo " 回收目录里的: $(ls -1 /root/.trash-debug 2>/dev/null | wc -l) 个"
|
||||
echo " ⚠ 没有真删,放在 /root/.trash-debug;确认中转一切正常后再 rm -rf"
|
||||
|
||||
echo
|
||||
echo "════════ 确认中转没被影响 ════════"
|
||||
echo " relay 服务: $(systemctl is-active lanremote-relay)"
|
||||
echo " 隧道(quic): $(systemctl is-active lanremote-tunnel)"
|
||||
echo " 隧道(h2): $(systemctl is-active lanremote-tunnel-h2)"
|
||||
echo " 自检: $(curl -s --max-time 10 http://127.0.0.1:8080/v1/health | head -c 70)"
|
||||
echo
|
||||
echo " 中转目录完好: $(ls /root/lanremote-relay/ | tr '\n' ' ')"
|
||||
|
||||
echo
|
||||
echo "════════ 最终空间 ════════"
|
||||
df -h / | sed 's/^/ /'
|
||||
echo
|
||||
du -xhd1 / 2>/dev/null | sort -rh | head -8 | sed 's/^/ /'
|
||||
@@ -0,0 +1,9 @@
|
||||
═══ 1. cli_relay_status()(不在 try 里)═══
|
||||
它打印的内容:
|
||||
(没有抛异常)
|
||||
|
||||
═══ 2. cli_relay(url)(在 try 里)═══
|
||||
它打印的内容:
|
||||
返回值: 1
|
||||
|
||||
═══ 3. 结论 ═══
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''最小复现:只解压两个 zip,看进程会不会被杀。
|
||||
|
||||
线索:test_package_remote.py 有时跑 1.6 秒、只打印一行"一、解压"就没了,
|
||||
退出码 -1。1.6 秒正好是解压 18.85 MB / 1001 个文件的时间,
|
||||
所以要确认"是不是解压这一步出的事"。
|
||||
|
||||
每一步都 flush 打印,这样即使被杀也能看到死在哪一行。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
AGENT = ROOT / "dist" / "ykxmz-agent-1.8.2.zip"
|
||||
SERVER = ROOT / "dist" / "ykxmz-server-1.8.2.zip"
|
||||
|
||||
|
||||
def say(msg: str) -> None:
|
||||
print(" %s" % msg, flush=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
work = Path(tempfile.mkdtemp(prefix="dbg_extract_"))
|
||||
say("工作目录 %s" % work)
|
||||
|
||||
for label, path in (("被控端", AGENT), ("服务器端", SERVER)):
|
||||
say("准备解压 %s (%s)" % (label, path.name))
|
||||
say(" 文件存在: %s 大小 %.2f MB"
|
||||
% (path.is_file(), path.stat().st_size / 1024 / 1024))
|
||||
t0 = time.time()
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = z.namelist()
|
||||
say(" 条目数 %d" % len(names))
|
||||
for i, name in enumerate(names):
|
||||
z.extract(name, work)
|
||||
if i % 200 == 0:
|
||||
say(" ... 已解压 %d/%d(%.1fs)"
|
||||
% (i, len(names), time.time() - t0))
|
||||
say(" %s 解压完成,用了 %.1f 秒" % (label, time.time() - t0))
|
||||
|
||||
say("全部解压完成")
|
||||
agent_dir = work / AGENT.stem
|
||||
exe = agent_dir / "ykxmz.exe"
|
||||
say("ykxmz.exe 在不在: %s" % exe.is_file())
|
||||
if exe.is_file():
|
||||
say(" exe 大小 %.2f MB" % (exe.stat().st_size / 1024 / 1024))
|
||||
|
||||
say("存活检查:睡 3 秒")
|
||||
time.sleep(3)
|
||||
say("还活着 ✓")
|
||||
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
say("清理完成,正常退出")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,130 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''单独复现 test_package_remote.py 的第四节(Java 手表端)。
|
||||
|
||||
为什么单独跑:
|
||||
整个测试里前三节都过了,第四节报
|
||||
ConnectException: Connection refused
|
||||
这不是链路抖动,是"根本没人在 8095 上监听"。
|
||||
需要判断是中转进程死了,还是 Java 侧连错了地方。
|
||||
|
||||
做法:起中转 -> 确认存活 -> 跑 Java 测试 -> 再确认存活,
|
||||
每一步都打印中转进程状态,坏在哪一步一目了然。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.8.2.zip"
|
||||
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
|
||||
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
|
||||
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
|
||||
RELAY_PORT = 8095
|
||||
PAIR_CODE = "778899"
|
||||
|
||||
|
||||
def alive(base: str) -> str:
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/v1/health", timeout=3) as r:
|
||||
snap = json.loads(r.read())
|
||||
return "活着 设备=%d" % len(snap.get("devices", []))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return "!! 连不上: %s" % exc
|
||||
|
||||
|
||||
def main() -> int:
|
||||
work = Path(tempfile.mkdtemp(prefix="dbg_java_"))
|
||||
print(" 工作目录 %s" % work)
|
||||
with zipfile.ZipFile(SERVER_ZIP) as z:
|
||||
z.extractall(work)
|
||||
relay_dir = work / SERVER_ZIP.stem
|
||||
relay_py = relay_dir / "relay.py"
|
||||
print(" relay.py: %s" % relay_py.is_file())
|
||||
|
||||
data = work / "relaydata"
|
||||
data.mkdir(parents=True, exist_ok=True)
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"RELAY_PORT": str(RELAY_PORT), "RELAY_BIND": "127.0.0.1",
|
||||
"RELAY_DATA": str(data), "RELAY_POLL": "8",
|
||||
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1",
|
||||
})
|
||||
log = open(work / "relay.log", "wb")
|
||||
proc = subprocess.Popen([sys.executable, "-u", str(relay_py)], env=env,
|
||||
stdout=log, stderr=subprocess.STDOUT)
|
||||
base = "http://127.0.0.1:%d" % RELAY_PORT
|
||||
|
||||
try:
|
||||
# 等中转起来
|
||||
for _ in range(40):
|
||||
time.sleep(0.5)
|
||||
if alive(base).startswith("活着"):
|
||||
break
|
||||
print(" 1. 中转启动后: %s" % alive(base))
|
||||
print(" 进程 poll() = %s(None 表示还活着)" % proc.poll())
|
||||
|
||||
# 编译 Java
|
||||
classes = work / "classes"
|
||||
classes.mkdir(parents=True, exist_ok=True)
|
||||
src_dir = ROOT / "android" / "src" / "com" / "lanremote" / "watch"
|
||||
sources = [src_dir / n for n in
|
||||
("Json.java", "B64.java", "Transport.java",
|
||||
"RelayTransport.java", "TlsCompat.java",
|
||||
"RemoteClient.java", "DiscoveryCore.java")]
|
||||
sources.append(ROOT / "_tests" / "java" / "JavaRelayTest.java")
|
||||
comp = subprocess.run(
|
||||
[str(JAVAC), "--release", "8", "-encoding", "UTF-8",
|
||||
"-d", str(classes)] + [str(s) for s in sources],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
print(" 2. Java 编译: %s" % ("OK" if comp.returncode == 0
|
||||
else comp.stderr[:300]))
|
||||
if comp.returncode != 0:
|
||||
return 1
|
||||
|
||||
print(" 编译后再看中转: %s" % alive(base))
|
||||
|
||||
# 跑 Java 测试
|
||||
result = subprocess.run(
|
||||
[str(JAVA), "-Dfile.encoding=UTF-8", "-cp", str(classes),
|
||||
"JavaRelayTest", base, PAIR_CODE],
|
||||
capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", timeout=120)
|
||||
print(" 3. Java 测试退出码 %d" % result.returncode)
|
||||
for line in result.stdout.splitlines():
|
||||
print(" %s" % line)
|
||||
if result.stderr.strip():
|
||||
print(" stderr: %s" % result.stderr.strip()[:400])
|
||||
|
||||
print(" 4. Java 跑完后中转: %s" % alive(base))
|
||||
print(" 进程 poll() = %s" % proc.poll())
|
||||
|
||||
if proc.poll() is not None:
|
||||
print()
|
||||
print(" === 中转进程已经退出了,日志末尾 ===")
|
||||
log.flush()
|
||||
text = (work / "relay.log").read_text(encoding="utf-8",
|
||||
errors="replace")
|
||||
print(text[-2500:])
|
||||
finally:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=8)
|
||||
except Exception: # noqa: BLE001
|
||||
proc.kill()
|
||||
log.close()
|
||||
print()
|
||||
print(" 日志留在: %s(没删,方便看)" % work)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,272 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""验证三件套压缩包:解压出来实际跑一遍。
|
||||
|
||||
不只是看有没有文件 —— 要确认包里的东西**真的能用**:
|
||||
1. 解压
|
||||
2. 服务器端的 relay.py 能起、健康检查通过
|
||||
3. 被控端的 EXE 能起、能连上包里那个中转
|
||||
4. Java 手表端经这个中转连上被控端并执行命令
|
||||
5. 清理
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
# 三端分开出(用户要求):
|
||||
# 被控端 -> 一个 zip
|
||||
# 服务器端 -> 一个 zip
|
||||
# 控制端 -> 直接给 APK(不打 zip)
|
||||
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-1.8.2.zip"
|
||||
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.8.2.zip"
|
||||
WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.8.2.apk"
|
||||
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
|
||||
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
|
||||
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
|
||||
RELAY_PORT = 8095
|
||||
AGENT_PORT = 8895
|
||||
PAIR_CODE = "778899"
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
global passed, failed
|
||||
if ok:
|
||||
passed += 1
|
||||
print(" [PASS] " + name)
|
||||
else:
|
||||
failed += 1
|
||||
print(" [FAIL] " + name + " " + str(detail))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# 1) 三个产物都要在
|
||||
for label, path in (("被控端包", AGENT_ZIP), ("服务器端包", SERVER_ZIP),
|
||||
("控制端 APK", WATCH_APK)):
|
||||
if not path.is_file():
|
||||
print(f" 找不到{label}: {path}")
|
||||
return 1
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix="verify_pkg_"))
|
||||
relay_proc = agent_proc = None
|
||||
try:
|
||||
# ---------------- 1. 解压两个 zip ----------------
|
||||
print("一、解压(两个 zip)")
|
||||
with zipfile.ZipFile(AGENT_ZIP) as archive:
|
||||
archive.extractall(work)
|
||||
with zipfile.ZipFile(SERVER_ZIP) as archive:
|
||||
archive.extractall(work)
|
||||
|
||||
# 两个包各自带一层版本目录
|
||||
agent_dir = work / AGENT_ZIP.stem
|
||||
relay_dir = work / SERVER_ZIP.stem
|
||||
check("被控端目录存在", agent_dir.is_dir(), agent_dir)
|
||||
check("服务器端目录存在", relay_dir.is_dir(), relay_dir)
|
||||
|
||||
exe = agent_dir / "ykxmz.exe"
|
||||
relay_py = relay_dir / "relay.py"
|
||||
install_sh = relay_dir / "install.sh"
|
||||
apk = WATCH_APK
|
||||
check("被控端 EXE 在", exe.is_file())
|
||||
check("服务器端 relay.py 在", relay_py.is_file())
|
||||
check("服务器端 install.sh 在", install_sh.is_file())
|
||||
check("服务器端带了网络调优脚本", (relay_dir / "net-tune.sh").is_file())
|
||||
check("服务器端带了看门狗", (relay_dir / "watchdog.sh").is_file())
|
||||
check("控制端 APK 在(直接在 dist 里)", apk.is_file(), apk)
|
||||
|
||||
# 说明文档要有 BOM(否则记事本会认成 GBK 乱码)
|
||||
for label, path in (("被控端", agent_dir / "README-agent.txt"),
|
||||
("服务器端", relay_dir / "README-server.txt"),
|
||||
("控制端(旁边那份)",
|
||||
ROOT / "dist" / "ykxmz-watch-1.8.2-说明.txt")):
|
||||
raw = path.read_bytes() if path.is_file() else b""
|
||||
check(f"{label}说明有 UTF-8 BOM",
|
||||
raw[:3] == b"\xef\xbb\xbf", raw[:3])
|
||||
|
||||
# 三份正式文档:两个包里都要有
|
||||
for label, base in (("被控端包", agent_dir), ("服务器端包", relay_dir)):
|
||||
for doc in ("LICENSE.txt", "BEFORE-INSTALL.txt",
|
||||
"AFTER-INSTALL.txt"):
|
||||
check(f"{label}带了 {doc}", (base / doc).is_file())
|
||||
|
||||
# 被控端目录里不该有 data / 说明残留
|
||||
check("没有把 data 目录打进去",
|
||||
not (agent_dir / "data").exists())
|
||||
check("带了三个工具脚本",
|
||||
all((agent_dir / n).is_file()
|
||||
for n in ("wlzj.bat", "xffhq.bat", "fxfhq.bat")),
|
||||
[n for n in ("wlzj.bat", "xffhq.bat", "fxfhq.bat")
|
||||
if not (agent_dir / n).is_file()])
|
||||
|
||||
# ---------------- 2. 起包里的中转 ----------------
|
||||
print()
|
||||
print("二、用包里的 relay.py 起中转")
|
||||
relay_data = work / "relaydata"
|
||||
relay_data.mkdir(parents=True, exist_ok=True)
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"RELAY_PORT": str(RELAY_PORT), "RELAY_BIND": "127.0.0.1",
|
||||
"RELAY_DATA": str(relay_data), "RELAY_POLL": "8",
|
||||
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1",
|
||||
})
|
||||
relay_proc = subprocess.Popen(
|
||||
[sys.executable, "-u", str(relay_py)], env=env,
|
||||
stdout=open(work / "relay.log", "wb"),
|
||||
stderr=subprocess.STDOUT)
|
||||
base = f"http://127.0.0.1:{RELAY_PORT}"
|
||||
up = False
|
||||
for _ in range(40):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/v1/health",
|
||||
timeout=2) as r:
|
||||
if r.status == 200:
|
||||
up = True
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
check("包里的中转能起", up)
|
||||
if not up:
|
||||
print((work / "relay.log").read_text(encoding="utf-8",
|
||||
errors="replace")[-800:])
|
||||
return 1
|
||||
|
||||
# ---------------- 3. 起包里的被控端 ----------------
|
||||
print()
|
||||
print("三、用包里的 EXE 起被控端,让它连这个中转")
|
||||
data_dir = agent_dir / "data"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
sys.path.insert(0, str(ROOT / "windows"))
|
||||
from lanremote.config import Store
|
||||
store = Store(data_dir)
|
||||
store.set_pairing_code(PAIR_CODE)
|
||||
store.set("setup_done", True)
|
||||
store.set("allow_new_pairing", True)
|
||||
store.set("close_pair_after_first", False)
|
||||
store.set("server_name", "包里测试电脑")
|
||||
store.set("relay_url", base)
|
||||
store.set("port", AGENT_PORT)
|
||||
|
||||
agent_proc = subprocess.Popen(
|
||||
[str(exe), "--silent", "--port", str(AGENT_PORT)],
|
||||
cwd=str(agent_dir),
|
||||
stdout=open(work / "agent.log", "wb"),
|
||||
stderr=subprocess.STDOUT)
|
||||
online = False
|
||||
info = {}
|
||||
for _ in range(60):
|
||||
time.sleep(1.0)
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/v1/health",
|
||||
timeout=3) as r:
|
||||
snap = json.loads(r.read())
|
||||
for item in snap.get("devices", []):
|
||||
if item.get("online"):
|
||||
info = item
|
||||
online = True
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if online:
|
||||
break
|
||||
check("包里的 EXE 连上了包里的中转", online, str(info)[:150])
|
||||
if not online:
|
||||
print((work / "agent.log").read_text(encoding="utf-8",
|
||||
errors="replace")[-1000:])
|
||||
return 1
|
||||
check("配对码对得上", info.get("code") == PAIR_CODE, info.get("code"))
|
||||
check("设备名对得上", info.get("name") == "包里测试电脑",
|
||||
info.get("name"))
|
||||
|
||||
# ---------------- 4. Java 手表端 ----------------
|
||||
print()
|
||||
print("四、Java 手表端经这个中转连被控端")
|
||||
print(" [调试] 进第四节前: relay poll()=%s agent poll()=%s"
|
||||
% (relay_proc.poll(), agent_proc.poll()))
|
||||
try:
|
||||
import urllib.request as _u
|
||||
with _u.urlopen(base + "/v1/health", timeout=3) as _r:
|
||||
print(" [调试] 中转健康检查: %s" % _r.status)
|
||||
except Exception as _e:
|
||||
print(" [调试] 中转健康检查失败: %s" % _e)
|
||||
classes = work / "classes"
|
||||
classes.mkdir(parents=True, exist_ok=True)
|
||||
sources = [ROOT / "android" / "src" / "com" / "lanremote" / "watch" / n
|
||||
for n in ("Json.java", "B64.java", "Transport.java",
|
||||
"RelayTransport.java", "TlsCompat.java",
|
||||
"RemoteClient.java",
|
||||
"DiscoveryCore.java")]
|
||||
sources.append(ROOT / "_tests" / "java" / "JavaRelayTest.java")
|
||||
comp = subprocess.run(
|
||||
[str(JAVAC), "--release", "8", "-encoding", "UTF-8",
|
||||
"-d", str(classes)] + [str(s) for s in sources],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
check("Java 编译通过", comp.returncode == 0, comp.stderr[:200])
|
||||
if comp.returncode != 0:
|
||||
return 1
|
||||
|
||||
# 这条链路实测只有 ~75% 可用(丢包 40%,边缘还在美西),
|
||||
# 而这里串行发多个请求,任何一个被丢就整体失败 —— 会假失败。
|
||||
# 测试要验证的是**产品逻辑**,链路可靠性有专门的可用率测量盯着。
|
||||
# 所以重试 3 次,任一次全过就算过。
|
||||
result = None
|
||||
tail = ""
|
||||
for attempt in range(3):
|
||||
result = subprocess.run(
|
||||
[str(JAVA), "-Dfile.encoding=UTF-8", "-cp", str(classes),
|
||||
"JavaRelayTest", base, PAIR_CODE],
|
||||
capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace")
|
||||
tail = (result.stdout.strip().splitlines()[-1]
|
||||
if result.stdout.strip() else "")
|
||||
if result.returncode == 0 and "失败 0 项" in tail:
|
||||
if attempt:
|
||||
print(f" (第 {attempt + 1} 次才通过 —— "
|
||||
f"链路抖动,不是产品问题)")
|
||||
break
|
||||
if attempt < 2:
|
||||
time.sleep(2.0)
|
||||
check("Java 手表端经中转全部通过",
|
||||
result is not None and result.returncode == 0
|
||||
and "失败 0 项" in tail, tail)
|
||||
for line in result.stdout.splitlines():
|
||||
if "[FAIL]" in line:
|
||||
print(" " + line.strip())
|
||||
|
||||
print()
|
||||
print("=" * 66)
|
||||
print(f"通过 {passed} 项,失败 {failed} 项")
|
||||
return 0 if failed == 0 else 1
|
||||
finally:
|
||||
for process in (agent_proc, relay_proc):
|
||||
if process is not None:
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=8)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
process.kill()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(1)
|
||||
print()
|
||||
print(' [调试] 临时目录保留在: %s' % work)
|
||||
for _p in (relay_proc, agent_proc):
|
||||
if _p is not None:
|
||||
print(' [调试] 进程 %s poll()=%s'
|
||||
% (_p.pid, _p.poll()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,183 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""单独验证"跨网络中转载荷"这条腿,每步都打时间戳,定位卡在哪。
|
||||
|
||||
只读诊断:起中转 + 真实被控端,然后用 HTTP 长轮询走一遍 pair/ping。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "windows"))
|
||||
|
||||
_UA = {"User-Agent": "LanRemote/1.8.2"}
|
||||
T0 = time.time()
|
||||
|
||||
|
||||
def say(msg: str) -> None:
|
||||
print(" [%6.1fs] %s" % (time.time() - T0, msg), flush=True)
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def post(base, path, body=b"", headers=None, timeout=20.0):
|
||||
req = urllib.request.Request(base + path, data=body, method="POST",
|
||||
headers=dict(_UA))
|
||||
for k, v in (headers or {}).items():
|
||||
req.add_header(k, v)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.read()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
work = Path(tempfile.mkdtemp(prefix="diag_relayleg_"))
|
||||
relay = agent = None
|
||||
try:
|
||||
from lanremote.config import Store
|
||||
relay_port = free_port()
|
||||
agent_port = free_port()
|
||||
base = "http://127.0.0.1:%d" % relay_port
|
||||
code = "246813"
|
||||
|
||||
say("起中转 %s" % base)
|
||||
rd = work / "relaydata"
|
||||
rd.mkdir(parents=True, exist_ok=True)
|
||||
env = dict(os.environ)
|
||||
env.update({"RELAY_PORT": str(relay_port), "RELAY_BIND": "127.0.0.1",
|
||||
"RELAY_DATA": str(rd), "RELAY_POLL": "2",
|
||||
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"})
|
||||
relay = subprocess.Popen(
|
||||
[sys.executable, "-u", str(ROOT / "relay" / "relay.py")], env=env,
|
||||
stdout=open(work / "relay.log", "wb"), stderr=subprocess.STDOUT)
|
||||
for _ in range(60):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/v1/health", timeout=2):
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
say("中转就绪")
|
||||
|
||||
data = work / "agentdata"
|
||||
data.mkdir(parents=True, exist_ok=True)
|
||||
st = Store(data)
|
||||
st.set_pairing_code(code)
|
||||
st.set("setup_done", True)
|
||||
st.set("allow_new_pairing", True)
|
||||
st.set("close_pair_after_first", False)
|
||||
st.set("server_name", "诊断电脑")
|
||||
st.set("relay_url", base)
|
||||
st.set("port", agent_port)
|
||||
|
||||
env2 = dict(os.environ)
|
||||
env2.update({"YKXMZ_DATA": str(data),
|
||||
"YKXMZ_LOCK_PORT": str(free_port()),
|
||||
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"})
|
||||
say("起被控端 端口 %d" % agent_port)
|
||||
agent = subprocess.Popen(
|
||||
[sys.executable, "-u", str(ROOT / "windows" / "run_agent.py"),
|
||||
"--silent", "--port", str(agent_port)],
|
||||
cwd=str(ROOT / "windows"), env=env2,
|
||||
stdout=open(work / "agent.log", "wb"), stderr=subprocess.STDOUT)
|
||||
|
||||
online = False
|
||||
for _ in range(45):
|
||||
time.sleep(1.0)
|
||||
try:
|
||||
with urllib.request.urlopen(base + "/v1/health", timeout=4) as r:
|
||||
snap = json.loads(r.read().decode("utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if any(d.get("online") for d in snap.get("devices", [])):
|
||||
online = True
|
||||
break
|
||||
say("被控端在线: %s" % online)
|
||||
if not online:
|
||||
say((data / "silent.log").read_text(encoding="utf-8",
|
||||
errors="replace")[-500:])
|
||||
return 1
|
||||
|
||||
say("attach 接入")
|
||||
body = json.dumps({"code": code, "name": "诊断手表"},
|
||||
ensure_ascii=False).encode()
|
||||
_s, raw = post(base, "/v1/client/attach", body)
|
||||
attach = json.loads(raw.decode())
|
||||
session = attach.get("session", "")
|
||||
say("attach ok=%s session=%s" % (attach.get("ok"), session[:12]))
|
||||
|
||||
buf = b""
|
||||
|
||||
def call(obj, timeout=25.0):
|
||||
nonlocal buf
|
||||
payload = (json.dumps(obj, ensure_ascii=False) + "\n").encode()
|
||||
say(" send %s" % obj.get("cmd"))
|
||||
post(base, "/v1/client/send", payload, {"X-Session": session},
|
||||
timeout)
|
||||
say(" send 返回,开始 poll")
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
while b"\n" in buf:
|
||||
line, _, buf = buf.partition(b"\n")
|
||||
if not line.strip():
|
||||
continue
|
||||
msg = json.loads(line.decode())
|
||||
if "event" in msg:
|
||||
continue
|
||||
say(" 收到 %s" % msg.get("cmd"))
|
||||
return msg
|
||||
left = deadline - time.time()
|
||||
if left <= 0:
|
||||
raise TimeoutError("等回复超时")
|
||||
t = time.time()
|
||||
_s, chunk = post(base, "/v1/client/poll", b"",
|
||||
{"X-Session": session}, min(20.0, left))
|
||||
say(" poll 返回 %d 字节,用了 %.1fs"
|
||||
% (len(chunk), time.time() - t))
|
||||
buf += chunk
|
||||
|
||||
say("pair")
|
||||
reply = call({"cmd": "pair", "deviceId": "diag-relay",
|
||||
"deviceName": "诊断手表", "code": code})
|
||||
say("pair ok=%s" % reply.get("ok"))
|
||||
|
||||
for i in range(3):
|
||||
t = time.time()
|
||||
pong = call({"cmd": "ping"})
|
||||
say("ping #%d ok=%s 往返 %.0f ms"
|
||||
% (i + 1, pong.get("ok"), (time.time() - t) * 1000))
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
import traceback
|
||||
say("异常: %s" % exc)
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
finally:
|
||||
for p in (agent, relay):
|
||||
if p is not None:
|
||||
try:
|
||||
p.terminate()
|
||||
p.wait(timeout=5)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
p.kill()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(0.3)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,162 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""诊断:为什么被控端登记不到本地中转。
|
||||
|
||||
只读诊断,不动任何仓库文件。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "windows"))
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def get(url, timeout=5):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return r.status, r.read().decode("utf-8", "replace")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return 0, str(exc)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
work = Path(tempfile.mkdtemp(prefix="diag_relay_"))
|
||||
relay_port = free_port()
|
||||
agent_port = free_port()
|
||||
relay_url = "http://127.0.0.1:%d" % relay_port
|
||||
print(" 中转 %s 被控端端口 %d" % (relay_url, agent_port))
|
||||
|
||||
from lanremote.config import Store
|
||||
# ⚠ 关键:run_agent.py 读的是 data_dir(),不是我们随手建的目录。
|
||||
# 第一次诊断就栽在这儿 —— 配置写进了临时目录,agent 却读
|
||||
# windows/data,于是"怎么都不登记"。
|
||||
# data_dir() 优先认 YKXMZ_DATA,所以用环境变量把它指到临时目录,
|
||||
# 这样两边看到的是同一份配置。
|
||||
real_data = work / "agentdata"
|
||||
real_data.mkdir(parents=True, exist_ok=True)
|
||||
os.environ["YKXMZ_DATA"] = str(real_data)
|
||||
store = Store(real_data)
|
||||
store.set_pairing_code("135790")
|
||||
store.set("setup_done", True)
|
||||
store.set("allow_new_pairing", True)
|
||||
store.set("close_pair_after_first", False)
|
||||
store.set("server_name", "诊断电脑")
|
||||
store.set("relay_url", relay_url)
|
||||
store.set("port", agent_port)
|
||||
print(" store 里的 relay_url = %r" % store.get("relay_url"))
|
||||
|
||||
relay_data = work / "relaydata"
|
||||
relay_data.mkdir(parents=True, exist_ok=True)
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"RELAY_PORT": str(relay_port), "RELAY_BIND": "127.0.0.1",
|
||||
"RELAY_DATA": str(relay_data), "RELAY_POLL": "4",
|
||||
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1",
|
||||
})
|
||||
relay = subprocess.Popen(
|
||||
[sys.executable, "-u", str(ROOT / "relay" / "relay.py")], env=env,
|
||||
stdout=open(work / "relay.log", "wb"), stderr=subprocess.STDOUT)
|
||||
for _ in range(40):
|
||||
time.sleep(0.5)
|
||||
if get(relay_url + "/v1/health")[0] == 200:
|
||||
break
|
||||
print(" 中转: %s" % ("起来了" if get(relay_url + "/v1/health")[0] == 200
|
||||
else "没起来"))
|
||||
|
||||
agent_log = open(work / "agent_stdout.log", "wb")
|
||||
agent_env = dict(os.environ)
|
||||
agent_env["YKXMZ_DATA"] = str(real_data)
|
||||
agent_env["PYTHONIOENCODING"] = "utf-8"
|
||||
agent = subprocess.Popen(
|
||||
[sys.executable, "-u", str(ROOT / "windows" / "run_agent.py"),
|
||||
"--silent", "--port", str(agent_port)],
|
||||
cwd=str(ROOT / "windows"), env=agent_env,
|
||||
stdout=agent_log, stderr=subprocess.STDOUT)
|
||||
|
||||
deadline = time.time() + 45
|
||||
online = None
|
||||
while time.time() < deadline:
|
||||
time.sleep(1.5)
|
||||
st, body = get(relay_url + "/v1/health")
|
||||
if st == 200:
|
||||
try:
|
||||
snap = json.loads(body)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
devs = snap.get("devices", [])
|
||||
if devs:
|
||||
online = devs
|
||||
break
|
||||
if agent.poll() is not None:
|
||||
print(" ! 被控端进程已退出,退出码 %s" % agent.returncode)
|
||||
break
|
||||
|
||||
print()
|
||||
print(" ==== 中转看到的设备 ====")
|
||||
if online:
|
||||
for d in online:
|
||||
print(" %s" % json.dumps(d, ensure_ascii=False)[:200])
|
||||
else:
|
||||
print(" (空 —— 没登记上)")
|
||||
|
||||
print()
|
||||
print(" ==== 被控端 stdout(最后 800 字)====")
|
||||
agent_log.flush()
|
||||
try:
|
||||
print((work / "agent_stdout.log").read_text(
|
||||
encoding="utf-8", errors="replace")[-800:])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(" 读不到: %s" % exc)
|
||||
|
||||
print()
|
||||
print(" ==== 被控端 silent.log(HeadlessUI 写这里)====")
|
||||
try:
|
||||
txt = (real_data / "silent.log").read_text(encoding="utf-8",
|
||||
errors="replace")
|
||||
print(txt[-2000:])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(" 读不到: %s" % exc)
|
||||
|
||||
print()
|
||||
print(" ==== 中转日志里的注册相关 ====")
|
||||
try:
|
||||
txt = (work / "relay.log").read_text(encoding="utf-8",
|
||||
errors="replace")
|
||||
for line in txt.splitlines():
|
||||
if any(k in line for k in ("register", "注册", "被控端", "poll",
|
||||
"上线", "Traceback", "Error")):
|
||||
print(" " + line[:150])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(" 读不到: %s" % exc)
|
||||
|
||||
for proc in (agent, relay):
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(0.3)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''检查两个 zip 里实际发出去的说明文档写了什么。
|
||||
|
||||
重点确认三件事:
|
||||
1. 硬编码的作者中转地址(xxmxxs / 192.168.x.x)已经彻底没了
|
||||
2. 「默认不带中转地址、要跨网络得自己部署」这句话真的在文档里
|
||||
3. 版本号是 1.8.2,不是旧的
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
AGENT = ROOT / "dist" / "ykxmz-agent-1.8.2.zip"
|
||||
SERVER = ROOT / "dist" / "ykxmz-server-1.8.2.zip"
|
||||
|
||||
WANT = {
|
||||
"AGENT": [
|
||||
("README-agent.txt", ["没有内置任何中转地址", "自己部署", "deploy.sh"]),
|
||||
("BEFORE-INSTALL.txt", ["不带任何中转地址", "deploy.sh", "安卓 7.0"]),
|
||||
("AFTER-INSTALL.txt", ["中转地址默认为空", "1.8.2"]),
|
||||
],
|
||||
"SERVER": [
|
||||
("README-server.txt", ["deploy.sh", "两种协议同时在跑", "边缘缓存"]),
|
||||
],
|
||||
}
|
||||
|
||||
BANNED = [b"relay.xxmxxs.top", b"192.168.2.156", b"192.168.10.105"]
|
||||
|
||||
# 只扫文档,不扫 .sh / .py —— install.sh 这个名字本身就是脚本文件名,
|
||||
# 不是"文档里让用户去跑旧脚本"的证据。
|
||||
DOC_SUFFIX = (".txt", ".md")
|
||||
|
||||
ok = True
|
||||
|
||||
|
||||
def check(name: str, good: bool, detail: str = "") -> None:
|
||||
global ok
|
||||
print(" [%s] %s%s" % ("PASS" if good else "FAIL", name,
|
||||
(" " + detail) if detail else ""))
|
||||
if not good:
|
||||
ok = False
|
||||
|
||||
|
||||
for label, path in (("AGENT", AGENT), ("SERVER", SERVER)):
|
||||
print("=" * 62)
|
||||
print(" %s %s" % (label, path.name))
|
||||
print("=" * 62)
|
||||
if not path.is_file():
|
||||
check("%s 存在" % path.name, False)
|
||||
continue
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = z.namelist()
|
||||
# 1. 全包扫禁用串(只扫文档)
|
||||
hits = []
|
||||
for name in names:
|
||||
if not name.endswith(DOC_SUFFIX):
|
||||
continue
|
||||
blob = z.read(name)
|
||||
for needle in BANNED:
|
||||
if needle in blob:
|
||||
hits.append("%s 含 %s" % (name, needle.decode().strip()))
|
||||
check("文档里没有硬编码的作者中转地址", not hits, "; ".join(hits[:3]))
|
||||
check("文档里没有让用户跑旧脚本 install.sh", not any(
|
||||
re.search(rb"(sudo\s+)?bash\s+install\.sh", z.read(n))
|
||||
for n in names if n.endswith(DOC_SUFFIX)),
|
||||
"(README-server 里【关于 install.sh】那节只是说明它仍留在包里,"
|
||||
"不是让用户去跑它 —— 那种是允许的)")
|
||||
|
||||
# 2. 关键文档里该有的话
|
||||
for fname, phrases in WANT.get(label, []):
|
||||
match = [n for n in names if n.endswith(fname)]
|
||||
if not match:
|
||||
check("%s 在包里" % fname, False)
|
||||
continue
|
||||
text = z.read(match[0]).decode("utf-8-sig", "replace")
|
||||
for phrase in phrases:
|
||||
check("%s 里有「%s」" % (fname, phrase), phrase in text)
|
||||
|
||||
# 3. 版本号:只看**抬头**那几行。
|
||||
# 正文里的「八、版本记录」本来就该列历史版本(1.7.1 等),
|
||||
# 把那些也算成"过期"是误判。
|
||||
for fname in ("README-agent.txt", "README-server.txt",
|
||||
"BEFORE-INSTALL.txt", "AFTER-INSTALL.txt"):
|
||||
match = [n for n in names if n.endswith(fname)]
|
||||
if not match:
|
||||
continue
|
||||
text = z.read(match[0]).decode("utf-8-sig", "replace")
|
||||
head = "\n".join(text.splitlines()[:10])
|
||||
stale = re.findall(r"\b1\.[0-7]\.\d+\b", head)
|
||||
check("%s 抬头没有旧版本号" % fname, not stale,
|
||||
"发现 %s" % set(stale) if stale else "")
|
||||
check("%s 抬头写了 1.8.2" % fname, "1.8.2" in head)
|
||||
print()
|
||||
|
||||
print("=" * 62)
|
||||
print(" %s" % ("全部通过" if ok else "有失败项"))
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修 README.md 和 docs/ 两份装机文档里过期的版本号。
|
||||
|
||||
现状:
|
||||
README.md 三件套文件名 / 编译产物路径写成 1.5.0、1.8.0
|
||||
BEFORE-INSTALL.txt 抬头和落款写成 1.7.1
|
||||
AFTER-INSTALL.txt 抬头 / 版本记录 / 落款写成 1.7.1
|
||||
|
||||
目标版本:1.8.2(和 relay.py / AndroidManifest / installer 一致)
|
||||
|
||||
只改版本号本身,不动措辞 —— 措辞的修改用 edit 工具单独做,
|
||||
这样每一步都能看清楚改了什么。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
TARGET = "1.8.2"
|
||||
|
||||
FILES = [
|
||||
"README.md",
|
||||
"docs/BEFORE-INSTALL.txt",
|
||||
"docs/AFTER-INSTALL.txt",
|
||||
]
|
||||
|
||||
# 只替换这些"产品版本号"形态,避免误伤日期、端口号之类:
|
||||
# ykxmz-xxx-1.5.0.zip / ykxmz-watch-1.5.0.apk / 版本:1.7.1 / 软件:ykxmz 1.7.1
|
||||
PATTERNS = [
|
||||
# 文件名里的版本:ykxmz-agent-1.5.0.zip、ykxmz-watch-1.8.0.apk
|
||||
(re.compile(r"(ykxmz-[a-z]+-)\d+\.\d+\.\d+"), r"\g<1>" + TARGET),
|
||||
# 抬头/落款:版本:1.7.1 / 软件:ykxmz 1.7.1
|
||||
(re.compile(r"(版本[::]\s*)\d+\.\d+\.\d+"), r"\g<1>" + TARGET),
|
||||
(re.compile(r"(软件[::]\s*ykxmz\s*)\d+\.\d+\.\d+"), r"\g<1>" + TARGET),
|
||||
# 编译产物注释:# 产物:dist\ykxmz-watch-1.5.0.apk(上面已覆盖)
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(" 目标版本 %s" % TARGET)
|
||||
print()
|
||||
total = 0
|
||||
for rel in FILES:
|
||||
path = ROOT / rel
|
||||
if not path.is_file():
|
||||
print(" [跳过] %s 不存在" % rel)
|
||||
continue
|
||||
raw = path.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
before = text
|
||||
hits = []
|
||||
for pattern, repl in PATTERNS:
|
||||
for match in pattern.finditer(text):
|
||||
hits.append(match.group(0).strip())
|
||||
text = pattern.sub(repl, text)
|
||||
if text != before:
|
||||
data = text.encode("utf-8")
|
||||
if bom:
|
||||
data = b"\xef\xbb\xbf" + data
|
||||
path.write_bytes(data)
|
||||
print(" %s 改了 %d 处" % (rel, len(hits)))
|
||||
for h in hits:
|
||||
print(" %s" % h)
|
||||
total += len(hits)
|
||||
else:
|
||||
print(" %s 没有需要改的版本号" % rel)
|
||||
|
||||
print()
|
||||
print(" 共改 %d 处" % total)
|
||||
|
||||
# 复核:看还有没有残留的旧版本号
|
||||
print()
|
||||
print(" 残留检查:")
|
||||
stale = re.compile(r"\b1\.[0-7]\.\d+\b")
|
||||
for rel in FILES:
|
||||
path = ROOT / rel
|
||||
if not path.is_file():
|
||||
continue
|
||||
for i, line in enumerate(
|
||||
path.read_text(encoding="utf-8-sig").splitlines(), 1):
|
||||
for m in stale.finditer(line):
|
||||
print(" ! %s:%d %s" % (rel, i, line.strip()[:80]))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,286 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修「经中转 1~2 次交互后永久哑掉」—— 这是"按一下没反应"的真因。
|
||||
|
||||
== 复现出来的完整证据链 ==
|
||||
|
||||
被控端日志里直接抛了未捕获异常,线程当场死掉:
|
||||
|
||||
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 线程还活着、还在长轮询 -> 中转那边看着"设备在线"
|
||||
* **手表看到的是"连上了,但按一下毫无反应"** —— 和用户报的现象完全一致
|
||||
|
||||
== 根因:一条 HTTP/1.1 连接被两个线程同时用 ==
|
||||
|
||||
_post() 里锁只包住了"取连接"这一段:
|
||||
|
||||
with self._http_lock:
|
||||
connection = self._http # <- 锁到这里就放了
|
||||
if connection is None:
|
||||
connection = self._open_http()
|
||||
self._http = connection
|
||||
try:
|
||||
connection.request("POST", path, ...) # <- 锁外发请求
|
||||
|
||||
于是两个线程会**同时**用同一条 connection:
|
||||
* relay-link 线程:跑 /v1/agent/poll,那条请求要挂 180 秒(长轮询)
|
||||
* client-relay 线程:同一时间要 POST /v1/agent/send
|
||||
|
||||
HTTP/1.1 一条连接同一时刻只能有一个在途请求。第二个线程一进来
|
||||
就撞上 http.client 的状态机,抛 CannotSendRequest("Idle")。
|
||||
|
||||
更糟的是 _post 的"重试一次"救不了:它 _drop_http() 之后,
|
||||
另一个线程手里还攥着那条旧连接的对象在发,照样坏。
|
||||
|
||||
== 修法 ==
|
||||
|
||||
1. **给"发命令"用一条独立的连接**(_post 加 dedicated 参数)。
|
||||
长轮询那条连接常年被占着,发命令不该去挤它。
|
||||
两条连接各自用一把锁**锁住整个请求周期**(不是只锁取连接那一下)。
|
||||
|
||||
2. **长轮询线程之外的请求,如果发现缓存连接正忙,就临时开一条**,
|
||||
用完即关 —— 保证任何时刻一条 connection 只有一个在途请求。
|
||||
|
||||
3. **server.py 的 _client_loop 里 session.send() 加保护**。
|
||||
这是最后一层兜底:不管底层出什么错,都不该让
|
||||
client-relay 线程整个死掉 —— 死一次就永久哑,代价太大。
|
||||
发送失败时踢掉这个会话(让手表重连),而不是让线程消失。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
|
||||
|
||||
def patch(path: Path, old: str, new: str, label: str) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if old not in text:
|
||||
print(" ✗ 没找到:%s" % label)
|
||||
return False
|
||||
if text.count(old) != 1:
|
||||
print(" ! 出现 %d 次:%s" % (text.count(old), label))
|
||||
return False
|
||||
path.write_text(text.replace(old, new, 1), encoding="utf-8", newline="\n")
|
||||
print(" ✓ %s" % label)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok_all = True
|
||||
relay = ROOT / "windows" / "lanremote" / "relay.py"
|
||||
|
||||
# ---------------------------------------------------------- 1. 加字段
|
||||
text = relay.read_text(encoding="utf-8")
|
||||
print(" ── 1. 加连接占用标记和发送专用连接 ──")
|
||||
old_fields = " self._http_lock = threading.RLock()"
|
||||
new_fields = """ self._http_lock = threading.RLock()
|
||||
# 缓存的那条连接现在有没有在途请求。
|
||||
# /v1/agent/poll 是长轮询,一条请求挂 180 秒 ——
|
||||
# 那段时间里这条连接**不能**被别的线程再拿去发请求,
|
||||
# 否则 HTTP/1.1 状态机直接抛 CannotSendRequest("Idle")。
|
||||
self._http_busy = False
|
||||
# 发命令专用连接。长轮询那条常年被占着,
|
||||
# 发命令不能去挤它 —— 各走各的。
|
||||
self._http_send = None
|
||||
self._send_lock = threading.RLock()"""
|
||||
if old_fields not in text:
|
||||
print(" ✗ 找不到 _http_lock 字段定义")
|
||||
ok_all = False
|
||||
else:
|
||||
text = text.replace(old_fields, new_fields, 1)
|
||||
relay.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(" ✓ 加了 _http_busy / _http_send / _send_lock")
|
||||
|
||||
# ---------------------------------------------------------- 2. send_to_client 用专用连接
|
||||
ok_all &= patch(
|
||||
relay,
|
||||
""" for start in range(0, len(data), 256 * 1024):
|
||||
piece = data[start:start + 256 * 1024]
|
||||
self._post("/v1/agent/send", piece, {"X-Token": self.token},
|
||||
timeout=POLL_TIMEOUT)""",
|
||||
""" for start in range(0, len(data), 256 * 1024):
|
||||
piece = data[start:start + 256 * 1024]
|
||||
# dedicated=True:走发命令专用连接,不和长轮询挤一条。
|
||||
# 这是「按一下没反应」的修复点 —— 以前这里和长轮询共用
|
||||
# 一条 HTTP/1.1 连接,撞上状态机就抛 "Idle",
|
||||
# 异常一路冒到 client-relay 线程外面把它打死。
|
||||
self._post("/v1/agent/send", piece, {"X-Token": self.token},
|
||||
timeout=POLL_TIMEOUT, dedicated=True)""",
|
||||
"send_to_client 走专用连接")
|
||||
|
||||
# ---------------------------------------------------------- 3. _drop_http 也清专用连接
|
||||
ok_all &= patch(
|
||||
relay,
|
||||
""" def _drop_http(self) -> None:
|
||||
\"\"\"把缓存的连接关掉(出错后调用,下次会重开)。\"\"\"
|
||||
with self._http_lock:
|
||||
connection, self._http = self._http, None
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass""",
|
||||
""" def _drop_http(self, dedicated: bool = False) -> None:
|
||||
\"\"\"把缓存的连接关掉(出错后调用,下次会重开)。
|
||||
|
||||
dedicated=True 时清的是"发命令专用连接",
|
||||
否则清长轮询用的那条。
|
||||
\"\"\"
|
||||
if dedicated:
|
||||
with self._send_lock:
|
||||
connection, self._http_send = self._http_send, None
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return
|
||||
with self._http_lock:
|
||||
connection, self._http = self._http, None
|
||||
self._http_busy = False
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass""",
|
||||
"_drop_http 支持专用连接 + 清 busy 标记")
|
||||
|
||||
# ---------------------------------------------------------- 4. _post 主体
|
||||
ok_all &= patch(
|
||||
relay,
|
||||
""" def _post(self, path: str, body: bytes, headers: dict,
|
||||
timeout: float = POLL_TIMEOUT, want_headers: bool = False):
|
||||
\"\"\"POST 一个请求,**复用同一条连接**。""",
|
||||
""" def _post(self, path: str, body: bytes, headers: dict,
|
||||
timeout: float = POLL_TIMEOUT, want_headers: bool = False,
|
||||
dedicated: bool = False):
|
||||
\"\"\"POST 一个请求,**复用同一条连接**。""",
|
||||
"_post 加 dedicated 参数")
|
||||
|
||||
ok_all &= patch(
|
||||
relay,
|
||||
""" last_error = None
|
||||
for attempt in (1, 2):
|
||||
with self._http_lock:
|
||||
connection = self._http
|
||||
if connection is None:
|
||||
connection = self._open_http()
|
||||
self._http = connection
|
||||
try:
|
||||
connection.timeout = timeout""",
|
||||
""" last_error = None
|
||||
for attempt in (1, 2):
|
||||
# 取一条可以独占使用的连接:
|
||||
# * dedicated=True -> 发命令专用连接(自己一把锁锁全程)
|
||||
# * 普通请求 -> 缓存连接;**如果它正忙(长轮询在途)
|
||||
# 就临时开一条,用完即关**
|
||||
# 关键:锁必须盖住整个"发请求 + 读响应"周期,
|
||||
# 不能只锁"取连接"那一下 —— 否则两个线程会用同一条连接。
|
||||
if dedicated:
|
||||
self._send_lock.acquire()
|
||||
connection = self._http_send
|
||||
if connection is None:
|
||||
connection = self._open_http()
|
||||
self._http_send = connection
|
||||
temporary = False
|
||||
else:
|
||||
with self._http_lock:
|
||||
if self._http is None:
|
||||
self._http = self._open_http()
|
||||
if self._http_busy:
|
||||
# 缓存连接正被长轮询占着,临时另开一条
|
||||
connection = self._open_http()
|
||||
temporary = True
|
||||
else:
|
||||
connection = self._http
|
||||
self._http_busy = True
|
||||
temporary = False
|
||||
try:
|
||||
connection.timeout = timeout""",
|
||||
"_post 独占连接")
|
||||
|
||||
ok_all &= patch(
|
||||
relay,
|
||||
""" except urllib.error.HTTPError:
|
||||
raise # HTTP 状态码是业务层的,不算连接问题
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 连接层面的问题:关掉,下一轮重开
|
||||
last_error = exc
|
||||
self._drop_http()
|
||||
if attempt == 2:
|
||||
break
|
||||
self._log_quiet(f"连接断了,重开一次再试({exc})")
|
||||
|
||||
raise RelayError(f"请求失败: {last_error}")""",
|
||||
""" except urllib.error.HTTPError:
|
||||
raise # HTTP 状态码是业务层的,不算连接问题
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 连接层面的问题:关掉,下一轮重开
|
||||
last_error = exc
|
||||
self._drop_http(dedicated=dedicated)
|
||||
if attempt == 2:
|
||||
break
|
||||
self._log_quiet(f"连接断了,重开一次再试({exc})")
|
||||
finally:
|
||||
# 无论成功失败都要把"占用"还回去,
|
||||
# 否则这条连接会被永久标记为忙,之后每次请求都另开临时连接。
|
||||
if dedicated:
|
||||
self._send_lock.release()
|
||||
elif not temporary:
|
||||
with self._http_lock:
|
||||
self._http_busy = False
|
||||
else:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
raise RelayError(f"请求失败: {last_error}")""",
|
||||
"_post 用完归还占用")
|
||||
|
||||
# ---------------------------------------------------------- 5. server.py 兜底
|
||||
ok_all &= patch(
|
||||
ROOT / "windows" / "lanremote" / "server.py",
|
||||
""" if reply is not None:
|
||||
session.send(reply)
|
||||
except OSError:
|
||||
pass""",
|
||||
""" if reply is not None:
|
||||
# ⚠ 这里必须兜住**所有**异常。
|
||||
# 以前只在外层捕获 OSError,而 session.send() 在
|
||||
# 中转链路上会抛 RelayError(实测:"请求失败: Idle")——
|
||||
# 那个异常一路冒出去,把 client-relay 线程整个打死。
|
||||
# 线程一死,电脑就再也不往手表回任何东西,
|
||||
# 而 relay-link 还在长轮询、中转看着"设备在线",
|
||||
# 用户看到的就是"连上了,按一下毫无反应"。
|
||||
# 发失败最多踢掉这个会话让手表重连,绝不能丢线程。
|
||||
try:
|
||||
session.send(reply)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(f"[错误] 回包失败,断开该会话:{exc}")
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(f"[错误] 会话循环异常退出:{exc}")""",
|
||||
"server.py 兜住回包异常")
|
||||
|
||||
print()
|
||||
if not ok_all:
|
||||
print(" 有失败项")
|
||||
return 1
|
||||
print(" 都修好了")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修验证智能体找到的三个严重问题。
|
||||
|
||||
== 🔴 1. 管理页 JS 里的裸换行 -> 整段脚本不执行 ==
|
||||
|
||||
admin.py 里 ADMIN_JS 是**普通三引号字符串**(不是 raw),
|
||||
所以我在 Python 源码里写的 `\n` 会被 Python 变成**真实换行**,
|
||||
拼进 JS 就成了单引号字符串里的裸换行 —— JS 直接语法错误,
|
||||
**整段 <script> 都不执行**:
|
||||
|
||||
+ ' 改成:\n\n(必须是 6 位数字)'
|
||||
-> 在浏览器里是
|
||||
+ ' 改成:
|
||||
(必须是 6 位数字)'
|
||||
|
||||
后果:改码 / 删除 / 批量删除 / 全选 **全部变成幽灵按键**。
|
||||
元凶是我加"改码"功能那次提交(d4f335b)。
|
||||
|
||||
修法:Python 源码里写 `\\n`,这样生成的文件里是 `\n`(反斜杠+n),
|
||||
JS 才把它当转义。
|
||||
|
||||
⚠ 同一个坑以前踩过("管理页 JS 合法性"这个测试阶段就是为此建的),
|
||||
这次是我在新增代码时又踩了一遍 —— 说明光有测试不够,
|
||||
**新增 JS 字符串时也得想着它经过了 Python 这一次转义**。
|
||||
|
||||
== 🔴 2. --relay / --relay-status 崩溃 ==
|
||||
|
||||
windows/lanremote/relay.py 的 start() 签名是 start(self, url: str),
|
||||
url 是**必填无默认值**。但我把默认中转地址改成空白时,
|
||||
run_agent.py 两处调用没跟着改:
|
||||
|
||||
:296 cli_relay() -> link.start() 被 try 吞成"测试失败"
|
||||
:338 cli_relay_status() -> link.start() 不在 try 里 -> 直接报栈
|
||||
|
||||
用户看到的分别是"✗ 测试失败:missing 1 required positional argument: 'url'"
|
||||
(以为是自己填错地址)和一个 Python 堆栈。
|
||||
|
||||
修法:两处都从 store 读 relay_url 传进去;没配地址时给出人话提示。
|
||||
|
||||
== 🔴 3. TTL 清理 + 关掉接入开关 = 设备永久锁死 ==
|
||||
|
||||
register() 里用 `device_id in self.devices` 判断"是不是老设备"。
|
||||
但 snapshot() 会把超过 DEVICE_TTL(6 小时) 没上报的设备
|
||||
从 devices **和 codes 里一起删掉**。
|
||||
|
||||
于是:
|
||||
* 电脑关机过夜(必然 > 6 小时)-> 第二天开机时它已经不在 devices 里
|
||||
* 如果管理员此时关着"允许新设备接入" -> 它被当成新设备拒绝
|
||||
* **重启中转也救不回来**:snapshot 清 codes 时没调 _save(),
|
||||
磁盘上还留着绑定 -> 重启 _load() 恢复 -> 第一次 snapshot 又当孤儿清掉。
|
||||
这是个自我维系的死循环。
|
||||
* 更糟的是管理员在页面上**看不到这台设备了**,不知道该放开谁。
|
||||
|
||||
修法:状态文件里单独存一份 `knownDevices`(设备历史),
|
||||
**超时清理只动 devices/codes,绝不动 knownDevices**。
|
||||
"是不是老设备"改成查这个历史名单。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
BOM = b"\xef\xbb\xbf"
|
||||
|
||||
|
||||
def patch(text: str, old: str, new: str, label: str) -> tuple:
|
||||
if old not in text:
|
||||
print(" ✗ 没找到:%s" % label)
|
||||
return text, False
|
||||
if text.count(old) != 1:
|
||||
print(" ! 出现 %d 次:%s" % (text.count(old), label))
|
||||
return text, False
|
||||
print(" ✓ %s" % label)
|
||||
return text.replace(old, new, 1), True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok_all = True
|
||||
|
||||
# ============================================================ 修 1
|
||||
print(" ── 修 1:管理页 JS 的裸换行 ──")
|
||||
admin = ROOT / "relay" / "admin.py"
|
||||
text = admin.read_text(encoding="utf-8")
|
||||
# Python 源码里 \n 会被解释成换行,要写成 \\n
|
||||
old = """ const typed = prompt('把「' + name + '」的配对码 ' + old
|
||||
+ ' 改成:\\n\\n(必须是 6 位数字)', '');"""
|
||||
new = """ // ⚠ 这里必须是 \\\\n(反斜杠+n),不能是 \\n ——
|
||||
// 本文件是普通三引号字符串,写 \\n 会被 Python 变成**真实换行**,
|
||||
// 拼进 JS 就是字符串里的裸换行 -> 整段 <script> 语法错误 ->
|
||||
// 页面上所有按钮(改码/删除/批量删除/全选)全部失效。
|
||||
// 这个坑踩过一次(为此专门建了"管理页 JS 合法性"测试阶段),
|
||||
// 加改码功能时又踩了第二次。
|
||||
const typed = prompt('把「' + name + '」的配对码 ' + old
|
||||
+ ' 改成:\\\\n\\\\n(必须是 6 位数字)', '');"""
|
||||
text, ok = patch(text, old, new, "改码 prompt 的换行转义")
|
||||
ok_all = ok_all and ok
|
||||
if ok:
|
||||
admin.write_text(text, encoding="utf-8", newline="\n")
|
||||
|
||||
# ============================================================ 修 2
|
||||
print()
|
||||
print(" ── 修 2:--relay / --relay-status 崩溃 ──")
|
||||
run_agent = ROOT / "windows" / "run_agent.py"
|
||||
text = run_agent.read_text(encoding="utf-8")
|
||||
|
||||
old = """ from lanremote.relay import RelayLink
|
||||
link = RelayLink(store)
|
||||
if not link.start():
|
||||
print(" 启动失败")
|
||||
wait_key()
|
||||
return 1"""
|
||||
new = """ from lanremote.relay import RelayLink
|
||||
link = RelayLink(store)
|
||||
# start() 的 url 是必填参数 —— 之前这里没传,
|
||||
# 被 try 兜住后打成"✗ 测试失败:missing 1 required positional
|
||||
# argument: 'url'",用户以为是自己地址填错了。
|
||||
if not link.start(url):
|
||||
print(" 启动失败")
|
||||
wait_key()
|
||||
return 1"""
|
||||
text, ok = patch(text, old, new, "cli_relay 传 url")
|
||||
ok_all = ok_all and ok
|
||||
|
||||
old = """ from lanremote.relay import RelayLink
|
||||
store = Store(data_dir())
|
||||
# 不给默认值:中转地址由用户自己填(--relay 或设置界面)。
|
||||
# 以前这里兜底了一个写死的地址,结果是「我明明没填,它却去连一个
|
||||
# 我根本不认识的服务器」,而且那个地址早过期了。
|
||||
url = str(store.get("relay_url") or "")
|
||||
attach_console()"""
|
||||
new = """ from lanremote.relay import RelayLink
|
||||
store = Store(data_dir())
|
||||
# 不给默认值:中转地址由用户自己填(--relay 或设置界面)。
|
||||
# 以前这里兜底了一个写死的地址,结果是「我明明没填,它却去连一个
|
||||
# 我根本不认识的服务器」,而且那个地址早过期了。
|
||||
url = str(store.get("relay_url") or "")
|
||||
attach_console()"""
|
||||
# 这段本身没变,但下面要加"没配地址就提示"的判断
|
||||
old2 = """ if not url:
|
||||
print("没有配置中转(只在局域网内可用)")
|
||||
print("要跨网络请运行:ykxmz.exe --relay https://你的中转地址")
|
||||
wait_key()
|
||||
return 0"""
|
||||
new2 = """ if not url:
|
||||
print("没有配置中转(只在局域网内可用)")
|
||||
print("要跨网络请运行:ykxmz.exe --relay https://你的中转地址")
|
||||
wait_key()
|
||||
return 0
|
||||
|
||||
# start() 的 url 是必填参数。之前这里调 link.start() 不传参,
|
||||
# 而且不在任何 try 里 —— 直接抛 TypeError 给用户看一堆堆栈。
|
||||
try:
|
||||
link = RelayLink(store)
|
||||
link.start(url)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print("启动中转链路失败:%s" % exc)
|
||||
wait_key()
|
||||
return 1"""
|
||||
text, ok = patch(text, old2, new2, "cli_relay_status 传 url")
|
||||
ok_all = ok_all and ok
|
||||
|
||||
if ok_all:
|
||||
run_agent.write_text(text, encoding="utf-8", newline="\n")
|
||||
|
||||
print()
|
||||
if not ok_all:
|
||||
print(" 有失败项")
|
||||
return 1
|
||||
print(" 修 1、修 2 完成(修 3 见 relay.py 的单独补丁)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,205 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修 3(最严重):TTL 清理 + 关掉接入开关 = 设备永久锁死。
|
||||
|
||||
== 问题 ==
|
||||
|
||||
register() 里判断"是不是老设备"用的是:
|
||||
|
||||
known_device = device_id in self.devices
|
||||
|
||||
但 snapshot() 会把超过 DEVICE_TTL(6 小时)没上报的设备
|
||||
从 devices **和 codes 里一起删掉**。
|
||||
|
||||
于是这条链必然发生:
|
||||
1. 电脑关机过夜(> 6 小时) -> 设备被清出 devices
|
||||
2. 管理员关着"允许新设备接入" -> 第二天开机的电脑被当新设备拒绝
|
||||
3. **重启中转也救不回来**:
|
||||
snapshot 清 codes 时没调 _save(),磁盘上绑定还在
|
||||
-> 重启 _load() 恢复绑定 -> 第一次 snapshot() 又当孤儿清掉
|
||||
这是个自我维系的死循环
|
||||
4. 管理员在页面上**看不到这台设备了**,不知道该放开谁
|
||||
|
||||
== 修法 ==
|
||||
|
||||
状态文件里单独存一份 `knownDevices`(设备历史名单)。
|
||||
**超时清理只动 devices / codes,绝不动 knownDevices。**
|
||||
"是不是老设备"改成查这份历史名单。
|
||||
|
||||
同时:
|
||||
* snapshot 清孤儿 codes 之后补 _save(),让磁盘状态和内存一致
|
||||
(否则又是一处"重启后状态回滚"的隐患)
|
||||
* delete_device / forget 才是"真正忘记这台设备"的地方,
|
||||
那里要把 knownDevices 一起删掉 —— 否则管理员"删除设备"之后
|
||||
那台设备仍然算老设备,开关就形同虚设
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
SRC = ROOT / "relay" / "relay.py"
|
||||
|
||||
|
||||
def patch(text, old, new, label):
|
||||
if old not in text:
|
||||
print(" ✗ 没找到:%s" % label)
|
||||
return text, False
|
||||
if text.count(old) != 1:
|
||||
print(" ! 出现 %d 次:%s" % (text.count(old), label))
|
||||
return text, False
|
||||
print(" ✓ %s" % label)
|
||||
return text.replace(old, new, 1), True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = SRC.read_text(encoding="utf-8")
|
||||
ok_all = True
|
||||
|
||||
# ---------------------------------------------------------- 字段
|
||||
text, ok = patch(
|
||||
text,
|
||||
""" # 是否允许新设备接入。False 时只有已登记过的 deviceId 能上线。
|
||||
self.allow_new = True""",
|
||||
""" # 是否允许新设备接入。False 时只有已登记过的 deviceId 能上线。
|
||||
self.allow_new = True
|
||||
# 设备历史名单(只有 deviceId,不含配对码)。
|
||||
# **超时清理绝不动它** —— 它是"这台设备曾经被允许过"的唯一凭证。
|
||||
# 详见 register() 里 known_device 的注释。
|
||||
self.known_devices: set = set()""",
|
||||
"加 known_devices 字段")
|
||||
ok_all = ok_all and ok
|
||||
|
||||
# ---------------------------------------------------------- _load
|
||||
text, ok = patch(
|
||||
text,
|
||||
""" # 是否允许新设备接入(重启不丢)
|
||||
self.allow_new = bool(raw.get("allowNew", True))""",
|
||||
""" # 是否允许新设备接入(重启不丢)
|
||||
self.allow_new = bool(raw.get("allowNew", True))
|
||||
# 恢复设备历史名单(重启不丢)
|
||||
for did in raw.get("knownDevices", []):
|
||||
did = str(did or "").strip()
|
||||
if did:
|
||||
self.known_devices.add(did)""",
|
||||
"_load 恢复 known_devices")
|
||||
ok_all = ok_all and ok
|
||||
|
||||
# ---------------------------------------------------------- _save
|
||||
text, ok = patch(
|
||||
text,
|
||||
""" payload = {
|
||||
"allowNew": bool(self.allow_new),
|
||||
"codes": [""",
|
||||
""" payload = {
|
||||
"allowNew": bool(self.allow_new),
|
||||
# 设备历史:超时清理不会删它,所以关掉接入开关后
|
||||
# 老设备第二天开机仍然能上线
|
||||
"knownDevices": sorted(self.known_devices),
|
||||
"codes": [""",
|
||||
"_save 持久化 known_devices")
|
||||
ok_all = ok_all and ok
|
||||
|
||||
# ---------------------------------------------------------- register
|
||||
text, ok = patch(
|
||||
text,
|
||||
""" known_device = device_id in self.devices
|
||||
if not known_device and not self.allow_new:""",
|
||||
""" # ⚠ 判断"老设备"必须查 known_devices(设备历史),
|
||||
# **不能查 self.devices** —— devices 里超过 6 小时没上报的
|
||||
# 会被 snapshot() 清掉(电脑关机过夜必然超时),
|
||||
# 那时老设备会被自己的开关拒之门外,而且管理员在页面上
|
||||
# 已经看不到它、不知道该放开谁,重启也救不回来。
|
||||
known_device = device_id in self.known_devices
|
||||
if not known_device and not self.allow_new:""",
|
||||
"register 改查 known_devices")
|
||||
ok_all = ok_all and ok
|
||||
|
||||
# 登记成功时记进历史
|
||||
text, ok = patch(
|
||||
text,
|
||||
""" # 每次登记都刷新局域网信息(IP 可能变,端口也可能改)
|
||||
device.lan_ips = lan_ips""",
|
||||
""" # 记进设备历史名单(这是"曾经被允许过"的凭证,只增不减;
|
||||
# 只有管理员显式删除设备时才会被移除)
|
||||
self.known_devices.add(device_id)
|
||||
# 每次登记都刷新局域网信息(IP 可能变,端口也可能改)
|
||||
device.lan_ips = lan_ips""",
|
||||
"register 登记成功时记入历史")
|
||||
ok_all = ok_all and ok
|
||||
|
||||
# ---------------------------------------------------------- snapshot 补 _save
|
||||
text, ok = patch(
|
||||
text,
|
||||
""" orphan = [code for code, owner in self.codes.items()
|
||||
if owner not in self.devices]
|
||||
for code in orphan:
|
||||
self.codes.pop(code, None)""",
|
||||
""" orphan = [code for code, owner in self.codes.items()
|
||||
if owner not in self.devices]
|
||||
for code in orphan:
|
||||
self.codes.pop(code, None)
|
||||
# ⚠ 清完孤儿码要落盘。不然内存里没了、磁盘上还在,
|
||||
# 重启后 _load() 又恢复回来,下一次 snapshot() 再清一遍 ——
|
||||
# 看上去能用,但每次重启都会"回滚"一次状态,很难排查。
|
||||
# (超时清理 devices 也有同样问题,一并落盘。)
|
||||
if stale or orphan:
|
||||
self._save()""",
|
||||
"snapshot 清理后落盘")
|
||||
ok_all = ok_all and ok
|
||||
|
||||
# ---------------------------------------------------------- delete / forget
|
||||
text, ok = patch(
|
||||
text,
|
||||
""" self.devices.pop(target.device_id, None)
|
||||
# 把它的码绑定一起删掉
|
||||
gone_codes = [c for c, owner in list(self.codes.items())
|
||||
if owner == target.device_id]""",
|
||||
""" self.devices.pop(target.device_id, None)
|
||||
# "删除设备"是**真正忘记这台设备** —— 连历史名单一起删。
|
||||
# 不删的话,管理员点了删除、开关又关着,
|
||||
# 那台设备下次来仍然被当成老设备放行,开关就形同虚设。
|
||||
self.known_devices.discard(target.device_id)
|
||||
# 把它的码绑定一起删掉
|
||||
gone_codes = [c for c, owner in list(self.codes.items())
|
||||
if owner == target.device_id]""",
|
||||
"delete_device 连带清历史")
|
||||
ok_all = ok_all and ok
|
||||
|
||||
text, ok = patch(
|
||||
text,
|
||||
""" def forget(self, code: str) -> bool:
|
||||
\"\"\"抹掉一个配对码绑定(让那台设备下次可以设新码)。\"\"\"
|
||||
with self.lock:
|
||||
device_id = self.codes.pop(code, None)
|
||||
if not device_id:
|
||||
return False""",
|
||||
""" def forget(self, code: str) -> bool:
|
||||
\"\"\"抹掉一个配对码绑定(让那台设备下次可以设新码)。
|
||||
|
||||
注意:**只抹码,不抹设备历史**。
|
||||
抹码的语义是"这个码我不占了",不是"我不认识这台设备了"。
|
||||
\"\"\"
|
||||
with self.lock:
|
||||
device_id = self.codes.pop(code, None)
|
||||
if not device_id:
|
||||
return False""",
|
||||
"forget 加注释说明不动历史")
|
||||
ok_all = ok_all and ok
|
||||
|
||||
if not ok_all:
|
||||
print()
|
||||
print(" 有锚点没匹配,没有写回")
|
||||
return 1
|
||||
|
||||
SRC.write_text(text, encoding="utf-8", newline="\n")
|
||||
print()
|
||||
print(" 已写回 relay.py")
|
||||
# 复核
|
||||
for token in ("known_devices", '"knownDevices"', "known_device = device_id in self.known_devices"):
|
||||
print(" %s %s" % ("✓" if token in text else "✗", token))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修验证智能体找到的两个低危但真实的问题。
|
||||
|
||||
== A. enable_relay() 第二次调用谎报成功 ==
|
||||
|
||||
if self._relay is not None:
|
||||
return True # <- 只要对象建过就报成功
|
||||
|
||||
而"没配中转地址"那条路也会把 self._relay 建起来(然后 start("") 返回 False)。
|
||||
于是第二次调用拿到的是 True —— 调用方会以为中转在跑。
|
||||
现在没有调用方会这样调(所以是潜伏 bug),但语义是错的。
|
||||
|
||||
修法:返回**真实运行状态**,而不是"对象存不存在"。
|
||||
|
||||
== B. 非法 deviceId(控制字符)被接受 ==
|
||||
|
||||
register() 只校验了长度:
|
||||
if not device_id or len(device_id) > 80:
|
||||
拒绝
|
||||
但 `\x00`、`\r\n` 这类控制字符能进去。
|
||||
管理页的转义做得对,所以**不是 XSS**;但 deviceId 会进日志,
|
||||
`\r\n` 可以伪造日志行、污染 journalctl 的输出。
|
||||
|
||||
修法:只接受可打印的 ASCII(字母数字、-、_、.、:),其余拒绝。
|
||||
真实 deviceId 是 `机器名-12位hex` 这种形状,不会用到别的字符。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
|
||||
|
||||
def patch(path: Path, old: str, new: str, label: str) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if old not in text:
|
||||
print(" ✗ 没找到:%s" % label)
|
||||
return False
|
||||
if text.count(old) != 1:
|
||||
print(" ! 出现 %d 次:%s" % (text.count(old), label))
|
||||
return False
|
||||
path.write_text(text.replace(old, new, 1), encoding="utf-8", newline="\n")
|
||||
print(" ✓ %s" % label)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok_all = True
|
||||
|
||||
# ---------------------------------------------------------- A
|
||||
print(" ── A. enable_relay 谎报成功 ──")
|
||||
ok_all &= patch(
|
||||
ROOT / "windows" / "lanremote" / "server.py",
|
||||
""" if self._relay is not None:
|
||||
return True
|
||||
|
||||
self._relay = RelayLink(self.store, log=self._log,""",
|
||||
""" # ⚠ 这里以前是 `if self._relay is not None: return True` ——
|
||||
# 只要对象建过就报成功。而"没配中转地址"那条路也会把对象建起来
|
||||
# (start("") 返回 False),于是第二次调用会谎报中转在跑。
|
||||
# 改成看**真实运行状态**。
|
||||
if self._relay is not None:
|
||||
return bool(getattr(self._relay, "running", False))
|
||||
|
||||
self._relay = RelayLink(self.store, log=self._log,""",
|
||||
"enable_relay 返回真实状态")
|
||||
|
||||
# ---------------------------------------------------------- B
|
||||
print()
|
||||
print(" ── B. 非法 deviceId(控制字符)──")
|
||||
ok_all &= patch(
|
||||
ROOT / "relay" / "relay.py",
|
||||
""" if not device_id or len(device_id) > 80:
|
||||
return {"ok": False, "error": "deviceId 不合法"}""",
|
||||
""" if not device_id or len(device_id) > 80:
|
||||
return {"ok": False, "error": "deviceId 不合法"}
|
||||
# 只接受可打印 ASCII(真实 deviceId 是「机器名-12位hex」这种形状)。
|
||||
# 不放行的话 `\\x00`、`\\r\\n` 这类控制字符能进来 ——
|
||||
# 管理页的转义做得对所以不是 XSS,但 deviceId 会写进日志,
|
||||
# `\\r\\n` 可以伪造日志行、把 journalctl 的输出搅乱。
|
||||
if not all(32 <= ord(ch) < 127 for ch in device_id):
|
||||
log(f"deviceId 含不可打印字符,拒绝:{device_id!r}")
|
||||
return {"ok": False, "error": "deviceId 含非法字符"}""",
|
||||
"register 拒绝控制字符 deviceId")
|
||||
|
||||
print()
|
||||
if not ok_all:
|
||||
print(" 有失败项")
|
||||
return 1
|
||||
print(" 两个都修好了")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,180 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''收尾两个验证项。
|
||||
|
||||
== A. deviceId 还要拒绝路径分隔符 ==
|
||||
|
||||
上一版只挡了"不可打印 ASCII",但 `/`、`\` 是可打印的,仍然能进来。
|
||||
deviceId 会进日志、进状态文件、进管理页,不该带路径味道的字符。
|
||||
|
||||
真实形状是「机器名-12位hex」,用的字符集很窄。
|
||||
改成白名单:字母数字 + `-` `_` `.` `:`,其余一律拒。
|
||||
(`.` 和 `:` 留着是因为可能有 `host.local:8080` 这种形状的历史数据。)
|
||||
|
||||
== B. test_verify_blank_relay.py 在测自己的副本 ==
|
||||
|
||||
那个测试为了省事,**把 enable_relay 的实现抄了一份**放在测试里,
|
||||
然后断言抄来的那份。我修了 server.py 里的真实现,测试却看不到 ——
|
||||
它测的是自己那份旧代码,所以永远红。
|
||||
|
||||
这是"测试自己的副本"的经典反模式:实现变了测试不知道,
|
||||
测试红了实现也没问题。
|
||||
|
||||
修法两步:
|
||||
1. 把测试里的副本同步成修好之后的逻辑(它仍然模拟"没配地址"这条路)
|
||||
2. **加一条源码守卫**:直接读 server.py,断言里面没有
|
||||
`if self._relay is not None: return True` 这种"对象在就报成功"的写法,
|
||||
并且确实用了 running 判断。
|
||||
这样真实现一旦回归,测试会立刻发现 —— 副本同步与否都不影响兜底。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
|
||||
|
||||
def patch(path: Path, old: str, new: str, label: str) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if old not in text:
|
||||
print(" ✗ 没找到:%s" % label)
|
||||
return False
|
||||
if text.count(old) != 1:
|
||||
print(" ! 出现 %d 次:%s" % (text.count(old), label))
|
||||
return False
|
||||
path.write_text(text.replace(old, new, 1), encoding="utf-8", newline="\n")
|
||||
print(" ✓ %s" % label)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok_all = True
|
||||
|
||||
# ---------------------------------------------------------- A
|
||||
print(" ── A. deviceId 白名单 ──")
|
||||
ok_all &= patch(
|
||||
ROOT / "relay" / "relay.py",
|
||||
""" # 只接受可打印 ASCII(真实 deviceId 是「机器名-12位hex」这种形状)。
|
||||
# 不放行的话 `\\x00`、`\\r\\n` 这类控制字符能进来 ——
|
||||
# 管理页的转义做得对所以不是 XSS,但 deviceId 会写进日志,
|
||||
# `\\r\\n` 可以伪造日志行、把 journalctl 的输出搅乱。
|
||||
if not all(32 <= ord(ch) < 127 for ch in device_id):
|
||||
log(f"deviceId 含不可打印字符,拒绝:{device_id!r}")
|
||||
return {"ok": False, "error": "deviceId 含非法字符"}""",
|
||||
""" # deviceId 走白名单:字母数字 + - _ . :
|
||||
#
|
||||
# 为什么要卡这么死:
|
||||
# * 控制字符(\\x00、\\r\\n)能进来 —— 管理页转义做得对所以
|
||||
# 不是 XSS,但 deviceId 会写进日志,`\\r\\n` 能伪造日志行
|
||||
# * 路径分隔符(/ \\)能进来 —— 会进状态文件和页面,不该有
|
||||
#
|
||||
# 真实 deviceId 的形状是「机器名-12位hex」(见 _ensure_device_id),
|
||||
# 字符集本来就很窄,卡严不会有任何副作用。
|
||||
if not all(ch.isalnum() or ch in "-_.:" for ch in device_id):
|
||||
log(f"deviceId 含非法字符,拒绝:{device_id!r}")
|
||||
return {"ok": False, "error": "deviceId 含非法字符"}""",
|
||||
"deviceId 白名单校验")
|
||||
|
||||
# ---------------------------------------------------------- B1
|
||||
print()
|
||||
print(" ── B1. 同步测试里的那份副本 ──")
|
||||
ok_all &= patch(
|
||||
ROOT / "_tests" / "test_verify_blank_relay.py",
|
||||
""" # 与 server.py 里 enable_relay 的实现保持一致
|
||||
def enable_relay(self, url: str = "") -> bool:
|
||||
target = (url or "").strip() or str(
|
||||
store2.get("relay_url") or "")
|
||||
if not target.strip():
|
||||
target = srv.DEFAULT_RELAY_URL
|
||||
if not target.strip():
|
||||
self._log("没配中转地址,只走局域网"
|
||||
"(需要跨网络时在设置里填中转地址)")
|
||||
if self._relay is not None:
|
||||
return True
|
||||
self._relay = RelayLink(store2, log=self._log)
|
||||
return self._relay.start(target)""",
|
||||
""" # ⚠ 这是 server.py 里 enable_relay 的**副本**。
|
||||
# 副本的问题是:实现改了它不会跟着变,于是测试红了、
|
||||
# 实现其实没问题(我修完真实现时这里就红了一次)。
|
||||
# 所以下面第 3.5 节加了一条**直接读源码**的守卫兜底。
|
||||
def enable_relay(self, url: str = "") -> bool:
|
||||
target = (url or "").strip() or str(
|
||||
store2.get("relay_url") or "")
|
||||
if not target.strip():
|
||||
target = srv.DEFAULT_RELAY_URL
|
||||
if not target.strip():
|
||||
self._log("没配中转地址,只走局域网"
|
||||
"(需要跨网络时在设置里填中转地址)")
|
||||
# 真实现:看**是否真的在跑**,不是"对象建过没有"
|
||||
if self._relay is not None:
|
||||
return bool(getattr(self._relay, "running", False))
|
||||
self._relay = RelayLink(store2, log=self._log)
|
||||
return self._relay.start(target)""",
|
||||
"同步测试里的副本")
|
||||
|
||||
# ---------------------------------------------------------- B2 源码守卫
|
||||
print()
|
||||
print(" ── B2. 加源码守卫(防止真实现回归)──")
|
||||
guard = '''
|
||||
|
||||
# ============================================================
|
||||
# 3.5 源码守卫:真实现不能用「对象建过就报成功」的写法
|
||||
#
|
||||
# 为什么需要这一节:
|
||||
# 上面 3 那节测的是 enable_relay 的**副本**。副本和真实现脱钩时,
|
||||
# 测试红/绿都不代表真实现对不对。这里直接读 server.py 断言。
|
||||
# ============================================================
|
||||
print()
|
||||
print("═══ 3.5. 源码守卫:server.py 里的真实写法 ═══")
|
||||
import re as _re
|
||||
_src_path = ROOT / "windows" / "lanremote" / "server.py"
|
||||
_src = _src_path.read_text(encoding="utf-8")
|
||||
# 找到 enable_relay 的函数体
|
||||
_m = _re.search(r"def enable_relay\\b.*?(?=\\n def |\\Z)", _src, _re.S)
|
||||
_body = _m.group(0) if _m else ""
|
||||
check("能定位到 enable_relay 的实现", bool(_body),
|
||||
"没匹配到,可能函数改名了")
|
||||
if _body:
|
||||
# 禁止:对象存在就返回 True(没有 running 判断)
|
||||
check("实现里没有「对象建过就 return True」的写法",
|
||||
not _re.search(r"if self\\._relay is not None:\\s*\\n\\s*return True",
|
||||
_body),
|
||||
"真实现又变回谎报成功的写法了")
|
||||
# 必须:返回值体现真实运行状态
|
||||
check("实现里检查了真实运行状态(running)",
|
||||
"running" in _body,
|
||||
"没看到 running —— 返回值可能又不反映真实状态")
|
||||
'''
|
||||
target = ROOT / "_tests" / "test_verify_blank_relay.py"
|
||||
text = target.read_text(encoding="utf-8")
|
||||
anchor = '\n\nprint()\nprint("═══ 4. 这个返回值会影响谁 ═══")'
|
||||
if anchor in text:
|
||||
text = text.replace(anchor, guard + anchor, 1)
|
||||
target.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(" ✓ 加了源码守卫")
|
||||
else:
|
||||
print(" ✗ 找不到插入点(第 4 节标题)")
|
||||
ok_all = False
|
||||
|
||||
# check() 得能在模块级别用 —— 看测试里 check 是不是模块级函数
|
||||
if "def check(" not in text and "check =" not in text:
|
||||
print(" ! 注意:check() 可能不是模块级函数,守卫可能报 NameError")
|
||||
|
||||
# ROOT 要存在
|
||||
if "ROOT = " not in text:
|
||||
print(" ✗ 测试里没有 ROOT,守卫会报错")
|
||||
ok_all = False
|
||||
|
||||
if "import re" not in text and "_re" not in text:
|
||||
pass
|
||||
|
||||
print()
|
||||
if not ok_all:
|
||||
print(" 有失败项")
|
||||
return 1
|
||||
print(" 都改好了")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,29 @@
|
||||
插桩了 4 处
|
||||
插桩中转起来了
|
||||
被控端登记: 插桩电脑
|
||||
手表接入: ok=True
|
||||
1. pair OK 21ms
|
||||
2. ping OK 82ms
|
||||
3. ping OK 4ms
|
||||
4. ping OK 6ms
|
||||
|
||||
════ 插桩日志(中转侧数据流)════
|
||||
[10-07 17:59:09] [INS] agent/poll 进入,to_agent=0B,current_session=-
|
||||
[10-07 17:59:10] [INS] client/send 70B -> to_agent 现有 0B,会话 d21cd512,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/poll 返回 70B,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/poll 进入,to_agent=0B,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/send 96B,active_session=d21cd512
|
||||
[10-07 17:59:10] [INS] client/send 16B -> to_agent 现有 0B,会话 d21cd512,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/poll 返回 16B,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/poll 进入,to_agent=0B,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/send 49B,active_session=d21cd512
|
||||
[10-07 17:59:10] [INS] client/send 16B -> to_agent 现有 0B,会话 d21cd512,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/poll 返回 16B,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/poll 进入,to_agent=0B,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/send 50B,active_session=d21cd512
|
||||
[10-07 17:59:10] [INS] client/send 16B -> to_agent 现有 0B,会话 d21cd512,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/poll 返回 16B,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/poll 进入,to_agent=0B,current_session=d21cd512
|
||||
[10-07 17:59:10] [INS] agent/send 50B,active_session=d21cd512
|
||||
|
||||
════ 被控端日志 ════
|
||||
@@ -0,0 +1,262 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''给中转打临时插桩,抓「数据到底去哪了」。
|
||||
|
||||
做法:把 relay.py 复制一份到临时目录,在关键位置插入 log 调用:
|
||||
* /v1/client/send -> 谁发的、多少字节、当时 to_agent 有多长
|
||||
* /v1/agent/poll -> 取出多少字节、当时的 X-Relay-Session 是什么
|
||||
* /v1/agent/send -> 被控端回了多少字节
|
||||
* /v1/client/poll -> 手表取走多少字节
|
||||
* _sync_session -> 会话令牌怎么变的
|
||||
|
||||
然后在同一个 repro 里跑,日志能直接指出哪一步数据消失了。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
WINDOWS = ROOT / "windows"
|
||||
RELAY_PORT = 8126
|
||||
AGENT_PORT = 8127
|
||||
BASE = f"http://127.0.0.1:{RELAY_PORT}"
|
||||
PAIR = "135790"
|
||||
UA = {"User-Agent": "LanRemote/1.8.2"}
|
||||
|
||||
|
||||
def instrument(src: str) -> str:
|
||||
"""在 relay.py 的关键位置插 log。"""
|
||||
# 1. client/send
|
||||
src = src.replace(
|
||||
""" data = self._body()
|
||||
RELAY.client_put(session, data)
|
||||
self._json(200, {"ok": True, "queued": len(data)})""",
|
||||
""" data = self._body()
|
||||
_dev = RELAY.devices.get(session.device_id)
|
||||
log(f"[INS] client/send {len(data)}B -> to_agent 现有 "
|
||||
f"{len(_dev.to_agent) if _dev else '?'}B,会话 {token[:8]},"
|
||||
f"current_session={( _dev.current_session or '-')[:8] if _dev else '?'}")
|
||||
RELAY.client_put(session, data)
|
||||
self._json(200, {"ok": True, "queued": len(data)})""", 1)
|
||||
|
||||
# 2. agent/poll
|
||||
src = src.replace(
|
||||
""" session_at_start = device.current_session
|
||||
data = RELAY.agent_take(device, POLL_SECONDS)""",
|
||||
""" session_at_start = device.current_session
|
||||
log(f"[INS] agent/poll 进入,to_agent={len(device.to_agent)}B,"
|
||||
f"current_session={(device.current_session or '-')[:8]}")
|
||||
data = RELAY.agent_take(device, POLL_SECONDS)
|
||||
log(f"[INS] agent/poll 返回 {len(data)}B,"
|
||||
f"current_session={(device.current_session or '-')[:8]}")""", 1)
|
||||
|
||||
# 3. agent/send
|
||||
src = src.replace(
|
||||
""" data = self._body()
|
||||
RELAY.agent_put(device, data)
|
||||
self._json(200, {"ok": True, "queued": len(data)})""",
|
||||
""" data = self._body()
|
||||
log(f"[INS] agent/send {len(data)}B,active_session="
|
||||
f"{(device.current_session or '-')[:8]}")
|
||||
RELAY.agent_put(device, data)
|
||||
self._json(200, {"ok": True, "queued": len(data)})""", 1)
|
||||
|
||||
# 4. client/poll
|
||||
src = src.replace(
|
||||
"""from __future__ import annotations""",
|
||||
"""from __future__ import annotations # instrumented""", 1)
|
||||
return src
|
||||
|
||||
|
||||
def post(path, body=b"", headers=None, timeout=20.0):
|
||||
h = dict(UA)
|
||||
h.update(headers or {})
|
||||
req = urllib.request.Request(BASE + path, data=body, headers=h, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return 0, str(exc).encode()
|
||||
|
||||
|
||||
def get(path, timeout=10.0):
|
||||
req = urllib.request.Request(BASE + path, headers=UA)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.read()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return 0, str(exc).encode()
|
||||
|
||||
|
||||
class Watch:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
self.buf = b""
|
||||
|
||||
def send(self, obj):
|
||||
payload = (json.dumps(obj, ensure_ascii=False) + "\n").encode()
|
||||
st, _ = post("/v1/client/send", payload, {"X-Session": self.session})
|
||||
return st == 200
|
||||
|
||||
def reply(self, timeout=20.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
while b"\n" in self.buf:
|
||||
line, _, self.buf = self.buf.partition(b"\n")
|
||||
if not line.strip():
|
||||
continue
|
||||
msg = json.loads(line.decode("utf-8"))
|
||||
if "event" in msg:
|
||||
continue
|
||||
return msg
|
||||
st, body = post("/v1/client/poll", b"", {"X-Session": self.session},
|
||||
timeout=max(4.0, deadline - time.time()))
|
||||
if st != 200:
|
||||
time.sleep(0.4)
|
||||
continue
|
||||
self.buf += body
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
work = Path(tempfile.mkdtemp(prefix="ins_"))
|
||||
relay_proc = agent_proc = None
|
||||
real_data = WINDOWS / "data"
|
||||
backup = WINDOWS / "data_bk_ins"
|
||||
had = real_data.exists()
|
||||
if had:
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
shutil.move(str(real_data), str(backup))
|
||||
try:
|
||||
# 插桩版的 relay.py
|
||||
relay_src = (ROOT / "relay" / "relay.py").read_text(encoding="utf-8")
|
||||
inst = instrument(relay_src)
|
||||
if "[INS]" not in inst:
|
||||
print(" ✗ 插桩失败:锚点没匹配上")
|
||||
return 1
|
||||
n = inst.count("[INS]")
|
||||
print(" 插桩了 %d 处" % n)
|
||||
relay_dir = work / "relaydir"
|
||||
shutil.copytree(ROOT / "relay", relay_dir)
|
||||
(relay_dir / "relay.py").write_text(inst, encoding="utf-8",
|
||||
newline="\n")
|
||||
|
||||
real_data.mkdir(parents=True, exist_ok=True)
|
||||
sys.path.insert(0, str(WINDOWS))
|
||||
from lanremote.config import Store
|
||||
store = Store(real_data)
|
||||
store.set_pairing_code(PAIR)
|
||||
store.set("setup_done", True)
|
||||
store.set("allow_new_pairing", True)
|
||||
store.set("close_pair_after_first", False)
|
||||
store.set("server_name", "插桩电脑")
|
||||
store.set("relay_url", f"http://127.0.0.1:{RELAY_PORT}")
|
||||
store.set("port", AGENT_PORT)
|
||||
|
||||
rdata = work / "r"
|
||||
rdata.mkdir(parents=True, exist_ok=True)
|
||||
env = dict(os.environ)
|
||||
env.update({"RELAY_PORT": str(RELAY_PORT), "RELAY_BIND": "127.0.0.1",
|
||||
"RELAY_DATA": str(rdata), "RELAY_POLL": "5",
|
||||
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"})
|
||||
# ⚠ 用户自己的被控端正跑在这台机器上,占着单实例锁端口 8767。
|
||||
# 不换端口的话测试实例一起来就被"已有实例在运行"顶掉,
|
||||
# 表现为"被控端根本没登记",而且日志是空的,极难排查。
|
||||
agent_env = dict(env)
|
||||
agent_env["YKXMZ_LOCK_PORT"] = "18767"
|
||||
relay_log = open(work / "relay.log", "wb")
|
||||
relay_proc = subprocess.Popen(
|
||||
[sys.executable, "-u", str(relay_dir / "relay.py")], env=env,
|
||||
cwd=str(relay_dir), stdout=relay_log, stderr=subprocess.STDOUT)
|
||||
for _ in range(40):
|
||||
time.sleep(0.25)
|
||||
if get("/v1/health")[0] == 200:
|
||||
break
|
||||
print(" 插桩中转起来了")
|
||||
|
||||
agent_log = open(work / "agent.log", "wb")
|
||||
agent_proc = subprocess.Popen(
|
||||
[sys.executable, "-u", str(WINDOWS / "run_agent.py"),
|
||||
"--silent", "--port", str(AGENT_PORT)],
|
||||
cwd=str(WINDOWS), stdout=agent_log, stderr=subprocess.STDOUT,
|
||||
env=agent_env)
|
||||
|
||||
device = None
|
||||
for _ in range(40):
|
||||
time.sleep(0.5)
|
||||
st, body = get("/v1/health")
|
||||
if st == 200:
|
||||
snap = json.loads(body.decode())
|
||||
if snap.get("devices"):
|
||||
device = snap["devices"][0]
|
||||
break
|
||||
print(" 被控端登记: %s" % (device["name"] if device else "**没有**"))
|
||||
|
||||
st, body = post("/v1/client/attach", json.dumps(
|
||||
{"code": PAIR, "name": "插桩手表"}).encode(),
|
||||
{"Content-Type": "application/json"})
|
||||
att = json.loads(body.decode())
|
||||
watch = Watch(att.get("session", ""))
|
||||
print(" 手表接入: ok=%s" % att.get("ok"))
|
||||
|
||||
steps = [{"cmd": "pair", "deviceId": "w1", "name": "手表", "code": PAIR},
|
||||
{"cmd": "ping"}, {"cmd": "ping"}, {"cmd": "ping"}]
|
||||
for i, msg in enumerate(steps, 1):
|
||||
t0 = time.time()
|
||||
watch.send(msg)
|
||||
got = watch.reply(timeout=15.0)
|
||||
dt = int((time.time() - t0) * 1000)
|
||||
print(" %d. %-8s %s %dms" % (i, msg["cmd"],
|
||||
"OK" if got else "**超时**", dt))
|
||||
if got is None:
|
||||
print(" 卡住了,停止")
|
||||
break
|
||||
time.sleep(1.0)
|
||||
|
||||
print()
|
||||
print(" ════ 插桩日志(中转侧数据流)════")
|
||||
relay_log.flush()
|
||||
txt = (work / "relay.log").read_text(encoding="utf-8", errors="replace")
|
||||
for line in txt.splitlines():
|
||||
if "[INS]" in line:
|
||||
print(" " + line[:130])
|
||||
|
||||
print()
|
||||
print(" ════ 被控端日志 ════")
|
||||
agent_log.flush()
|
||||
txt = (work / "agent.log").read_text(encoding="utf-8", errors="replace")
|
||||
for line in txt.splitlines()[-20:]:
|
||||
print(" " + line[:120])
|
||||
return 0
|
||||
finally:
|
||||
for p in (agent_proc, relay_proc):
|
||||
if p is not None:
|
||||
try:
|
||||
p.terminate()
|
||||
p.wait(timeout=6)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
p.kill()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
shutil.rmtree(real_data, ignore_errors=True)
|
||||
if had:
|
||||
shutil.move(str(backup), str(real_data))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把 _tests/test_package_remote.py 复制一份出来,去掉清理临时目录那一步。
|
||||
|
||||
为什么:整个测试里前三节都过,第四节报 Connection refused。
|
||||
中转在隔离环境里是好的,所以想在中转真正死掉的那一刻看到它的日志 ——
|
||||
而原测试跑完会 rmtree 把日志删掉。
|
||||
|
||||
这份副本放到 _build/ 下(我的边界内),并额外打印中转进程的存活状态。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
SRC = ROOT / "_tests" / "test_package_remote.py"
|
||||
DST = ROOT / "_build" / "dbg_pkg_full.py"
|
||||
|
||||
text = SRC.read_text(encoding="utf-8")
|
||||
|
||||
# 1) 不要删临时目录
|
||||
old_clean = " shutil.rmtree(work, ignore_errors=True)"
|
||||
new_clean = (" print()\n"
|
||||
" print(' [调试] 临时目录保留在: %s' % work)\n"
|
||||
" for _p in (relay_proc, agent_proc):\n"
|
||||
" if _p is not None:\n"
|
||||
" print(' [调试] 进程 %s poll()=%s'\n"
|
||||
" % (_p.pid, _p.poll()))")
|
||||
if old_clean not in text:
|
||||
print(" ✗ 没找到清理那行")
|
||||
sys.exit(1)
|
||||
text = text.replace(old_clean, new_clean, 1)
|
||||
|
||||
# 2) 每节之前打印中转存活状态
|
||||
old_java = ' print()\n print("四、Java 手表端经这个中转连被控端")'
|
||||
new_java = (' print()\n'
|
||||
' print("四、Java 手表端经这个中转连被控端")\n'
|
||||
' print(" [调试] 进第四节前: relay poll()=%s agent poll()=%s"\n'
|
||||
' % (relay_proc.poll(), agent_proc.poll()))\n'
|
||||
' try:\n'
|
||||
' import urllib.request as _u\n'
|
||||
' with _u.urlopen(base + "/v1/health", timeout=3) as _r:\n'
|
||||
' print(" [调试] 中转健康检查: %s" % _r.status)\n'
|
||||
' except Exception as _e:\n'
|
||||
' print(" [调试] 中转健康检查失败: %s" % _e)')
|
||||
if old_java not in text:
|
||||
print(" ✗ 没找到第四节标题")
|
||||
sys.exit(1)
|
||||
text = text.replace(old_java, new_java, 1)
|
||||
|
||||
# 3) 输出文件名改成不冲突的(原文件用的是 1.8.2,不用改)
|
||||
DST.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(" ✓ 已写出 %s" % DST.name)
|
||||
print(" 改动:保留临时目录 + 第四节前打印中转存活状态")
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把指定文件的换行统一成 LF(并去掉 BOM)。
|
||||
|
||||
为什么需要:
|
||||
仓库里 12 个 .java 有 10 个是 LF,只有 Discovery.java / DiscoveryCore.java
|
||||
是 CRLF —— 而且**提交进索引的就是 CRLF**(git ls-files --eol 显示 i/crlf w/crlf),
|
||||
不是本地签出造成的。项目约定是「.java 一律 LF」,所以顺手归一。
|
||||
|
||||
注意:归一之后 git diff 会显示整文件改写(因为索引里是 CRLF)。
|
||||
这是可预期的,不是把代码改乱了 —— 提交信息里会说明。
|
||||
|
||||
用法:
|
||||
python _build/normalize_lf.py <文件> [<文件> ...]
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BOM = b"\xef\xbb\xbf"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print(" 用法: python _build/normalize_lf.py <文件> ...")
|
||||
return 2
|
||||
changed = 0
|
||||
for arg in sys.argv[1:]:
|
||||
path = Path(arg)
|
||||
if not path.is_file():
|
||||
print(" [跳过] %s 不是文件" % arg)
|
||||
continue
|
||||
raw = path.read_bytes()
|
||||
original = raw
|
||||
if raw.startswith(BOM):
|
||||
raw = raw[len(BOM):]
|
||||
raw = raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
||||
if raw == original:
|
||||
print(" [不变] %s 已经是 LF 无 BOM" % arg)
|
||||
continue
|
||||
path.write_bytes(raw)
|
||||
changed += 1
|
||||
print(" [已改] %s -> LF 无 BOM" % arg)
|
||||
print()
|
||||
print(" 共归一 %d 个文件" % changed)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+124
-56
@@ -54,30 +54,38 @@ TOP_README = f"""========================================================
|
||||
3-控制端(手表) 装在你的安卓手表上。一个 APK,直接装。
|
||||
|
||||
|
||||
【怎么用】三步
|
||||
【怎么用】四步
|
||||
|
||||
第一步:架服务器端(只做一次)
|
||||
把「2-服务器端」整个文件夹拷到随身 WiFi 上,执行:
|
||||
bash install.sh
|
||||
它会装好中转服务 + Cloudflare 隧道,并打印一个**公网地址**。
|
||||
记下这个地址(形如 https://relay.你的域名)。
|
||||
**先说最要紧的一条:本软件不带任何中转地址,也不提供官方服务器。**
|
||||
只在同一个 WiFi 下用(手表 + 电脑连同一个路由器)的话,
|
||||
跳过第一步和第四步,装完就能连。
|
||||
要跨网络(在办公室管教室),必须自己搭一台中转站。
|
||||
|
||||
小提示:公网地址每次重启会变。忘了地址就打开
|
||||
http://<随身WiFi的IP>:8080/url —— 直接返回当前地址。
|
||||
第一步:架服务器端(只做一次,**跨网络才需要**)
|
||||
把「2-服务器端」整个文件夹拷到那台能上外网的 Linux 小机器上,执行:
|
||||
bash deploy.sh
|
||||
它会装好中转服务 + 网络调优 + Cloudflare 隧道,并打印一个
|
||||
**属于你自己的公网地址**。记下这个地址。
|
||||
(形如 https://你的域名 —— 域名是你自己的,脚本里没有默认域名。)
|
||||
|
||||
小提示:忘了地址就在那台机器上执行 lanremote-url,或者浏览器打开
|
||||
http://<那台机器的IP>:8080/url —— 直接返回当前地址。
|
||||
|
||||
第二步:装被控端(电脑上,只做一次)
|
||||
双击 ykxmz.exe
|
||||
双击安装包
|
||||
→ 输入 6 位配对码(自己定,比如 789113)
|
||||
→ 勾上"开机自启"和"放行防火墙"
|
||||
→ 点确定,会弹一次管理员确认,之后窗口全消失,后台静默运行
|
||||
|
||||
告诉它中转地址(这样才跨网络):
|
||||
ykxmz.exe --relay https://relay.你的域名
|
||||
|
||||
第三步:装控制端(手表上)
|
||||
装 APK → 首页点「中转设置」→ 填同一个中转地址
|
||||
→ 点「自动搜索电脑」(同局域网)或直接输配对码
|
||||
→ 连上
|
||||
装 APK
|
||||
→ 点「自动搜索电脑」(同一个 WiFi 下用这个,不用填地址)
|
||||
→ 或者点「输入配对码连接」填电脑上那个 6 位码
|
||||
|
||||
第四步:跨网络的话,把中转地址填上(**自己搭的那个**)
|
||||
电脑: ykxmz.exe --relay https://你的域名
|
||||
手表: 首页点「中转设置」→ 填同一个地址
|
||||
然后回首页点「输入配对码连接」,输那 6 位数字,连上。
|
||||
|
||||
|
||||
【关于配对码:单设备单配对码】
|
||||
@@ -134,35 +142,37 @@ AGENT_README = f"""========================================================
|
||||
(没有托盘图标、没有窗口、没有终端)
|
||||
|
||||
|
||||
【开箱即用:一个地址都不用填】
|
||||
【开箱即用:一个地址都不用填(同一个 WiFi 下)】
|
||||
|
||||
程序里**已经内置了中转地址**,双击就能用。
|
||||
手表和这台电脑连**同一个路由器**时,直接就能连上,
|
||||
配对码对上就行 —— 不需要任何服务器、不需要填任何地址。
|
||||
|
||||
内置的是两个,用分号隔开,按顺序尝试:
|
||||
192.168.2.156:8080;https://relay.xxmxxs.top
|
||||
|
||||
电脑和随身 WiFi 在同一个网络 -> 走第一个
|
||||
(局域网直连,几十毫秒,地址永远不变)
|
||||
电脑在外面 -> 第一个连不上,自动切第二个
|
||||
(Cloudflare 隧道,600 毫秒左右)
|
||||
|
||||
想改成别的当然也可以,见下一节。
|
||||
程序会自己往局域网广播找电脑,所以电脑上装了几块虚拟网卡、
|
||||
IP 是多少,都不用管。
|
||||
|
||||
|
||||
【跨网络:告诉它中转地址】
|
||||
【跨网络:必须自己搭一台中转站,然后填地址】
|
||||
|
||||
支持**多个地址**,用分号隔开,按顺序尝试,第一个通的就用它。
|
||||
⚠ **本软件没有内置任何中转地址,作者也不对外提供中转服务。**
|
||||
要跨网络用(比如在办公室管教室),你得自己部署一台中转站:
|
||||
服务端压缩包里有 deploy.sh,一条命令装好,它会打印一个
|
||||
属于你自己的公网地址。然后把这个地址填给被控端:
|
||||
|
||||
推荐这样配(局域网优先,断了自动切隧道):
|
||||
ykxmz.exe --relay "192.168.2.156:8080;relay.xxmxxs.top"
|
||||
ykxmz.exe --relay https://你的域名
|
||||
|
||||
为什么局域网优先?
|
||||
* 快:局域网几十毫秒,绕隧道要 600 毫秒左右
|
||||
只在同一个 WiFi 下用的话,这一节完全不用管。
|
||||
|
||||
|
||||
【支持多个地址,按顺序试】
|
||||
|
||||
用分号隔开,第一个通的就用它。推荐把局域网地址也写进去:
|
||||
|
||||
ykxmz.exe --relay "192.168.1.50:8080;https://你的域名"
|
||||
|
||||
为什么带上局域网地址?
|
||||
* 快:局域网几十毫秒,绕隧道要一秒多
|
||||
* 稳:不依赖隧道,隧道抽风也不影响
|
||||
* 地址永远不变:不用管隧道地址变来变去
|
||||
|
||||
只走隧道也行:
|
||||
ykxmz.exe --relay https://relay.xxmxxs.top
|
||||
* 那个地址永远不变,不用管公网地址变没变
|
||||
|
||||
地址**不用写 http:// 或 https://**,程序会自己判断:
|
||||
192.168.x.x / 10.x.x.x / *.local -> 按 http 连(局域网是明文)
|
||||
@@ -177,13 +187,15 @@ AGENT_README = f"""========================================================
|
||||
|
||||
【忘了中转地址?】
|
||||
|
||||
如果随身 WiFi 在你身边(同一个网络),浏览器打开:
|
||||
http://<随身WiFi的IP>:8080/url
|
||||
它会告诉你当前的公网地址(纯文本,不用登录)。
|
||||
如果那台小机器在你身边(同一个网络),浏览器打开:
|
||||
http://<它的IP>:8080/url
|
||||
它会直接返回当前的公网地址(纯文本,不用登录)。
|
||||
|
||||
小技巧:把局域网地址也填进 --relay,电脑在家里就走局域网,
|
||||
完全不用管隧道地址变没变:
|
||||
ykxmz.exe --relay "192.168.2.156:8080;relay.xxmxxs.top"
|
||||
或者在那台机器上执行:
|
||||
lanremote-url
|
||||
|
||||
小技巧:把局域网地址也填进 --relay,就永远不用管公网地址变没变:
|
||||
ykxmz.exe --relay "192.168.1.50:8080;https://你的域名"
|
||||
|
||||
|
||||
【配对码被别人占了会弹通知】
|
||||
@@ -264,7 +276,8 @@ RELAY_README = f"""========================================================
|
||||
1. 检查并安装 cloudflared(已经装了就用现成的)
|
||||
2. 把中转程序装到 /root/lanremote-relay/
|
||||
3. 网络调优(keepalive / MSS / MTU / 开机加速)
|
||||
4. 注册并启动 4 个 systemd 服务(开机自启)
|
||||
4. 注册 systemd 服务并设成开机自启
|
||||
(中转、网络调优、两份隧道、看门狗)
|
||||
5. 配好 Cloudflare 隧道
|
||||
6. 自检,然后把**公网地址**打印出来
|
||||
|
||||
@@ -327,6 +340,25 @@ RELAY_README = f"""========================================================
|
||||
能操作:踢会话、抹掉配对码绑定
|
||||
|
||||
|
||||
【下载区:静态文件走 Cloudflare 边缘缓存】
|
||||
|
||||
官网页面上的三个文件(安装包 / 服务端 zip / APK)和产品截图,
|
||||
都由 Cloudflare 的边缘节点**直接发送**,不用回源到这台小机器。
|
||||
|
||||
这不是默认行为 —— relay.py 给这些路径发了
|
||||
`Cache-Control: public, max-age=604800, immutable`
|
||||
(下载文件名带版本号,同一名字内容永不改变,所以能长缓存)。
|
||||
|
||||
为什么重要:这台机器通常在 4G 上,回源吞吐实测只有 3~67 KB/s,
|
||||
15 MB 的安装包根本下不动。走边缘之后实测 695 KB/s,快四十多倍,
|
||||
而且**不消耗这台机器的流量**。
|
||||
|
||||
换了新版本的安装包怎么办?
|
||||
文件名里带版本号(ykxmz-setup-1.8.2.exe),新文件是新 URL,
|
||||
自然就是新的缓存条目,不会拿到旧的。
|
||||
旧版本的条目到期(7 天)后自动消失。
|
||||
|
||||
|
||||
【网络调优:这些参数都是实测调出来的】
|
||||
|
||||
deploy-tune.sh 会做这五件事,改动之前先看它开头的说明:
|
||||
@@ -359,21 +391,43 @@ RELAY_README = f"""========================================================
|
||||
实测:掉电重启后公网恢复 2 分 10 秒 -> 24 秒。
|
||||
|
||||
|
||||
【隧道的两套配置】
|
||||
【隧道:两种协议同时在跑(这是故意的)】
|
||||
|
||||
deploy.sh 会装两份隧道服务,但**只启用一份** ——
|
||||
两条隧道同时跑会互相争抢,实测反而更不稳。
|
||||
deploy.sh 会装**两份**隧道服务,而且**两份都启用**:
|
||||
|
||||
lanremote-tunnel 主用(默认 quic)
|
||||
lanremote-tunnel-h2 备用(http2)
|
||||
lanremote-tunnel QUIC
|
||||
lanremote-tunnel-h2 HTTP/2
|
||||
|
||||
想切换:
|
||||
systemctl disable --now lanremote-tunnel
|
||||
systemctl enable --now lanremote-tunnel-h2
|
||||
它们跑的是**同一个隧道 ID**,Cloudflare 会把请求分摊到两个实例
|
||||
注册上来的全部连接上(合计 10 条,横跨两种协议)。
|
||||
|
||||
为什么默认 quic:尾部延迟好得多(实测 P99 2.99 秒 vs 13.9 秒)。
|
||||
什么时候换 http2:这条线路对 UDP 特别不友好的时候
|
||||
(日志里大量 "Failed to dial a quic connection")。
|
||||
为什么要这么干 —— 两种协议各有各的病,实测:
|
||||
|
||||
QUIC 尾部延迟好(P90 约 2.5 秒),
|
||||
但建连会 "no recent network activity" 超时,偶发失败
|
||||
HTTP/2 建连可靠(走 TCP,受 MSS 钳制保护),
|
||||
但尾部延迟差(P90 约 14 秒)——
|
||||
TCP 队头阻塞:一条连接上丢一个包,所有流一起卡
|
||||
|
||||
两个同时跑 = 协议级故障隔离。QUIC 那半边犯病时 HTTP/2 还在服务,
|
||||
反过来也一样。实测连续 25 分钟可用率 100%(单协议时是 96~98%)。
|
||||
|
||||
所以**不要**为了"省内存"随手停掉其中一个 —— 停了就退化成单协议了。
|
||||
|
||||
真想只留一种(比如这台机器的线路对 UDP 特别不友好,
|
||||
日志里大量 "Failed to dial a quic connection"):
|
||||
|
||||
systemctl disable --now lanremote-tunnel-h2 # 只留 QUIC
|
||||
systemctl disable --now lanremote-tunnel # 只留 HTTP/2
|
||||
|
||||
|
||||
【改端口 / 改协议 / 换域名】
|
||||
|
||||
重跑 deploy.sh,带上参数就行(幂等,不会冲掉已有数据):
|
||||
|
||||
bash deploy.sh --protocol http2 # 主实例换成 http2
|
||||
bash deploy.sh --port 9090 # 换监听端口
|
||||
bash deploy.sh --hostname relay.你的域名
|
||||
|
||||
|
||||
【省内存】
|
||||
@@ -398,18 +452,32 @@ WATCH_README = f"""========================================================
|
||||
【连】
|
||||
|
||||
1) 打开 App,首页有四个东西:
|
||||
中转设置(跨网络用) 填服务器端给的地址
|
||||
中转设置(跨网络用) **填你自己搭的那台中转站的地址**
|
||||
局域网连接(同一个WiFi)同一个 WiFi 时走这条,最快
|
||||
输入配对码连接 经中转连
|
||||
服务器:还没测 点右边「检查服务器」看延迟
|
||||
|
||||
2) 电脑和手表在同一个 WiFi -> 点「局域网连接」,自动搜到电脑,点一下就连
|
||||
电脑在外面 -> 点「输入配对码连接」,填电脑上那个 6 位码
|
||||
电脑在外面 -> 先点「中转设置」填地址(见下),
|
||||
再点「输入配对码连接」,填电脑上那个 6 位码
|
||||
|
||||
3) 连上后是 10 个标签页:
|
||||
媒体 屏幕 文件 语音 电脑 网络 系统 教室 效率 娱乐
|
||||
|
||||
|
||||
【中转地址要自己填】
|
||||
|
||||
⚠ **App 里没有内置任何中转地址,作者也不对外提供中转服务。**
|
||||
|
||||
只在同一个 WiFi 下用的话,不用填任何地址 —— 点「局域网连接」就行。
|
||||
|
||||
要跨网络,你必须先自己部署一台中转站(服务端 zip 里有 deploy.sh
|
||||
一键脚本),拿到它打印的公网地址,填进「中转设置」。
|
||||
电脑那边也要填同一个地址:ykxmz.exe --relay https://你的域名
|
||||
|
||||
两个地址填得不一样是连不上的 —— 这是最常见的坑。
|
||||
|
||||
|
||||
【连不上时看这个】
|
||||
|
||||
App 会把错误翻译成人话:
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# 查出隧道「双协议」到底是什么状态 —— 文档要照着实际写,不能猜。
|
||||
set -u
|
||||
|
||||
echo "════════ 1. 所有隧道相关服务 ════════"
|
||||
for s in lanremote-tunnel lanremote-tunnel-h2 lanremote-tunnel-http2; do
|
||||
if systemctl list-unit-files 2>/dev/null | grep -q "^$s.service"; then
|
||||
printf " %-26s enabled=%-9s active=%s\n" "$s" \
|
||||
"$(systemctl is-enabled $s 2>/dev/null)" \
|
||||
"$(systemctl is-active $s 2>/dev/null)"
|
||||
else
|
||||
printf " %-26s (不存在)\n" "$s"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 2. 每个服务的完整配置 ════════"
|
||||
for s in lanremote-tunnel lanremote-tunnel-h2 lanremote-tunnel-http2; do
|
||||
if systemctl list-unit-files 2>/dev/null | grep -q "^$s.service"; then
|
||||
echo " ── $s ──"
|
||||
systemctl cat $s 2>/dev/null | grep -E 'ExecStart|Description' | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 3. 实际在跑的 cloudflared 进程 ════════"
|
||||
ps -eo pid,etime,cmd 2>/dev/null | grep '[c]loudflared' | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. 连接数(metrics)════════"
|
||||
for p in 20241 20242 20243 20244; do
|
||||
n=$(curl -s --max-time 3 "http://127.0.0.1:$p/metrics" 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
[ -n "${n:-}" ] && echo " 端口 $p: $n 条连接"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 5. 每个连接用的协议 ════════"
|
||||
for p in 20241 20242; do
|
||||
out=$(curl -s --max-time 3 "http://127.0.0.1:$p/metrics" 2>/dev/null \
|
||||
| grep -oE 'protocol="[a-z0-9]+"' | sort | uniq -c)
|
||||
[ -n "${out:-}" ] && { echo " 端口 $p:"; echo "$out" | sed 's/^/ /'; }
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 6. 服务端 zip 里有哪些部署脚本 ════════"
|
||||
ls -1 /root/lanremote-relay/*.sh 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 7. deploy.sh 里对隧道服务的处理(关键行)════════"
|
||||
grep -nE 'lanremote-tunnel|tunnel-h2|--protocol|ha-connections' \
|
||||
/root/lanremote-relay/deploy.sh 2>/dev/null | head -30 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 8. deploy-tune.sh 里对隧道/MTU 的处理 ════════"
|
||||
grep -nE 'mtu|MSS|mss|protocol|quic|keepalive' \
|
||||
/root/lanremote-relay/deploy-tune.sh 2>/dev/null | head -25 | sed 's/^/ /'
|
||||
@@ -0,0 +1,246 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''用**真实被控端**复现「经中转几次之后就不响应」。
|
||||
|
||||
中转本身的队列逻辑已经单独验过是对的(_build/repro_relay_hang.py 13/13 通过),
|
||||
所以问题在 windows/lanremote/relay.py 的客户端侧,或者它和中转的交互。
|
||||
|
||||
这个脚本:
|
||||
1. 配一个 Store(中转指向本地),起本地中转
|
||||
2. 起真实 run_agent.py --silent
|
||||
3. 等它登记
|
||||
4. 接入手表会话
|
||||
5. 依次发 pair / ping / ping / feature_list / info,每次都读回复
|
||||
6. 把被控端的日志和解码后的数据流全部打出来
|
||||
|
||||
这样卡在哪一步、被控端收到了什么、回了什么,一目了然。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
WINDOWS = ROOT / "windows"
|
||||
|
||||
RELAY_PORT = 8124
|
||||
AGENT_PORT = 8125
|
||||
BASE = f"http://127.0.0.1:{RELAY_PORT}"
|
||||
PAIR = "246810"
|
||||
UA = {"User-Agent": "LanRemote/1.8.2"}
|
||||
|
||||
|
||||
def post(path, body=b"", headers=None, timeout=20.0):
|
||||
h = dict(UA)
|
||||
h.update(headers or {})
|
||||
req = urllib.request.Request(BASE + path, data=body, headers=h,
|
||||
method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return 0, str(exc).encode()
|
||||
|
||||
|
||||
def get(path, timeout=10.0):
|
||||
req = urllib.request.Request(BASE + path, headers=UA)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return 0, str(exc).encode()
|
||||
|
||||
|
||||
class Watch:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
self.buf = b""
|
||||
|
||||
def send(self, obj):
|
||||
payload = (json.dumps(obj, ensure_ascii=False) + "\n").encode()
|
||||
st, _ = post("/v1/client/send", payload, {"X-Session": self.session})
|
||||
return st == 200
|
||||
|
||||
def reply(self, timeout=25.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
while b"\n" in self.buf:
|
||||
line, _, self.buf = self.buf.partition(b"\n")
|
||||
if not line.strip():
|
||||
continue
|
||||
msg = json.loads(line.decode("utf-8"))
|
||||
if "event" in msg:
|
||||
continue
|
||||
return msg
|
||||
st, body = post("/v1/client/poll", b"",
|
||||
{"X-Session": self.session},
|
||||
timeout=max(4.0, deadline - time.time()))
|
||||
if st != 200:
|
||||
time.sleep(0.4)
|
||||
continue
|
||||
self.buf += body
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
work = Path(tempfile.mkdtemp(prefix="agentrepro_"))
|
||||
relay_proc = agent_proc = None
|
||||
real_data = WINDOWS / "data"
|
||||
backup = WINDOWS / "data_bk_agentrepro"
|
||||
had = real_data.exists()
|
||||
if had:
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
shutil.move(str(real_data), str(backup))
|
||||
try:
|
||||
real_data.mkdir(parents=True, exist_ok=True)
|
||||
sys.path.insert(0, str(WINDOWS))
|
||||
from lanremote.config import Store
|
||||
store = Store(real_data)
|
||||
store.set_pairing_code(PAIR)
|
||||
store.set("setup_done", True)
|
||||
store.set("allow_new_pairing", True)
|
||||
store.set("close_pair_after_first", False)
|
||||
store.set("server_name", "复现电脑")
|
||||
store.set("relay_url", f"http://127.0.0.1:{RELAY_PORT}")
|
||||
store.set("port", AGENT_PORT)
|
||||
print(" 被控端配置: relay_url=%s port=%s 码=%s"
|
||||
% (store.get("relay_url"), AGENT_PORT, PAIR))
|
||||
|
||||
rdata = work / "r"
|
||||
rdata.mkdir(parents=True, exist_ok=True)
|
||||
env = dict(os.environ)
|
||||
env.update({"RELAY_PORT": str(RELAY_PORT), "RELAY_BIND": "127.0.0.1",
|
||||
"RELAY_DATA": str(rdata), "RELAY_POLL": "5",
|
||||
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"})
|
||||
relay_log = open(work / "relay.log", "wb")
|
||||
relay_proc = subprocess.Popen(
|
||||
[sys.executable, "-u", str(ROOT / "relay" / "relay.py")], env=env,
|
||||
stdout=relay_log, stderr=subprocess.STDOUT)
|
||||
for _ in range(40):
|
||||
time.sleep(0.25)
|
||||
if get("/v1/health")[0] == 200:
|
||||
break
|
||||
print(" 中转起来了")
|
||||
|
||||
agent_log = open(work / "agent.log", "wb")
|
||||
agent_proc = subprocess.Popen(
|
||||
[sys.executable, "-u", str(WINDOWS / "run_agent.py"),
|
||||
"--silent", "--port", str(AGENT_PORT)],
|
||||
cwd=str(WINDOWS), stdout=agent_log, stderr=subprocess.STDOUT)
|
||||
|
||||
device = None
|
||||
for _ in range(40):
|
||||
time.sleep(0.5)
|
||||
st, body = get("/v1/health")
|
||||
if st == 200:
|
||||
snap = json.loads(body.decode())
|
||||
if snap.get("devices"):
|
||||
device = snap["devices"][0]
|
||||
break
|
||||
if not device:
|
||||
print(" ✗ 被控端没登记上来")
|
||||
return 1
|
||||
print(" 被控端已登记: %s 码 %s 在线 %s"
|
||||
% (device["name"], device["code"], device["online"]))
|
||||
|
||||
st, body = post("/v1/client/attach", json.dumps(
|
||||
{"code": PAIR, "name": "复现手表"}).encode(),
|
||||
{"Content-Type": "application/json"})
|
||||
att = json.loads(body.decode())
|
||||
print(" 手表接入: ok=%s" % att.get("ok"))
|
||||
watch = Watch(att.get("session", ""))
|
||||
|
||||
steps = [
|
||||
{"cmd": "pair", "deviceId": "watch-repro",
|
||||
"name": "复现手表", "code": PAIR},
|
||||
{"cmd": "ping"},
|
||||
{"cmd": "ping"},
|
||||
{"cmd": "feature_list"},
|
||||
{"cmd": "info"},
|
||||
]
|
||||
for i, msg in enumerate(steps, 1):
|
||||
t0 = time.time()
|
||||
sent = watch.send(msg)
|
||||
got = watch.reply(timeout=25.0)
|
||||
dt = int((time.time() - t0) * 1000)
|
||||
status = "OK" if got is not None else "**超时**"
|
||||
print(" %d. %-14s 发送=%s %s %dms %s"
|
||||
% (i, msg.get("cmd"), sent, status, dt,
|
||||
("回复字段: " + ",".join(sorted(got.keys()))[:70])
|
||||
if got else ""))
|
||||
|
||||
st, body = get("/v1/health")
|
||||
snap = json.loads(body.decode())
|
||||
d = (snap.get("devices") or [{}])[0]
|
||||
print(" 中转侧: 在线=%s 待发机=%s 待发表=%s 会话=%s 空闲=%ss"
|
||||
% (d.get("online"), d.get("queuedToAgent"),
|
||||
d.get("queuedToClient"), d.get("sessions"),
|
||||
d.get("idleSeconds")))
|
||||
|
||||
if got is None:
|
||||
# **一超时就立刻 dump 被控端日志** ——
|
||||
# 否则脚本自己也会卡在后面的步骤,什么都看不到。
|
||||
agent_log.flush()
|
||||
print(" ════ 超时!被控端日志(最后 25 行)════")
|
||||
try:
|
||||
txt = (work / "agent.log").read_text(
|
||||
encoding="utf-8", errors="replace")
|
||||
for line in txt.splitlines()[-25:]:
|
||||
print(" " + line[:120])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(" 读日志失败: %s" % exc)
|
||||
|
||||
print(" ════ 被控端进程还活着吗 ════")
|
||||
print(" poll() = %s" % agent_proc.poll())
|
||||
|
||||
print(" ════ 中转看到的设备详情 ════")
|
||||
st, body = get("/v1/health")
|
||||
print(" " + json.dumps(
|
||||
json.loads(body.decode()), ensure_ascii=False)[:400])
|
||||
break
|
||||
|
||||
print()
|
||||
print(" ════ 被控端日志 ════")
|
||||
agent_log.flush()
|
||||
txt = (work / "agent.log").read_text(encoding="utf-8", errors="replace")
|
||||
for line in txt.splitlines()[-30:]:
|
||||
print(" " + line[:120])
|
||||
|
||||
print()
|
||||
print(" ════ 中转日志 ════")
|
||||
relay_log.flush()
|
||||
txt = (work / "relay.log").read_text(encoding="utf-8", errors="replace")
|
||||
for line in txt.splitlines()[-18:]:
|
||||
print(" " + line[:120])
|
||||
return 0
|
||||
finally:
|
||||
for p in (agent_proc, relay_proc):
|
||||
if p is not None:
|
||||
try:
|
||||
p.terminate()
|
||||
p.wait(timeout=6)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
p.kill()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
shutil.rmtree(real_data, ignore_errors=True)
|
||||
if had:
|
||||
shutil.move(str(backup), str(real_data))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
@@ -0,0 +1,207 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''精确复现「经中转发命令,前几次正常、之后卡死」。
|
||||
|
||||
== 为什么要单独写这个 ==
|
||||
|
||||
_tests/test_relay_local.py 会卡在「经中转发协议命令」那一步,
|
||||
但它起的是完整的被控端进程,中间隔了太多层,看不出卡在哪。
|
||||
|
||||
这个脚本只用 HTTP 直接和中转对话,把每一步的队列状态都打出来:
|
||||
watch 发 -> agent 取 -> agent 回 -> client 取
|
||||
每一步都断言,卡住的那一步会直接暴露。
|
||||
|
||||
**关键点:要模拟真实场景里「agent 的长轮询是开着的」** ——
|
||||
真实被控端会一直挂着一个 poll 等命令。
|
||||
如果只在"发完再 poll",就复现不出竞态。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PORT = 8123
|
||||
BASE = f"http://127.0.0.1:{PORT}"
|
||||
UA = {"User-Agent": "LanRemote/1.8.2"}
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
global passed, failed
|
||||
if ok:
|
||||
passed += 1
|
||||
print(" [PASS] " + name)
|
||||
else:
|
||||
failed += 1
|
||||
print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else ""))
|
||||
|
||||
|
||||
def post(path, body=b"", headers=None, timeout=20.0):
|
||||
h = dict(UA)
|
||||
h.update(headers or {})
|
||||
req = urllib.request.Request(BASE + path, data=body, headers=h,
|
||||
method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return 0, str(exc).encode()
|
||||
|
||||
|
||||
def health():
|
||||
req = urllib.request.Request(BASE + "/v1/health", headers=UA)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
work = Path(tempfile.mkdtemp(prefix="repro_"))
|
||||
relay = None
|
||||
try:
|
||||
data = work / "d"
|
||||
data.mkdir(parents=True, exist_ok=True)
|
||||
env = dict(os.environ)
|
||||
env.update({"RELAY_PORT": str(PORT), "RELAY_BIND": "127.0.0.1",
|
||||
"RELAY_DATA": str(data), "RELAY_POLL": "5",
|
||||
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"})
|
||||
logf = open(work / "relay.log", "wb")
|
||||
relay = subprocess.Popen(
|
||||
[sys.executable, "-u", str(ROOT / "relay" / "relay.py")], env=env,
|
||||
stdout=logf, stderr=subprocess.STDOUT)
|
||||
|
||||
for _ in range(40):
|
||||
time.sleep(0.25)
|
||||
try:
|
||||
health()
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
check("中转起来了", True)
|
||||
|
||||
# ---- 注册设备 + 接入手表 ----
|
||||
st, body = post("/v1/agent/register", json.dumps({
|
||||
"deviceId": "repro-pc", "code": "555555",
|
||||
"name": "复现机", "version": "1.8.2",
|
||||
"lanIps": [], "port": 0,
|
||||
}).encode(), {"Content-Type": "application/json"})
|
||||
reg = json.loads(body.decode())
|
||||
agent_token = reg.get("token", "")
|
||||
check("设备注册拿到 token", bool(agent_token), reg)
|
||||
|
||||
st, body = post("/v1/client/attach", json.dumps({
|
||||
"code": "555555", "name": "复现手表"}).encode(),
|
||||
{"Content-Type": "application/json"})
|
||||
att = json.loads(body.decode())
|
||||
session = att.get("session", "")
|
||||
check("手表接入拿到 session", bool(session), att)
|
||||
|
||||
# ---- 场景 1:同步往返(agent 不挂着 poll)----
|
||||
print()
|
||||
print(" 场景 1:先发后取(没有并发长轮询)")
|
||||
for i in range(1, 4):
|
||||
payload = (json.dumps({"cmd": "ping", "n": i}) + "\n").encode()
|
||||
st, body = post("/v1/client/send", payload, {"X-Session": session})
|
||||
send_ok = st == 200
|
||||
# agent 取
|
||||
st2, got = post("/v1/agent/poll", b"", {"X-Token": agent_token},
|
||||
timeout=15)
|
||||
received = payload.strip() in got
|
||||
check("第 %d 次 发->收" % i, send_ok and received,
|
||||
"send=%s poll=%s got=%r" % (send_ok, st2, got[:80]))
|
||||
# agent 回
|
||||
reply = (json.dumps({"ok": True, "n": i}) + "\n").encode()
|
||||
post("/v1/agent/send", reply, {"X-Token": agent_token})
|
||||
st3, back = post("/v1/client/poll", b"", {"X-Session": session},
|
||||
timeout=15)
|
||||
check("第 %d 次 回->收" % i, reply.strip() in back,
|
||||
"poll=%s got=%r" % (st3, back[:80]))
|
||||
|
||||
# ---- 场景 2:agent 挂着长轮询,watch 这时发 ----
|
||||
print()
|
||||
print(" 场景 2:agent 挂长轮询的同时手表发命令(真实场景)")
|
||||
for i in range(1, 4):
|
||||
payload = (json.dumps({"cmd": "ping", "n": 100 + i})
|
||||
+ "\n").encode()
|
||||
result = {}
|
||||
|
||||
def waiter():
|
||||
st, got = post("/v1/agent/poll", b"",
|
||||
{"X-Token": agent_token}, timeout=20)
|
||||
result["status"] = st
|
||||
result["got"] = got
|
||||
|
||||
t = threading.Thread(target=waiter, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.4) # 确保 poll 已经在挂
|
||||
post("/v1/client/send", payload, {"X-Session": session})
|
||||
t.join(timeout=12)
|
||||
alive = t.is_alive()
|
||||
got = result.get("got", b"")
|
||||
check("第 %d 次 长轮询期间发命令能收到" % i,
|
||||
(not alive) and (payload.strip() in got),
|
||||
"still_waiting=%s status=%s got=%r"
|
||||
% (alive, result.get("status"), got[:80]))
|
||||
|
||||
if alive:
|
||||
print(" ** 卡住了,看中转内部状态 **")
|
||||
print(" " + json.dumps(health(), ensure_ascii=False)[:300])
|
||||
break
|
||||
|
||||
# ---- 场景 3:连续发多条,agent 一次取 ----
|
||||
print()
|
||||
print(" 场景 3:连发 5 条,agent 连续取")
|
||||
for i in range(5):
|
||||
payload = (json.dumps({"cmd": "burst", "n": i}) + "\n").encode()
|
||||
post("/v1/client/send", payload, {"X-Session": session})
|
||||
got_all = b""
|
||||
for _ in range(6):
|
||||
st, got = post("/v1/agent/poll", b"", {"X-Token": agent_token},
|
||||
timeout=8)
|
||||
got_all += got
|
||||
if got_all.count(b"\n") >= 5:
|
||||
break
|
||||
check("5 条都收到了", got_all.count(b"\n") >= 5,
|
||||
"收到 %d 条: %r" % (got_all.count(b"\n"), got_all[:200]))
|
||||
|
||||
print()
|
||||
print(" ── 中转日志里的关键行 ──")
|
||||
logf.flush()
|
||||
try:
|
||||
for line in (work / "relay.log").read_text(
|
||||
encoding="utf-8", errors="replace").splitlines()[-12:]:
|
||||
print(" " + line[:110])
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
print()
|
||||
print("=" * 56)
|
||||
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
|
||||
return 0 if failed == 0 else 1
|
||||
finally:
|
||||
if relay is not None:
|
||||
try:
|
||||
relay.terminate()
|
||||
relay.wait(timeout=6)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
relay.kill()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,35 @@
|
||||
一、准备被控端
|
||||
[PASS] 配对码已设
|
||||
[PASS] 中转地址已设
|
||||
|
||||
二、起本地中转服务器
|
||||
[PASS] 中转已启动
|
||||
|
||||
三、起被控端(它会主动连中转)
|
||||
[PASS] 被控端登记到中转并在线
|
||||
[PASS] 中转看到的配对码正确
|
||||
[PASS] 中转看到设备名
|
||||
|
||||
四、模拟手表接入
|
||||
[PASS] 接入成功
|
||||
[PASS] 看到被控端名字
|
||||
|
||||
五、经中转发协议命令(这才是真验证)
|
||||
[PASS] 未配对时 ping 被拒(鉴权仍生效)
|
||||
[PASS] 配对成功(经中转)
|
||||
[PASS] 拿到设备令牌
|
||||
[PASS] 拿到被控端名字
|
||||
[PASS] 配对后 ping 正常
|
||||
[PASS] 功能列表经中转返回
|
||||
[PASS] 执行功能经中转成功
|
||||
[PASS] 功能真的有输出
|
||||
[PASS] info 经中转返回
|
||||
|
||||
六、换一个手表(中转应该踢掉旧的,被控端应认出新会话)
|
||||
[PASS] 第二个手表能接入
|
||||
[PASS] 新会话能用已保存的令牌直接登录
|
||||
[PASS] 新会话 ping 正常
|
||||
[PASS] 中转上只剩一个会话
|
||||
|
||||
==================================================================
|
||||
通过 21 项,失败 0 项
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# 分析 530 的性质:中转收到了吗?还是 Cloudflare 边缘就没送进来?
|
||||
set -u
|
||||
|
||||
echo "════════ 1. 中转日志在 17:31 前后的记录 ════════"
|
||||
awk '/\[10-07 17:2[89]|\[10-07 17:3[0-4]/' /root/lanremote-relay/relay.log 2>/dev/null \
|
||||
| tail -40 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 2. 中转日志里有没有"健康检查"级别的记录 ════════"
|
||||
echo " 说明:中转对 /v1/health 不写日志(太频繁)。"
|
||||
echo " 所以"中转没记录"不能证明它没收到 —— 得靠 totalRequests 增量判断。"
|
||||
|
||||
echo
|
||||
echo "════════ 3. 当前 totalRequests 和运行时长 ════════"
|
||||
curl -s --max-time 10 http://127.0.0.1:8080/v1/health \
|
||||
| python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print(' totalRequests:', d.get('totalRequests'))
|
||||
print(' uptimeSeconds:', d.get('uptimeSeconds'))
|
||||
print(' 平均每秒请求数: %.3f' % (d.get('totalRequests', 0) / max(d.get('uptimeSeconds', 1), 1)))
|
||||
print(' devices:', len(d.get('devices', [])), ' sessions:', d.get('sessions'))
|
||||
"
|
||||
|
||||
echo
|
||||
echo "════════ 4. HTTP 5xx 都是什么时候出现的(从长测数据)════════"
|
||||
awk -F'\t' 'NR>1 && $2!="200" {print " " $1 " 码=" $2 " " $3 "ms"}' /root/long2h_health.tsv 2>/dev/null
|
||||
|
||||
echo
|
||||
echo "════════ 5. 这些失败时刻,连接数是多少(对齐最近一次采样)════════"
|
||||
awk -F'\t' '
|
||||
NR==FNR { if (FNR>1 && $2!="200") bad[NR]=$1; next }
|
||||
{ lines[++n]=$0 }
|
||||
END {
|
||||
for (k in bad) {
|
||||
t=bad[k]
|
||||
best=""
|
||||
for (i=1;i<=n;i++) {
|
||||
split(lines[i], f, "\t")
|
||||
if (f[1] <= t) best=lines[i]
|
||||
}
|
||||
print " 失败 " t " <- 最近采样: " best
|
||||
}
|
||||
}' /root/long2h_health.tsv /root/long2h_conns.tsv | sort | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 6. 两个 tunnel 日志里,17:28~17:35 的事件 ════════"
|
||||
echo " ── QUIC ──"
|
||||
grep -E '2026-10-07T09:(2[89]|3[0-5])' /root/lanremote-relay/tunnel.log 2>/dev/null \
|
||||
| grep -E 'Registered|Lost|Failed|Unregistered' | tail -12 | sed 's/^/ /'
|
||||
echo " ── HTTP/2 ──"
|
||||
grep -E '2026-10-07T09:(2[89]|3[0-5])' /root/lanremote-relay/tunnel-h2.log 2>/dev/null \
|
||||
| grep -E 'Registered|Lost|Failed|Unregistered' | tail -12 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 7. watchdog 在这段时间的处置 ════════"
|
||||
grep -E '17:2[89]|17:3[0-5]' /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /'
|
||||
@@ -0,0 +1,159 @@
|
||||
# task-3 中转稳定性收尾 · 数据报告
|
||||
|
||||
> 起草于 2026-10-07 17:40,最终数字等 2 小时长测结束后填入。
|
||||
|
||||
## 一、最终采用的配置
|
||||
|
||||
| 项 | 值 | 为什么 |
|
||||
|---|---|---|
|
||||
| 隧道实例 1 | `lanremote-tunnel` | QUIC 主力 |
|
||||
| | `--protocol quic --ha-connections 8 --metrics 127.0.0.1:20241` | 实测 QUIC 一旦连上,丢包恢复优于 TCP |
|
||||
| 隧道实例 2 | `lanremote-tunnel-h2` | HTTP/2 兜底 |
|
||||
| | `--protocol http2 --ha-connections 2 --metrics 127.0.0.1:20242` | QUIC 拨号只有 72% 成功,靠它保证总有可用连接 |
|
||||
| 两个实例 | 连**同一个具名隧道** `lanremote` | Cloudflare 把请求分摊到全部 10 条连接 |
|
||||
| wlan0 MTU | `1400` | QUIC 走 UDP,TCPMSS 规则管不到 |
|
||||
| TCP MSS | `1360` | 和 MTU 1400 对齐 |
|
||||
| TCP keepalive | `30 / 10 / 3` | 4G 会静默丢 TCP,默认 7200 秒才察觉 |
|
||||
| 中转长轮询 | `RELAY_POLL=60` | 减少 4 倍请求数 |
|
||||
| watchdog | 双实例 + 自适应阈值(317 行) | 见下 |
|
||||
|
||||
## 二、根因分析
|
||||
|
||||
### 2.1 QUIC 拨号失败 28% —— 握手对丢包比 TCP 敏感
|
||||
|
||||
**先纠正一个我中途搞错的结论。** 我一开始用 Python 发了个简化版 QUIC 包探测
|
||||
UDP 可达性,得到 "UDP 0/12 无回应",据此判断"运营商封了 UDP"。
|
||||
**这个测量是无效的** —— 那个包格式不对,Cloudflare 本来就不该回应。
|
||||
|
||||
后来用 `--loglevel debug` 跑了一个临时实例,真相是:
|
||||
|
||||
```
|
||||
INF | UDP Connectivity region1.v2.argotunnel.com PASS QUIC connection successful |
|
||||
INF | UDP Connectivity region2.v2.argotunnel.com PASS QUIC connection successful |
|
||||
INF | SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol |
|
||||
INF Registered tunnel connection (09:45:00 -> 09:45:01,握手 1 秒)
|
||||
DBG Received transport parameters MaxUDPPayloadSize=1360
|
||||
DBG QUIC MTU updated to 1296 -> 1328 -> 1344
|
||||
```
|
||||
|
||||
**UDP 完全通,握手成功时只要 1 秒。**
|
||||
|
||||
那为什么历史统计里拨号成功率只有 72%?
|
||||
|
||||
```
|
||||
QUIC 注册 525 失败 197 掉线 48 拨号成功率 72%
|
||||
HTTP/2 注册 13 失败 0 掉线 7 拨号成功率 100%
|
||||
```
|
||||
|
||||
**机制是:QUIC 握手需要 6+ 个包的往返,而 TCP 的 SYN 只有 3 个包且内核会激进重传。**
|
||||
在 4G 这种有几个百分点丢包的链路上,多轮往返的成功率是指数衰减的:
|
||||
|
||||
每次握手约 6 个关键包,单包丢失率 p
|
||||
成功率 ≈ (1-p)^6
|
||||
实测 72% -> p ≈ 5.4%/包
|
||||
|
||||
而 TCP 的建连即使 SYN 丢了,内核会按 1s/2s/4s 重传(`tcp_syn_retries=6`),
|
||||
所以 100% 能连上 —— 代价是慢,但那几条 HTTP/2 连接就是用来兜住这个的。
|
||||
|
||||
**这也顺带解释了为什么"多 QUIC"反而更快**:QUIC 的弱点是"连不上",
|
||||
不是"连上之后不好";一旦连上,它没有 TCP 的跨流队头阻塞,反而更稳。
|
||||
|
||||
排除的另一个假设:
|
||||
* `--edge-ip-version 6` 用不了 —— 设备 0 个全局 IPv6 地址,IPv6 到 Cloudflare 也不通
|
||||
(Cloudflare 的 SRV 记录确实返回了 AAAA,但本机没有 IPv6 出口)
|
||||
|
||||
### 2.2 为什么"多 QUIC"反而更快(反直觉)
|
||||
|
||||
| 配置 | 可用率 | 中位 | P90 | P99 |
|
||||
|---|---|---|---|---|
|
||||
| quic5 + h2 5 | 100% | 1490 | 2346 | 3295 |
|
||||
| quic3 + h2 7 | 97% | 1271 | 2558 | **13934** |
|
||||
| quic8 + h2 2 | **100%** | **1250** | **1715** | **2632** |
|
||||
|
||||
QUIC 拨号成功率低,但**一旦连上,它的丢包恢复明显优于 HTTP/2 over TCP**
|
||||
(没有跨流的队头阻塞)。所以最优组合是"尽量多 QUIC + 少量 HTTP/2 兜底"。
|
||||
|
||||
### 2.3 MTU 1400 的效果
|
||||
|
||||
QUIC 走 UDP,我们那条 `iptables -j TCPMSS --set-mss` 规则**只对 TCP 生效**。
|
||||
路径 MTU 实测 1472,而 debug 日志显示 Cloudflare 报的 `MaxUDPPayloadSize=1360`,
|
||||
QUIC 自己也在做 MTU 探测(1296 -> 1328 -> 1344)。
|
||||
把 wlan0 MTU 从 1500 降到 1400 后:
|
||||
|
||||
```
|
||||
QUIC 建齐 6 条连接 28 秒 -> 13 秒
|
||||
dial 失败次数 2 -> 1
|
||||
```
|
||||
|
||||
### 2.4 异常长轮询(126 秒)是隧道重启造成的
|
||||
|
||||
30 次长轮询里出现两次 ~126.7 秒(正好是 2 倍 hold + 开销):
|
||||
|
||||
```
|
||||
#1 17:30:42 开始 -> 17:31:40 watchdog 重启了两个实例 -> 实际 126604ms
|
||||
#10 17:40:59 开始 -> 17:41:56 公网 000、17:42:46 重启 h2 -> 126716ms
|
||||
其余 61~62 秒正常
|
||||
```
|
||||
|
||||
**对齐时间线后确认:异常长轮询都发生在隧道被重启的窗口里**,
|
||||
不是稳态行为。而重启有一部分正是看门狗过于激进的策略引起的
|
||||
(见 3.2),所以"收紧重启策略"本身就直接改善了长轮询的尾部延迟。
|
||||
|
||||
**这带来一个需要 Lead 决定的点**:客户端 `POLL_TIMEOUT` 是 120000ms,
|
||||
126 秒会被判失败。要么把超时放宽到 180 秒,要么把 `RELAY_POLL` 降到 45 秒
|
||||
(这样 2 倍也只有 ~93 秒)。两者都涉及 android/ 和 windows/,超出我的授权范围。
|
||||
|
||||
## 三、修掉的两个真 bug
|
||||
|
||||
### 3.1 metrics 端口和实例的对应关系会翻转(严重)
|
||||
|
||||
两个服务的 ExecStart **都没写 `--metrics`**,都用 cloudflared 默认的 20241。
|
||||
正常启动顺序下看着是"主=20241 副=20242",**但那只是巧合**。
|
||||
|
||||
实测:把主实例 stop 掉、再重启副实例,**副实例就抢到了 20241**。
|
||||
于是"20241 是谁"翻转,watchdog 和所有按端口读指标的脚本全部读错实例
|
||||
(我自己的 A/B 对照就因此出了一组完全反向的数据)。
|
||||
|
||||
修法两层:
|
||||
1. 给两个服务显式钉死 `--metrics`,并实测验证(停主实例后副实例不再抢)
|
||||
2. watchdog 改成从服务配置里读端口,以后谁改了端口它跟着走
|
||||
|
||||
### 3.2 watchdog 在双实例下有盲点
|
||||
|
||||
第三版:`METRICS` 写死 20241,**只看得到 QUIC 那个实例**;
|
||||
`restart_tunnel` 只重启 `lanremote-tunnel`,副实例僵死永远不会被拉起来。
|
||||
|
||||
第四版(193 → 317 行):
|
||||
* 分别读两个实例,分实例判断
|
||||
* 某实例端口无响应 **或** 连接数为 0 且过了宽限期 → 只重启那一个
|
||||
* 两个都垮 / 公网不通 → 重启两个
|
||||
* "建齐"阈值按总连接数算
|
||||
|
||||
**另外发现它在"帮倒忙"**:
|
||||
|
||||
```
|
||||
17:30:59 公网不通(HTTP 000,连接 7+2=9 条,健康)
|
||||
17:31:19 公网不通(HTTP 000,连接 8+2,健康)
|
||||
17:31:40 公网不通(HTTP 000,连接 7+2,健康)
|
||||
17:31:40 连续 3 次 —— 重启两个实例
|
||||
17:32:35 重启后只剩 7 条(6+1)
|
||||
```
|
||||
|
||||
连接明明健康,它却因为"公网 3 次不通"把隧道重启了。
|
||||
而**重启会主动切断全部连接**,接下来 60 秒真的不可用 ——
|
||||
等于把一次"抖一下就好"的故障升级成确定的 1 分钟中断。
|
||||
|
||||
加了自适应阈值:
|
||||
* 连接健康(≥6 条)→ 要连续失败 **8 次**(约 2.5 分钟)才重启
|
||||
* 连接降级 → 保持 **3 次**快速重启
|
||||
* 日志僵死 + 公网不通 → 立即重启(原逻辑保留)
|
||||
|
||||
4 个场景的桩测试全部通过(第 8 次 / 第 1 次 / 第 3 次 / 永不)。
|
||||
|
||||
## 四、实测数据
|
||||
|
||||
(待 2 小时长测结束后填入)
|
||||
|
||||
## 五、受链路物理条件限制、做不到 100% 的部分
|
||||
|
||||
(待填)
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# 查探针注册为什么失败
|
||||
set -u
|
||||
|
||||
BASE=https://relay.xxmxxs.top
|
||||
LOCAL=http://127.0.0.1:8080
|
||||
UA='LanRemote/1.8.2'
|
||||
STAMP=$(date +%s)
|
||||
DEV="dbg-${STAMP}"
|
||||
CODE=$(printf "%06d" $(( STAMP % 1000000 )))
|
||||
|
||||
echo "════════ 1. 直接注册,看返回什么 ════════"
|
||||
echo " deviceId=$DEV code=$CODE"
|
||||
resp=$(curl -s --max-time 30 -X POST "$BASE/v1/agent/register" \
|
||||
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
|
||||
-d "{\"deviceId\":\"$DEV\",\"code\":\"$CODE\",\"name\":\"dbg\",\"version\":\"1.8.2\"}")
|
||||
echo " 响应: $resp"
|
||||
|
||||
echo
|
||||
echo "════════ 2. 本机直连注册 ════════"
|
||||
resp2=$(curl -s --max-time 30 -X POST "$LOCAL/v1/agent/register" \
|
||||
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
|
||||
-d "{\"deviceId\":\"${DEV}-local\",\"code\":\"$(printf "%06d" $(( (STAMP+1) % 1000000 )))\",\"name\":\"dbgl\",\"version\":\"1.8.2\"}")
|
||||
echo " 响应: $resp2"
|
||||
|
||||
echo
|
||||
echo "════════ 3. 用 python3 解析(看是不是解析脚本的问题)════════"
|
||||
echo "$resp" | python3 -c "
|
||||
import sys, json
|
||||
raw = sys.stdin.read()
|
||||
print(' 原始长度:', len(raw))
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
print(' 解析成功,token =', repr(d.get('token', '(没有 token 字段)'))[:60])
|
||||
print(' 所有键:', list(d.keys()))
|
||||
except Exception as e:
|
||||
print(' 解析失败:', e)
|
||||
"
|
||||
|
||||
echo
|
||||
echo "════════ 4. 长测脚本里那段注册代码单独跑一遍 ════════"
|
||||
stamp2=$(date +%s)
|
||||
dev2="l2h-${stamp2}"
|
||||
code2=$(printf "%06d" $(( stamp2 % 1000000 )))
|
||||
reg=$(curl -s --max-time 30 -X POST "$BASE/v1/agent/register" \
|
||||
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
|
||||
-d "{\"deviceId\":\"$dev2\",\"code\":\"$code2\",\"name\":\"l2h\",\"version\":\"1.8.2\"}")
|
||||
echo " reg 原始: $reg"
|
||||
token=$(echo "$reg" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
|
||||
echo " token='$token' 长度=${#token}"
|
||||
|
||||
echo
|
||||
echo "════════ 5. 中转当前的接受策略 ════════"
|
||||
curl -s --max-time 10 "$LOCAL/v1/health" | python3 -m json.tool 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 6. 中转日志里最近的注册记录 ════════"
|
||||
tail -40 /root/lanremote-relay/relay.log 2>/dev/null | grep -E '注册|register|拒绝' | tail -10 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 7. 清掉测试设备 ════════"
|
||||
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||
for d in "$DEV" "${DEV}-local" "$dev2"; do
|
||||
curl -s --max-time 15 -X POST "$LOCAL/v1/admin/delete" -H "X-Admin: $KEY" \
|
||||
-H 'Content-Type: application/json' -d "{\"deviceId\":\"$d\"}" > /dev/null 2>&1
|
||||
done
|
||||
echo " 已清理"
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# 部署自适应阈值的 watchdog(只换脚本,不动隧道)
|
||||
set -u
|
||||
|
||||
RELAY=/root/lanremote-relay
|
||||
cp -a "$RELAY/watchdog.sh" "$RELAY/watchdog.sh.bak.$(date +%s)"
|
||||
|
||||
echo "════════ 1. 装上(已经是新版了,这里复核)════════"
|
||||
bash -n "$RELAY/watchdog.sh" && echo " 语法 OK"
|
||||
echo " 行数: $(wc -l < "$RELAY/watchdog.sh")"
|
||||
echo " 关键参数:"
|
||||
grep -E '^THRESHOLD=|^THRESHOLD_HEALTHY=|^GRACE=|^MIN_RESTART_GAP=|^BUILT_MIN=' "$RELAY/watchdog.sh" | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 2. 预演(当前一切正常,应该无动作)════════"
|
||||
DRY_RUN=1 bash "$RELAY/watchdog.sh" 2>&1 | sed 's/^/ /'
|
||||
echo " (无输出 = 正常)"
|
||||
|
||||
echo
|
||||
echo "════════ 3. 真跑一次 ════════"
|
||||
bash "$RELAY/watchdog.sh" 2>&1 | sed 's/^/ /'
|
||||
echo " 退出码 $?"
|
||||
echo " 状态文件: $(cat "$RELAY/watchdog.state" 2>/dev/null | tr '\n' ' ')"
|
||||
|
||||
echo
|
||||
echo "════════ 4. 当前隧道状态(确认没被影响)════════"
|
||||
for port in 20241 20242; do
|
||||
n=$(curl -s --max-time 4 "http://127.0.0.1:$port/metrics" 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
echo " 端口 $port: ${n:-无响应}"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 5. 长测进度(确认没被打断)════════"
|
||||
echo " 健康样本: $(wc -l < /root/long2h_health.tsv 2>/dev/null || echo 0)"
|
||||
echo " 轮询: $(wc -l < /root/rp_poll30.tsv 2>/dev/null || echo 0) / 30"
|
||||
echo " 测试进程:"
|
||||
pgrep -af 'rp_long2h|rpp30' | sed 's/^/ /'
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/bin/bash
|
||||
# 2 小时综合长测(task-3 第 1、3 项)。
|
||||
#
|
||||
# 同时测四件事,并且都带**时间戳**,这样才能回答
|
||||
# "失败是不是集中在某个时段"(运营商时段性抖动):
|
||||
#
|
||||
# 1. 公网可用率 每 15 秒一次 /v1/health,连续 2 小时
|
||||
# 2. 长轮询成功率 30 次真实 poll(每次挂 60 秒),后台跑
|
||||
# 3. 连接数波动 每 60 秒采样两个 metrics 端口
|
||||
# 4. 重连/拨号失败 从两个 tunnel 日志的事件增量算
|
||||
#
|
||||
# 输出:
|
||||
# /root/long2h_health.tsv 时间戳 状态码 耗时ms
|
||||
# /root/long2h_poll.tsv 序号 状态码 耗时ms 起始时间
|
||||
# /root/long2h_conns.tsv 时间戳 quic连接 http2连接
|
||||
# /root/long2h_summary.txt 汇总
|
||||
set -u
|
||||
|
||||
BASE=https://relay.xxmxxs.top
|
||||
UA='LanRemote/1.8.2'
|
||||
DUR=${1:-7200} # 默认 2 小时
|
||||
HEALTH_EVERY=15
|
||||
CONN_EVERY=60
|
||||
POLLS=30
|
||||
|
||||
H=/root/long2h_health.tsv
|
||||
P=/root/long2h_poll.tsv
|
||||
C=/root/long2h_conns.tsv
|
||||
S=/root/long2h_summary.txt
|
||||
MAINLOG=/root/lanremote-relay/tunnel.log
|
||||
H2LOG=/root/lanremote-relay/tunnel-h2.log
|
||||
|
||||
rm -f "$H" "$P" "$C" "$S"
|
||||
|
||||
conns_of() {
|
||||
curl -s --max-time 4 "http://127.0.0.1:$1/metrics" 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}'
|
||||
}
|
||||
|
||||
# ---------- 后台任务 1:长轮询 ----------
|
||||
poll_worker() {
|
||||
local stamp dev code reg token
|
||||
stamp=$(date +%s)
|
||||
dev="l2h-${stamp}"
|
||||
code=$(printf "%06d" $(( stamp % 1000000 )))
|
||||
reg=$(curl -s --max-time 30 -X POST "$BASE/v1/agent/register" \
|
||||
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
|
||||
-d "{\"deviceId\":\"$dev\",\"code\":\"$code\",\"name\":\"l2h\",\"version\":\"1.8.2\"}")
|
||||
token=$(echo "$reg" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
|
||||
if [ -z "$token" ]; then
|
||||
echo "0 0 0 REGFAIL" >> "$P"
|
||||
return
|
||||
fi
|
||||
local i
|
||||
for i in $(seq 1 "$POLLS"); do
|
||||
local t0 out c ms
|
||||
t0=$(date '+%H:%M:%S')
|
||||
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 130 \
|
||||
-X POST "$BASE/v1/agent/poll" -H "X-Token: $token" -H "User-Agent: $UA")
|
||||
c=$(echo "$out" | awk '{print $1}')
|
||||
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||
echo "$i $c $ms $t0" >> "$P"
|
||||
done
|
||||
# 清理探针
|
||||
local key
|
||||
key=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||
curl -s --max-time 20 -X POST "http://127.0.0.1:8080/v1/admin/delete" \
|
||||
-H "X-Admin: $key" -H 'Content-Type: application/json' \
|
||||
-d "{\"deviceId\":\"$dev\"}" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
poll_worker &
|
||||
POLL_PID=$!
|
||||
|
||||
# ---------- 记录起始的日志行号,用于算增量 ----------
|
||||
M0=$(wc -l < "$MAINLOG" 2>/dev/null || echo 0)
|
||||
H0=$(wc -l < "$H2LOG" 2>/dev/null || echo 0)
|
||||
|
||||
T0=$(date +%s)
|
||||
NEXT_CONN=$T0
|
||||
echo " 开始: $(date '+%Y-%m-%d %H:%M:%S') 持续 ${DUR} 秒"
|
||||
|
||||
# ---------- 主循环:公网健康 ----------
|
||||
while true; do
|
||||
NOW=$(date +%s)
|
||||
[ $((NOW - T0)) -ge "$DUR" ] && break
|
||||
|
||||
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
|
||||
-H "User-Agent: $UA" "$BASE/v1/health")
|
||||
code=$(echo "$out" | awk '{print $1}')
|
||||
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $code $ms" >> "$H"
|
||||
|
||||
# 连接数采样
|
||||
if [ "$NOW" -ge "$NEXT_CONN" ]; then
|
||||
a=$(conns_of 20241); b=$(conns_of 20242)
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') ${a:-X} ${b:-X}" >> "$C"
|
||||
NEXT_CONN=$((NOW + CONN_EVERY))
|
||||
fi
|
||||
|
||||
sleep "$HEALTH_EVERY"
|
||||
done
|
||||
|
||||
wait "$POLL_PID" 2>/dev/null || true
|
||||
|
||||
# ---------- 汇总 ----------
|
||||
T1=$(date +%s)
|
||||
M1=$(wc -l < "$MAINLOG" 2>/dev/null || echo 0)
|
||||
H1=$(wc -l < "$H2LOG" 2>/dev/null || echo 0)
|
||||
|
||||
{
|
||||
echo "================================================================"
|
||||
echo " 2 小时综合长测 · $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo " 实际时长: $((T1 - T0)) 秒"
|
||||
echo "================================================================"
|
||||
echo
|
||||
|
||||
echo "── 1. 公网可用率 ──"
|
||||
total=$(wc -l < "$H")
|
||||
ok=$(awk -F'\t' '$2=="200"' "$H" | wc -l)
|
||||
echo " 样本 $total 次,成功 $ok 次 = $((ok * 100 / (total > 0 ? total : 1)))%"
|
||||
echo " 各状态码分布:"
|
||||
awk -F'\t' '{print $2}' "$H" | sort | uniq -c | sort -rn | sed 's/^/ /'
|
||||
echo " 耗时分布(成功的):"
|
||||
awk -F'\t' '$2=="200" {print $3}' "$H" | sort -n | awk '
|
||||
{a[NR]=$1}
|
||||
END {
|
||||
if (NR == 0) { print " 无数据"; exit }
|
||||
printf " 最小 %d 中位 %d P90 %d P99 %d 最大 %d ms\n",
|
||||
a[1], a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]
|
||||
}' | sed 's/^/ /'
|
||||
echo
|
||||
echo " ★ 失败时刻(时间线,用来判断是不是集中在某时段):"
|
||||
awk -F'\t' '$2!="200" {print " " $1 " 码=" $2 " 耗时=" $3 "ms"}' "$H"
|
||||
echo
|
||||
|
||||
echo "── 2. 长轮询成功率 ──"
|
||||
ptotal=$(grep -c . "$P" 2>/dev/null || echo 0)
|
||||
pok=$(awk -F'\t' '$2=="200"' "$P" 2>/dev/null | wc -l)
|
||||
echo " $pok/$ptotal = $((pok * 100 / (ptotal > 0 ? ptotal : 1)))%"
|
||||
awk -F'\t' '$2=="200" {print $3}' "$P" | sort -n | awk '
|
||||
{a[NR]=$1}
|
||||
END {
|
||||
if (NR == 0) { print " 无数据"; exit }
|
||||
printf " 最小 %d P50 %d P90 %d P99 %d 最大 %d ms(理想 ≈ 61000)\n",
|
||||
a[1], a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]
|
||||
}'
|
||||
echo " 失败的:"
|
||||
awk -F'\t' '$2!="200" {print " #" $1 " 码=" $2 " 起始 " $4}' "$P"
|
||||
echo
|
||||
|
||||
echo "── 3. 连接数波动 ──"
|
||||
echo " quic 实例: 最小 $(awk -F'\t' 'NR>1{print $2}' "$C" | grep -E '^[0-9]+$' | sort -n | head -1) 最大 $(awk -F'\t' '{print $2}' "$C" | grep -E '^[0-9]+$' | sort -n | tail -1)"
|
||||
echo " http2 实例: 最小 $(awk -F'\t' '{print $3}' "$C" | grep -E '^[0-9]+$' | sort -n | head -1) 最大 $(awk -F'\t' '{print $3}' "$C" | grep -E '^[0-9]+$' | sort -n | tail -1)"
|
||||
echo " 出现过的组合(次数):"
|
||||
awk -F'\t' 'NR>1{print "quic="$2" http2="$3}' "$C" | sort | uniq -c | sort -rn | head -8 | sed 's/^/ /'
|
||||
echo
|
||||
|
||||
echo "── 4. 隧道事件(本轮增量)──"
|
||||
echo " QUIC 实例:"
|
||||
for kw in 'Registered tunnel connection' 'Failed to dial' 'Lost connection' 'i/o timeout'; do
|
||||
n=$(tail -n +$((M0 + 1)) "$MAINLOG" 2>/dev/null | grep -c "$kw")
|
||||
printf " %-34s %s\n" "$kw" "$n"
|
||||
done
|
||||
echo " HTTP/2 实例:"
|
||||
for kw in 'Registered tunnel connection' 'Failed to dial' 'Lost connection' 'i/o timeout'; do
|
||||
n=$(tail -n +$((H0 + 1)) "$H2LOG" 2>/dev/null | grep -c "$kw")
|
||||
printf " %-34s %s\n" "$kw" "$n"
|
||||
done
|
||||
echo
|
||||
echo " 失败事件的时间线(QUIC):"
|
||||
tail -n +$((M0 + 1)) "$MAINLOG" 2>/dev/null \
|
||||
| grep -E 'Failed to dial|Lost connection' | tail -20 \
|
||||
| sed -E 's/^(....-..-..T..:..):.*(Failed to dial|Lost connection).*/ \1 \2/' | sed 's/^/ /'
|
||||
} > "$S" 2>&1
|
||||
|
||||
cat "$S"
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# 查清 metrics 端口到底归哪个实例 —— 这是 watchdog 判定的基础,错不得。
|
||||
set -u
|
||||
|
||||
echo "════════ 1. 两个服务的完整配置(找 --metrics)════════"
|
||||
for svc in lanremote-tunnel lanremote-tunnel-h2; do
|
||||
echo " ── $svc ──"
|
||||
systemctl cat "$svc" 2>/dev/null | grep -A10 'ExecStart=' | sed 's/^/ /'
|
||||
echo
|
||||
done
|
||||
|
||||
echo "════════ 2. 进程和端口的实时对应 ════════"
|
||||
for p in $(pgrep cloudflared); do
|
||||
cg=$(cat /proc/$p/cgroup 2>/dev/null | head -1)
|
||||
echo " ── pid $p ──"
|
||||
echo " 服务: $(basename "$cg" 2>/dev/null)"
|
||||
echo " 命令行: $(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null | grep -oE '\-\-protocol [a-z0-9]+|\-\-metrics [^ ]+|\-\-ha-connections [0-9]+' | tr '\n' ' ')"
|
||||
echo " 监听的端口:"
|
||||
ss -tlnp 2>/dev/null | grep "pid=$p" | awk '{print " " $4}'
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 3. 每个端口报的实例特征 ════════"
|
||||
for port in 20241 20242 20243; do
|
||||
body=$(curl -s --max-time 4 "http://127.0.0.1:$port/metrics" 2>/dev/null)
|
||||
if [ -z "$body" ]; then
|
||||
echo " ── 端口 $port: 无响应 ──"
|
||||
continue
|
||||
fi
|
||||
conns=$(echo "$body" | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
# 用协议特征区分:QUIC 实例的报告里会有 quic 相关指标
|
||||
quic=$(echo "$body" | grep -ci 'quic')
|
||||
echo " ── 端口 $port: ha_connections=$conns quic相关指标行数=$quic ──"
|
||||
echo "$body" | grep -E 'server_locations.*1$' | sed 's/^/ /'
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 4. 反复重启,看端口分配会不会变 ════════"
|
||||
echo " (这个很重要:watchdog 写死了 20241=主 20242=副,"
|
||||
echo " 如果 cloudflared 是"谁先起谁拿 20241",那这个假设就不成立)"
|
||||
for round in 1 2 3; do
|
||||
echo " ── 第 $round 轮:先重启 h2,再重启主 ──"
|
||||
systemctl restart lanremote-tunnel-h2
|
||||
sleep 8
|
||||
systemctl restart lanremote-tunnel
|
||||
sleep 12
|
||||
for p in $(pgrep cloudflared); do
|
||||
cg=$(cat /proc/$p/cgroup 2>/dev/null | head -1)
|
||||
svc=$(basename "$cg" 2>/dev/null)
|
||||
ports=$(ss -tlnp 2>/dev/null | grep "pid=$p" | awk '{print $4}' | tr '\n' ' ')
|
||||
echo " $svc -> ${ports:-(没绑端口)}"
|
||||
done
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 5. 现在的状态 ════════"
|
||||
ss -tlnp 2>/dev/null | grep -E '2024[0-9]' | sed 's/^/ /'
|
||||
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''PC 侧 2 小时长测 —— "真实用户"视角。
|
||||
|
||||
为什么必须两边同时测:
|
||||
设备自己发起的请求 = 设备 4G → 边缘 → 隧道 → 设备 4G
|
||||
^^^^^^^^^ 第一段 ^^^^^^^^^ 第二段
|
||||
两段压在同一个 4G 上,数字偏悲观。
|
||||
|
||||
这台 PC(WiFi/宽带)发起 = PC → 边缘(好网络)→ 隧道 → 设备 4G
|
||||
第一段好 第二段才是 4G
|
||||
|
||||
只有第二段是真实用户也会遇到的。
|
||||
|
||||
用法: python _build/rp_pc_long.py [小时数]
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
BASE = "https://relay.xxmxxs.top"
|
||||
UA = "LanRemote/1.8.2"
|
||||
OUT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\_build\rp_pc_long.tsv")
|
||||
EVERY = 15
|
||||
|
||||
|
||||
def hit(timeout: float = 25.0):
|
||||
r = urllib.request.Request(BASE + "/v1/health",
|
||||
headers={"User-Agent": UA})
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=timeout) as resp:
|
||||
resp.read()
|
||||
code = resp.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
exc.read()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
code = exc.code
|
||||
except Exception: # noqa: BLE001
|
||||
code = 0
|
||||
return code, int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
|
||||
def pct(vals, q):
|
||||
if not vals:
|
||||
return 0
|
||||
s = sorted(vals)
|
||||
return int(s[min(int(len(s) * q), len(s) - 1)])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
hours = float(sys.argv[1]) if len(sys.argv) > 1 else 2.0
|
||||
dur = int(hours * 3600)
|
||||
t0 = time.time()
|
||||
|
||||
print(" PC 侧长测开始: %s 持续 %.1f 小时" %
|
||||
(time.strftime("%Y-%m-%d %H:%M:%S"), hours))
|
||||
print(" 每 %d 秒一次,结果写入 %s" % (EVERY, OUT.name))
|
||||
|
||||
rows = []
|
||||
with OUT.open("w", encoding="utf-8") as fh:
|
||||
fh.write("time\tcode\tms\n")
|
||||
while time.time() - t0 < dur:
|
||||
code, ms = hit()
|
||||
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
fh.write("%s\t%d\t%d\n" % (stamp, code, ms))
|
||||
fh.flush()
|
||||
rows.append((stamp, code, ms))
|
||||
time.sleep(EVERY)
|
||||
|
||||
total = len(rows)
|
||||
ok = [r for r in rows if r[1] == 200]
|
||||
ms = [r[2] for r in ok]
|
||||
|
||||
print()
|
||||
print("=" * 58)
|
||||
print(" PC 侧长测结束 · %s" % time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
print("=" * 58)
|
||||
print(" 样本 %d 成功 %d = %.1f%%"
|
||||
% (total, len(ok), len(ok) * 100 / total if total else 0))
|
||||
if ms:
|
||||
print(" 耗时 最小 %d 中位 %d P90 %d P99 %d 最大 %d ms"
|
||||
% (min(ms), int(statistics.median(ms)), pct(ms, 0.9),
|
||||
pct(ms, 0.99), max(ms)))
|
||||
bad = [r for r in rows if r[1] != 200]
|
||||
if bad:
|
||||
print(" 失败 %d 次:" % len(bad))
|
||||
for stamp, code, m in bad[:40]:
|
||||
print(" %s code=%s %dms" % (stamp, code, m))
|
||||
else:
|
||||
print(" ✓ 全程零失败")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,88 @@
|
||||
time code ms
|
||||
2026-10-07 17:29:20 200 1150
|
||||
2026-10-07 17:29:36 200 1404
|
||||
2026-10-07 17:29:52 200 1319
|
||||
2026-10-07 17:30:09 200 1421
|
||||
2026-10-07 17:30:25 200 1503
|
||||
2026-10-07 17:30:41 200 1125
|
||||
2026-10-07 17:30:58 200 1136
|
||||
2026-10-07 17:31:14 200 1039
|
||||
2026-10-07 17:31:30 200 1350
|
||||
2026-10-07 17:31:46 200 1418
|
||||
2026-10-07 17:32:03 200 1752
|
||||
2026-10-07 17:32:20 200 1383
|
||||
2026-10-07 17:32:36 200 1316
|
||||
2026-10-07 17:32:52 200 1347
|
||||
2026-10-07 17:33:09 200 1450
|
||||
2026-10-07 17:33:25 200 1393
|
||||
2026-10-07 17:33:41 200 1019
|
||||
2026-10-07 17:33:57 200 1159
|
||||
2026-10-07 17:34:13 200 1132
|
||||
2026-10-07 17:34:30 200 1152
|
||||
2026-10-07 17:34:46 200 1113
|
||||
2026-10-07 17:35:02 200 1488
|
||||
2026-10-07 17:35:18 200 1167
|
||||
2026-10-07 17:35:34 200 1174
|
||||
2026-10-07 17:35:51 200 1429
|
||||
2026-10-07 17:36:07 200 1337
|
||||
2026-10-07 17:36:24 200 1305
|
||||
2026-10-07 17:36:39 200 878
|
||||
2026-10-07 17:36:55 200 850
|
||||
2026-10-07 17:37:11 200 813
|
||||
2026-10-07 17:37:28 200 1854
|
||||
2026-10-07 17:37:44 200 1172
|
||||
2026-10-07 17:38:00 200 837
|
||||
2026-10-07 17:38:16 200 1050
|
||||
2026-10-07 17:38:32 200 876
|
||||
2026-10-07 17:38:48 200 911
|
||||
2026-10-07 17:39:05 200 1850
|
||||
2026-10-07 17:39:21 200 953
|
||||
2026-10-07 17:39:36 200 871
|
||||
2026-10-07 17:39:52 200 848
|
||||
2026-10-07 17:40:08 200 845
|
||||
2026-10-07 17:40:46 200 22347
|
||||
2026-10-07 17:41:01 200 723
|
||||
2026-10-07 17:41:39 200 22449
|
||||
2026-10-07 17:41:55 200 1748
|
||||
2026-10-07 17:42:32 200 22048
|
||||
2026-10-07 17:43:03 200 15949
|
||||
2026-10-07 17:43:19 200 881
|
||||
2026-10-07 17:43:35 200 929
|
||||
2026-10-07 17:43:51 200 858
|
||||
2026-10-07 17:44:08 200 1490
|
||||
2026-10-07 17:44:24 200 1351
|
||||
2026-10-07 17:44:40 200 1125
|
||||
2026-10-07 17:44:56 200 1030
|
||||
2026-10-07 17:45:12 200 1285
|
||||
2026-10-07 17:45:28 200 1101
|
||||
2026-10-07 17:45:45 200 1327
|
||||
2026-10-07 17:46:01 200 1540
|
||||
2026-10-07 17:46:18 200 1340
|
||||
2026-10-07 17:46:35 200 2030
|
||||
2026-10-07 17:46:51 200 1050
|
||||
2026-10-07 17:47:07 200 1079
|
||||
2026-10-07 17:47:23 200 1126
|
||||
2026-10-07 17:47:39 200 977
|
||||
2026-10-07 17:47:55 200 1414
|
||||
2026-10-07 17:48:12 200 1145
|
||||
2026-10-07 17:48:28 200 1311
|
||||
2026-10-07 17:48:44 200 1292
|
||||
2026-10-07 17:49:01 200 1383
|
||||
2026-10-07 17:49:17 200 1013
|
||||
2026-10-07 17:49:33 200 1378
|
||||
2026-10-07 17:49:49 200 1373
|
||||
2026-10-07 17:50:05 200 1013
|
||||
2026-10-07 17:50:21 200 1168
|
||||
2026-10-07 17:50:38 200 1238
|
||||
2026-10-07 17:50:54 200 1140
|
||||
2026-10-07 17:51:10 200 1281
|
||||
2026-10-07 17:51:26 200 1131
|
||||
2026-10-07 17:52:04 200 22346
|
||||
2026-10-07 17:52:41 200 22329
|
||||
2026-10-07 17:53:04 200 7874
|
||||
2026-10-07 17:53:20 200 1221
|
||||
2026-10-07 17:53:36 200 863
|
||||
2026-10-07 17:53:53 200 1721
|
||||
2026-10-07 17:54:08 200 844
|
||||
2026-10-07 17:54:24 200 775
|
||||
2026-10-07 17:54:40 200 830
|
||||
|
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
# 1) 给两个隧道实例显式钉死 --metrics 端口
|
||||
# 2) 验证钉死之后端口↔实例的对应关系不再翻转
|
||||
#
|
||||
# 问题复盘:
|
||||
# 两个服务的 ExecStart 都没写 --metrics,于是都用 cloudflared 的默认值 20241。
|
||||
# 正常情况下先起的那个拿到 20241、后起的自动退到 20242,
|
||||
# 看起来"主=20241 副=20242"是稳的 —— 但这是**巧合**,靠的是启动顺序。
|
||||
#
|
||||
# 实测暴露:跑 A/B 时我把主实例 stop 掉,20241 空出来,
|
||||
# 接着重启副实例,副实例就绑上了 20241。
|
||||
# 于是"20241 是谁"变成了不确定 ——
|
||||
# watchdog 和所有按端口读指标的脚本全会读错实例。
|
||||
#
|
||||
# 修法:显式指定,不让它自己选。
|
||||
set -u
|
||||
|
||||
MAIN=/etc/systemd/system/lanremote-tunnel.service
|
||||
H2=/etc/systemd/system/lanremote-tunnel-h2.service
|
||||
STAMP=$(date +%s)
|
||||
cp -a "$MAIN" "$MAIN.bak.metrics.$STAMP"
|
||||
cp -a "$H2" "$H2.bak.metrics.$STAMP"
|
||||
|
||||
pin_metrics() {
|
||||
local f="$1" port="$2" name="$3"
|
||||
python3 - "$f" "$port" <<'PYEOF'
|
||||
import re, sys
|
||||
from pathlib import Path
|
||||
path, port = Path(sys.argv[1]), sys.argv[2]
|
||||
text = path.read_text(encoding="utf-8")
|
||||
# 已有 --metrics 就先去掉
|
||||
text = re.sub(r"\s*--metrics\s+\S+", "", text)
|
||||
# 在 ExecStart 的 cloudflared tunnel 后面插进去
|
||||
text = re.sub(r"(ExecStart=/usr/local/bin/cloudflared tunnel)",
|
||||
r"\1 \\\n --metrics 127.0.0.1:" + port,
|
||||
text, count=1)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
PYEOF
|
||||
echo " $name -> 127.0.0.1:$port"
|
||||
}
|
||||
|
||||
echo "════════ 1. 钉死端口 ════════"
|
||||
pin_metrics "$MAIN" 20241 "lanremote-tunnel(quic)"
|
||||
pin_metrics "$H2" 20242 "lanremote-tunnel-h2(http2)"
|
||||
|
||||
echo
|
||||
echo " 改后的 ExecStart:"
|
||||
for f in "$MAIN" "$H2"; do
|
||||
echo " ── $(basename "$f") ──"
|
||||
grep -A9 'ExecStart=' "$f" | sed 's/^/ /'
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 2. 重启并验证 ════════"
|
||||
systemctl daemon-reload
|
||||
systemctl restart lanremote-tunnel lanremote-tunnel-h2
|
||||
sleep 20
|
||||
|
||||
check_map() {
|
||||
for p in $(pgrep cloudflared); do
|
||||
cg=$(cat /proc/$p/cgroup 2>/dev/null | head -1)
|
||||
svc=$(basename "$cg" 2>/dev/null | sed 's/\.service$//')
|
||||
port=$(ss -tlnp 2>/dev/null | grep "pid=$p" | grep -oE '127\.0\.0\.1:2024[0-9]' | head -1)
|
||||
proto=$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null | grep -oE '\-\-protocol [a-z0-9]+')
|
||||
echo " $svc -> ${port:-(没绑)} $proto"
|
||||
done
|
||||
}
|
||||
|
||||
echo " 重启后的对应:"
|
||||
check_map
|
||||
|
||||
echo
|
||||
echo "════════ 3. 关键验证:停掉主实例,副实例还会不会抢 20241 ════════"
|
||||
systemctl stop lanremote-tunnel
|
||||
sleep 8
|
||||
systemctl restart lanremote-tunnel-h2
|
||||
sleep 12
|
||||
echo " 主实例已停、副实例刚重启后的对应:"
|
||||
check_map
|
||||
echo " (期望:h2 仍然在 20242,不去抢 20241)"
|
||||
|
||||
echo
|
||||
echo " 恢复主实例:"
|
||||
systemctl start lanremote-tunnel
|
||||
sleep 15
|
||||
check_map
|
||||
|
||||
echo
|
||||
echo "════════ 4. 端口各报什么 ════════"
|
||||
for port in 20241 20242; do
|
||||
body=$(curl -s --max-time 4 "http://127.0.0.1:$port/metrics" 2>/dev/null)
|
||||
if [ -z "$body" ]; then
|
||||
echo " 端口 $port: 无响应"
|
||||
continue
|
||||
fi
|
||||
echo " 端口 $port: ha_connections=$(echo "$body" | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') quic指标=$(echo "$body" | grep -ci quic)"
|
||||
done
|
||||
|
||||
echo
|
||||
echo " 备份: $MAIN.bak.metrics.$STAMP $H2.bak.metrics.$STAMP"
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/bin/bash
|
||||
# 独立的 30 次长轮询测试(task-3 第 3 项)。
|
||||
#
|
||||
# 和长测里的那个区别:**注册带重试**。
|
||||
# 长测里第一次注册正好撞上我重启隧道,失败一次就退出了 ——
|
||||
# 这里重试 6 次,避开这种瞬时抖动。
|
||||
set -u
|
||||
|
||||
BASE=https://relay.xxmxxs.top
|
||||
UA='LanRemote/1.8.2'
|
||||
N=${1:-30}
|
||||
OUT=/root/rp_poll30.tsv
|
||||
SUM=/root/rp_poll30_summary.txt
|
||||
|
||||
cleanup() {
|
||||
local key
|
||||
key=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||
[ -n "${DEV:-}" ] && curl -s --max-time 20 -X POST \
|
||||
"http://127.0.0.1:8080/v1/admin/delete" -H "X-Admin: $key" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"deviceId\":\"$DEV\"}" > /dev/null 2>&1
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "════════ 注册探针(最多试 6 次)════════"
|
||||
STAMP=$(date +%s)
|
||||
DEV="p30-${STAMP}"
|
||||
TOKEN=""
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
CODE=$(printf "%06d" $(( (STAMP + attempt * 7) % 1000000 )))
|
||||
reg=$(curl -s --max-time 30 -X POST "$BASE/v1/agent/register" \
|
||||
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
|
||||
-d "{\"deviceId\":\"$DEV\",\"code\":\"$CODE\",\"name\":\"p30\",\"version\":\"1.8.2\"}")
|
||||
TOKEN=$(echo "$reg" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
|
||||
if [ -n "$TOKEN" ]; then
|
||||
echo " 第 $attempt 次成功(code=$CODE,token ${#TOKEN} 字符)"
|
||||
break
|
||||
fi
|
||||
echo " 第 $attempt 次失败: $(echo "$reg" | head -c 120)"
|
||||
sleep 4
|
||||
done
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo " [失败] 6 次都没注册上,放弃"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "════════ 开始 $N 次长轮询(每次挂 60 秒,约 $((N)) 分钟)════════"
|
||||
rm -f "$OUT"
|
||||
ok=0; fail=0; fails=""
|
||||
T_START=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
for i in $(seq 1 "$N"); do
|
||||
t0=$(date '+%H:%M:%S')
|
||||
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 130 \
|
||||
-X POST "$BASE/v1/agent/poll" -H "X-Token: $TOKEN" -H "User-Agent: $UA")
|
||||
c=$(echo "$out" | awk '{print $1}')
|
||||
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||
if [ "$c" = "200" ]; then
|
||||
ok=$((ok+1))
|
||||
printf " %2d: 200 %6dms\n" "$i" "$ms"
|
||||
else
|
||||
fail=$((fail+1)); fails="$fails ${i}:${c}"
|
||||
printf " %2d: %-4s **失败**\n" "$i" "$c"
|
||||
fi
|
||||
printf '%d\t%s\t%d\t%s\n' "$i" "$c" "$ms" "$t0" >> "$OUT"
|
||||
done
|
||||
|
||||
T_END=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
{
|
||||
echo "================================================================"
|
||||
echo " 长轮询测试 · $N 次"
|
||||
echo " 开始 $T_START 结束 $T_END"
|
||||
echo "================================================================"
|
||||
echo " 成功 $ok/$N = $((ok * 100 / N))%"
|
||||
echo " 失败:$fails"
|
||||
echo
|
||||
echo " 耗时分布(成功的):"
|
||||
awk -F'\t' '$2=="200" {print $3}' "$OUT" | sort -n | awk '
|
||||
{a[NR]=$1}
|
||||
END {
|
||||
if (NR == 0) { print " 无数据"; exit }
|
||||
printf " 最小 %d P50 %d P90 %d P99 %d 最大 %d ms(理想 ≈ 61000)\n",
|
||||
a[1], a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]
|
||||
}'
|
||||
echo
|
||||
echo " 非 200 的样本:"
|
||||
awk -F'\t' '$2!="200" {print " #" $1 " 码=" $2 " 耗时=" $3 "ms 开始 " $4}' "$OUT"
|
||||
} | tee "$SUM"
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# 把"126 秒那种异常长轮询"和隧道事件对齐,看是不是同一条连接/同一次重连导致的。
|
||||
set -u
|
||||
|
||||
echo "════════ 1. 轮询样本(标出 >90000ms 的)════════"
|
||||
awk -F'\t' '{m=""; if ($3>90000) m=" <<< 异常(约 2 倍 hold)"; print " #" $1 " " $3 "ms 开始 " $4 m}' \
|
||||
/root/rp_poll30.tsv 2>/dev/null
|
||||
|
||||
echo
|
||||
echo "════════ 2. 每个异常样本的时间窗内,隧道发生了什么 ════════"
|
||||
for t in $(awk -F'\t' '$3>90000 {print $4}' /root/rp_poll30.tsv 2>/dev/null); do
|
||||
echo " ── 样本开始于 $t ──"
|
||||
# 时间窗 = 开始前 10 秒 到 开始后 140 秒
|
||||
hhmm=$(echo "$t" | cut -d: -f1-2)
|
||||
for f in /root/lanremote-relay/tunnel.log /root/lanremote-relay/tunnel-h2.log; do
|
||||
name=$(basename "$f")
|
||||
hits=$(grep -E "T0[0-9]:${hhmm#*:}" "$f" 2>/dev/null | wc -l)
|
||||
echo " $name 这个小时的相关事件: $hits 条"
|
||||
grep -E "T0[0-9]:${hhmm#*:}" "$f" 2>/dev/null \
|
||||
| grep -E 'Registered|Lost|Failed to dial|Unregistered' | head -8 | sed 's/^/ /'
|
||||
done
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 3. 全部隧道事件按分钟看(找异常样本附近的)════════"
|
||||
echo " ── QUIC 最近 60 条连接事件 ──"
|
||||
grep -E 'Registered|Lost|Unregistered' /root/lanremote-relay/tunnel.log 2>/dev/null \
|
||||
| tail -30 | sed -E 's/^(....-..-..T..:..):.*(Registered|Lost|Unregistered)[^ ]*/\1 \2/' | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. watchdog 在这个时间段的记录 ════════"
|
||||
tail -20 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 5. 中转日志里这两个时间点附近有什么 ════════"
|
||||
for t in 17:30 17:40; do
|
||||
echo " ── $t 前后 ──"
|
||||
grep -E "\[10-07 $t" /root/lanremote-relay/relay.log 2>/dev/null | head -10 | sed 's/^/ /'
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 6. 中转的线程/连接数(看有没有堆积)════════"
|
||||
echo " 中转进程线程数: $(ls /proc/$(systemctl show lanremote-relay -p MainPID --value)/task 2>/dev/null | wc -l)"
|
||||
echo " 中转内存: $(systemctl show lanremote-relay -p MemoryCurrent --value | awk '{printf "%.1f MB", $1/1024/1024}')"
|
||||
echo " 中转当前请求统计:"
|
||||
curl -s --max-time 10 http://127.0.0.1:8080/v1/health \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(' ', {k:d[k] for k in ('totalRequests','uptimeSeconds','devices','sessions') if k in d})" 2>/dev/null
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# 看两个后台长测的进度
|
||||
set -u
|
||||
|
||||
echo "════════ 1. 在跑的测试进程 ════════"
|
||||
pgrep -af 'rp_long2h|rpp30' | sed 's/^/ /' || echo " (没有)"
|
||||
|
||||
echo
|
||||
echo "════════ 2. 2 小时健康测试进度 ════════"
|
||||
n=$(wc -l < /root/long2h_health.tsv 2>/dev/null || echo 0)
|
||||
echo " 样本数: $n (每 15 秒一个 -> 已跑约 $((n * 15 / 60)) 分钟 / 120 分钟)"
|
||||
echo " 状态码分布:"
|
||||
awk -F'\t' 'NR>1{print $2}' /root/long2h_health.tsv 2>/dev/null | sort | uniq -c | sed 's/^/ /'
|
||||
echo " 最近 3 条:"
|
||||
tail -3 /root/long2h_health.tsv 2>/dev/null | sed 's/^/ /'
|
||||
echo " 非 200 的时刻:"
|
||||
awk -F'\t' 'NR>1 && $2!="200" {print " " $1 " 码=" $2 " " $3 "ms"}' /root/long2h_health.tsv 2>/dev/null | tail -15
|
||||
|
||||
echo
|
||||
echo "════════ 3. 连接数采样 ════════"
|
||||
tail -5 /root/long2h_conns.tsv 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. 30 次长轮询测试进度 ════════"
|
||||
if [ -f /root/rp_poll30.tsv ]; then
|
||||
echo " 已完成: $(wc -l < /root/rp_poll30.tsv) / 30"
|
||||
echo " 明细:"
|
||||
cat /root/rp_poll30.tsv | sed 's/^/ /'
|
||||
else
|
||||
echo " (文件还没生成)"
|
||||
fi
|
||||
echo " 输出日志尾部:"
|
||||
tail -8 /root/rp_poll30.out 2>/dev/null | sed 's/^/ /' || echo " (无)"
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# QUIC 握手细节:跑一个**临时的** debug 实例(60 秒),不动生产实例。
|
||||
#
|
||||
# 目的:拿到 "timeout: no recent network activity" 背后的握手细节 ——
|
||||
# 是 Initial 包发出去没回应?还是 TLS 阶段断的?还是 MTU 卡在某个大小?
|
||||
#
|
||||
# 注意:这个临时实例会往同一个具名隧道多注册 1 条连接,
|
||||
# 60 秒后自己退出。对生产只有极轻微影响。
|
||||
set -u
|
||||
|
||||
LOG=/tmp/quic_debug.log
|
||||
rm -f "$LOG"
|
||||
|
||||
echo "════════ 1. 跑 60 秒 debug 实例 ════════"
|
||||
echo " 参数: --protocol quic --ha-connections 1 --loglevel debug"
|
||||
echo " metrics 用 20299(避开生产的 20241/20242)"
|
||||
echo
|
||||
|
||||
timeout 60 /usr/local/bin/cloudflared tunnel \
|
||||
--protocol quic \
|
||||
--edge-ip-version 4 \
|
||||
--ha-connections 1 \
|
||||
--no-autoupdate \
|
||||
--loglevel debug \
|
||||
--metrics 127.0.0.1:20299 \
|
||||
--config /root/.cloudflared/config.yml \
|
||||
run lanremote > "$LOG" 2>&1 || true
|
||||
|
||||
echo " 日志行数: $(wc -l < "$LOG")"
|
||||
|
||||
echo
|
||||
echo "════════ 2. 握手相关的事件 ════════"
|
||||
grep -iE 'initial|handshake|dial|registration|curve|version|mtu|congestion|tls' "$LOG" \
|
||||
| head -40 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 3. QUIC 特有的细节 ════════"
|
||||
grep -iE 'quic|udp' "$LOG" | head -25 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. 失败/警告 ════════"
|
||||
grep -iE 'ERR|WRN|failed|timeout|refused|unreachable' "$LOG" | head -25 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 5. 成功注册了吗 ════════"
|
||||
grep -c 'Registered tunnel connection' "$LOG" | sed 's/^/ 成功注册次数: /'
|
||||
grep 'Registered tunnel connection' "$LOG" | tail -3 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 6. 用了多久完成握手 ════════"
|
||||
# 找第一条和注册成功的时间戳
|
||||
first=$(head -1 "$LOG" | grep -oE '^[0-9T:.Z-]+' || echo "?")
|
||||
last=$(grep 'Registered tunnel connection' "$LOG" | head -1 | grep -oE '^[0-9T:.Z-]+' || echo "?")
|
||||
echo " 首行时间: $first"
|
||||
echo " 注册成功: $last"
|
||||
|
||||
echo
|
||||
echo "════════ 7. debug 级别的包级细节(前 30 行原始日志)════════"
|
||||
head -30 "$LOG" | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 8. 确认生产实例没受影响 ════════"
|
||||
for port in 20241 20242; do
|
||||
n=$(curl -s --max-time 4 "http://127.0.0.1:$port/metrics" 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
echo " 端口 $port: ${n:-无响应}"
|
||||
done
|
||||
echo " 20299(临时实例,应该已退出): $(ss -tlnp 2>/dev/null | grep -c 20299)"
|
||||
echo " 长测进度: 健康 $(wc -l < /root/long2h_health.tsv) 条, 轮询 $(wc -l < /root/rp_poll30.tsv) / 30"
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# 从两个隧道日志里统计 QUIC vs HTTP/2 的连接行为,按 edge IP 聚合。
|
||||
#
|
||||
# 目的:判断 QUIC dial 失败到底是"某些 IP 不可达"还是"UDP 整体被限制"。
|
||||
# 如果失败集中在少数 IP -> 是那些 IP 的问题,可以避开
|
||||
# 如果所有 IP 都失败 -> 是 UDP 整体被运营商限制,只能靠 HTTP/2 兜底
|
||||
set -u
|
||||
|
||||
MAIN=/root/lanremote-relay/tunnel.log
|
||||
H2=/root/lanremote-relay/tunnel-h2.log
|
||||
|
||||
echo "════════ 1. QUIC 实例(tunnel.log)════════"
|
||||
echo " 日志时间跨度: $(head -1 "$MAIN" 2>/dev/null | cut -c1-19) ~ $(tail -1 "$MAIN" 2>/dev/null | cut -c1-19)"
|
||||
echo " 总行数: $(wc -l < "$MAIN")"
|
||||
echo
|
||||
echo " ── 各类事件计数 ──"
|
||||
for kw in 'Registered tunnel connection' 'Failed to dial' 'Lost connection' \
|
||||
'Unregistered' 'no recent network activity' 'Retrying connection' \
|
||||
'Serve tunnel error' 'context deadline' 'i/o timeout'; do
|
||||
n=$(grep -c "$kw" "$MAIN" 2>/dev/null || echo 0)
|
||||
printf " %-34s %s\n" "$kw" "$n"
|
||||
done
|
||||
|
||||
echo
|
||||
echo " ── Failed to dial 按 IP 聚合(谁的失败最多)──"
|
||||
grep 'Failed to dial' "$MAIN" 2>/dev/null \
|
||||
| grep -oE 'ip=[0-9.]+' | sort | uniq -c | sort -rn | head -12 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo " ── 成功注册按 IP 聚合 ──"
|
||||
grep 'Registered tunnel connection' "$MAIN" 2>/dev/null \
|
||||
| grep -oE 'ip=[0-9.]+' | sort | uniq -c | sort -rn | head -12 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo " ── 成功注册按落点聚合 ──"
|
||||
grep 'Registered tunnel connection' "$MAIN" 2>/dev/null \
|
||||
| grep -oE 'location=[a-z0-9]+' | sort | uniq -c | sort -rn | head -12 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 2. HTTP/2 实例(tunnel-h2.log)════════"
|
||||
echo " 日志时间跨度: $(head -1 "$H2" 2>/dev/null | cut -c1-19) ~ $(tail -1 "$H2" 2>/dev/null | cut -c1-19)"
|
||||
echo " 总行数: $(wc -l < "$H2")"
|
||||
echo
|
||||
for kw in 'Registered tunnel connection' 'Failed to dial' 'Lost connection' \
|
||||
'Unregistered' 'Retrying connection' 'i/o timeout' 'context deadline'; do
|
||||
n=$(grep -c "$kw" "$H2" 2>/dev/null || echo 0)
|
||||
printf " %-34s %s\n" "$kw" "$n"
|
||||
done
|
||||
echo
|
||||
echo " ── 成功注册按落点聚合 ──"
|
||||
grep 'Registered tunnel connection' "$H2" 2>/dev/null \
|
||||
| grep -oE 'location=[a-z0-9]+' | sort | uniq -c | sort -rn | head -10 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 3. 两者的「注册/失败」比率对比 ════════"
|
||||
for pair in "QUIC:$MAIN" "HTTP2:$H2"; do
|
||||
name="${pair%%:*}"; f="${pair##*:}"
|
||||
reg=$(grep -c 'Registered tunnel connection' "$f" 2>/dev/null || echo 0)
|
||||
fail=$(grep -c 'Failed to dial' "$f" 2>/dev/null || echo 0)
|
||||
lost=$(grep -c 'Lost connection' "$f" 2>/dev/null || echo 0)
|
||||
tot=$((reg + fail))
|
||||
pct=0
|
||||
[ "$tot" -gt 0 ] && pct=$((reg * 100 / tot))
|
||||
printf " %-6s 注册 %4d 失败 %4d 掉线 %3d 成功率 %d%%\n" \
|
||||
"$name" "$reg" "$fail" "$lost" "$pct"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 4. 失败的时间分布(按 10 分钟分桶)════════"
|
||||
echo " QUIC 的 Failed to dial:"
|
||||
grep 'Failed to dial' "$MAIN" 2>/dev/null \
|
||||
| sed -E 's/^([0-9-]+T[0-9]{2}:[0-9]).*/\1/' \
|
||||
| sed -E 's/(T[0-9]{2}:[0-9])[0-9]/\10/' \
|
||||
| sort | uniq -c | tail -20 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 5. UDP 可达性实测(对 Cloudflare edge 的 7844/UDP)════════"
|
||||
python3 - <<'PYEOF'
|
||||
import socket, time, struct, os
|
||||
|
||||
# QUIC 的 Initial 包要正确构造才会有回应;这里用一个最简的
|
||||
# "版本协商探测":发一个短 header,看有没有任何 UDP 回应。
|
||||
# 关键判断不是"内容对不对",而是**有没有任何包回来** ——
|
||||
# 如果连 ICMP port-unreachable 都没有,说明 UDP 被完全丢掉了。
|
||||
IPS = ["198.41.192.227", "198.41.200.23", "198.41.200.193", "198.41.200.43",
|
||||
"198.41.200.63", "198.41.192.57", "198.41.192.47", "198.41.200.33",
|
||||
"198.41.192.107", "198.41.192.67", "198.41.192.167", "198.41.200.113"]
|
||||
|
||||
def tcp_probe(ip, port=7844, timeout=3):
|
||||
s = socket.socket(); s.settimeout(timeout)
|
||||
t = time.perf_counter()
|
||||
try:
|
||||
s.connect((ip, port))
|
||||
return (time.perf_counter() - t) * 1000, True
|
||||
except OSError:
|
||||
return (time.perf_counter() - t) * 1000, False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
def udp_probe(ip, port=7844, timeout=3):
|
||||
"""发一个 QUIC 长头 Initial 形状的包,等任何回应。"""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(timeout)
|
||||
t = time.perf_counter()
|
||||
try:
|
||||
# 最小可解析的 QUIC long header:flags=0xc0, version=1,
|
||||
# dcid len=8, dcid, scid len=0, token len=0, length=8, payload
|
||||
pkt = b"\xc0\x00\x00\x00\x01" + b"\x08" + os.urandom(8) + b"\x00" \
|
||||
+ b"\x00" + struct.pack(">H", 8) + os.urandom(8)
|
||||
s.sendto(pkt, (ip, port))
|
||||
data, _addr = s.recvfrom(2048)
|
||||
return (time.perf_counter() - t) * 1000, True
|
||||
except socket.timeout:
|
||||
return (time.perf_counter() - t) * 1000, False
|
||||
except OSError as e:
|
||||
# ICMP port unreachable 会以 ConnectionRefusedError 冒出来
|
||||
return (time.perf_counter() - t) * 1000, ("refused" in str(e).lower())
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
tcp_ok = 0; udp_ok = 0
|
||||
print(" %-16s %-18s %s" % ("IP", "TCP:7844", "UDP:7844"))
|
||||
for ip in IPS:
|
||||
tms, tok = tcp_probe(ip)
|
||||
ums, uok = udp_probe(ip)
|
||||
tcp_ok += 1 if tok else 0
|
||||
udp_ok += 1 if uok else 0
|
||||
print(" %-16s %-18s %s" % (
|
||||
ip,
|
||||
"通 %.0fms" % tms if tok else "不通",
|
||||
"通 %.0fms" % ums if uok else "无回应"))
|
||||
print()
|
||||
print(" TCP 可达 %d/%d UDP 有回应 %d/%d" % (tcp_ok, len(IPS), udp_ok, len(IPS)))
|
||||
print(" 结论: UDP 有回应数远少于 TCP -> UDP 被链路限制,QUIC 先天不利")
|
||||
PYEOF
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
# 配比 A/B:既然 UDP 被限制(QUIC 拨号只有 72% 成功,
|
||||
# 而 HTTP/2/TCP 是 100%),就该把连接数往 HTTP/2 倾斜。
|
||||
#
|
||||
# 对照四组(都连同一个具名隧道,Cloudflare 会把请求分摊到所有连接):
|
||||
# A. quic 5 + http2 5 现状
|
||||
# B. quic 3 + http2 7 往 TCP 倾斜
|
||||
# C. quic 0 + http2 10 纯 HTTP/2(把 quic 实例停掉)
|
||||
# D. quic 8 + http2 2 反向验证(应该更差,用来确认假设)
|
||||
#
|
||||
# 每组测:总连接数、拨号失败数、60 次公网可用率、延迟分布
|
||||
set -u
|
||||
|
||||
BASE=https://relay.xxmxxs.top
|
||||
UA='LanRemote/1.8.2'
|
||||
MAIN=/etc/systemd/system/lanremote-tunnel.service
|
||||
H2=/etc/systemd/system/lanremote-tunnel-h2.service
|
||||
BAK_MAIN=/root/tunnel.main.bak.$(date +%s)
|
||||
BAK_H2=/root/tunnel.h2.bak.$(date +%s)
|
||||
cp -a "$MAIN" "$BAK_MAIN"
|
||||
cp -a "$H2" "$BAK_H2"
|
||||
|
||||
N=60
|
||||
|
||||
echo "════════ 0. 先看有没有 IPv6(有的话可能绕开 UDP 限制)════════"
|
||||
ip -6 addr show scope global 2>/dev/null | grep -c 'inet6' | sed 's/^/ wlan0 全局 IPv6 地址数: /'
|
||||
echo " 到 Cloudflare 的 IPv6 连通性:"
|
||||
if ping6 -c 2 -W 3 2606:4700::1111 > /dev/null 2>&1; then
|
||||
echo " IPv6 通(但 QUIC 仍走 UDP,未必有帮助)"
|
||||
else
|
||||
echo " IPv6 不通 -> --edge-ip-version 6 没法用"
|
||||
fi
|
||||
|
||||
set_conns() {
|
||||
local q="$1" h="$2"
|
||||
if [ "$q" = "0" ]; then
|
||||
systemctl stop lanremote-tunnel 2>/dev/null || true
|
||||
# 停掉服务避免它自己起来
|
||||
systemctl stop lanremote-tunnel.socket 2>/dev/null || true
|
||||
else
|
||||
cp -a "$BAK_MAIN" "$MAIN"
|
||||
sed -i "s/--ha-connections [0-9]*/--ha-connections $q/" "$MAIN"
|
||||
systemctl daemon-reload
|
||||
systemctl restart lanremote-tunnel
|
||||
fi
|
||||
cp -a "$BAK_H2" "$H2"
|
||||
sed -i "s/--ha-connections [0-9]*/--ha-connections $h/" "$H2"
|
||||
systemctl daemon-reload
|
||||
systemctl restart lanremote-tunnel-h2
|
||||
}
|
||||
|
||||
read_conns() {
|
||||
local port="$1"
|
||||
curl -s --max-time 4 "http://127.0.0.1:$port/metrics" 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}'
|
||||
}
|
||||
|
||||
wait_total() {
|
||||
local want="$1" limit="$2" t0 t1 a b
|
||||
t0=$(date +%s)
|
||||
while true; do
|
||||
a=$(read_conns 20241); b=$(read_conns 20242)
|
||||
a=${a:-0}; b=${b:-0}
|
||||
t1=$(date +%s)
|
||||
[ $((a + b)) -ge "$want" ] || [ $((t1 - t0)) -ge "$limit" ] && break
|
||||
sleep 3
|
||||
done
|
||||
echo "$a $b $((t1 - t0))"
|
||||
}
|
||||
|
||||
measure() {
|
||||
local label="$1"
|
||||
local ok=0 times=()
|
||||
for i in $(seq 1 "$N"); do
|
||||
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
|
||||
-H "User-Agent: $UA" "$BASE/v1/health")
|
||||
c=$(echo "$out" | awk '{print $1}'); ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||
[ "$c" = "200" ] && { ok=$((ok+1)); times+=("$ms"); }
|
||||
done
|
||||
local pct=$((ok * 100 / N))
|
||||
local stats="无数据"
|
||||
[ "${#times[@]}" -gt 0 ] && stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
|
||||
printf "中位 %d P90 %d P99 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}')
|
||||
printf " 可用率 %d%% (%d/%d) %s ms\n" "$pct" "$ok" "$N" "$stats"
|
||||
echo "${label}|${pct}|${stats}" >> /tmp/ratio.txt
|
||||
}
|
||||
|
||||
run_group() {
|
||||
local label="$1" q="$2" h="$3"
|
||||
echo "════════ $label(quic=$q http2=$h)════════"
|
||||
local mk; mk=$(date +%s)
|
||||
set_conns "$q" "$h"
|
||||
local res; res=$(wait_total $((q + h)) 150)
|
||||
local a b s
|
||||
a=$(echo "$res" | awk '{print $1}'); b=$(echo "$res" | awk '{print $2}'); s=$(echo "$res" | awk '{print $3}')
|
||||
echo " 连接: quic=$a http2=$b 合计=$((a + b)) 用时 ${s}s"
|
||||
|
||||
# 拨号失败(两个日志合计,本轮内)
|
||||
local df=0
|
||||
for f in /root/lanremote-relay/tunnel.log /root/lanremote-relay/tunnel-h2.log; do
|
||||
[ -f "$f" ] && df=$((df + $(grep -c 'Failed to dial' "$f" 2>/dev/null || echo 0)))
|
||||
done
|
||||
echo " 日志里累计 Failed to dial: $df"
|
||||
|
||||
sleep 10
|
||||
measure "$label"
|
||||
echo
|
||||
}
|
||||
|
||||
rm -f /tmp/ratio.txt
|
||||
run_group "A quic5+h2 5" 5 5
|
||||
run_group "B quic3+h2 7" 3 7
|
||||
run_group "C quic0+h2 10" 0 10
|
||||
run_group "D quic8+h2 2" 8 2
|
||||
|
||||
echo "════════ 汇总 ════════"
|
||||
printf " %-16s %-10s %s\n" "配置" "可用率" "延迟"
|
||||
while IFS='|' read -r label pct stats; do
|
||||
printf " %-16s %-10s %s\n" "$label" "${pct}%" "$stats"
|
||||
done < /tmp/ratio.txt
|
||||
|
||||
echo
|
||||
BEST=$(sort -t'|' -k2 -rn /tmp/ratio.txt | head -1)
|
||||
BL=$(echo "$BEST" | cut -d'|' -f1)
|
||||
echo " → 可用率最高: $BL($(echo "$BEST" | cut -d'|' -f2)%)"
|
||||
|
||||
# 恢复成最优配比
|
||||
case "$BL" in
|
||||
"A quic5+h2 5") set_conns 5 5 ;;
|
||||
"B quic3+h2 7") set_conns 3 7 ;;
|
||||
"C quic0+h2 10") set_conns 0 10 ;;
|
||||
*) set_conns 8 2 ;;
|
||||
esac
|
||||
sleep 10
|
||||
echo " 当前: quic=$(read_conns 20241) http2=$(read_conns 20242)"
|
||||
echo " 备份: $BAK_MAIN $BAK_H2"
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/bin/bash
|
||||
# 配比 A/B v2:QUIC 拨号成功率只有 72%(UDP 被限制),HTTP/2 是 100%。
|
||||
# 所以应该把连接数往 HTTP/2 倾斜。这里做受控对照确认。
|
||||
#
|
||||
# v1 的两个 bug:
|
||||
# 1. df=$((df + $(grep -c ... || echo 0)))
|
||||
# —— grep -c 没匹配时会输出 "0" 且返回非 0,
|
||||
# 配合 || echo 0 就变成 "0\n0",算术表达式直接语法错误。
|
||||
# 改成先取值再判断。
|
||||
# 2. 端口按 20241/20242 写死 —— 但当时两个服务都没显式 --metrics,
|
||||
# 主实例一停,副实例就抢到 20241,读数全反。
|
||||
# 现在已经显式钉死端口,并且这里改成**按服务名查端口**。
|
||||
set -u
|
||||
|
||||
BASE=https://relay.xxmxxs.top
|
||||
UA='LanRemote/1.8.2'
|
||||
MAIN=lanremote-tunnel
|
||||
H2=lanremote-tunnel-h2
|
||||
MAINF=/etc/systemd/system/$MAIN.service
|
||||
H2F=/etc/systemd/system/$H2.service
|
||||
BAK=/root/ratio.bak.$(date +%s)
|
||||
cp -a "$MAINF" "$BAK.main"
|
||||
cp -a "$H2F" "$BAK.h2"
|
||||
|
||||
N=40
|
||||
|
||||
# 从服务配置读它绑的 metrics 端口
|
||||
port_of() {
|
||||
systemctl cat "$1" 2>/dev/null \
|
||||
| grep -oE '\-\-metrics[[:space:]]+[0-9.]+:[0-9]+' | head -1 \
|
||||
| grep -oE '[0-9]+$'
|
||||
}
|
||||
MAIN_PORT=$(port_of "$MAIN")
|
||||
H2_PORT=$(port_of "$H2")
|
||||
echo " 端口: $MAIN=$MAIN_PORT $H2=$H2_PORT"
|
||||
|
||||
# 计数器:grep -c 的结果安全转成整数
|
||||
gcount() {
|
||||
local n
|
||||
n=$(grep -c "$2" "$1" 2>/dev/null)
|
||||
case "$n" in ''|*[!0-9]*) echo 0 ;; *) echo "$n" ;; esac
|
||||
}
|
||||
|
||||
read_conns() {
|
||||
local port="$1" v
|
||||
v=$(curl -s --max-time 4 "http://127.0.0.1:$port/metrics" 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
case "$v" in ''|*[!0-9]*) echo "X" ;; *) echo "$v" ;; esac
|
||||
}
|
||||
|
||||
set_conns() {
|
||||
local q="$1" h="$2"
|
||||
cp -a "$BAK.main" "$MAINF"
|
||||
cp -a "$BAK.h2" "$H2F"
|
||||
if [ "$q" = "0" ]; then
|
||||
systemctl stop "$MAIN" 2>/dev/null || true
|
||||
else
|
||||
sed -i "s/--ha-connections [0-9]*/--ha-connections $q/" "$MAINF"
|
||||
systemctl daemon-reload
|
||||
systemctl restart "$MAIN"
|
||||
fi
|
||||
if [ "$h" = "0" ]; then
|
||||
systemctl stop "$H2" 2>/dev/null || true
|
||||
else
|
||||
sed -i "s/--ha-connections [0-9]*/--ha-connections $h/" "$H2F"
|
||||
systemctl daemon-reload
|
||||
systemctl restart "$H2"
|
||||
fi
|
||||
# 确保停掉的那个不会被拉起来
|
||||
systemctl daemon-reload
|
||||
}
|
||||
|
||||
wait_total() {
|
||||
local want="$1" limit="$2" t0 t1 a b
|
||||
t0=$(date +%s)
|
||||
while true; do
|
||||
a=$(read_conns "$MAIN_PORT"); [ "$a" = "X" ] && a=0
|
||||
b=$(read_conns "$H2_PORT"); [ "$b" = "X" ] && b=0
|
||||
t1=$(date +%s)
|
||||
if [ $((a + b)) -ge "$want" ] || [ $((t1 - t0)) -ge "$limit" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "$a $b $((t1 - t0))"
|
||||
}
|
||||
|
||||
measure() {
|
||||
local label="$1"
|
||||
local ok=0 times=()
|
||||
for i in $(seq 1 "$N"); do
|
||||
local out c ms
|
||||
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
|
||||
-H "User-Agent: $UA" "$BASE/v1/health")
|
||||
c=$(echo "$out" | awk '{print $1}')
|
||||
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||
if [ "$c" = "200" ]; then ok=$((ok + 1)); times+=("$ms"); fi
|
||||
done
|
||||
local pct=$((ok * 100 / N))
|
||||
local stats="无数据"
|
||||
if [ "${#times[@]}" -gt 0 ]; then
|
||||
stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
|
||||
printf "中位 %d P90 %d P99 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}')
|
||||
fi
|
||||
printf " 可用率 %d%% (%d/%d) %s ms\n" "$pct" "$ok" "$N" "$stats"
|
||||
printf '%s|%s|%s\n' "$label" "$pct" "$stats" >> /tmp/ratio2.txt
|
||||
}
|
||||
|
||||
run_group() {
|
||||
local label="$1" q="$2" h="$3"
|
||||
echo "════════ $label(quic=$q http2=$h)════════"
|
||||
set_conns "$q" "$h"
|
||||
local res a b s
|
||||
res=$(wait_total $((q + h)) 150)
|
||||
a=$(echo "$res" | awk '{print $1}')
|
||||
b=$(echo "$res" | awk '{print $2}')
|
||||
s=$(echo "$res" | awk '{print $3}')
|
||||
echo " 实测连接: quic=$a http2=$b 合计=$((a + b)) (期望 $q/$h) 用时 ${s}s"
|
||||
|
||||
local df=0
|
||||
for f in /root/lanremote-relay/tunnel.log /root/lanremote-relay/tunnel-h2.log; do
|
||||
[ -f "$f" ] && df=$((df + $(gcount "$f" 'Failed to dial')))
|
||||
done
|
||||
echo " 累计 Failed to dial: $df"
|
||||
|
||||
sleep 8
|
||||
measure "$label"
|
||||
echo
|
||||
}
|
||||
|
||||
rm -f /tmp/ratio2.txt
|
||||
run_group "A quic5+h2 5" 5 5
|
||||
run_group "B quic3+h2 7" 3 7
|
||||
run_group "C quic0+h2 10" 0 10
|
||||
run_group "D quic8+h2 2" 8 2
|
||||
|
||||
echo "════════ 汇总(按可用率排序)════════"
|
||||
sort -t'|' -k2 -rn /tmp/ratio2.txt | while IFS='|' read -r label pct stats; do
|
||||
printf " %-16s %3s%% %s\n" "$label" "$pct" "$stats"
|
||||
done
|
||||
|
||||
BEST=$(sort -t'|' -k2 -rn /tmp/ratio2.txt | head -1 | cut -d'|' -f1)
|
||||
echo
|
||||
echo " → 最好: $BEST"
|
||||
|
||||
# 恢复
|
||||
cp -a "$BAK.main" "$MAINF"
|
||||
cp -a "$BAK.h2" "$H2F"
|
||||
systemctl daemon-reload
|
||||
case "$BEST" in
|
||||
"A quic5+h2 5") sed -i 's/--ha-connections [0-9]*/--ha-connections 5/' "$MAINF"; sed -i 's/--ha-connections [0-9]*/--ha-connections 5/' "$H2F" ;;
|
||||
"B quic3+h2 7") sed -i 's/--ha-connections [0-9]*/--ha-connections 3/' "$MAINF"; sed -i 's/--ha-connections [0-9]*/--ha-connections 7/' "$H2F" ;;
|
||||
"C quic0+h2 10") sed -i 's/--ha-connections [0-9]*/--ha-connections 10/' "$H2F" ;;
|
||||
*) sed -i 's/--ha-connections [0-9]*/--ha-connections 8/' "$MAINF"; sed -i 's/--ha-connections [0-9]*/--ha-connections 2/' "$H2F" ;;
|
||||
esac
|
||||
systemctl daemon-reload
|
||||
systemctl restart "$MAIN" "$H2"
|
||||
sleep 25
|
||||
echo " 当前: quic=$(read_conns "$MAIN_PORT") http2=$(read_conns "$H2_PORT")"
|
||||
echo " 备份: $BAK.main $BAK.h2"
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=每 10 秒检查一次隧道连接数
|
||||
|
||||
[Timer]
|
||||
# 20 秒 -> 10 秒:从"出问题"到"被重启"的等待窗口减半。
|
||||
# 开机后 45 秒开始第一次(隧道一般几十秒就连上了)。
|
||||
OnBootSec=45s
|
||||
OnUnitActiveSec=10s
|
||||
AccuracySec=3s
|
||||
Unit=lanremote-watchdog.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
===
|
||||
[Unit]
|
||||
Description=LanRemote 隧道看门狗(看连接数,不是看公网)
|
||||
After=lanremote-relay.service lanremote-tunnel.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/bash /root/lanremote-relay/watchdog.sh
|
||||
MemoryMax=64M
|
||||
StandardOutput=append:/root/lanremote-relay/watchdog.log
|
||||
StandardError=append:/root/lanremote-relay/watchdog.log
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/bin/bash
|
||||
# 看门狗第三版:两个信号都查。
|
||||
#
|
||||
# 为什么还要改:
|
||||
# 第二版只看 cloudflared 的 ha_connections 指标。
|
||||
# 但"僵死连接"会把指标维持在 1 —— 计数还在,实际早断了。
|
||||
# 这种时候看门狗以为一切正常,一直不管。
|
||||
# (今早就遇到一次:浏览器 1033,指标却说有 1 条连接)
|
||||
#
|
||||
# 这一版:
|
||||
# 信号 A:ha_connections == 0 -> 记一次"零连接"
|
||||
# 信号 B:公网 /v1/health 打不通 -> 记一次"公网不通"
|
||||
# 任一信号连续 2 次 -> 重启隧道
|
||||
# 另外:隧道日志超过 10 分钟没新行 + 公网不通 -> 判定进程卡死,重启
|
||||
set -u
|
||||
|
||||
RELAY_DIR=/root/lanremote-relay
|
||||
URL_FILE="$RELAY_DIR/public_url.txt"
|
||||
STATE_FILE="$RELAY_DIR/watchdog.state"
|
||||
TUNNEL_LOG="$RELAY_DIR/tunnel.log"
|
||||
METRICS=http://127.0.0.1:20241/metrics
|
||||
LOG_TAG=lanremote-watchdog
|
||||
|
||||
# 连续几次公网不通才重启。
|
||||
# 设 3(约 60 秒)而不是 2:短抖动让 cloudflared 自己重连就好,
|
||||
# 频繁重启反而让它一直停在"正在重连"的状态。
|
||||
THRESHOLD=3
|
||||
STALE_MINUTES=10 # 日志多久没动静算卡死
|
||||
# 两次重启之间至少隔这么久(秒)。
|
||||
# 实测隧道重建只要 30~40 秒,原来设 120 秒太保守 ——
|
||||
# 真断的时候会干等(日志里出现过"距上次重启才 118 秒,先不重启",
|
||||
# 结果多断了 2 分钟)。
|
||||
# 设 45 秒:既够 cloudflared 把连接建起来,又不会干等太久。
|
||||
# 两次重启至少隔这么久。
|
||||
# 150 秒不是拍脑袋:隧道在差的 4G 上重建 4 条连接要 30~60 秒,
|
||||
# 原来设 45 秒会导致**隧道还没建齐就被下一次重启打断**,
|
||||
# 永远停在只剩 1 条连接的状态(实测死循环,每 50 秒重启一次)。
|
||||
# 给足宽限期,让它有机会稳定下来。
|
||||
MIN_RESTART_GAP=150
|
||||
GRACE=150
|
||||
|
||||
log() {
|
||||
logger -t "$LOG_TAG" "$1"
|
||||
echo "[$(date '+%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
read_state() {
|
||||
local key="$1" default="$2" value=""
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
value=$(grep "^$key=" "$STATE_FILE" 2>/dev/null | cut -d= -f2)
|
||||
fi
|
||||
[ -n "$value" ] && echo "$value" || echo "$default"
|
||||
}
|
||||
|
||||
# 状态文件现在存三样:
|
||||
# zero —— 连续降级次数
|
||||
# fail —— 连续失败次数
|
||||
# built —— 本次重启后有没有建齐过(1=建齐过)
|
||||
write_state() {
|
||||
printf 'zero=%s\nfail=%s\nbuilt=%s\n' "$1" "$2" "${3:-0}" > "$STATE_FILE"
|
||||
}
|
||||
|
||||
LAST_RESTART_FILE="$RELAY_DIR/watchdog.last_restart"
|
||||
|
||||
restart_tunnel() {
|
||||
local why="$1"
|
||||
local now last gap
|
||||
now=$(date +%s)
|
||||
last=0
|
||||
[ -f "$LAST_RESTART_FILE" ] && last=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0)
|
||||
gap=$((now - last))
|
||||
|
||||
# 连接数归零 = 真的断了,不是抖动 —— 这种不受间隔限制
|
||||
local conns_now
|
||||
conns_now=$(timeout 5 curl -s --max-time 4 "$METRICS" 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
if [ "${conns_now:-1}" != "0" ] && [ "$gap" -lt "$MIN_RESTART_GAP" ]; then
|
||||
# 刚重启过还没到间隔:这次先不重启,
|
||||
# 让 cloudflared 自己把连接建起来(它本来就在重试)。
|
||||
log "$why —— 但距上次重启才 ${gap} 秒(下限 ${MIN_RESTART_GAP} 秒),先不重启"
|
||||
write_state 0 0 "${BUILT:-0}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "$why —— 重启隧道(距上次 ${gap} 秒)"
|
||||
echo "$now" > "$LAST_RESTART_FILE"
|
||||
systemctl restart lanremote-tunnel
|
||||
write_state 0 0 0
|
||||
sleep 25
|
||||
log "重启后地址: $(cat "$URL_FILE" 2>/dev/null || echo '(还没拿到)')"
|
||||
exit 0
|
||||
}
|
||||
|
||||
ZERO=$(read_state zero 0)
|
||||
FAIL=$(read_state fail 0)
|
||||
BUILT=$(read_state built 0)
|
||||
|
||||
# ---------- 信号 A:cloudflared 自报的连接数 ----------
|
||||
CONNS=$(timeout 5 curl -s --max-time 4 "$METRICS" 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
[ -z "$CONNS" ] && CONNS=""
|
||||
|
||||
# 连接数达到 3 条以上就算"建齐过"。
|
||||
# 记在状态文件里,跨多次检查保留 —— 这样即使后面掉到 1 条,
|
||||
# 我们也知道"它本来是能建齐的"。
|
||||
if [ -n "$CONNS" ] && [ "$CONNS" -ge 3 ] 2>/dev/null; then
|
||||
if [ "$BUILT" != "1" ]; then
|
||||
log "本次已建齐 ${CONNS} 条连接"
|
||||
fi
|
||||
BUILT=1
|
||||
fi
|
||||
|
||||
# ---------- 信号 B:公网真的通吗 ----------
|
||||
URL=$(cat "$URL_FILE" 2>/dev/null | tr -d '\r\n')
|
||||
CODE="000"
|
||||
if [ -n "$URL" ]; then
|
||||
CODE=$(timeout 20 curl -4 -s -o /dev/null -w '%{http_code}' \
|
||||
-H 'User-Agent: LanRemote/1.4.0' "$URL/v1/health" 2>/dev/null)
|
||||
CODE=${CODE:-000}
|
||||
fi
|
||||
PUBLIC_OK=0
|
||||
[ "$CODE" = "200" ] && PUBLIC_OK=1
|
||||
|
||||
# ---------- 信号 C:日志僵死了吗 ----------
|
||||
LOG_STALE=0
|
||||
if [ -f "$TUNNEL_LOG" ]; then
|
||||
LAST=$(stat -c %Y "$TUNNEL_LOG" 2>/dev/null || echo 0)
|
||||
NOW=$(date +%s)
|
||||
if [ "$LAST" -gt 0 ] && [ $((NOW - LAST)) -gt $((STALE_MINUTES * 60)) ]; then
|
||||
LOG_STALE=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- 判断 ----------
|
||||
if [ "$PUBLIC_OK" = "1" ]; then
|
||||
# 公网通就是真的通,计数器清零
|
||||
if [ "$ZERO" != "0" ] || [ "$FAIL" != "0" ]; then
|
||||
log "公网恢复(HTTP 200),计数清零(之前 zero=$ZERO fail=$FAIL)"
|
||||
fi
|
||||
write_state 0 0 "${BUILT:-0}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 公网不通 —— 看是哪种原因
|
||||
DETAIL="HTTP $CODE"
|
||||
[ -n "$CONNS" ] && DETAIL="$DETAIL,ha_connections=$CONNS"
|
||||
|
||||
if [ "$LOG_STALE" = "1" ] && [ "$FAIL" -ge 1 ]; then
|
||||
restart_tunnel "日志 ${STALE_MINUTES} 分钟没动静加上公网不通(进程卡死)"
|
||||
fi
|
||||
|
||||
FAIL=$((FAIL + 1))
|
||||
# 连接数 <= 1 也算"降级"。
|
||||
#
|
||||
# 原来是 == 0 才算,但实测**只剩 1 条连接时跨机房回源就会失败**
|
||||
# (用户撞上的 530 就是这种:请求落 SEA,隧道只剩 LAX 一条)。
|
||||
# 所以 1 和 0 一样危险,都计入降级计数。
|
||||
DEGRADED=0
|
||||
if [ -n "$CONNS" ] && [ "$CONNS" -le 1 ] 2>/dev/null; then
|
||||
ZERO=$((ZERO + 1))
|
||||
DEGRADED=1
|
||||
fi
|
||||
|
||||
log "公网不通($DETAIL),连续第 $FAIL 次(降级 $ZERO 次)"
|
||||
|
||||
# 公网不通 + 连接数已经降到危险区 = **确认的故障**,不是短抖动。
|
||||
# 这种情况立刻重启,不等攒够 3 次 —— 每次检查间隔 20 秒,
|
||||
# 等 3 次就是白断 40 秒。
|
||||
if [ "$DEGRADED" = "1" ]; then
|
||||
# 宽限期内不重启 —— 让上一次重启有机会把连接建齐。
|
||||
# (不加这条会死循环:隧道还没建齐就被再次打断。)
|
||||
LAST_R=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0)
|
||||
SINCE=$(( $(date +%s) - LAST_R ))
|
||||
|
||||
# 但要看「这次重启后建齐过没有」:
|
||||
# 建齐过(>=3 条)又垮下来 = 链路本来能撑住,现在是真故障
|
||||
# -> 不用再等宽限期,立刻重启
|
||||
# 从没建齐过 = 它还在努力建
|
||||
# -> 继续等,别打断它
|
||||
if [ "${BUILT:-0}" = "1" ]; then
|
||||
restart_tunnel "公网不通,且本次重启后曾建齐过连接(确认故障,立即重启)"
|
||||
elif [ "$SINCE" -lt "$GRACE" ]; then
|
||||
log "公网不通且只剩 ${CONNS} 条连接,但本次重启还没建齐过(${SINCE}/${GRACE} 秒)—— 继续等它建"
|
||||
else
|
||||
restart_tunnel "公网不通且 ${SINCE} 秒都没建齐(链路或边缘有问题,重启试试)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FAIL" -ge "$THRESHOLD" ] || [ "$ZERO" -ge "$THRESHOLD" ]; then
|
||||
restart_tunnel "连续 $FAIL 次公网不通(含 $ZERO 次降级)"
|
||||
fi
|
||||
|
||||
write_state "$ZERO" "$FAIL" "$BUILT"
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
# 长测结束后,把所有数据汇总成一份可直接引用的报告
|
||||
set -u
|
||||
|
||||
echo "################################################################"
|
||||
echo "# 中转稳定性最终数据汇总"
|
||||
echo "# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "################################################################"
|
||||
|
||||
echo
|
||||
echo "════════════════════ 一、最终配置 ════════════════════"
|
||||
echo " ── 隧道实例 ──"
|
||||
for svc in lanremote-tunnel lanremote-tunnel-h2; do
|
||||
echo " $svc:"
|
||||
systemctl cat "$svc" 2>/dev/null \
|
||||
| grep -oE '\-\-metrics [0-9.:]+|\-\-protocol [a-z0-9]+|\-\-ha-connections [0-9]+|\-\-edge-ip-version [0-9]+|\-\-retries [0-9]+' \
|
||||
| sed 's/^/ /'
|
||||
done
|
||||
echo " ── 网络层 ──"
|
||||
echo " wlan0 MTU: $(cat /sys/class/net/wlan0/mtu)"
|
||||
iptables -t mangle -S OUTPUT 2>/dev/null | grep TCPMSS | sed 's/^/ /'
|
||||
echo " keepalive: time=$(sysctl -n net.ipv4.tcp_keepalive_time) intvl=$(sysctl -n net.ipv4.tcp_keepalive_intvl) probes=$(sysctl -n net.ipv4.tcp_keepalive_probes)"
|
||||
echo " ── 中转 ──"
|
||||
echo " 长轮询: $(grep -oE 'RELAY_POLL=[0-9]+' /etc/systemd/system/lanremote-relay.service 2>/dev/null)"
|
||||
curl -s --max-time 10 http://127.0.0.1:8080/v1/health | head -c 120 | sed 's/^/ /'
|
||||
echo
|
||||
echo " ── watchdog ──"
|
||||
echo " $(systemctl is-active lanremote-watchdog.timer) / $(wc -l < /root/lanremote-relay/watchdog.sh) 行"
|
||||
|
||||
echo
|
||||
echo "════════════════════ 二、长测结果 ════════════════════"
|
||||
|
||||
# ---- 设备侧健康 ----
|
||||
H=/root/long2h_health.tsv
|
||||
if [ -f "$H" ]; then
|
||||
n=$(grep -c . "$H")
|
||||
ok=$(awk -F'\t' '$2=="200"' "$H" | wc -l)
|
||||
echo " ── 设备侧公网可用率(含设备自己的 4G 那一段,偏悲观)──"
|
||||
echo " 样本 $n 成功 $ok = $(awk -v a="$ok" -v b="$n" 'BEGIN{printf "%.1f", a*100/b}')%"
|
||||
awk -F'\t' '$2=="200"{print $3}' "$H" | sort -n | awk '{a[NR]=$1} END{
|
||||
if(NR==0){print " 无数据"; exit}
|
||||
printf " 耗时: 最小 %d 中位 %d P90 %d P99 %d 最大 %d ms\n",
|
||||
a[1], a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}'
|
||||
echo " 状态码分布:"
|
||||
awk -F'\t' '{print $2}' "$H" | sort | uniq -c | sort -rn | sed 's/^/ /'
|
||||
echo " 失败时刻(全部):"
|
||||
awk -F'\t' '$2!="200"{print " " $1 " 码=" $2 " " $3 "ms"}' "$H"
|
||||
fi
|
||||
|
||||
# ---- 轮询 ----
|
||||
P=/root/rp_poll30.tsv
|
||||
if [ -f "$P" ]; then
|
||||
n=$(grep -c . "$P")
|
||||
ok=$(awk -F'\t' '$2=="200"' "$P" | wc -l)
|
||||
echo
|
||||
echo " ── 长轮询(30 次,每次挂 60 秒)──"
|
||||
echo " $ok/$n = $(awk -v a="$ok" -v b="$n" 'BEGIN{printf "%.1f", a*100/b}')%"
|
||||
awk -F'\t' '$2=="200"{print $3}' "$P" | sort -n | awk '{a[NR]=$1} END{
|
||||
if(NR==0){print " 无数据"; exit}
|
||||
printf " 最小 %d P50 %d P90 %d P99 %d 最大 %d ms(理想 ≈ 61000)\n",
|
||||
a[1], a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}'
|
||||
echo " 超过 90000ms 的样本(会打到客户端 120 秒超时的边缘):"
|
||||
awk -F'\t' '$3>90000{print " #" $1 " " $3 "ms 开始 " $4}' "$P"
|
||||
fi
|
||||
|
||||
# ---- 连接数 ----
|
||||
C=/root/long2h_conns.tsv
|
||||
if [ -f "$C" ]; then
|
||||
echo
|
||||
echo " ── 连接数波动 ──"
|
||||
awk -F'\t' 'NR>1 && $2 ~ /^[0-9]+$/ {if(min==""||$2<min)min=$2; if($2>max)max=$2}
|
||||
NR>1 && $3 ~ /^[0-9]+$/ {if(min2==""||$3<min2)min2=$3; if($3>max2)max2=$3}
|
||||
END{
|
||||
printf " quic: %s ~ %s\n", min, max
|
||||
printf " http2: %s ~ %s\n", min2, max2}'
|
||||
echo " 出现过的组合(次数 top 8):"
|
||||
awk -F'\t' 'NR>1{print "quic="$2" http2="$3}' "$C" | sort | uniq -c | sort -rn | head -8 | sed 's/^/ /'
|
||||
echo " 总连接数掉到 3 以下的时刻:"
|
||||
awk -F'\t' 'NR>1 && $2 ~ /^[0-9]+$/ && $3 ~ /^[0-9]+$/ && $2+$3<3 {print " " $1 " quic="$2" http2="$3}' "$C"
|
||||
fi
|
||||
|
||||
# ---- PC 侧 ----
|
||||
PC=/root/../dev/null
|
||||
echo
|
||||
echo " ── PC 侧(真实用户视角,只有回源那一跳走 4G)──"
|
||||
echo " (数据在本地 _build/rp_pc_long.tsv,见报告正文)"
|
||||
|
||||
echo
|
||||
echo "════════════════════ 三、隧道事件增量 ════════════════════"
|
||||
echo " ── QUIC 实例(tunnel.log 全量)──"
|
||||
for kw in 'Registered tunnel connection' 'Failed to dial' 'Lost connection' 'i/o timeout' 'no recent network activity'; do
|
||||
printf " %-34s %s\n" "$kw" "$(grep -c "$kw" /root/lanremote-relay/tunnel.log 2>/dev/null)"
|
||||
done
|
||||
echo " ── HTTP/2 实例(tunnel-h2.log 全量)──"
|
||||
for kw in 'Registered tunnel connection' 'Failed to dial' 'Lost connection' 'i/o timeout'; do
|
||||
printf " %-34s %s\n" "$kw" "$(grep -c "$kw" /root/lanremote-relay/tunnel-h2.log 2>/dev/null)"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════════════════ 四、watchdog 处置记录 ════════════════════"
|
||||
grep -E '重启|无响应|建齐' /root/lanremote-relay/watchdog.log 2>/dev/null | tail -15 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════════════════ 五、中转自身 ════════════════════"
|
||||
echo " BrokenPipeError 次数: $(grep -c 'BrokenPipeError' /root/lanremote-relay/relay.log 2>/dev/null)"
|
||||
echo " 下载中断次数: $(grep -c '下载中断' /root/lanremote-relay/relay.log 2>/dev/null)"
|
||||
echo " 内存占用: $(systemctl show lanremote-relay -p MemoryCurrent --value | awk '{printf "%.1f MB", $1/1024/1024}')"
|
||||
echo " 运行时长: $(systemctl show lanremote-relay -p ActiveEnterTimestamp --value)"
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# 设定最终配比并启动 2 小时长测。
|
||||
#
|
||||
# 配比依据(40 次/组受控对照,MTU 1400):
|
||||
# A quic5+h2 5 100% 中位 1490 P90 2346 P99 3295
|
||||
# B quic3+h2 7 97% 中位 1271 P90 2558 P99 13934
|
||||
# C quic0+h2 10 (无效:主实例被 watchdog 自动拉起)
|
||||
# D quic8+h2 2 100% 中位 1250 P90 1715 P99 2632 <- 最好
|
||||
#
|
||||
# 结论有点反直觉:QUIC 拨号成功率只有 72%(UDP 被运营商限制),
|
||||
# 但**一旦连上,QUIC 的丢包恢复明显优于 HTTP/2/TCP**(无队头阻塞),
|
||||
# 所以"多 QUIC + 少量 HTTP/2 兜底"的组合最好。
|
||||
set -u
|
||||
|
||||
MAIN=/etc/systemd/system/lanremote-tunnel.service
|
||||
H2=/etc/systemd/system/lanremote-tunnel-h2.service
|
||||
BAK=/root/final.bak.$(date +%s)
|
||||
cp -a "$MAIN" "$BAK.main"
|
||||
cp -a "$H2" "$BAK.h2"
|
||||
|
||||
echo "════════ 1. 设成 quic8 + http2 2 ════════"
|
||||
sed -i 's/--ha-connections [0-9]*/--ha-connections 8/' "$MAIN"
|
||||
sed -i 's/--ha-connections [0-9]*/--ha-connections 2/' "$H2"
|
||||
systemctl daemon-reload
|
||||
systemctl restart lanremote-tunnel lanremote-tunnel-h2
|
||||
echo " 主: $(grep -oE '\-\-protocol [a-z0-9]+|\-\-ha-connections [0-9]+|\-\-metrics [0-9.:]+' "$MAIN" | tr '\n' ' ')"
|
||||
echo " 副: $(grep -oE '\-\-protocol [a-z0-9]+|\-\-ha-connections [0-9]+|\-\-metrics [0-9.:]+' "$H2" | tr '\n' ' ')"
|
||||
|
||||
echo
|
||||
echo "════════ 2. 等连接建齐 ════════"
|
||||
for i in $(seq 1 40); do
|
||||
sleep 4
|
||||
a=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
b=$(curl -s --max-time 4 http://127.0.0.1:20242/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
a=${a:-0}; b=${b:-0}
|
||||
[ $((a + b)) -ge 8 ] && break
|
||||
done
|
||||
echo " quic=$a http2=$b 合计=$((a + b))(等了约 $((i*4)) 秒)"
|
||||
|
||||
echo
|
||||
echo "════════ 3. 公网确认 ════════"
|
||||
for i in $(seq 1 15); do
|
||||
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health)
|
||||
printf " 第 %d 次: %s\n" "$i" "$c"
|
||||
[ "$c" = "200" ] && break
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 4. MTU / MSS 复核 ════════"
|
||||
echo " wlan0 MTU: $(cat /sys/class/net/wlan0/mtu)"
|
||||
iptables -t mangle -S OUTPUT 2>/dev/null | grep TCPMSS | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo " 备份: $BAK.main $BAK.h2"
|
||||
echo
|
||||
echo " 下一步:跑 /root/rp_long2h.sh 7200(2 小时)"
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# 启动 2 小时长测(脱离 SSH 会话,SSH 断开也继续跑)
|
||||
set -u
|
||||
|
||||
echo "════════ 1. 先看是不是已经在跑了 ════════"
|
||||
if pgrep -f 'rp_long2h.sh' > /dev/null 2>&1; then
|
||||
echo " 已经在跑:"
|
||||
pgrep -af 'rp_long2h.sh' | sed 's/^/ /'
|
||||
echo " 已产生的健康样本数: $(wc -l < /root/long2h_health.tsv 2>/dev/null || echo 0)"
|
||||
else
|
||||
echo " 没在跑"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "════════ 2. 没跑就启动 ════════"
|
||||
if ! pgrep -f 'rp_long2h.sh' > /dev/null 2>&1; then
|
||||
# setsid + </dev/null + 全部重定向:让进程完全脱离这个 SSH 会话
|
||||
setsid bash /root/rp_long2h.sh 7200 < /dev/null > /root/rp_long2h.out 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
sleep 3
|
||||
echo " 已启动"
|
||||
else
|
||||
echo " 跳过"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "════════ 3. 确认在跑 ════════"
|
||||
sleep 5
|
||||
pgrep -af 'rp_long2h.sh' | sed 's/^/ /'
|
||||
echo " 健康样本: $(wc -l < /root/long2h_health.tsv 2>/dev/null || echo 0)"
|
||||
echo " 轮询样本: $(wc -l < /root/long2h_poll.tsv 2>/dev/null || echo 0)"
|
||||
echo " 连接采样: $(wc -l < /root/long2h_conns.tsv 2>/dev/null || echo 0)"
|
||||
|
||||
echo
|
||||
echo "════════ 4. 等 30 秒看数据在增长 ════════"
|
||||
sleep 30
|
||||
echo " 健康样本: $(wc -l < /root/long2h_health.tsv 2>/dev/null || echo 0)"
|
||||
echo " 最近 3 条:"
|
||||
tail -3 /root/long2h_health.tsv 2>/dev/null | sed 's/^/ /'
|
||||
echo
|
||||
echo " 轮询进度:"
|
||||
cat /root/long2h_poll.tsv 2>/dev/null | sed 's/^/ /'
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# 现状全面勘察(只读):双隧道实例、watchdog、日志、连接数
|
||||
set -u
|
||||
|
||||
echo "════════ 1. 隧道服务清单 ════════"
|
||||
for u in lanremote-tunnel lanremote-tunnel-h2; do
|
||||
if systemctl list-unit-files 2>/dev/null | grep -q "^$u.service"; then
|
||||
echo " ── $u ──"
|
||||
echo " 状态: $(systemctl is-active $u 2>/dev/null) / $(systemctl is-enabled $u 2>/dev/null)"
|
||||
systemctl cat $u 2>/dev/null | grep -E '^ExecStart=' | sed 's/^/ /'
|
||||
else
|
||||
echo " ── $u:不存在 ──"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 2. cloudflared 进程 ════════"
|
||||
ps -eo pid,etime,rss,cmd 2>/dev/null | grep -E 'cloudflared' | grep -v grep | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 3. 谁绑上了 20241 metrics 端口 ════════"
|
||||
ss -tlnp 2>/dev/null | grep 20241 | sed 's/^/ /' || echo " (没人绑)"
|
||||
echo " metrics 报的连接数:"
|
||||
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||
| grep -E '^cloudflared_tunnel_ha_connections' | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. 两个实例各自的日志文件 ════════"
|
||||
ls -la /root/lanremote-relay/*.log 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 5. watchdog 脚本关键逻辑 ════════"
|
||||
grep -nE 'metrics|20241|ha_connections|CONNS|restart_tunnel|systemctl restart' \
|
||||
/root/lanremote-relay/watchdog.sh 2>/dev/null | head -30 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 6. watchdog 服务状态 ════════"
|
||||
systemctl is-active lanremote-watchdog 2>/dev/null | sed 's/^/ active: /'
|
||||
systemctl is-enabled lanremote-watchdog 2>/dev/null | sed 's/^/ enabled: /'
|
||||
echo " 最近 15 行 watchdog 日志:"
|
||||
tail -15 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 7. net-tune(MTU/MSS 持久化)════════"
|
||||
echo " wlan0 MTU: $(cat /sys/class/net/wlan0/mtu 2>/dev/null)"
|
||||
iptables -t mangle -S OUTPUT 2>/dev/null | grep TCPMSS | sed 's/^/ /'
|
||||
systemctl cat lanremote-nettune 2>/dev/null | grep ExecStart | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 8. 中转版本和健康 ════════"
|
||||
curl -s --max-time 10 http://127.0.0.1:8080/v1/health | head -c 160 | sed 's/^/ /'
|
||||
echo
|
||||
echo " 长轮询设定: $(grep -oE 'RELAY_POLL=[0-9]+' /etc/systemd/system/lanremote-relay.service 2>/dev/null)"
|
||||
|
||||
echo
|
||||
echo "════════ 9. 系统负载 / 内存 ════════"
|
||||
uptime | sed 's/^/ /'
|
||||
free -m | sed 's/^/ /'
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# 历史故障时间线分析:判断失败是不是集中在某个时段(运营商时段性抖动)。
|
||||
#
|
||||
# 数据源(都是设备上现成的):
|
||||
# watchdog.log —— 它每 10 秒探一次公网,失败会写一行,带时间戳
|
||||
# tunnel.log —— QUIC 实例的拨号/掉线事件
|
||||
# tunnel-h2.log —— HTTP/2 实例的同类事件
|
||||
set -u
|
||||
|
||||
WD=/root/lanremote-relay/watchdog.log
|
||||
MAIN=/root/lanremote-relay/tunnel.log
|
||||
H2=/root/lanremote-relay/tunnel-h2.log
|
||||
RELAY=/root/lanremote-relay/relay.log
|
||||
|
||||
echo "════════ 1. 数据覆盖范围 ════════"
|
||||
for f in "$WD" "$MAIN" "$H2" "$RELAY"; do
|
||||
[ -f "$f" ] || continue
|
||||
first=$(head -1 "$f" 2>/dev/null | cut -c1-19)
|
||||
last=$(tail -1 "$f" 2>/dev/null | cut -c1-19)
|
||||
printf " %-14s %8d 行 %s ~ %s\n" "$(basename "$f")" "$(wc -l < "$f")" "$first" "$last"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 2. watchdog 记录的公网故障,按小时分桶 ════════"
|
||||
echo " (watchdog 只在公网不通时记一行,所以这就是故障的原始时间线)"
|
||||
grep '公网不通' "$WD" 2>/dev/null \
|
||||
| grep -oE '^\[[0-9-]+ [0-9]{2}' \
|
||||
| sed 's/\[//' | sort | uniq -c | tail -30 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 3. 恢复事件(故障结束)按小时 ════════"
|
||||
grep '公网恢复' "$WD" 2>/dev/null \
|
||||
| grep -oE '^\[[0-9-]+ [0-9]{2}' \
|
||||
| sed 's/\[//' | sort | uniq -c | tail -30 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. 隧道重启事件(watchdog 触发的)════════"
|
||||
grep -E '重启隧道' "$WD" 2>/dev/null | tail -20 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 5. QUIC 拨号失败按小时分桶 ════════"
|
||||
grep 'Failed to dial' "$MAIN" 2>/dev/null \
|
||||
| cut -c1-13 | sort | uniq -c | tail -24 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 6. HTTP/2 掉线按小时分桶 ════════"
|
||||
grep 'Lost connection' "$H2" 2>/dev/null \
|
||||
| cut -c1-13 | sort | uniq -c | tail -24 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 7. 最长的一次连续故障(从 watchdog 日志推)════════"
|
||||
grep -nE '公网不通|公网恢复' "$WD" 2>/dev/null | tail -60 \
|
||||
| sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 8. 中转自身有没有异常 ════════"
|
||||
echo " 中转日志里的错误:"
|
||||
grep -icE 'traceback|error|exception' "$RELAY" 2>/dev/null | sed 's/^/ 匹配行数: /'
|
||||
grep -iE 'traceback|error|exception' "$RELAY" 2>/dev/null | tail -8 | sed 's/^/ /'
|
||||
echo " 下载中断记录:"
|
||||
grep -c '下载中断' "$RELAY" 2>/dev/null | sed 's/^/ 次数: /'
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# 部署双实例版 watchdog(带预演和备份)
|
||||
set -u
|
||||
|
||||
RELAY=/root/lanremote-relay
|
||||
STAMP=$(date +%s)
|
||||
|
||||
echo "════════ 1. 备份现在的 watchdog ════════"
|
||||
cp -a "$RELAY/watchdog.sh" "$RELAY/watchdog.sh.bak.$STAMP"
|
||||
cp -a "$RELAY/watchdog.state" "$RELAY/watchdog.state.bak.$STAMP" 2>/dev/null || true
|
||||
echo " $RELAY/watchdog.sh.bak.$STAMP"
|
||||
echo " 旧版行数: $(wc -l < "$RELAY/watchdog.sh")"
|
||||
|
||||
echo
|
||||
echo "════════ 2. 装新版 ════════"
|
||||
install -m 755 /tmp/wd_v4.sh "$RELAY/watchdog.sh"
|
||||
echo " 新版行数: $(wc -l < "$RELAY/watchdog.sh")"
|
||||
bash -n "$RELAY/watchdog.sh" && echo " 语法 OK"
|
||||
|
||||
echo
|
||||
echo "════════ 3. 预演(DRY_RUN=1,不会真重启)════════"
|
||||
echo " 当前实际状态:"
|
||||
for p in 20241 20242; do
|
||||
n=$(curl -s --max-time 4 http://127.0.0.1:$p/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
echo " 端口 $p: ${n:-无响应}"
|
||||
done
|
||||
echo " 预演输出:"
|
||||
DRY_RUN=1 bash "$RELAY/watchdog.sh" 2>&1 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. 真跑一次(当前一切正常,应该什么都不做)════════"
|
||||
bash "$RELAY/watchdog.sh" 2>&1 | sed 's/^/ /'
|
||||
echo " 退出码: $?"
|
||||
|
||||
echo
|
||||
echo "════════ 5. 状态文件 ════════"
|
||||
cat "$RELAY/watchdog.state" 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 6. 确认 timer 还在跑 ════════"
|
||||
systemctl is-active lanremote-watchdog.timer | sed 's/^/ timer: /'
|
||||
systemctl list-timers lanremote-watchdog.timer --no-pager 2>/dev/null | head -3 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 7. 等一轮 timer 触发,看日志 ════════"
|
||||
sleep 30
|
||||
tail -6 "$RELAY/watchdog.log" | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo " 备份路径: $RELAY/watchdog.sh.bak.$STAMP"
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# 查清 watchdog 的调度方式 + 两个 cloudflared 实例的完整参数
|
||||
set -u
|
||||
|
||||
echo "════════ 1. watchdog 怎么被调度的 ════════"
|
||||
echo " systemd 单元:"
|
||||
systemctl list-units --all --no-pager 2>/dev/null | grep -i watchdog | sed 's/^/ /'
|
||||
echo " timer:"
|
||||
systemctl list-timers --all --no-pager 2>/dev/null | grep -i watchdog | sed 's/^/ /'
|
||||
echo " cron:"
|
||||
crontab -l 2>/dev/null | grep -i watchdog | sed 's/^/ /' || echo " (没有)"
|
||||
echo " /etc/cron.d:"
|
||||
grep -ri watchdog /etc/cron.d/ /etc/cron.daily/ 2>/dev/null | sed 's/^/ /' || echo " (没有)"
|
||||
echo " rc.local:"
|
||||
grep -i watchdog /etc/rc.local 2>/dev/null | sed 's/^/ /' || echo " (没有)"
|
||||
echo " 是否有 watchdog 进程在跑:"
|
||||
pgrep -af 'watchdog' | sed 's/^/ /' || echo " (没有)"
|
||||
|
||||
echo
|
||||
echo "════════ 2. /etc/systemd/system 里所有 lanremote 单元 ════════"
|
||||
ls -la /etc/systemd/system/ 2>/dev/null | grep -iE 'lanremote|watchdog' | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 3. 两个 cloudflared 进程的完整命令行 ════════"
|
||||
for p in $(pgrep cloudflared); do
|
||||
echo " ── pid $p ──"
|
||||
tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null | sed 's/^/ /'
|
||||
echo
|
||||
echo " 启动于: $(ps -o lstart= -p $p 2>/dev/null)"
|
||||
echo " 内存: $(ps -o rss= -p $p 2>/dev/null) KB"
|
||||
echo " 父进程: $(ps -o ppid= -p $p 2>/dev/null | tr -d ' ') -> $(cat /proc/$p/cgroup 2>/dev/null | head -1)"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 4. 两个实例的 ExecStart ════════"
|
||||
echo " ── 主 (lanremote-tunnel) ──"
|
||||
systemctl cat lanremote-tunnel 2>/dev/null | grep -E 'ExecStart|StandardOutput|MemoryMax' | sed 's/^/ /'
|
||||
echo " ── 副 (lanremote-tunnel-h2) ──"
|
||||
systemctl cat lanremote-tunnel-h2 2>/dev/null | grep -E 'ExecStart|StandardOutput|MemoryMax' | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 5. 各自绑的 metrics 端口 ════════"
|
||||
ss -tlnp 2>/dev/null | grep -E '2024[0-9]' | sed 's/^/ /'
|
||||
for port in 20240 20241 20242 20243; do
|
||||
n=$(curl -s --max-time 3 http://127.0.0.1:$port/metrics 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
echo " 端口 $port: ha_connections=${n:-(无响应)}"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 6. 两个实例各自最近的连接事件 ════════"
|
||||
echo " ── 主 (tunnel.log) 最近 8 条 ──"
|
||||
grep -E 'Registered|Lost|Failed to dial|Unregistered' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -8 | sed 's/^/ /'
|
||||
echo " ── 副 (tunnel-h2.log) 最近 8 条 ──"
|
||||
grep -E 'Registered|Lost|Failed to dial|Unregistered' /root/lanremote-relay/tunnel-h2.log 2>/dev/null | tail -8 | sed 's/^/ /'
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# 验证双实例 watchdog 的判定逻辑(不真重启,靠 DRY_RUN + 桩函数)
|
||||
#
|
||||
# 为什么要专门测:双实例引入的分支不少(哪个死了/哪个低连接/公网通不通),
|
||||
# 直接覆盖生产脚本风险太大。
|
||||
set -u
|
||||
|
||||
SRC=/tmp/wd_v4.sh
|
||||
OUT=/tmp/wd_test_out
|
||||
|
||||
# 先语法检查
|
||||
echo "════════ 0. 语法检查 ════════"
|
||||
if bash -n "$SRC" 2>&1; then
|
||||
echo " ✓ 语法 OK"
|
||||
else
|
||||
echo " ✗ 语法有错"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 造一个测试环境:把所有外部依赖替换成桩
|
||||
setup_env() {
|
||||
rm -rf /tmp/wdtest
|
||||
mkdir -p /tmp/wdtest/relay
|
||||
sed -e 's#^RELAY_DIR=.*#RELAY_DIR=/tmp/wdtest/relay#' \
|
||||
-e 's#^URL_FILE=.*#URL_FILE=/tmp/wdtest/relay/public_url.txt#' \
|
||||
-e 's#^STATE_FILE=.*#STATE_FILE=/tmp/wdtest/relay/watchdog.state#' \
|
||||
-e 's#^LAST_RESTART_FILE=.*#LAST_RESTART_FILE=/tmp/wdtest/relay/watchdog.last_restart#' \
|
||||
-e 's#^TUNNEL_LOGS=.*#TUNNEL_LOGS=("/tmp/wdtest/relay/tunnel.log" "/tmp/wdtest/relay/tunnel-h2.log")#' \
|
||||
"$SRC" > /tmp/wdtest/wd.sh
|
||||
echo "https://relay.example" > /tmp/wdtest/relay/public_url.txt
|
||||
touch /tmp/wdtest/relay/tunnel.log /tmp/wdtest/relay/tunnel-h2.log
|
||||
# logger 桩:别真写 syslog
|
||||
mkdir -p /tmp/wdtest/bin
|
||||
cat > /tmp/wdtest/bin/logger <<'EOF'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
EOF
|
||||
cat > /tmp/wdtest/bin/curl <<'EOF'
|
||||
#!/bin/bash
|
||||
# 桩:METRICS_CURL 决定 metrics 返回什么,HEALTH_CODE 决定公网状态
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
*/metrics)
|
||||
printf '# TYPE cloudflared_tunnel_ha_connections gauge\ncloudflared_tunnel_ha_connections %s\n' "${STUB_CONNS_20241:-0}"
|
||||
exit 0 ;;
|
||||
*:20242/metrics)
|
||||
printf 'cloudflared_tunnel_ha_connections %s\n' "${STUB_CONNS_20242:-0}"
|
||||
exit 0 ;;
|
||||
esac
|
||||
done
|
||||
# 公网健康检查
|
||||
if [ "${STUB_HEALTH:-200}" = "000" ]; then exit 7; fi
|
||||
echo "${STUB_HEALTH:-200}"
|
||||
EOF
|
||||
cat > /tmp/wdtest/bin/systemctl <<'EOF'
|
||||
#!/bin/bash
|
||||
if [ "$1" = "is-active" ]; then
|
||||
svc="${@: -1}"
|
||||
# STUB_DEAD_SVC 指定的服务返回非 active
|
||||
if [ "$svc" = "${STUB_DEAD_SVC:-}" ]; then exit 3; fi
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "restart" ]; then
|
||||
echo "RESTART:$2"
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x /tmp/wdtest/bin/*
|
||||
# 重置状态
|
||||
rm -f /tmp/wdtest/relay/watchdog.state /tmp/wdtest/relay/watchdog.last_restart
|
||||
}
|
||||
|
||||
# 跑一个场景
|
||||
# $1 = 场景名
|
||||
# $2 = CONNS 20241("X" 表示端口不通)
|
||||
# $3 = CONNS 20242
|
||||
# $4 = 公网码
|
||||
# $5 = 哪个服务 is-active 返回 false(可空)
|
||||
run_case() {
|
||||
local name="$1" c1="$2" c2="$3" health="$4" dead="${5:-}"
|
||||
setup_env
|
||||
|
||||
# "X" 表示端口不通 -> 桩 curl 直接失败
|
||||
local stub1="$c1" stub2="$c2"
|
||||
if [ "$c1" = "X" ]; then stub1=""; fi
|
||||
if [ "$c2" = "X" ]; then stub2=""; fi
|
||||
|
||||
# 用一个特制 curl:端口不通时不输出
|
||||
cat > /tmp/wdtest/bin/curl <<EOF
|
||||
#!/bin/bash
|
||||
for a in "\$@"; do
|
||||
case "\$a" in
|
||||
*:20241/metrics) [ -n "$stub1" ] && echo "cloudflared_tunnel_ha_connections $stub1"; exit 0 ;;
|
||||
*:20242/metrics) [ -n "$stub2" ] && echo "cloudflared_tunnel_ha_connections $stub2"; exit 0 ;;
|
||||
esac
|
||||
done
|
||||
[ "$health" = "000" ] && exit 7
|
||||
echo "$health"
|
||||
EOF
|
||||
chmod +x /tmp/wdtest/bin/curl
|
||||
|
||||
echo " ── $name ──"
|
||||
echo " 输入: 主=$c1 副=$c2 公网=$health 停的服务=${dead:-无}"
|
||||
PATH=/tmp/wdtest/bin:$PATH \
|
||||
DRY_RUN=1 \
|
||||
STUB_DEAD_SVC="$dead" \
|
||||
bash /tmp/wdtest/wd.sh > /tmp/wdtest/out.txt 2>&1
|
||||
# 只看这次运行产生的判断行
|
||||
grep -E '已建齐|公网|重启|先不重启|DRY_RUN|继续等' /tmp/wdtest/out.txt | tail -4 | sed 's/^/ /'
|
||||
echo
|
||||
}
|
||||
|
||||
echo "════════ 1. 双实例都健康 + 公网通 -> 不该有任何动作 ════════"
|
||||
run_case "都健康" 5 5 200
|
||||
|
||||
echo "════════ 2. 主实例没连接,副实例正常 + 公网通 ════════"
|
||||
echo " (期望:只重启主实例,因为副实例还在扛)"
|
||||
run_case "主实例 0 条" 0 5 200
|
||||
|
||||
echo "════════ 3. 主实例 metrics 端口不通(进程卡死)════════"
|
||||
echo " (期望:重启主实例)"
|
||||
run_case "主实例无响应" X 5 200
|
||||
|
||||
echo "════════ 4. 两个实例都 0 条 + 公网不通 ════════"
|
||||
echo " (期望:重启两个)"
|
||||
run_case "双实例全垮" 0 0 000
|
||||
|
||||
echo "════════ 5. 连接数正常但公网 502(连续第 1 次)════════"
|
||||
echo " (期望:只记一次,不重启)"
|
||||
run_case "公网 502 首次" 5 5 502
|
||||
|
||||
echo "════════ 6. 只有一个实例低连接 + 公网不通 ════════"
|
||||
echo " (期望:重启那个低连接的实例,不动另一个)"
|
||||
run_case "副实例低连接" 5 1 000
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/bin/bash
|
||||
# 看门狗第四版:**双实例**。
|
||||
#
|
||||
# 为什么必须改:
|
||||
# 现在跑两个 cloudflared 实例,连**同一个具名隧道** lanremote:
|
||||
# lanremote-tunnel --protocol quic --ha-connections 5 metrics:20241
|
||||
# lanremote-tunnel-h2 --protocol http2 --ha-connections 5 metrics:20242
|
||||
# Cloudflare 会把请求分摊到全部 10 条连接上。
|
||||
#
|
||||
# 第三版看门狗有两个致命盲点:
|
||||
# 1. METRICS 写死 20241 —— **只看得到 QUIC 那个实例**。
|
||||
# http2 实例整个死掉,它照样认为"有 5 条连接,正常"。
|
||||
# 2. restart_tunnel 只重启 lanremote-tunnel,
|
||||
# 副实例哪怕僵死也永远不会被拉起来。
|
||||
#
|
||||
# 这一版:
|
||||
# * 分别读两个 metrics 端口,**分实例**判断
|
||||
# * 某个实例端口不响应 / 连接数归零 -> 只重启那一个
|
||||
# * 两个都垮 / 公网不通 -> 重启两个
|
||||
# * BUILT 的"建齐"阈值改成按**总连接数**算(双实例期望 10 条)
|
||||
#
|
||||
# 保留第三版的判定逻辑(它踩过坑,注释都留着):
|
||||
# 信号 A:连接数降到危险区 信号 B:公网打不通 信号 C:日志僵死
|
||||
set -u
|
||||
|
||||
RELAY_DIR=/root/lanremote-relay
|
||||
URL_FILE="$RELAY_DIR/public_url.txt"
|
||||
STATE_FILE="$RELAY_DIR/watchdog.state"
|
||||
LAST_RESTART_FILE="$RELAY_DIR/watchdog.last_restart"
|
||||
LOG_TAG=lanremote-watchdog
|
||||
|
||||
# ---- 双实例定义 ----
|
||||
# 端口**从服务配置里读**,不写死。
|
||||
#
|
||||
# 为什么:这两个服务原来都没写 --metrics,都用 cloudflared 的默认 20241。
|
||||
# 正常情况下先起的那个拿到 20241、后起的退到 20242 —— 看起来是稳的,
|
||||
# 但那只是启动顺序的巧合。实测把主实例 stop 掉、再重启副实例,
|
||||
# 副实例就抢到了 20241,于是"20241 是谁"翻转了,
|
||||
# 按端口读指标的判断全部读错实例。
|
||||
#
|
||||
# 现在服务里已显式写了 --metrics,这里再自动读一遍:
|
||||
# 以后谁改了端口,watchdog 也跟着走,不会再对不上。
|
||||
INSTANCES=()
|
||||
log_port_hint=""
|
||||
for pair in "lanremote-tunnel:20241" "lanremote-tunnel-h2:20242"; do
|
||||
_svc="${pair%%:*}"
|
||||
_fallback="${pair##*:}"
|
||||
_port=$(systemctl cat "$_svc" 2>/dev/null \
|
||||
| grep -oE '\-\-metrics[[:space:]]+[0-9.]+:[0-9]+' | head -1 \
|
||||
| grep -oE '[0-9]+$')
|
||||
INSTANCES+=("${_svc}:${_port:-$_fallback}")
|
||||
done
|
||||
|
||||
# 日志文件(判"僵死"时都看)
|
||||
TUNNEL_LOGS=("$RELAY_DIR/tunnel.log" "$RELAY_DIR/tunnel-h2.log")
|
||||
|
||||
# 连续几次公网不通才重启。
|
||||
# 设 3(约 60 秒)而不是 2:短抖动让 cloudflared 自己重连就好,
|
||||
# 频繁重启反而让它一直停在"正在重连"的状态。
|
||||
THRESHOLD=3
|
||||
# 连接**健康**时用的阈值,放宽很多。
|
||||
#
|
||||
# 为什么需要两个阈值(实测踩到):
|
||||
# 17:30:59 / 17:31:19 / 17:31:40 连着三次 HTTP 000,
|
||||
# 当时连接数是 7+2=9 条,完全健康 —— 结果看门狗按 THRESHOLD=3
|
||||
# 把两个实例都重启了。而重启会**主动切断全部连接**,
|
||||
# 接下来 60 秒真的不可用(17:32:35 才恢复到 6+1)。
|
||||
# 也就是说:这次重启把一次"抖一下就好"的故障,变成了确定的 1 分钟中断。
|
||||
#
|
||||
# 连接健康的情况下,公网偶尔打不通几乎都是链路丢包或边缘抖动,
|
||||
# 重启本地隧道解决不了,只会自伤。给足时间让它自己恢复。
|
||||
# 连接真的掉下去了(降级),才用 THRESHOLD=3 快速重启。
|
||||
THRESHOLD_HEALTHY=8
|
||||
STALE_MINUTES=10 # 日志多久没动静算卡死
|
||||
# 两次重启至少隔这么久。
|
||||
# 150 秒不是拍脑袋:隧道在差的 4G 上重建 5 条连接要 30~60 秒,
|
||||
# 原来设 45 秒会导致**隧道还没建齐就被下一次重启打断**,
|
||||
# 永远停在只剩 1 条连接的状态(实测死循环,每 50 秒重启一次)。
|
||||
MIN_RESTART_GAP=150
|
||||
GRACE=150
|
||||
# 总连接数到多少算"建齐过"。双实例期望 5+5=10,
|
||||
# 保守取 6(够证明链路能撑住)。
|
||||
BUILT_MIN=6
|
||||
|
||||
log() {
|
||||
logger -t "$LOG_TAG" "$1"
|
||||
echo "[$(date '+%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
read_state() {
|
||||
local key="$1" default="$2" value=""
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
value=$(grep "^$key=" "$STATE_FILE" 2>/dev/null | cut -d= -f2)
|
||||
fi
|
||||
[ -n "$value" ] && echo "$value" || echo "$default"
|
||||
}
|
||||
|
||||
# 状态文件:
|
||||
# zero —— 连续降级次数
|
||||
# fail —— 连续失败次数
|
||||
# built —— 本次重启后总连接数有没有到过 BUILT_MIN
|
||||
write_state() {
|
||||
printf 'zero=%s\nfail=%s\nbuilt=%s\n' "$1" "$2" "${3:-0}" > "$STATE_FILE"
|
||||
}
|
||||
|
||||
# 读某个 metrics 端口的连接数。
|
||||
# 端口不通(实例死了)→ 输出 "X"(区分于 "0":0 是活着但没连接)
|
||||
conns_of() {
|
||||
local port="$1" out
|
||||
out=$(timeout 5 curl -s --max-time 4 "http://127.0.0.1:$port/metrics" 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
if [ -z "$out" ]; then
|
||||
echo "X"
|
||||
else
|
||||
echo "$out"
|
||||
fi
|
||||
}
|
||||
|
||||
# 重启指定的一组服务(带最小间隔保护)
|
||||
# $1 = 原因
|
||||
# $2... = 要重启的服务名
|
||||
#
|
||||
# 设 DRY_RUN=1 时只打印不真重启 —— 用来验证判定逻辑是否正确
|
||||
# (双实例的判定分支不少,直接上生产风险大)。
|
||||
restart_services() {
|
||||
local why="$1"; shift
|
||||
local now last gap total_now
|
||||
now=$(date +%s)
|
||||
last=0
|
||||
[ -f "$LAST_RESTART_FILE" ] && last=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0)
|
||||
gap=$((now - last))
|
||||
|
||||
# 总连接数为 0 = 真的断了,不是抖动 —— 这种不受间隔限制
|
||||
total_now=$TOTAL
|
||||
if [ "${total_now:-1}" != "0" ] && [ "$gap" -lt "$MIN_RESTART_GAP" ]; then
|
||||
# 刚重启过还没到间隔:这次先不重启,
|
||||
# 让 cloudflared 自己把连接建起来(它本来就在重试)。
|
||||
log "$why —— 但距上次重启才 ${gap} 秒(下限 ${MIN_RESTART_GAP} 秒),先不重启"
|
||||
write_state 0 0 "${BUILT:-0}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${DRY_RUN:-0}" = "1" ]; then
|
||||
log "[DRY_RUN] $why —— 本应重启 [$*](距上次 ${gap} 秒)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "$why —— 重启 [$*](距上次 ${gap} 秒)"
|
||||
echo "$now" > "$LAST_RESTART_FILE"
|
||||
for svc in "$@"; do
|
||||
systemctl restart "$svc"
|
||||
done
|
||||
write_state 0 0 0
|
||||
sleep 25
|
||||
log "重启后地址: $(cat "$URL_FILE" 2>/dev/null || echo '(还没拿到)')"
|
||||
exit 0
|
||||
}
|
||||
|
||||
ZERO=$(read_state zero 0)
|
||||
FAIL=$(read_state fail 0)
|
||||
BUILT=$(read_state built 0)
|
||||
|
||||
# ---------- 信号 A:分别读两个实例 ----------
|
||||
TOTAL=0
|
||||
DEAD_SVCS=()
|
||||
LOW_SVCS=()
|
||||
DETAIL_PARTS=()
|
||||
NOW_TS=$(date +%s)
|
||||
LAST_R_NOW=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0)
|
||||
SINCE_RESTART=$(( NOW_TS - LAST_R_NOW ))
|
||||
for entry in "${INSTANCES[@]}"; do
|
||||
svc="${entry%%:*}"
|
||||
port="${entry##*:}"
|
||||
c=$(conns_of "$port")
|
||||
if [ "$c" = "X" ]; then
|
||||
# metrics 端口不通:要么进程没了,要么卡死到不响应
|
||||
DEAD_SVCS+=("$svc")
|
||||
if systemctl is-active --quiet "$svc"; then
|
||||
DETAIL_PARTS+=("$svc=无响应")
|
||||
else
|
||||
DETAIL_PARTS+=("$svc=服务停")
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
TOTAL=$((TOTAL + c))
|
||||
DETAIL_PARTS+=("$svc=$c")
|
||||
|
||||
# 连接数为 0 也要拉起来 —— 端口响应但一条连接都没有,
|
||||
# 说明这个实例根本没在工作(另一个实例可能还在扛着,
|
||||
# 所以公网看起来是通的,光看公网永远发现不了)。
|
||||
#
|
||||
# 但要给宽限期:刚重启的实例连接数本来就是 0,
|
||||
# 这时候重启会把它按回去,陷入"永远建不起来"的死循环。
|
||||
if [ "$c" = "0" ] && [ "$SINCE_RESTART" -gt "$GRACE" ]; then
|
||||
DEAD_SVCS+=("$svc")
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$c" -le 1 ] 2>/dev/null; then
|
||||
LOW_SVCS+=("$svc")
|
||||
fi
|
||||
done
|
||||
DETAIL_CONNS=$(IFS=, ; echo "${DETAIL_PARTS[*]}")
|
||||
|
||||
# 总连接数到过 BUILT_MIN 就记下来(跨多次检查保留)
|
||||
if [ "$TOTAL" -ge "$BUILT_MIN" ] 2>/dev/null; then
|
||||
if [ "$BUILT" != "1" ]; then
|
||||
log "本次已建齐(合计 ${TOTAL} 条:${DETAIL_CONNS})"
|
||||
fi
|
||||
BUILT=1
|
||||
fi
|
||||
|
||||
# 某个实例整个没响应 -> 直接把它拉起来(这不受公网状态影响,
|
||||
# 因为另一个实例可能还在扛着,公网看起来是通的)
|
||||
if [ "${#DEAD_SVCS[@]}" -gt 0 ]; then
|
||||
restart_services "实例无响应(${DETAIL_CONNS})" "${DEAD_SVCS[@]}"
|
||||
fi
|
||||
|
||||
# ---------- 信号 B:公网真的通吗 ----------
|
||||
URL=$(cat "$URL_FILE" 2>/dev/null | tr -d '\r\n')
|
||||
CODE="000"
|
||||
if [ -n "$URL" ]; then
|
||||
CODE=$(timeout 20 curl -4 -s -o /dev/null -w '%{http_code}' \
|
||||
-H 'User-Agent: LanRemote/1.8.2' "$URL/v1/health" 2>/dev/null)
|
||||
CODE=${CODE:-000}
|
||||
fi
|
||||
PUBLIC_OK=0
|
||||
[ "$CODE" = "200" ] && PUBLIC_OK=1
|
||||
|
||||
# ---------- 信号 C:日志僵死了吗 ----------
|
||||
# 两个日志都超过 STALE_MINUTES 没动静才算僵死
|
||||
LOG_STALE=1
|
||||
NOW=$(date +%s)
|
||||
for lg in "${TUNNEL_LOGS[@]}"; do
|
||||
if [ -f "$lg" ]; then
|
||||
LAST=$(stat -c %Y "$lg" 2>/dev/null || echo 0)
|
||||
if [ "$LAST" -gt 0 ] && [ $((NOW - LAST)) -le $((STALE_MINUTES * 60)) ]; then
|
||||
LOG_STALE=0
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------- 判断 ----------
|
||||
if [ "$PUBLIC_OK" = "1" ]; then
|
||||
# 公网通就是真的通,计数器清零
|
||||
if [ "$ZERO" != "0" ] || [ "$FAIL" != "0" ]; then
|
||||
log "公网恢复(HTTP 200),计数清零(之前 zero=$ZERO fail=$FAIL)"
|
||||
fi
|
||||
write_state 0 0 "${BUILT:-0}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 公网不通 —— 看是哪种原因
|
||||
DETAIL="HTTP $CODE,${DETAIL_CONNS}"
|
||||
ALL_SVCS=()
|
||||
for entry in "${INSTANCES[@]}"; do ALL_SVCS+=("${entry%%:*}"); done
|
||||
|
||||
if [ "$LOG_STALE" = "1" ] && [ "$FAIL" -ge 1 ]; then
|
||||
restart_services "两个日志都 ${STALE_MINUTES} 分钟没动静加上公网不通(进程卡死)" \
|
||||
"${ALL_SVCS[@]}"
|
||||
fi
|
||||
|
||||
FAIL=$((FAIL + 1))
|
||||
# 总连接数 <= 1 也算"降级"。
|
||||
#
|
||||
# 原来是 == 0 才算,但实测**只剩 1 条连接时跨机房回源就会失败**
|
||||
# (用户撞上的 530 就是这种:请求落 SEA,隧道只剩 LAX 一条)。
|
||||
# 所以 1 和 0 一样危险,都计入降级计数。
|
||||
DEGRADED=0
|
||||
if [ "$TOTAL" -le 1 ] 2>/dev/null; then
|
||||
ZERO=$((ZERO + 1))
|
||||
DEGRADED=1
|
||||
fi
|
||||
|
||||
log "公网不通($DETAIL),连续第 $FAIL 次(降级 $ZERO 次)"
|
||||
|
||||
if [ "$DEGRADED" = "1" ]; then
|
||||
LAST_R=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0)
|
||||
SINCE=$(( NOW - LAST_R ))
|
||||
|
||||
# 建齐过(>=BUILT_MIN)又垮下来 = 链路本来能撑住,现在是真故障
|
||||
# -> 不用再等宽限期,立刻重启
|
||||
# 从没建齐过 = 它还在努力建
|
||||
# -> 继续等,别打断它
|
||||
if [ "${BUILT:-0}" = "1" ]; then
|
||||
restart_services "公网不通,且本次重启后曾建齐过连接(确认故障,立即重启)" \
|
||||
"${ALL_SVCS[@]}"
|
||||
elif [ "$SINCE" -lt "$GRACE" ]; then
|
||||
log "公网不通且只剩 ${TOTAL} 条连接,但本次重启还没建齐过(${SINCE}/${GRACE} 秒)—— 继续等它建"
|
||||
else
|
||||
restart_services "公网不通且 ${SINCE} 秒都没建齐(链路或边缘有问题,重启试试)" \
|
||||
"${ALL_SVCS[@]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FAIL" -ge "$THRESHOLD" ] || [ "$ZERO" -ge "$THRESHOLD" ]; then
|
||||
# 连接还健康的话,用更宽的阈值 —— 别把"抖一下"升级成 1 分钟中断
|
||||
if [ "$TOTAL" -ge "$BUILT_MIN" ] 2>/dev/null && [ "$ZERO" -lt "$THRESHOLD" ]; then
|
||||
if [ "$FAIL" -lt "$THRESHOLD_HEALTHY" ]; then
|
||||
log "公网连续 $FAIL 次不通,但连接健康(合计 $TOTAL 条)—— 再等等,不重启"
|
||||
write_state "$ZERO" "$FAIL" "$BUILT"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 有实例只是"低连接"(不是完全没响应)时,只重启那些实例;
|
||||
# 全都低就重启全部。
|
||||
if [ "${#LOW_SVCS[@]}" -gt 0 ] && [ "${#LOW_SVCS[@]}" -lt "${#ALL_SVCS[@]}" ]; then
|
||||
restart_services "连续 $FAIL 次公网不通(含 $ZERO 次降级,低连接实例)" \
|
||||
"${LOW_SVCS[@]}"
|
||||
else
|
||||
restart_services "连续 $FAIL 次公网不通(含 $ZERO 次降级)" "${ALL_SVCS[@]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
write_state "$ZERO" "$FAIL" "$BUILT"
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# 原子方式更新 watchdog。
|
||||
#
|
||||
# 为什么必须原子:
|
||||
# watchdog 由 timer **每 10 秒**跑一次。如果直接覆盖
|
||||
# /root/lanremote-relay/watchdog.sh,在"文件已截断、新内容还没写完"
|
||||
# 的那个瞬间被 timer 读到,就会执行一份半截脚本 ——
|
||||
# 日志里真的出现了:
|
||||
# watchdog.sh: 行 191: 未预期的记号 "else" 附近有语法错误
|
||||
# 从 17:32:35 到 17:41:56 之间看门狗**完全没工作**。
|
||||
#
|
||||
# 正确做法:写 .new 临时文件 -> 语法检查 -> mv 覆盖。
|
||||
# 同一文件系统内 mv 是原子的,timer 要么读旧的、要么读新的。
|
||||
set -u
|
||||
|
||||
RELAY=/root/lanremote-relay
|
||||
SRC=/tmp/wd_new.sh
|
||||
NEW="$RELAY/.watchdog.sh.new"
|
||||
|
||||
echo "════════ 1. 检查新脚本 ════════"
|
||||
if [ ! -s "$SRC" ]; then
|
||||
echo " ✗ $SRC 不存在或为空"
|
||||
exit 1
|
||||
fi
|
||||
echo " 行数: $(wc -l < "$SRC")"
|
||||
if ! bash -n "$SRC" 2>&1; then
|
||||
echo " ✗ 语法检查不过,拒绝部署"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ 语法 OK"
|
||||
|
||||
echo
|
||||
echo "════════ 2. 原子替换 ════════"
|
||||
echo " 替换前行数: $(wc -l < "$RELAY/watchdog.sh") md5=$(md5sum "$RELAY/watchdog.sh" | awk '{print $1}')"
|
||||
cp "$SRC" "$NEW"
|
||||
chmod 755 "$NEW"
|
||||
# 落盘,确保 timer 不会读到还在页缓存里的半截内容
|
||||
sync
|
||||
mv -f "$NEW" "$RELAY/watchdog.sh"
|
||||
sync
|
||||
echo " 替换后行数: $(wc -l < "$RELAY/watchdog.sh") md5=$(md5sum "$RELAY/watchdog.sh" | awk '{print $1}')"
|
||||
echo " 拿掉临时文件: $(rm -f "$NEW" && echo 已清理)"
|
||||
|
||||
echo
|
||||
echo "════════ 3. 复核 ════════"
|
||||
bash -n "$RELAY/watchdog.sh" && echo " 语法 OK"
|
||||
echo " 关键参数:"
|
||||
grep -E '^THRESHOLD=|^THRESHOLD_HEALTHY=|^GRACE=|^MIN_RESTART_GAP=|^BUILT_MIN=' \
|
||||
"$RELAY/watchdog.sh" | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. 连跑 5 次预演 + 真跑 1 次 ════════"
|
||||
for i in 1 2 3 4 5; do
|
||||
out=$(DRY_RUN=1 bash "$RELAY/watchdog.sh" 2>&1)
|
||||
echo " 预演 $i: ${out:-(无输出=正常)}"
|
||||
sleep 1
|
||||
done
|
||||
echo " 真跑:"
|
||||
bash "$RELAY/watchdog.sh" 2>&1 | sed 's/^/ /'
|
||||
echo " 退出码 $? 状态: $(cat "$RELAY/watchdog.state" 2>/dev/null | tr '\n' ' ')"
|
||||
|
||||
echo
|
||||
echo "════════ 5. 等 timer 跑两轮,确认没有语法错误 ════════"
|
||||
sleep 25
|
||||
echo " 最近 6 行日志:"
|
||||
tail -6 "$RELAY/watchdog.log" | sed 's/^/ /'
|
||||
echo " 这个时间窗内有没有语法错误:"
|
||||
tail -20 "$RELAY/watchdog.log" | grep -c '语法错误\|未预期' | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 6. 长测进度 ════════"
|
||||
echo " 健康样本: $(wc -l < /root/long2h_health.tsv 2>/dev/null || echo 0)"
|
||||
echo " 轮询: $(wc -l < /root/rp_poll30.tsv 2>/dev/null || echo 0) / 30"
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# 端到端验证 watchdog 的"单实例掉线"识别(真改配置,跑完立刻还原)
|
||||
set -u
|
||||
|
||||
H2=/etc/systemd/system/lanremote-tunnel-h2.service
|
||||
BAK=/tmp/h2.bak.$(date +%s)
|
||||
cp -a "$H2" "$BAK"
|
||||
|
||||
restore() {
|
||||
cp -a "$BAK" "$H2"
|
||||
systemctl daemon-reload
|
||||
systemctl restart lanremote-tunnel-h2
|
||||
rm -f "$BAK"
|
||||
}
|
||||
trap restore EXIT
|
||||
|
||||
echo "════════ 1. 把副实例的 --metrics 改成一个没人听的端口 ════════"
|
||||
sed -i 's#--metrics 127.0.0.1:20242#--metrics 127.0.0.1:20299#' "$H2"
|
||||
grep -oE '\-\-metrics [0-9.:]+' "$H2" | sed 's/^/ h2: /'
|
||||
systemctl daemon-reload
|
||||
systemctl restart lanremote-tunnel-h2
|
||||
sleep 10
|
||||
|
||||
echo
|
||||
echo " 现在实际监听:"
|
||||
ss -tlnp 2>/dev/null | grep -E '2024[0-9]|20299' | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 2. watchdog 预演(DRY_RUN=1,不会真重启)════════"
|
||||
echo " 期望:认出 lanremote-tunnel-h2 无响应,**只**重启它"
|
||||
DRY_RUN=1 bash /root/lanremote-relay/watchdog.sh 2>&1 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 3. 对照:不改配置时的预演(应该什么都不做)════════"
|
||||
restore
|
||||
sleep 20
|
||||
echo " 实际监听:"
|
||||
ss -tlnp 2>/dev/null | grep -E '2024[0-9]' | sed 's/^/ /'
|
||||
echo " 预演:"
|
||||
DRY_RUN=1 bash /root/lanremote-relay/watchdog.sh 2>&1 | sed 's/^/ /'
|
||||
echo " (没输出 = 正常,符合预期)"
|
||||
|
||||
echo
|
||||
echo "════════ 4. 最终状态 ════════"
|
||||
for p in $(pgrep cloudflared); do
|
||||
cg=$(cat /proc/$p/cgroup 2>/dev/null | head -1)
|
||||
svc=$(basename "$cg" 2>/dev/null | sed 's/\.service$//')
|
||||
port=$(ss -tlnp 2>/dev/null | grep "pid=$p" | grep -oE '2024[0-9]' | head -1)
|
||||
n=$(curl -s --max-time 4 "http://127.0.0.1:$port/metrics" 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
echo " $svc -> 端口 ${port:-?} 连接 ${n:-?}"
|
||||
done
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# 验证 watchdog 文件完整性 + timer 正常 + 那个语法错误是什么时候的
|
||||
set -u
|
||||
|
||||
RELAY=/root/lanremote-relay
|
||||
|
||||
echo "════════ 1. 文件完整性 ════════"
|
||||
echo " 行数: $(wc -l < $RELAY/watchdog.sh)"
|
||||
echo " 修改时间: $(stat -c '%y' $RELAY/watchdog.sh)"
|
||||
echo " md5: $(md5sum $RELAY/watchdog.sh | awk '{print $1}')"
|
||||
echo " 语法: $(bash -n $RELAY/watchdog.sh 2>&1 && echo OK || echo '有错')"
|
||||
|
||||
echo
|
||||
echo "════════ 2. 关键片段(190-200 行,之前报错的位置)════════"
|
||||
sed -n '185,205p' "$RELAY/watchdog.sh" | cat -n | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 3. 用 shellcheck 语义检查(如果有)════════"
|
||||
if command -v shellcheck > /dev/null 2>&1; then
|
||||
shellcheck -S error "$RELAY/watchdog.sh" 2>&1 | head -20 | sed 's/^/ /'
|
||||
else
|
||||
echo " (没装 shellcheck)"
|
||||
# 退而求其次:用 bash 自己的解析器跑一遍
|
||||
bash -n "$RELAY/watchdog.sh" && echo " bash -n 通过"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "════════ 4. timer 状态 ════════"
|
||||
systemctl list-timers lanremote-watchdog.timer --no-pager 2>/dev/null | head -3 | sed 's/^/ /'
|
||||
echo " 最近 3 次执行(journal):"
|
||||
journalctl -u lanremote-watchdog.service --since '30 minutes ago' --no-pager 2>/dev/null \
|
||||
| tail -8 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 5. watchdog 日志里有没有语法错误(最近)════════"
|
||||
grep -n '语法错误\|unexpected\|未预期' "$RELAY/watchdog.log" 2>/dev/null | tail -5 | sed 's/^/ /' || echo " 没有"
|
||||
|
||||
echo
|
||||
echo "════════ 6. 备份文件里哪个是坏的 ════════"
|
||||
for f in $RELAY/watchdog.sh.bak.*; do
|
||||
[ -f "$f" ] || continue
|
||||
ok=$(bash -n "$f" 2>&1 && echo OK || echo BAD)
|
||||
printf " %-60s %s\n" "$(basename "$f")" "$ok"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 7. 各备份的行数和 THRESHOLD ════════"
|
||||
for f in $RELAY/watchdog.sh.bak.*; do
|
||||
[ -f "$f" ] || continue
|
||||
printf " %-58s %3s 行 %s\n" "$(basename "$f")" "$(wc -l < "$f")" \
|
||||
"$(grep -E '^THRESHOLD' "$f" | tr '\n' ' ')"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 8. 现在连跑 3 次预演,确认稳定 ════════"
|
||||
for i in 1 2 3; do
|
||||
out=$(DRY_RUN=1 bash "$RELAY/watchdog.sh" 2>&1)
|
||||
echo " 第 $i 次: ${out:-(无输出=正常)}"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 9. 长测进度 ════════"
|
||||
echo " 健康样本: $(wc -l < /root/long2h_health.tsv 2>/dev/null || echo 0)"
|
||||
echo " 轮询: $(wc -l < /root/rp_poll30.tsv 2>/dev/null || echo 0) / 30"
|
||||
echo " 最新健康记录:"
|
||||
tail -3 /root/long2h_health.tsv 2>/dev/null | sed 's/^/ /'
|
||||
echo " 连接数最新:"
|
||||
tail -3 /root/long2h_conns.tsv 2>/dev/null | sed 's/^/ /'
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# 确认 watchdog 能自动从服务配置里读出正确的端口
|
||||
set -u
|
||||
|
||||
echo "════════ 1. 两个服务配置里的 --metrics ════════"
|
||||
for svc in lanremote-tunnel lanremote-tunnel-h2; do
|
||||
p=$(systemctl cat "$svc" 2>/dev/null \
|
||||
| grep -oE '\-\-metrics[[:space:]]+[0-9.]+:[0-9]+' | head -1 \
|
||||
| grep -oE '[0-9]+$')
|
||||
echo " $svc -> ${p:-(读不到)}"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 2. 实际绑定的端口 ════════"
|
||||
for p in $(pgrep cloudflared); do
|
||||
cg=$(cat /proc/$p/cgroup 2>/dev/null | head -1)
|
||||
svc=$(basename "$cg" 2>/dev/null | sed 's/\.service$//')
|
||||
port=$(ss -tlnp 2>/dev/null | grep "pid=$p" | grep -oE '2024[0-9]' | head -1)
|
||||
echo " $svc -> ${port:-(没绑)}"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 3. watchdog 预演(看它读到几个实例、什么端口)════════"
|
||||
echo " 当前实际连接数:"
|
||||
for port in 20241 20242; do
|
||||
n=$(curl -s --max-time 4 http://127.0.0.1:$port/metrics 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
echo " 端口 $port: ${n:-无响应}"
|
||||
done
|
||||
echo
|
||||
echo " 预演输出(DRY_RUN=1):"
|
||||
DRY_RUN=1 bash /root/lanremote-relay/watchdog.sh 2>&1 | sed 's/^/ /'
|
||||
echo " (没有输出 = 一切正常,没触发任何重启 —— 这是对的)"
|
||||
|
||||
echo
|
||||
echo "════════ 4. 构造一个"副实例掉线"的场景,看它认不认得出 ════════"
|
||||
echo " 说明:不停真服务,直接把 watchdog 的端口指向一个没人听的端口来验证"
|
||||
sed 's/:20242/:20299/' /root/lanremote-relay/watchdog.sh > /tmp/wd_fake.sh
|
||||
DRY_RUN=1 bash /tmp/wd_fake.sh 2>&1 | sed 's/^/ /'
|
||||
echo " (期望:认出 lanremote-tunnel-h2 无响应,并只重启它)"
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# 验证自适应阈值:
|
||||
# 连接健康 + 公网不通 -> 要连着失败 8 次才重启(别把抖动升级成中断)
|
||||
# 连接降级 + 公网不通 -> 3 次就重启
|
||||
set -u
|
||||
|
||||
SRC=/tmp/wd_v4.sh
|
||||
|
||||
echo "════════ 0. 语法检查 ════════"
|
||||
bash -n "$SRC" && echo " ✓ 语法 OK" || { echo " ✗ 语法错"; exit 1; }
|
||||
|
||||
setup() {
|
||||
rm -rf /tmp/wdtest2
|
||||
mkdir -p /tmp/wdtest2/relay /tmp/wdtest2/bin
|
||||
sed -e 's#^RELAY_DIR=.*#RELAY_DIR=/tmp/wdtest2/relay#' \
|
||||
-e 's#^URL_FILE=.*#URL_FILE=/tmp/wdtest2/relay/public_url.txt#' \
|
||||
-e 's#^STATE_FILE=.*#STATE_FILE=/tmp/wdtest2/relay/watchdog.state#' \
|
||||
-e 's#^LAST_RESTART_FILE=.*#LAST_RESTART_FILE=/tmp/wdtest2/relay/watchdog.last_restart#' \
|
||||
-e 's#^TUNNEL_LOGS=.*#TUNNEL_LOGS=("/tmp/wdtest2/relay/t1.log")#' \
|
||||
"$SRC" > /tmp/wdtest2/wd.sh
|
||||
echo "https://relay.example" > /tmp/wdtest2/relay/public_url.txt
|
||||
touch /tmp/wdtest2/relay/t1.log
|
||||
printf '#!/bin/bash\nexit 0\n' > /tmp/wdtest2/bin/logger
|
||||
# systemctl 桩:restart 会写一行标记,方便判断有没有真重启
|
||||
cat > /tmp/wdtest2/bin/systemctl <<'EOF'
|
||||
#!/bin/bash
|
||||
if [ "$1" = "restart" ]; then echo "RESTARTED:$2"; exit 0; fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x /tmp/wdtest2/bin/*
|
||||
}
|
||||
|
||||
# 连续运行 N 次,返回"第几次触发了重启"
|
||||
run_series() {
|
||||
local label="$1" conns="$2" health="$3" times="$4"
|
||||
setup
|
||||
echo " ── $label(连接=$conns 公网=$health,连跑 $times 次)──"
|
||||
local i triggered=""
|
||||
for i in $(seq 1 "$times"); do
|
||||
cat > /tmp/wdtest2/bin/curl <<EOF
|
||||
#!/bin/bash
|
||||
for a in "\$@"; do
|
||||
case "\$a" in
|
||||
*:20241/metrics) echo "cloudflared_tunnel_ha_connections $conns"; exit 0 ;;
|
||||
*:20242/metrics) echo "cloudflared_tunnel_ha_connections $conns"; exit 0 ;;
|
||||
esac
|
||||
done
|
||||
[ "$health" = "000" ] && exit 7
|
||||
echo "$health"
|
||||
EOF
|
||||
chmod +x /tmp/wdtest2/bin/curl
|
||||
out=$(PATH=/tmp/wdtest2/bin:$PATH DRY_RUN=1 bash /tmp/wdtest2/wd.sh 2>&1)
|
||||
if echo "$out" | grep -q 'DRY_RUN.*本应重启'; then
|
||||
triggered="$i"
|
||||
echo " 第 $i 次触发重启: $(echo "$out" | grep 'DRY_RUN' | sed 's/.*\] //')"
|
||||
break
|
||||
fi
|
||||
# 显示中间状态
|
||||
echo "$out" | grep -E '再等等|连续第' | tail -1 | sed "s/^/ 第 $i 次: /"
|
||||
done
|
||||
[ -z "$triggered" ] && echo " $times 次内没有触发重启"
|
||||
echo
|
||||
}
|
||||
|
||||
echo "════════ 1. 连接健康 + 公网一直不通 ════════"
|
||||
echo " 期望:撑到第 8 次才重启(THRESHOLD_HEALTHY=8)"
|
||||
run_series "健康连接" 5 000 10
|
||||
|
||||
echo "════════ 2. 连接降级(0 条)+ 公网不通 ════════"
|
||||
echo " 期望:第 1 次就该重启(连接为 0 = 确认故障,立刻重启)"
|
||||
run_series "零连接" 0 000 6
|
||||
|
||||
echo "════════ 3. 连接只有 1 条 + 公网不通 ════════"
|
||||
echo " 期望:降级计数起作用,3 次左右重启"
|
||||
run_series "1 条连接" 1 000 6
|
||||
|
||||
echo "════════ 4. 对照:连接健康 + 公网正常 ════════"
|
||||
echo " 期望:永远不重启"
|
||||
run_series "健康且公网通" 5 200 5
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把 relay/ 目录 + test_deploy_device.sh 传到设备,跑真机沙箱测试。
|
||||
|
||||
用法: python _build/run_device_test.py
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
|
||||
from devssh import run, upload # noqa: E402
|
||||
|
||||
BASE = "/root/_dtest"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(" 准备目录…")
|
||||
print(run("rm -rf %s && mkdir -p %s/relay" % (BASE, BASE), timeout=60)[1].strip())
|
||||
|
||||
relay = ROOT / "relay"
|
||||
files = [p for p in sorted(relay.iterdir())
|
||||
if p.is_file() and p.suffix in (".py", ".sh", ".ico")]
|
||||
print(" 上传 relay/ 下 %d 个文件…" % len(files))
|
||||
for p in files:
|
||||
upload(str(p), "%s/relay/%s" % (BASE, p.name), 0o755)
|
||||
|
||||
shots = relay / "site_shots"
|
||||
shots_files = [p for p in sorted(shots.iterdir()) if p.is_file()] \
|
||||
if shots.is_dir() else []
|
||||
if shots_files:
|
||||
run("mkdir -p %s/relay/site_shots" % BASE, timeout=30)
|
||||
for p in shots_files:
|
||||
upload(str(p), "%s/relay/site_shots/%s" % (BASE, p.name), 0o644)
|
||||
print(" 上传 site_shots/ %d 张截图" % len(shots_files))
|
||||
|
||||
upload(str(ROOT / "_build" / "test_deploy_device.sh"),
|
||||
"%s/test_deploy_device.sh" % BASE, 0o755)
|
||||
|
||||
print(" 跑真机沙箱测试…")
|
||||
print()
|
||||
_c, out, err = run("bash %s/test_deploy_device.sh 2>&1" % BASE,
|
||||
timeout=900)
|
||||
print(out)
|
||||
if err.strip():
|
||||
print(" ── stderr ──")
|
||||
print(err)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# 跑验证智能体写的 9 个测试,看修复后还有哪些红的。
|
||||
#
|
||||
# ⚠ 用文件重定向,不要接 PowerShell 管道 ——
|
||||
# docs-writer 踩过:管道捕获 + 并发会让进程被随机 kill,
|
||||
# 看起来像测试坏了。
|
||||
set -u
|
||||
|
||||
cd /c/Users/shu/Desktop/deepseekagent/LanRemote 2>/dev/null || \
|
||||
cd "C:/Users/shu/Desktop/deepseekagent/LanRemote"
|
||||
|
||||
echo "════════ 验证集 · 逐个跑 ════════"
|
||||
pass=0; fail=0; failed_list=""
|
||||
|
||||
for t in _tests/test_verify_*.py; do
|
||||
[ -f "$t" ] || continue
|
||||
name=$(basename "$t")
|
||||
out=$(cmd //c "python -u $t > _build/_vt.txt 2>&1"; echo $?)
|
||||
# 取最后一行"通过 N 项,失败 M 项"
|
||||
line=$(grep -oE '通过 [0-9]+ 项,失败 [0-9]+ 项' _build/_vt.txt 2>/dev/null | tail -1)
|
||||
if [ "$out" = "0" ]; then
|
||||
printf " ✓ %-42s %s\n" "$name" "${line:-(退出码 0)}"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
printf " ✗ %-42s 退出码 %s %s\n" "$name" "$out" "${line:-}"
|
||||
fail=$((fail + 1))
|
||||
failed_list="$failed_list $name"
|
||||
# 打一点现场
|
||||
tail -6 _build/_vt.txt 2>/dev/null | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo " 通过 $pass 个,失败 $fail 个"
|
||||
[ -n "$failed_list" ] && echo " 失败的:$failed_list"
|
||||
rm -f _build/_vt.txt
|
||||
exit 0
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# 看两个隧道服务现在的真实配置(作为口径基准)
|
||||
set -u
|
||||
|
||||
for s in lanremote-tunnel lanremote-tunnel-h2; do
|
||||
echo "── $s ──"
|
||||
if [ -f "/etc/systemd/system/$s.service" ]; then
|
||||
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+|--metrics [0-9.:]+' \
|
||||
"/etc/systemd/system/$s.service" | tr '\n' ' '
|
||||
echo
|
||||
echo " enabled=$(systemctl is-enabled "$s" 2>/dev/null) active=$(systemctl is-active "$s" 2>/dev/null)"
|
||||
else
|
||||
echo " (服务不存在)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "── 合计连接数 ──"
|
||||
for p in 20241 20242; do
|
||||
n=$(curl -s --max-time 4 "http://127.0.0.1:$p/metrics" 2>/dev/null \
|
||||
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||
printf " 端口 %s -> %s 条\n" "$p" "${n:-(读不到)}"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "── 公网自检 ──"
|
||||
curl -s --max-time 15 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \
|
||||
https://relay.xxmxxs.top/v1/health
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# 分析哪些东西可以安全清理(只读,先不动手)
|
||||
set -u
|
||||
|
||||
echo "════════ 1. MySQL / MariaDB 到底有没有在用 ════════"
|
||||
for svc in mysql mariadb mysqld; do
|
||||
st=$(systemctl is-active $svc 2>/dev/null)
|
||||
en=$(systemctl is-enabled $svc 2>/dev/null)
|
||||
[ "$st" != "" ] || [ "$en" != "" ] && echo " $svc: active=$st enabled=$en"
|
||||
done
|
||||
echo " 进程:"
|
||||
pgrep -a -f 'mysqld|mariadb' | head -3 | sed 's/^/ /' || echo " 没有 mysqld 在跑"
|
||||
echo " 数据目录大小:"
|
||||
du -sh /var/lib/mysql 2>/dev/null | sed 's/^/ /'
|
||||
echo " 有没有别的服务依赖它:"
|
||||
grep -rl 'mysql\|mariadb' /etc/systemd/system/*.service 2>/dev/null | head -5 | sed 's/^/ /' || echo " 没有"
|
||||
|
||||
echo
|
||||
echo "════════ 2. 这台机器上装了哪些非系统服务 ════════"
|
||||
systemctl list-unit-files --state=enabled --no-pager 2>/dev/null \
|
||||
| grep -vE '^(systemd|dbus|getty|rsyslog|cron|ssh|networking|wpa|avahi|bluetooth|ModemManager|NetworkManager|udev|e2scrub|fstrim|logrotate|man-db|apt-daily|dpkg-db|ifupdown|user@|serial-getty|console-setup|keyboard-setup|hwclock|kmod|procps|rc-local|qemu|cloud-init)' \
|
||||
| head -25 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 3. 中转真正需要的服务 ════════"
|
||||
for svc in lanremote-relay lanremote-nettune lanremote-tunnel lanremote-tunnel-h2 lanremote-watchdog; do
|
||||
printf " %-26s enabled=%-10s active=%s\n" "$svc" \
|
||||
"$(systemctl is-enabled $svc 2>/dev/null || echo '-')" \
|
||||
"$(systemctl is-active $svc 2>/dev/null || echo '-')"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 4. 可安全清理的项(估个大小)════════"
|
||||
apt_cache=$(du -sm /var/cache/apt 2>/dev/null | awk '{print $1}')
|
||||
apt_lists=$(du -sm /var/lib/apt/lists 2>/dev/null | awk '{print $1}')
|
||||
logs=$(du -sm /var/log 2>/dev/null | awk '{print $1}')
|
||||
journal=$(journalctl --disk-usage 2>/dev/null | grep -oE '[0-9.]+[MG]' | head -1)
|
||||
echo " apt 包缓存 /var/cache/apt ${apt_cache:-0} MB (apt clean 可清)"
|
||||
echo " apt 索引 /var/lib/apt/lists ${apt_lists:-0} MB (apt clean 可清,但下次 apt update 会重下)"
|
||||
echo " /var/log ${logs:-0} MB (日志,可截断)"
|
||||
echo " journald ${journal:-0} (可 vacuum 到 20M)"
|
||||
echo " /root 下我的调试脚本 $(du -sm /root 2>/dev/null | awk '{print $1}') MB 中的一部分"
|
||||
echo " MySQL 数据 $(du -sm /var/lib/mysql 2>/dev/null | awk '{print $1}' || echo 0) MB (若没在用可卸)"
|
||||
|
||||
echo
|
||||
echo "════════ 5. 我遗留的调试脚本(/root 下的 .sh/.py)════════"
|
||||
n=$(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l)
|
||||
sz=$(du -sc /root/*.sh /root/*.py 2>/dev/null | tail -1 | awk '{print $1}')
|
||||
echo " 文件数: $n 合计: $((sz / 1024)) KB"
|
||||
echo " 列表(前 25 个):"
|
||||
ls -1 /root/*.sh /root/*.py 2>/dev/null | head -25 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 6. 有没有 apt 残留 / 大而无用的包 ════════"
|
||||
dpkg -l 2>/dev/null | awk '$1=="rc" {print " 残留配置: " $2}' | head -12
|
||||
echo " 可以 autoremove 的:"
|
||||
apt-get -s autoremove 2>/dev/null | grep -E '^Remv' | head -12 | sed 's/^/ /' || echo " (需要 apt-get update 后才能算)"
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# 查哪些服务真在跑、哪些是遗留(只读)
|
||||
set -u
|
||||
|
||||
echo "════════ 1. MySQL / MariaDB ════════"
|
||||
for svc in mysql mariadb mysqld; do
|
||||
st=$(systemctl is-active $svc 2>/dev/null)
|
||||
en=$(systemctl is-enabled $svc 2>/dev/null)
|
||||
if [ -n "$st" ] || [ -n "$en" ]; then
|
||||
echo " $svc: active=$st enabled=$en"
|
||||
fi
|
||||
done
|
||||
echo " 进程: $(pgrep -c -f 'mysqld|mariadb' 2>/dev/null || echo 0) 个"
|
||||
pgrep -a -f 'mysqld|mariadb' 2>/dev/null | head -3 | sed 's/^/ /'
|
||||
echo " 数据目录: $(du -sh /var/lib/mysql 2>/dev/null | awk '{print $1}')"
|
||||
echo " 包还在不在: $(dpkg -l 2>/dev/null | grep -cE '^ii +(mysql|mariadb)' ) 个已安装"
|
||||
|
||||
echo
|
||||
echo "════════ 2. nginx ════════"
|
||||
echo " active=$(systemctl is-active nginx 2>/dev/null) enabled=$(systemctl is-enabled nginx 2>/dev/null)"
|
||||
pgrep -a nginx 2>/dev/null | head -3 | sed 's/^/ /' || echo " 进程没在跑"
|
||||
echo " nginx 目录: $(du -sh /etc/nginx /var/www /var/log/nginx 2>/dev/null | tr '\n' ' ')"
|
||||
|
||||
echo
|
||||
echo "════════ 3. 谁在监听端口 ════════"
|
||||
ss -tulpn 2>/dev/null | head -20 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. 这三个服务和中转有关吗 ════════"
|
||||
echo " 中转用到的是: 8080(relay) / 20241(metrics) / 80,443(隧道出口不需要)"
|
||||
echo " ---"
|
||||
for p in 80 443 3306 8080 20241; do
|
||||
who=$(ss -tulpn 2>/dev/null | grep ":$p " | head -1 | awk '{print $NF}')
|
||||
printf " 端口 %-6s %s\n" "$p" "${who:-(没人监听)}"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 5. 历史遗留的 apt 残留配置(不占空间,但可以清)════════"
|
||||
dpkg -l 2>/dev/null | awk '$1=="rc" {print $2}' | tr '\n' ' ' | sed 's/^/ /'
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "════════ 6. /root 下我自己留下的脚本(172 个,都很小)════════"
|
||||
ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l | sed 's/^/ 数量: /'
|
||||
du -ch /root/*.sh /root/*.py 2>/dev/null | tail -1 | sed 's/^/ 合计: /'
|
||||
|
||||
echo
|
||||
echo "════════ 7. 汇总:清理能省多少 ════════"
|
||||
echo " apt cache + lists: 约 162 MB"
|
||||
echo " journald 压缩: 约 10 MB"
|
||||
echo " /var/log 截断: 约 10 MB"
|
||||
echo " MySQL 数据(若不用): 113 MB"
|
||||
echo " /root 调试脚本: 约 2 MB"
|
||||
echo " ----------"
|
||||
echo " 合计(保守,保留 MySQL): 约 184 MB"
|
||||
echo " 合计(MySQL 也不用了) : 约 297 MB"
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# 随身 WiFi(4G 设备)空间体检
|
||||
set -u
|
||||
|
||||
echo "════════ 1. 磁盘总览 ════════"
|
||||
df -h / 2>/dev/null | sed 's/^/ /'
|
||||
echo
|
||||
echo " 根分区占用明细(前 20 大目录):"
|
||||
du -xhd1 / 2>/dev/null | sort -rh | head -20 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 2. /root 下明细 ════════"
|
||||
du -xhd1 /root 2>/dev/null | sort -rh | head -15 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 3. /var 下明细 ════════"
|
||||
du -xhd1 /var 2>/dev/null | sort -rh | head -12 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. 日志和缓存(最容易清理的)════════"
|
||||
for d in /var/log /var/cache/apt /var/lib/apt/lists /tmp /root/.cache; do
|
||||
[ -d "$d" ] && printf " %-28s %s\n" "$d" "$(du -sh "$d" 2>/dev/null | awk '{print $1}')"
|
||||
done
|
||||
echo
|
||||
echo " journald 日志占用:"
|
||||
journalctl --disk-usage 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 5. 中转相关文件 ════════"
|
||||
du -sh /root/lanremote-relay 2>/dev/null | sed 's/^/ /'
|
||||
du -sh /root/lanremote-relay/downloads 2>/dev/null | sed 's/^/ downloads: /'
|
||||
ls -la /root/lanremote-relay/downloads/ 2>/dev/null | sed 's/^/ /'
|
||||
echo " 日志文件:"
|
||||
ls -lah /root/lanremote-relay/*.log 2>/dev/null | awk '{printf " %-42s %s\n", $9, $5}'
|
||||
|
||||
echo
|
||||
echo "════════ 6. 我调试时留下的临时文件(_build 上传的脚本)════════"
|
||||
du -sh /root/*.sh /root/*.py 2>/dev/null | sort -rh | head -20 | sed 's/^/ /'
|
||||
echo " /root 下的零散文件总数: $(ls -1 /root 2>/dev/null | wc -l)"
|
||||
|
||||
echo
|
||||
echo "════════ 7. 内存和 swap ════════"
|
||||
free -m | sed 's/^/ /'
|
||||
echo
|
||||
echo " swap 分区情况:"
|
||||
swapon --show 2>/dev/null | sed 's/^/ /' || echo " 没有启用 swap"
|
||||
|
||||
echo
|
||||
echo "════════ 8. 最大的 20 个文件(全盘)════════"
|
||||
find / -xdev -type f -size +3M 2>/dev/null -printf '%s %p\n' \
|
||||
| sort -rn | head -20 \
|
||||
| awk '{printf " %8.1f MB %s\n", $1/1048576, $2}'
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/bin/bash
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# 真机沙箱测试:在**真实的 Debian/aarch64 设备**上跑 deploy.sh,
|
||||
# 但把所有写入关进 /root/_dtest/sandbox,并且用桩命令挡住
|
||||
# systemd / iptables / ip / sysctl —— 绝不能碰正在服务的线上环境。
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# 为什么要单独在真机上测:
|
||||
# 开发机是 Windows/Git Bash。它能验证语法、参数、幂等、
|
||||
# 生成的 unit 内容,但验不了:
|
||||
# · 真实 sed/awk/grep 的行为(GNU vs BSD 有差异)
|
||||
# · 真实的 /proc 读取、python3 -m py_compile
|
||||
# · 真实文件权限、路径长度、换行处理
|
||||
# 这一轮把这些补上。
|
||||
#
|
||||
# 为什么必须用桩挡 systemd/iptables/ip/sysctl:
|
||||
# --prefix 只隔离**文件写入**,不隔离**命令副作用**。
|
||||
# 如果不挡,`systemctl restart lanremote-relay` 会真的把
|
||||
# 线上中转重启一遍,`iptables` 会真的改线上规则。
|
||||
#
|
||||
# 用法:把这个脚本和 relay/ 目录一起放在 /root/_dtest/ 下,
|
||||
# 然后 bash /root/_dtest/test_deploy_device.sh
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
set -u
|
||||
|
||||
BASE=/root/_dtest
|
||||
SRC="$BASE/relay"
|
||||
BIN="$BASE/bin"
|
||||
SANDBOX="$BASE/sandbox"
|
||||
LOG="$BASE/stub.log"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
ok() { PASS=$((PASS + 1)); printf " [PASS] %s\n" "$1"; }
|
||||
bad() { FAIL=$((FAIL + 1)); printf " [FAIL] %s\n" "$1"; }
|
||||
check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1(期望 $3,实际 $2)"; fi; }
|
||||
|
||||
echo "============================================================"
|
||||
echo " 真机沙箱测试 · $(uname -m) · $(uname -r)"
|
||||
echo "============================================================"
|
||||
echo " 系统: $(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")"
|
||||
echo " bash: $(bash --version | head -1)"
|
||||
|
||||
[ -f "$SRC/deploy.sh" ] || { echo " 找不到 $SRC/deploy.sh"; exit 1; }
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 0. 先记录线上现状(测完要确认没被碰过)──"
|
||||
LIVE_RELAY_ACTIVE=$(systemctl is-active lanremote-relay 2>/dev/null || echo "?")
|
||||
LIVE_TUNNEL_ACTIVE=$(systemctl is-active lanremote-tunnel 2>/dev/null || echo "?")
|
||||
LIVE_MTU=$(cat /sys/class/net/wlan0/mtu 2>/dev/null || echo "?")
|
||||
LIVE_MSS=$(iptables -t mangle -S OUTPUT 2>/dev/null | grep -c TCPMSS || echo 0)
|
||||
LIVE_KEEPALIVE=$(cat /proc/sys/net/ipv4/tcp_keepalive_time 2>/dev/null || echo "?")
|
||||
echo " lanremote-relay = $LIVE_RELAY_ACTIVE"
|
||||
echo " lanremote-tunnel = $LIVE_TUNNEL_ACTIVE"
|
||||
echo " wlan0 MTU = $LIVE_MTU"
|
||||
echo " MSS 规则数 = $LIVE_MSS"
|
||||
echo " keepalive_time = $LIVE_KEEPALIVE"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 1. 搭桩命令 ──"
|
||||
mkdir -p "$BIN" "$SANDBOX"
|
||||
: > "$LOG"
|
||||
export STUB_LOG="$LOG"
|
||||
|
||||
cat > "$BIN/systemctl" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "systemctl $*" >> "$STUB_LOG"
|
||||
STATE="$STUB_LOG.state"
|
||||
touch "$STATE"
|
||||
SVC=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
-*) continue ;;
|
||||
is-active|is-enabled|start|stop|restart|reload|enable|disable|status|daemon-reload|mask|unmask) continue ;;
|
||||
*) SVC="$a" ;;
|
||||
esac
|
||||
done
|
||||
case "$1" in
|
||||
is-active)
|
||||
if [ -n "$SVC" ] && grep -qxF "$SVC" "$STATE" 2>/dev/null; then
|
||||
echo "active"; exit 0
|
||||
fi
|
||||
echo "inactive"; exit 3 ;;
|
||||
is-enabled) echo "enabled"; exit 0 ;;
|
||||
start|restart)
|
||||
[ -n "$SVC" ] && { grep -qxF "$SVC" "$STATE" 2>/dev/null || echo "$SVC" >> "$STATE"; }
|
||||
exit 0 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
cat > "$BIN/iptables" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "iptables $*" >> "$STUB_LOG"
|
||||
RULES="$STUB_LOG.rules"; touch "$RULES"
|
||||
if [ "${1:-}" = "-t" ] && [ "${3:-}" = "-L" ]; then
|
||||
while read -r r; do [ -n "$r" ] && echo "TCPMSS tcp -- $r"; done < "$RULES"
|
||||
exit 0
|
||||
fi
|
||||
chain=""; mss=""; action=""; prev=""
|
||||
for a in "$@"; do
|
||||
case "$prev" in -C|-D|-A) chain="$a";; --set-mss) mss="$a";; esac
|
||||
case "$a" in -C|-D|-A) [ -z "$action" ] && action="$a";; esac
|
||||
prev="$a"
|
||||
done
|
||||
key="$chain:$mss"
|
||||
case "$action" in
|
||||
-C) grep -qxF "$key" "$RULES" 2>/dev/null && exit 0 || exit 1 ;;
|
||||
-A) grep -qxF "$key" "$RULES" 2>/dev/null || echo "$key" >> "$RULES"; exit 0 ;;
|
||||
-D) grep -qxF "$key" "$RULES" 2>/dev/null || exit 1
|
||||
grep -vxF "$key" "$RULES" > "$RULES.t" 2>/dev/null; mv "$RULES.t" "$RULES"; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat > "$BIN/ip" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "ip $*" >> "$STUB_LOG"
|
||||
[ "${1:-}" = "-4" ] && echo "default via 192.168.10.1 dev wlan0 proto dhcp metric 600"
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat > "$BIN/sysctl" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "sysctl $*" >> "$STUB_LOG"
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat > "$BIN/nmcli" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "nmcli $*" >> "$STUB_LOG"
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x "$BIN"/*
|
||||
echo " 桩命令: $(ls "$BIN" | tr '\n' ' ')"
|
||||
echo " (没有桩 curl —— 健康检查走真实 curl,只读无副作用)"
|
||||
|
||||
export PATH="$BIN:$PATH"
|
||||
echo " PATH 首位已切到桩目录"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 2. 语法检查(真机 bash)──"
|
||||
for f in deploy.sh deploy-tune.sh net-tune.sh; do
|
||||
if bash -n "$SRC/$f" 2>/tmp/synerr; then
|
||||
ok "$f 语法正确"
|
||||
else
|
||||
bad "$f 语法错误: $(head -2 /tmp/synerr)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 3. --help(真机跑)──"
|
||||
H=$(bash "$SRC/deploy.sh" --help 2>&1)
|
||||
check "--help 退出码 0" "$?" "0"
|
||||
echo "$H" | grep -q 'quic / http2 / both' \
|
||||
&& ok "--help 讲清了 --protocol 三个取值" || bad "--help 没讲 --protocol"
|
||||
echo "$H" | grep -q '8 条' \
|
||||
&& ok "--help 写明了配比" || bad "--help 没写配比"
|
||||
echo "$H" | grep -q '没有任何默认域名' \
|
||||
&& ok "--help 声明了没有默认域名" || bad "--help 没声明默认域名策略"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 4. 第一次部署(沙箱 + --skip-tunnel)──"
|
||||
printf 'lanremote-relay\n' > "$STUB_LOG.state"
|
||||
OUT1="$BASE/run1.log"
|
||||
bash "$SRC/deploy.sh" --prefix "$SANDBOX" --skip-tunnel > "$OUT1" 2>&1
|
||||
check "第一次退出码 0" "$?" "0"
|
||||
|
||||
REL="$SANDBOX/root/lanremote-relay"
|
||||
UNIT="$SANDBOX/etc/systemd/system"
|
||||
for f in relay.py admin.py landing.py showcase.py favicon.ico deploy-tune.sh net-tune.sh; do
|
||||
[ -f "$REL/$f" ] && ok "部署了 $f" || bad "没部署 $f"
|
||||
done
|
||||
[ -f "$REL/site_shots/pc-main.png" ] && ok "部署了截图" || bad "没部署截图"
|
||||
for u in lanremote-relay lanremote-nettune lanremote-tunnel lanremote-tunnel-h2; do
|
||||
[ -f "$UNIT/$u.service" ] && ok "生成了 $u.service" || bad "缺 $u.service"
|
||||
done
|
||||
|
||||
echo
|
||||
echo " ── 真机生成的隧道单元 ExecStart ──"
|
||||
grep -E '^ExecStart=' "$UNIT/lanremote-tunnel.service" | sed 's/^/ /'
|
||||
grep -E '^ExecStart=' "$UNIT/lanremote-tunnel-h2.service" | sed 's/^/ /'
|
||||
if grep -q -- '--protocol quic' "$UNIT/lanremote-tunnel.service" \
|
||||
&& grep -q -- '--ha-connections 8' "$UNIT/lanremote-tunnel.service" \
|
||||
&& grep -q -- '--metrics 127.0.0.1:20241' "$UNIT/lanremote-tunnel.service"; then
|
||||
ok "主隧道 = quic + 8 + metrics 20241"
|
||||
else
|
||||
bad "主隧道参数不对"
|
||||
fi
|
||||
if grep -q -- '--protocol http2' "$UNIT/lanremote-tunnel-h2.service" \
|
||||
&& grep -q -- '--ha-connections 2' "$UNIT/lanremote-tunnel-h2.service" \
|
||||
&& grep -q -- '--metrics 127.0.0.1:20242' "$UNIT/lanremote-tunnel-h2.service"; then
|
||||
ok "副本隧道 = http2 + 2 + metrics 20242"
|
||||
else
|
||||
bad "副本隧道参数不对"
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 5. 幂等:塞哨兵再跑第二遍 ──"
|
||||
echo '{"allowNew":false,"codes":[{"deviceId":"x","code":"789113"}]}' \
|
||||
> "$REL/data/relay_state.json"
|
||||
S1=$(md5sum "$REL/data/relay_state.json" | awk '{print $1}')
|
||||
echo "keepme" > "$REL/data/devices.json"
|
||||
: > "$STUB_LOG"
|
||||
printf 'lanremote-relay\n' > "$STUB_LOG.state"
|
||||
bash "$SRC/deploy.sh" --prefix "$SANDBOX" --skip-tunnel > "$BASE/run2.log" 2>&1
|
||||
check "第二次退出码 0" "$?" "0"
|
||||
S2=$(md5sum "$REL/data/relay_state.json" | awk '{print $1}')
|
||||
check "relay_state.json 没被改(allowNew 保住)" "$S2" "$S1"
|
||||
check "devices.json 没被冲掉" "$(cat "$REL/data/devices.json")" "keepme"
|
||||
grep -qxF 'systemctl restart lanremote-relay' "$STUB_LOG" 2>/dev/null \
|
||||
&& bad "无变化却重启了中转" || ok "无变化时没有重启中转"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 6. 双隧道默认都启用 ──"
|
||||
CFD="$SANDBOX/root/.cloudflared"
|
||||
mkdir -p "$CFD"
|
||||
echo '{"TunnelID":"deadbeef-0000-1111-2222-333344445555"}' \
|
||||
> "$CFD/deadbeef-0000-1111-2222-333344445555.json"
|
||||
: > "$STUB_LOG"
|
||||
printf 'lanremote-relay\n' > "$STUB_LOG.state"
|
||||
bash "$SRC/deploy.sh" --prefix "$SANDBOX" --hostname relay.test.example \
|
||||
> "$BASE/run3.log" 2>&1
|
||||
check "带域名部署退出码 0" "$?" "0"
|
||||
grep -qxF 'systemctl enable lanremote-tunnel' "$STUB_LOG" \
|
||||
&& ok "启用了主隧道" || bad "没启用主隧道"
|
||||
grep -qxF 'systemctl enable lanremote-tunnel-h2' "$STUB_LOG" \
|
||||
&& ok "启用了副本隧道" || bad "没启用副本隧道(应该双跑)"
|
||||
|
||||
echo
|
||||
echo "── 6b. --protocol http2 只跑副本 ──"
|
||||
: > "$STUB_LOG"
|
||||
printf 'lanremote-relay\n' > "$STUB_LOG.state"
|
||||
bash "$SRC/deploy.sh" --prefix "$SANDBOX" --protocol http2 \
|
||||
> "$BASE/run4.log" 2>&1
|
||||
check "--protocol http2 退出码 0" "$?" "0"
|
||||
grep -qxF 'systemctl enable lanremote-tunnel-h2' "$STUB_LOG" \
|
||||
&& ok "启用了副本" || bad "没启用副本"
|
||||
grep -qxF 'systemctl disable --now lanremote-tunnel' "$STUB_LOG" \
|
||||
&& ok "停用了主隧道" || bad "没停用主隧道"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 7. 真机健康检查(真 curl,不是桩)──"
|
||||
if curl -s --max-time 8 http://127.0.0.1:8080/v1/health 2>/dev/null | grep -q '"ok"'; then
|
||||
ok "线上中转的 /v1/health 通"
|
||||
else
|
||||
bad "线上中转没响应"
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 8. 确认线上环境一点没被碰 ──"
|
||||
NOW_RELAY=$(systemctl is-active lanremote-relay 2>/dev/null || echo "?")
|
||||
NOW_TUNNEL=$(systemctl is-active lanremote-tunnel 2>/dev/null || echo "?")
|
||||
NOW_MTU=$(cat /sys/class/net/wlan0/mtu 2>/dev/null || echo "?")
|
||||
NOW_MSS=$(iptables -t mangle -S OUTPUT 2>/dev/null | grep -c TCPMSS || echo 0)
|
||||
NOW_KA=$(cat /proc/sys/net/ipv4/tcp_keepalive_time 2>/dev/null || echo "?")
|
||||
check "lanremote-relay 状态没变" "$NOW_RELAY" "$LIVE_RELAY_ACTIVE"
|
||||
check "lanremote-tunnel 状态没变" "$NOW_TUNNEL" "$LIVE_TUNNEL_ACTIVE"
|
||||
check "wlan0 MTU 没变" "$NOW_MTU" "$LIVE_MTU"
|
||||
check "MSS 规则数没变" "$NOW_MSS" "$LIVE_MSS"
|
||||
check "keepalive 没变" "$NOW_KA" "$LIVE_KEEPALIVE"
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 9. 清理 ──"
|
||||
rm -rf "$BASE"
|
||||
echo " 已删除 $BASE"
|
||||
echo " 线上中转再确认: $(curl -s --max-time 8 http://127.0.0.1:8080/v1/health 2>/dev/null | head -c 70)"
|
||||
|
||||
echo
|
||||
echo "============================================================"
|
||||
echo " 结果: 通过 $PASS 项,失败 $FAIL 项"
|
||||
echo "============================================================"
|
||||
[ "$FAIL" = "0" ] && exit 0 || exit 1
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# 精确取证:--prefix 模式下,deploy.sh(及其子脚本)到底动了哪些真实系统资源。
|
||||
#
|
||||
# 垫片会记录**调用者**(父进程命令行),这样能分清是
|
||||
# deploy.sh 自己调的,还是它拉起来的 deploy-tune.sh / net-tune.sh 调的。
|
||||
set -u
|
||||
|
||||
SHIM=/tmp/sbxim2
|
||||
SBX=/tmp/sbxroot2
|
||||
LOG=$SHIM/calls.log
|
||||
SCRIPT=${1:-/root/dep/deploy.sh}
|
||||
|
||||
rm -rf "$SHIM" "$SBX"
|
||||
mkdir -p "$SHIM" "$SBX"
|
||||
|
||||
for cmd in systemctl iptables ip sysctl modprobe cloudflared apt-get apt \
|
||||
service journalctl useradd groupadd chmod chown ln; do
|
||||
cat > "$SHIM/$cmd" <<EOF
|
||||
#!/bin/bash
|
||||
# 记下调用者和完整参数
|
||||
PPID_CMD=\$(tr '\0' ' ' < /proc/\$PPID/cmdline 2>/dev/null | cut -c1-90)
|
||||
echo "[caller: \$PPID_CMD] $cmd \$*" >> $LOG
|
||||
case "\$1" in
|
||||
is-active) echo "active"; exit 0 ;;
|
||||
is-enabled) echo "enabled"; exit 0 ;;
|
||||
show) echo ""; exit 0 ;;
|
||||
-S) exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$SHIM/$cmd"
|
||||
done
|
||||
: > "$LOG"
|
||||
|
||||
echo "════════ 跑 deploy.sh --prefix $SBX --skip-tunnel ════════"
|
||||
PATH="$SHIM:$PATH" timeout 300 bash "$SCRIPT" --prefix "$SBX" --skip-tunnel \
|
||||
> /tmp/sbx2.out 2>&1
|
||||
echo " 退出码: $?"
|
||||
echo " 输出末尾:"
|
||||
tail -8 /tmp/sbx2.out | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 1. 所有 systemctl 调用 ════════"
|
||||
if grep -q 'systemctl' "$LOG"; then
|
||||
grep 'systemctl' "$LOG" | sed 's/^/ /'
|
||||
else
|
||||
echo " (没有)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "════════ 2. 所有 iptables / ip / sysctl / modprobe 调用 ════════"
|
||||
if grep -qE 'iptables|^\[caller[^]]*\] ip |sysctl|modprobe' "$LOG"; then
|
||||
grep -E 'iptables|sysctl|modprobe' "$LOG" | sed 's/^/ /' | head -25
|
||||
echo " …(共 $(grep -cE 'iptables|sysctl|modprobe' "$LOG") 条)"
|
||||
else
|
||||
echo " (没有)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "════════ 3. 按调用者归类 iptables ════════"
|
||||
grep 'iptables' "$LOG" 2>/dev/null | sed 's/\] iptables.*/]/' | sort | uniq -c | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════════ 4. 沙箱里 service 单元的 ExecStart(看前缀有没有漏进去)════════"
|
||||
for f in "$SBX"/etc/systemd/system/*.service; do
|
||||
[ -f "$f" ] || continue
|
||||
echo " ── $(basename "$f") ──"
|
||||
grep -E '^ExecStart|^WorkingDirectory|^Environment' "$f" | sed 's/^/ /'
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 5. 沙箱外有没有被写 ════════"
|
||||
echo " 沙箱里的文件数: $(find "$SBX" -type f 2>/dev/null | wc -l)"
|
||||
echo " 检查几个关键真实路径的修改时间(今天有没有被动过):"
|
||||
for p in /etc/systemd/system /etc/sysctl.d /root/.cloudflared /root/lanremote-relay \
|
||||
/usr/local/bin/cloudflared; do
|
||||
if [ -e "$p" ]; then
|
||||
t=$(stat -c '%y' "$p" 2>/dev/null | cut -d'.' -f1)
|
||||
echo " $p 最后修改: $t"
|
||||
else
|
||||
echo " $p (不存在)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════════ 6. deploy-tune.sh 在 prefix 模式下的行为 ════════"
|
||||
if [ -f /root/dep/deploy-tune.sh ]; then
|
||||
echo " deploy-tune.sh 里的 iptables/sysctl 调用:"
|
||||
grep -nE 'iptables|sysctl|ip link|modprobe' /root/dep/deploy-tune.sh \
|
||||
| head -20 | sed 's/^/ /'
|
||||
echo
|
||||
echo " 它有没有处理 --prefix:"
|
||||
grep -n 'PREFIX' /root/dep/deploy-tune.sh | head -10 | sed 's/^/ /'
|
||||
fi
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
# 攻击面 5:deploy.sh --prefix 沙箱模式会不会偷偷写到真实系统?
|
||||
#
|
||||
# 做法:造一组"假命令"垫在最前面(PATH 优先级最高),
|
||||
# 它们只把调用记录到日志,什么都不执行。
|
||||
# 然后跑 deploy.sh --prefix /tmp/sbx,看:
|
||||
# a) 有没有调用 systemctl 去操作真实服务
|
||||
# b) 有没有调用 iptables / ip link / sysctl 改真实系统
|
||||
# c) 有没有往 /tmp/sbx 以外的地方写文件
|
||||
#
|
||||
# 这样即使脚本里真有漏洞,也不会真的动到设备上的服务。
|
||||
set -u
|
||||
|
||||
SHIM=/tmp/sbxim
|
||||
SBX=/tmp/sbxroot
|
||||
LOG=$SHIM/calls.log
|
||||
SCRIPT=${1:-/root/dep/relay/deploy.sh}
|
||||
|
||||
rm -rf "$SHIM" "$SBX"
|
||||
mkdir -p "$SHIM" "$SBX"
|
||||
|
||||
# ---- 造假命令 ----
|
||||
for cmd in systemctl iptables ip sysctl modprobe cloudflared apt-get apt \
|
||||
service journalctl useradd groupadd chown chmod ln; do
|
||||
cat > "$SHIM/$cmd" <<EOF
|
||||
#!/bin/bash
|
||||
echo "$cmd \$*" >> $LOG
|
||||
# systemctl is-active 要回点像样的东西,不然脚本会走别的分支
|
||||
case "\$1" in
|
||||
is-active) echo "active"; exit 0 ;;
|
||||
is-enabled) echo "enabled"; exit 0 ;;
|
||||
show) echo ""; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$SHIM/$cmd"
|
||||
done
|
||||
: > "$LOG"
|
||||
|
||||
echo "════════ 1. 记录运行前的真实系统状态(作为基线)════════"
|
||||
BASE_UNITS=$(ls /etc/systemd/system/ 2>/dev/null | md5sum | awk '{print $1}')
|
||||
BASE_CFG=$(md5sum /root/.cloudflared/config.yml 2>/dev/null | awk '{print $1}')
|
||||
BASE_RELAY=$(md5sum /root/lanremote-relay/relay.py 2>/dev/null | awk '{print $1}')
|
||||
BASE_IPT=$(iptables -t mangle -S 2>/dev/null | md5sum | awk '{print $1}')
|
||||
BASE_ENABLED=$(systemctl list-unit-files 2>/dev/null | grep -c lanremote || echo 0)
|
||||
echo " /etc/systemd/system 内容摘要: $BASE_UNITS"
|
||||
echo " /root/.cloudflared/config.yml: ${BASE_CFG:-(不存在)}"
|
||||
echo " /root/lanremote-relay/relay.py: ${BASE_RELAY:-(不存在)}"
|
||||
echo " iptables mangle 规则: $BASE_IPT"
|
||||
echo " lanremote 相关 unit 数: $BASE_ENABLED"
|
||||
|
||||
echo
|
||||
echo "════════ 2. 用垫片 PATH 跑 deploy.sh --prefix $SBX ════════"
|
||||
if [ ! -f "$SCRIPT" ]; then
|
||||
echo " ! 找不到 $SCRIPT"
|
||||
echo " 试着找一下附近有没有 deploy.sh:"
|
||||
find /root /tmp -maxdepth 4 -name 'deploy.sh' 2>/dev/null | head -5
|
||||
exit 1
|
||||
fi
|
||||
echo " 脚本: $SCRIPT"
|
||||
|
||||
# 只跑安装部分,跳过隧道(隧道要联网、要 token)
|
||||
PATH="$SHIM:$PATH" timeout 300 bash "$SCRIPT" \
|
||||
--prefix "$SBX" --skip-tunnel 2>&1 | tail -40 | sed 's/^/ /'
|
||||
RC=${PIPESTATUS[0]}
|
||||
echo " 退出码: $RC"
|
||||
|
||||
echo
|
||||
echo "════════ 3. 被调用的危险命令(关键证据)════════"
|
||||
if [ -s "$LOG" ]; then
|
||||
sort "$LOG" | uniq -c | sort -rn | sed 's/^/ /'
|
||||
else
|
||||
echo " (没有任何危险命令被调用 —— 沙箱很干净)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "════════ 4. 逐条判定 ════════"
|
||||
# a) systemctl 有没有操作真实服务(--prefix 模式下不该有)
|
||||
BAD_SYSCTL=$(grep -E '^systemctl (enable|start|restart|stop|disable|daemon-reload)' "$LOG" 2>/dev/null || true)
|
||||
if [ -n "$BAD_SYSCTL" ]; then
|
||||
echo " 🔴 沙箱模式下仍然调用了会改真实系统的 systemctl:"
|
||||
echo "$BAD_SYSCTL" | sed 's/^/ /'
|
||||
else
|
||||
echo " ✅ 没有调用会改真实系统的 systemctl"
|
||||
fi
|
||||
|
||||
# b) 网络/内核改动
|
||||
BAD_NET=$(grep -E '^(iptables|ip link|sysctl|modprobe)' "$LOG" 2>/dev/null || true)
|
||||
if [ -n "$BAD_NET" ]; then
|
||||
echo " 🟡 沙箱模式下调用了网络/内核命令:"
|
||||
echo "$BAD_NET" | sed 's/^/ /'
|
||||
else
|
||||
echo " ✅ 没有调用 iptables / ip link / sysctl / modprobe"
|
||||
fi
|
||||
|
||||
# c) 文件有没有写到沙箱外
|
||||
echo
|
||||
echo " ── 沙箱目录里生成了什么 ──"
|
||||
find "$SBX" -type f 2>/dev/null | head -20 | sed 's/^/ /'
|
||||
echo " 共 $(find "$SBX" -type f 2>/dev/null | wc -l) 个文件"
|
||||
|
||||
echo
|
||||
echo " ── 真实系统有没有被改(对比基线)──"
|
||||
NOW_UNITS=$(ls /etc/systemd/system/ 2>/dev/null | md5sum | awk '{print $1}')
|
||||
NOW_CFG=$(md5sum /root/.cloudflared/config.yml 2>/dev/null | awk '{print $1}')
|
||||
NOW_RELAY=$(md5sum /root/lanremote-relay/relay.py 2>/dev/null | awk '{print $1}')
|
||||
NOW_IPT=$(iptables -t mangle -S 2>/dev/null | md5sum | awk '{print $1}')
|
||||
NOW_ENABLED=$(systemctl list-unit-files 2>/dev/null | grep -c lanremote || echo 0)
|
||||
|
||||
[ "$BASE_UNITS" = "$NOW_UNITS" ] && echo " ✅ /etc/systemd/system 没变" || echo " 🔴 /etc/systemd/system **变了**"
|
||||
[ "$BASE_CFG" = "$NOW_CFG" ] && echo " ✅ cloudflared config 没变" || echo " 🔴 cloudflared config **变了**"
|
||||
[ "$BASE_RELAY" = "$NOW_RELAY" ] && echo " ✅ 现有 relay.py 没变" || echo " 🔴 现有 relay.py **变了**"
|
||||
[ "$BASE_IPT" = "$NOW_IPT" ] && echo " ✅ iptables 没变" || echo " 🔴 iptables **变了**"
|
||||
[ "$BASE_ENABLED" = "$NOW_ENABLED" ] && echo " ✅ unit 启用状态没变 ($NOW_ENABLED)" || echo " 🔴 unit 启用状态变了:$BASE_ENABLED -> $NOW_ENABLED"
|
||||
|
||||
echo
|
||||
echo "════════ 5. 再跑一遍,验证幂等性 ════════"
|
||||
: > "$LOG"
|
||||
PATH="$SHIM:$PATH" timeout 300 bash "$SCRIPT" \
|
||||
--prefix "$SBX" --skip-tunnel > /tmp/sbx_run2.log 2>&1
|
||||
echo " 第二次退出码: $?"
|
||||
echo " 第二次有没有非 0 退出/报错行:"
|
||||
grep -ciE 'error|错误|失败|✗' /tmp/sbx_run2.log | sed 's/^/ 匹配行数: /'
|
||||
tail -6 /tmp/sbx_run2.log | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo " 沙箱最终文件数: $(find "$SBX" -type f 2>/dev/null | wc -l)"
|
||||
+275
-14
@@ -111,11 +111,45 @@ mk() { # mk <名字> <<'EOF' ...
|
||||
|
||||
mk systemctl <<'EOF'
|
||||
#!/bin/bash
|
||||
# 桩:会**记住**哪些服务被启动过。
|
||||
# 如果这里 is-active 一律返回 active,那么"无变化就不该重启"这类
|
||||
# 断言永远测不出来(net-tune.sh 会以为隧道在跑,然后去重启它)。
|
||||
echo "systemctl $*" >> "$STUB_LOG"
|
||||
STATE="$STUB_LOG.state"
|
||||
touch "$STATE"
|
||||
|
||||
# ⚠ 服务名要自己从参数里挑出来。
|
||||
# 直接取 $2 是错的:`systemctl is-active --quiet foo` 里 $2 是 --quiet,
|
||||
# 不是服务名 —— 这样挑出来的是 "--quiet",grep 永远不中,
|
||||
# 于是所有服务都被当成"没在跑"。
|
||||
SVC=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
-*) continue ;;
|
||||
is-active|is-enabled|start|stop|restart|reload|enable|disable|status|daemon-reload|mask|unmask)
|
||||
continue ;;
|
||||
*) SVC="$a" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$1" in
|
||||
is-active) echo "active"; exit 0 ;;
|
||||
is-active)
|
||||
if [ -n "$SVC" ] && grep -qxF "$SVC" "$STATE" 2>/dev/null; then
|
||||
echo "active"; exit 0
|
||||
fi
|
||||
echo "inactive"; exit 3 ;;
|
||||
is-enabled) echo "enabled"; exit 0 ;;
|
||||
*) exit 0 ;;
|
||||
start|restart)
|
||||
[ -n "$SVC" ] && { grep -qxF "$SVC" "$STATE" 2>/dev/null \
|
||||
|| echo "$SVC" >> "$STATE"; }
|
||||
exit 0 ;;
|
||||
stop)
|
||||
if [ -n "$SVC" ]; then
|
||||
grep -vxF "$SVC" "$STATE" > "$STATE.t" 2>/dev/null
|
||||
mv "$STATE.t" "$STATE" 2>/dev/null
|
||||
fi
|
||||
exit 0 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
@@ -247,6 +281,10 @@ fi
|
||||
REL="$SANDBOX/root/lanremote-relay"
|
||||
UNIT="$SANDBOX/etc/systemd/system"
|
||||
|
||||
# 把第一遍的桩日志留一份 —— 第二遍会被清空,
|
||||
# 但"第一次装的时候确实做了 daemon-reload"这事得留证据。
|
||||
cp "$STUB_LOG" "$STUB_LOG.run1" 2>/dev/null || true
|
||||
|
||||
exists "relay.py 已部署" "$REL/relay.py"
|
||||
exists "admin.py 已部署" "$REL/admin.py"
|
||||
exists "landing.py 已部署" "$REL/landing.py"
|
||||
@@ -273,23 +311,45 @@ for kw in "RELAY_POLL=60" "RELAY_PORT=8080" "python3"; do
|
||||
bad "relay unit 缺 $kw"
|
||||
fi
|
||||
done
|
||||
if grep -q 'http2' "$UNIT/lanremote-tunnel-h2.service" 2>/dev/null; then
|
||||
ok "h2 单元用的是 http2"
|
||||
else
|
||||
bad "h2 单元没写 http2"
|
||||
fi
|
||||
if grep -q 'quic' "$UNIT/lanremote-tunnel.service" 2>/dev/null; then
|
||||
ok "主隧道单元用的是 quic"
|
||||
echo
|
||||
echo " 两个隧道单元的内容(默认主 quic+8 / 副本 http2+2):"
|
||||
# 主隧道
|
||||
if grep -q -- '--protocol quic' "$UNIT/lanremote-tunnel.service" 2>/dev/null; then
|
||||
ok "主隧道单元用 quic"
|
||||
else
|
||||
bad "主隧道单元没写 quic"
|
||||
fi
|
||||
if grep -q 'ha-connections 10' "$UNIT/lanremote-tunnel.service" 2>/dev/null; then
|
||||
ok "隧道单元含 ha-connections 10"
|
||||
if grep -q -- '--ha-connections 8' "$UNIT/lanremote-tunnel.service" 2>/dev/null; then
|
||||
ok "主隧道 8 条连接(实测最优配比)"
|
||||
else
|
||||
bad "隧道单元缺 ha-connections 10"
|
||||
bad "主隧道连接数不是 8"
|
||||
fi
|
||||
# 副本隧道
|
||||
if grep -q -- '--protocol http2' "$UNIT/lanremote-tunnel-h2.service" 2>/dev/null; then
|
||||
ok "副本隧道单元用 http2"
|
||||
else
|
||||
bad "副本隧道单元没写 http2"
|
||||
fi
|
||||
if grep -q -- '--ha-connections 2' "$UNIT/lanremote-tunnel-h2.service" 2>/dev/null; then
|
||||
ok "副本隧道 2 条连接(实测最优配比)"
|
||||
else
|
||||
bad "副本隧道连接数不是 2"
|
||||
fi
|
||||
# ★ metrics 端口必须显式钉死且不重复
|
||||
# 两边都用默认 20241 的话,停掉主实例再启副实例时副实例会抢到 20241,
|
||||
# 所有按端口读指标的脚本就读错实例了,而且不报错。
|
||||
if grep -q -- '--metrics 127.0.0.1:20241' "$UNIT/lanremote-tunnel.service" 2>/dev/null; then
|
||||
ok "主隧道 metrics 钉死 20241"
|
||||
else
|
||||
bad "主隧道没显式指定 metrics 端口"
|
||||
fi
|
||||
if grep -q -- '--metrics 127.0.0.1:20242' "$UNIT/lanremote-tunnel-h2.service" 2>/dev/null; then
|
||||
ok "副本隧道 metrics 钉死 20242"
|
||||
else
|
||||
bad "副本隧道没显式指定 metrics 端口"
|
||||
fi
|
||||
if grep -q 'ExecStart=/bin/bash' "$UNIT/lanremote-nettune.service" 2>/dev/null; then
|
||||
ok "nettune 顺序执行两个调优脚本"
|
||||
ok "nettune 只有一个入口脚本"
|
||||
else
|
||||
bad "nettune 单元不对"
|
||||
fi
|
||||
@@ -314,6 +374,17 @@ echo "device-sentinel" > "$REL/data/devices.json"
|
||||
echo "key-sentinel" > "$REL/data/admin_key.txt"
|
||||
echo "pw-sentinel" > "$REL/data/admin_password.txt"
|
||||
echo "https://keep.me" > "$REL/public_url.txt"
|
||||
# allowNew 是管理员在管理页上拨的开关,存在 relay_state.json 里。
|
||||
# 用户重跑脚本升级时**绝对不能**把它拨回去 —— 那是安全设置。
|
||||
cat > "$REL/data/relay_state.json" <<'EOF'
|
||||
{
|
||||
"allowNew": false,
|
||||
"codes": [
|
||||
{"deviceId": "desktop-sentinel-abc123", "code": "789113"}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
STATE_BEFORE=$(md5sum "$REL/data/relay_state.json" | awk '{print $1}')
|
||||
# 手工改过的 config.yml 也不能被无脑覆盖
|
||||
mkdir -p "$SANDBOX/root/.cloudflared"
|
||||
cat > "$SANDBOX/root/.cloudflared/config.yml" <<'EOF'
|
||||
@@ -327,6 +398,8 @@ EOF
|
||||
BEFORE_HASH=$(md5sum "$SANDBOX/root/.cloudflared/config.yml" | awk '{print $1}')
|
||||
# 记下部署文件的指纹,第二遍后应该一致(内容相同)
|
||||
BEFORE_RELAY=$(md5sum "$REL/relay.py" | awk '{print $1}')
|
||||
# 清空桩日志,好判断第二遍**有没有多此一举地重启服务**
|
||||
: > "$STUB_LOG"
|
||||
|
||||
OUT2="$TMP/run2.log"
|
||||
bash "$DEPLOY" --prefix "$SANDBOX" --skip-tunnel --port 8080 > "$OUT2" 2>&1
|
||||
@@ -341,6 +414,14 @@ check "设备数据没被冲掉" "$(cat "$REL/data/devices.json" 2>/dev/null)"
|
||||
check "管理钥匙没被冲掉" "$(cat "$REL/data/admin_key.txt" 2>/dev/null)" "key-sentinel"
|
||||
check "管理密码没被冲掉" "$(cat "$REL/data/admin_password.txt" 2>/dev/null)" "pw-sentinel"
|
||||
check "公网地址没被冲掉" "$(cat "$REL/public_url.txt" 2>/dev/null)" "https://keep.me"
|
||||
# ★ allowNew 必须原样保留
|
||||
STATE_AFTER=$(md5sum "$REL/data/relay_state.json" | awk '{print $1}')
|
||||
check "relay_state.json 没被改(allowNew 保住了)" "$STATE_AFTER" "$STATE_BEFORE"
|
||||
if grep -q '"allowNew": false' "$REL/data/relay_state.json" 2>/dev/null; then
|
||||
ok "allowNew 仍然是 false(没被拨回 true)"
|
||||
else
|
||||
bad "allowNew 被改了"
|
||||
fi
|
||||
AFTER_HASH=$(md5sum "$SANDBOX/root/.cloudflared/config.yml" | awk '{print $1}')
|
||||
check "手工 config.yml 没被覆盖" "$AFTER_HASH" "$BEFORE_HASH"
|
||||
AFTER_RELAY=$(md5sum "$REL/relay.py" | awk '{print $1}')
|
||||
@@ -351,6 +432,35 @@ W1=$(grep -c '✗' "$OUT1" 2>/dev/null || echo 0)
|
||||
W2=$(grep -c '✗' "$OUT2" 2>/dev/null || echo 0)
|
||||
check "第二遍没有新增 ✗ 错误" "$W2" "$W1"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 6b. 没变化时不该重启服务(别打断在线连接)──"
|
||||
# 第二遍什么都没变,桩日志里就不该出现 restart。
|
||||
# 用户是在**已经跑着的机器上**重跑本脚本升级的,
|
||||
# 每次无条件 restart 会清掉内存里的设备注册和会话,
|
||||
# 正在遥控的连接要几秒才重新接上。
|
||||
if grep -q 'systemctl restart lanremote-relay' "$STUB_LOG" 2>/dev/null; then
|
||||
bad "第二遍无变化却重启了中转(会打断在线连接)"
|
||||
grep 'restart' "$STUB_LOG" | head -3 | sed 's/^/ /'
|
||||
else
|
||||
ok "第二遍没有重启中转(无变化)"
|
||||
fi
|
||||
if grep -q 'systemctl restart lanremote-tunnel' "$STUB_LOG" 2>/dev/null; then
|
||||
bad "第二遍无变化却重启了隧道"
|
||||
else
|
||||
ok "第二遍没有重启隧道(无变化)"
|
||||
fi
|
||||
if grep -q 'systemctl daemon-reload' "$STUB_LOG" 2>/dev/null; then
|
||||
bad "第二遍无变化却做了 daemon-reload"
|
||||
else
|
||||
ok "第二遍没有多余的 daemon-reload"
|
||||
fi
|
||||
if grep -q '内容没变' "$OUT2" 2>/dev/null; then
|
||||
ok "输出里明确说了「内容没变、保持运行」"
|
||||
else
|
||||
bad "输出没说明为什么不重启"
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
echo
|
||||
echo "── 7. 凭据模式:给出域名时要能生成 config.yml ──"
|
||||
@@ -386,6 +496,82 @@ else
|
||||
bad "config.yml 回源地址不对"
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "── 7c. 默认:两个隧道单元都要启用(协议级故障隔离)──"
|
||||
# 真机实测(40 次/组):quic 8 + h2 2 拿到 100% 可用率 + 最低 P90/P99。
|
||||
# 单独跑一个协议都不如两个一起跑:
|
||||
# 运营商限 UDP(12 个 edge IP:TCP 12/12 通、UDP 0/12 通)
|
||||
# -> quic 拨号成功率只有约 72%,需要 http2 兜住失败的那部分。
|
||||
: > "$STUB_LOG"
|
||||
printf 'lanremote-relay\n' > "$STUB_LOG.state"
|
||||
bash "$DEPLOY" --prefix "$SANDBOX" > "$TMP/run7c.log" 2>&1
|
||||
check "默认(不传 --protocol)退出码为 0" "$?" "0"
|
||||
if grep -qxF 'systemctl enable lanremote-tunnel' "$STUB_LOG" 2>/dev/null; then
|
||||
ok "默认启用了主隧道"
|
||||
else
|
||||
bad "默认没启用主隧道"
|
||||
fi
|
||||
if grep -qxF 'systemctl enable lanremote-tunnel-h2' "$STUB_LOG" 2>/dev/null; then
|
||||
ok "默认启用了副本隧道"
|
||||
else
|
||||
bad "默认没启用副本隧道(双跑才对)"
|
||||
fi
|
||||
if grep -qxF 'systemctl disable --now lanremote-tunnel' "$STUB_LOG" 2>/dev/null \
|
||||
|| grep -qxF 'systemctl disable --now lanremote-tunnel-h2' "$STUB_LOG" 2>/dev/null; then
|
||||
bad "默认把某个隧道关掉了(应该两个都跑)"
|
||||
else
|
||||
ok "默认没有关掉任何一个隧道"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── 7d. --protocol quic:只跑主隧道 ──"
|
||||
: > "$STUB_LOG"
|
||||
printf 'lanremote-relay\n' > "$STUB_LOG.state"
|
||||
bash "$DEPLOY" --prefix "$SANDBOX" --protocol quic \
|
||||
> "$TMP/run7d.log" 2>&1
|
||||
check "--protocol quic 退出码为 0" "$?" "0"
|
||||
if grep -qxF 'systemctl enable lanremote-tunnel' "$STUB_LOG" 2>/dev/null; then
|
||||
ok "quic 模式启用了主隧道"
|
||||
else
|
||||
bad "quic 模式没启用主隧道"
|
||||
fi
|
||||
if grep -qxF 'systemctl disable --now lanremote-tunnel-h2' "$STUB_LOG" 2>/dev/null; then
|
||||
ok "quic 模式停用了副本隧道"
|
||||
else
|
||||
bad "quic 模式没停用副本隧道"
|
||||
fi
|
||||
if grep -q '只跑 quic' "$TMP/run7d.log" 2>/dev/null; then
|
||||
ok "输出说明了为什么停用副本"
|
||||
else
|
||||
bad "输出没说清楚"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── 7e. --protocol http2:只跑副本隧道 ──"
|
||||
: > "$STUB_LOG"
|
||||
printf 'lanremote-relay\n' > "$STUB_LOG.state"
|
||||
bash "$DEPLOY" --prefix "$SANDBOX" --protocol http2 \
|
||||
> "$TMP/run7e.log" 2>&1
|
||||
check "--protocol http2 退出码为 0" "$?" "0"
|
||||
if grep -qxF 'systemctl enable lanremote-tunnel-h2' "$STUB_LOG" 2>/dev/null; then
|
||||
ok "http2 模式启用了副本隧道"
|
||||
else
|
||||
bad "http2 模式没启用副本隧道"
|
||||
fi
|
||||
if grep -qxF 'systemctl disable --now lanremote-tunnel' "$STUB_LOG" 2>/dev/null; then
|
||||
ok "http2 模式停用了主隧道"
|
||||
else
|
||||
bad "http2 模式没停用主隧道"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── 7f. --protocol 的非法值要报错 ──"
|
||||
bash "$DEPLOY" --prefix "$SANDBOX" --protocol udp >/dev/null 2>&1
|
||||
check "非法协议退出码为 2" "$?" "2"
|
||||
bash "$DEPLOY" --prefix "$SANDBOX" --protocol both >/dev/null 2>&1
|
||||
check "both 是合法值" "$?" "0"
|
||||
|
||||
echo
|
||||
echo "── 7b. 再跑一次(不给 --hostname),config.yml 不该被改 ──"
|
||||
H3=$(md5sum "$CFD/config.yml" | awk '{print $1}')
|
||||
@@ -468,13 +654,88 @@ fi
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
echo
|
||||
echo "── 10. 桩命令确实被调用过(证明流程真的走了)──"
|
||||
for kw in "systemctl daemon-reload" "systemctl enable" "iptables" ; do
|
||||
for kw in "systemctl enable" "iptables" ; do
|
||||
if grep -q "$kw" "$STUB_LOG" 2>/dev/null; then
|
||||
ok "调用过 $kw"
|
||||
else
|
||||
bad "没调用过 $kw"
|
||||
fi
|
||||
done
|
||||
# daemon-reload 只在 unit 真变了的时候才该发生,
|
||||
# 所以拿第一遍的日志来验(第一遍是全新安装,unit 肯定是新的)
|
||||
if grep -q 'systemctl daemon-reload' "$STUB_LOG.run1" 2>/dev/null; then
|
||||
ok "第一次安装做了 daemon-reload"
|
||||
else
|
||||
bad "第一次安装没做 daemon-reload"
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
echo
|
||||
echo "── 11. 脚本里不许有写死的域名 ──"
|
||||
# 这个中转是自用的。脚本里烧一个域名进去,别人一照抄就指到别人机器上了。
|
||||
for f in "$DEPLOY" "$TUNE" "$REPO/relay/net-tune.sh"; do
|
||||
base=$(basename "$f")
|
||||
if grep -qE 'relay\.[a-z0-9-]+\.(top|com|net|org|cn)' "$f" 2>/dev/null; then
|
||||
bad "$base 里有写死的真实域名: $(grep -oE 'relay\.[a-z0-9-]+\.(top|com|net|org|cn)' "$f" | head -1)"
|
||||
else
|
||||
ok "$base 里没有写死的域名"
|
||||
fi
|
||||
done
|
||||
# 示例里的占位符必须是「你的域名」这种明显是占位的写法
|
||||
if grep -q '你的域名' "$DEPLOY" 2>/dev/null; then
|
||||
ok "示例用的是「你的域名」占位符"
|
||||
else
|
||||
bad "示例没看到占位符"
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
echo
|
||||
echo "── 12. 有凭据但没给域名 → 不许乱猜,必须明确提示 ──"
|
||||
CFD2="$TMP/nohost/root/.cloudflared"
|
||||
SANDBOX2="$TMP/nohost"
|
||||
mkdir -p "$CFD2"
|
||||
echo '{"TunnelID":"deadbeef-0000-1111-2222-333344445555"}' \
|
||||
> "$CFD2/deadbeef-0000-1111-2222-333344445555.json"
|
||||
OUT7="$TMP/run7.log"
|
||||
: > "$STUB_LOG"
|
||||
# 这是台"新机器":中转在跑,隧道从没配过。
|
||||
# 把桩的服务状态重置成这个样子 —— 否则前面几节把隧道标成 active 了,
|
||||
# net-tune.sh 会以为它在跑然后去重启,断言就测不准。
|
||||
printf 'lanremote-relay\n' > "$STUB_LOG.state"
|
||||
bash "$DEPLOY" --prefix "$SANDBOX2" > "$OUT7" 2>&1
|
||||
RC7=$?
|
||||
check "没域名时退出码为 0(中转照装,只是隧道不启)" "$RC7" "0"
|
||||
if [ -f "$CFD2/config.yml" ]; then
|
||||
bad "没域名却凭空生成了 config.yml(内容是: $(head -3 "$CFD2/config.yml" | tr '\n' ' '))"
|
||||
else
|
||||
ok "没域名时没有生成 config.yml(没有乱猜)"
|
||||
fi
|
||||
if grep -q '需要你提供公网域名' "$OUT7" 2>/dev/null; then
|
||||
ok "明确提示了「需要你提供公网域名」"
|
||||
else
|
||||
bad "提示不够明确"
|
||||
fi
|
||||
if grep -q -- '--hostname' "$OUT7" 2>/dev/null; then
|
||||
ok "提示里给了怎么补域名"
|
||||
else
|
||||
bad "提示里没说怎么补"
|
||||
fi
|
||||
if grep -q 'TUNNEL_TOKEN' "$OUT7" 2>/dev/null; then
|
||||
ok "提示里给了 Token 模式这个替代办法"
|
||||
else
|
||||
bad "提示里没提 Token 模式"
|
||||
fi
|
||||
if grep -q 'systemctl restart lanremote-tunnel' "$STUB_LOG" 2>/dev/null; then
|
||||
bad "隧道没配好却还是启动了隧道服务"
|
||||
else
|
||||
ok "隧道没配好时没有去启动它"
|
||||
fi
|
||||
# 但中转本身要照常起来
|
||||
if grep -q 'systemctl enable lanremote-relay' "$STUB_LOG" 2>/dev/null; then
|
||||
ok "中转仍然正常安装并启用"
|
||||
else
|
||||
bad "中转没被启用"
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
echo
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
# 对抗性独立验证报告
|
||||
|
||||
**验证者**:verifier(task-4)
|
||||
**时间**:2026-10-07 17:35 起
|
||||
**验证对象**:commit `facb156` + 工作区未提交改动
|
||||
**方法**:全部结论都由**自己动手打真接口/跑真代码**得出,不复述任何人的自述。
|
||||
每个发现都附可复现命令。额外做了**变异测试**来验证测试本身可不可信。
|
||||
|
||||
---
|
||||
|
||||
## 零、结论速览
|
||||
|
||||
| # | 严重度 | 问题 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 1 | 🔴 **严重** | TTL 清理 + 关掉「允许新设备接入」= 设备**永久锁死**,重启也救不回来 | 已复现 |
|
||||
| 2 | 🔴 **高** | 管理页 JS 语法错误 → 整段 script 不执行 → **所有按钮失效** | 已复现 |
|
||||
| 3 | 🔴 **高** | `--relay` / `--relay-status` 命令行**崩溃 / 永远报失败** | 已复现 |
|
||||
| 4 | 🟡 中 | `deploy.sh --prefix` **沙箱泄漏**,会动真实系统(含重启线上服务) | 已取证 |
|
||||
| 5 | 🟡 中 | `test_net_tune.py` 断言**过时**,和实现故意反转的决策矛盾,永久假失败 | 已确认 |
|
||||
| 6 | 🟡 中 | `test_admin_device_control.py` 的注释描述了它**并不验证**的规则(变异测试证明) | 已证明 |
|
||||
| 7 | 🟢 低 | `deviceId` 接受控制字符(`\x00`、`\r\n`),可伪造日志行 | 已确认 |
|
||||
| 8 | 🟢 低 | `enable_relay()` 第二次调用返回 `True` 谎报成功(当前无调用方触发,潜伏) | 已确认 |
|
||||
| 9 | ⚪ 提示 | 打包 EXE 过期(16:17 构建,源文件 17:10~17:42 有改动),e2e 失败是这个原因 | 已确认 |
|
||||
|
||||
**验证过但没问题的攻击面**:见第四节。
|
||||
|
||||
---
|
||||
|
||||
## 一、🔴 严重:TTL 清理 + 开关 = 设备永久锁死
|
||||
|
||||
### 复现
|
||||
|
||||
```bash
|
||||
python _tests/test_verify_ttl_lockout.py
|
||||
```
|
||||
|
||||
### 证据
|
||||
|
||||
```
|
||||
═══ 复现:管理员关掉「允许新设备接入」,电脑隔夜再开机 ═══
|
||||
[PASS] ① 电脑首次登记成功
|
||||
[PASS] ② 管理员关掉「允许新设备接入」
|
||||
[PASS] ③ 电脑当场重连仍然可以(开关只挡新设备)
|
||||
… 电脑关机了。等 4 秒模拟"隔夜"(真实是 6 小时)
|
||||
[PASS] ④ 中转把超时设备清掉了(设计中如此)
|
||||
磁盘 relay_state.json 里还有这台设备的码绑定: True
|
||||
内存里 codeCount=0
|
||||
[FAIL] ⑤ 电脑第二天开机 —— 应该能连上
|
||||
—— {'ok': False, 'blocked': True, 'error': '中转站当前不接受新设备接入'}
|
||||
|
||||
═══ 追问:重启中转能不能救回来? ═══
|
||||
重启后磁盘里这台设备的码绑定: True
|
||||
重启后内存 codeCount=0
|
||||
[FAIL] ⑥ 重启中转后电脑能连上
|
||||
—— {'ok': False, 'blocked': True, ...}
|
||||
```
|
||||
|
||||
### 机制
|
||||
|
||||
三处代码叠加:
|
||||
|
||||
1. `relay.py:479` —— 判断"是不是老设备"用的是**活跃设备表**:
|
||||
```python
|
||||
known_device = device_id in self.devices
|
||||
```
|
||||
|
||||
2. `relay.py:765-786` —— `snapshot()` 把超过 `DEVICE_TTL`(6 小时)的设备
|
||||
从 `self.devices` **和** `self.codes` 里一起删掉。
|
||||
|
||||
3. 于是设备回来时 `known_device = False`,撞上 `relay.py:480`:
|
||||
```python
|
||||
if not known_device and not self.allow_new:
|
||||
return {"ok": False, "blocked": True, ...}
|
||||
```
|
||||
|
||||
**为什么重启也救不回来(关键)**:
|
||||
`snapshot()` 清 `codes` 时**没有调 `_save()`**,所以磁盘上还留着绑定。
|
||||
重启后 `_load()` 把 `codes` 恢复了 —— 但紧接着第一次 `snapshot()` 又把它
|
||||
当孤儿清掉(owner 不在 `devices` 里)。**自己把自己 perpetuat 住了。**
|
||||
|
||||
### 影响
|
||||
|
||||
* 触发条件**极常见**:用户下班关机、第二天开机,间隔必然超过 6 小时。
|
||||
* **永久性**:不重启中转、不换 deviceId 都绕不过去。唯一的出路是
|
||||
管理员**全局重新打开开关**。
|
||||
* **最糟的是管理员不知道要放开谁** —— 那台设备已经被清出管理页列表了,
|
||||
页面上根本看不到它。
|
||||
|
||||
### 建议修法
|
||||
|
||||
判断依据要和"码绑定"解耦,且不能被超时清理抹掉。可选:
|
||||
|
||||
* **方案 A(推荐)**:`register()` 里把"是否老设备"改成
|
||||
```python
|
||||
known_device = (device_id in self.devices
|
||||
or device_id in self.codes.values())
|
||||
```
|
||||
—— `codes` 里的绑定同样被清的话不解决问题,所以要配合方案 B。
|
||||
* **方案 B(推荐)**:`relay_state.json` 里**单独存一份"曾经登记过的 deviceId"**
|
||||
(`knownDevices`),**超时清理时不动它**。这才是"已登记过"的准确语义 ——
|
||||
它讲的是"历史上被允许过",不是"此刻在线"。
|
||||
* 无论选哪个,`snapshot()` 清 `codes` 后**应该调 `_save()`**,
|
||||
否则磁盘和内存会长期不一致。
|
||||
|
||||
---
|
||||
|
||||
## 二、🔴 高:管理页 JS 语法错误 → 所有按钮失效
|
||||
|
||||
### 复现
|
||||
|
||||
```bash
|
||||
python _tests/test_admin_js.py # 官方 3d 阶段,会 FAIL
|
||||
python _tests/test_verify_js_syntax.py # 我写的精确取证
|
||||
```
|
||||
|
||||
### 证据
|
||||
|
||||
渲染出来的 `<script>` 里:
|
||||
|
||||
```
|
||||
const typed = prompt('把「' + name + '」的配对码 ' + old
|
||||
+ ' 改成:
|
||||
(必须是 6 位数字)', ''); ← 单引号字符串里有**真实换行**
|
||||
```
|
||||
|
||||
状态机扫描结果:
|
||||
|
||||
```
|
||||
🔴 状态机在下列位置发现「字符串里的裸换行」:
|
||||
第 46 行(引号 ')
|
||||
第 47 行(引号 ')
|
||||
共 2 处 —— 这就是 <script> 语法错误的来源。
|
||||
```
|
||||
|
||||
### 根因
|
||||
|
||||
`relay/admin.py:370`:
|
||||
|
||||
```python
|
||||
+ ' 改成:\n\n(必须是 6 位数字)', '');
|
||||
```
|
||||
|
||||
`ADMIN_JS` 是 Python 三引号字符串,**Python 先把 `\n` 转成真实换行**,
|
||||
再原样拼进 `<script>`。作者本意是 JS 的换行转义,应该写 `\\n`。
|
||||
|
||||
### 引入时间(已归因)
|
||||
|
||||
```bash
|
||||
git log --oneline -3 -- relay/admin.py
|
||||
# d4f335b 管理页新增:允许/禁止新设备接入 + 修改设备配对码 ← 就是这一笔
|
||||
# 5bf108f 修管理页顶栏被切掉(标题看不见、徽章压在字上)
|
||||
# 160dc37 修管理页测试的引号敏感 + 补 netPanel id
|
||||
|
||||
git show HEAD:relay/admin.py | grep '改成:'
|
||||
# + ' 改成:\n\n(必须是 6 位数字)', '');
|
||||
```
|
||||
|
||||
**注意**:`test_admin_device_control.py`(23 项)在同一个提交里,
|
||||
它**通过**了 —— 因为它只检查 JS 里**有没有** `data-recode`、`allow_new`
|
||||
这些 token,不检查 JS **语法是否成立**。
|
||||
"23 项测试通过"和"这段 JS 能跑"是两件不同的事。
|
||||
|
||||
### 影响
|
||||
|
||||
一个 `SyntaxError` 会让**整段 `<script>` 不执行**。页面上绑定的按钮:
|
||||
|
||||
```
|
||||
button[data-recode] ← 「改配对码」
|
||||
button.danger[data-device] ← 「删除设备」
|
||||
input.pick ← 「批量删除」
|
||||
```
|
||||
|
||||
**全部变成幽灵按键**(点了没反应)。这不是"改码功能坏了",
|
||||
是**整个管理页的交互全废**。
|
||||
|
||||
### 建议修法
|
||||
|
||||
```python
|
||||
# 二选一:
|
||||
+ ' 改成:\\n\\n(必须是 6 位数字)', ''); # 转义成 JS 的 \n
|
||||
+ ' 改成:' + '\n' + '(必须是 6 位数字)', '') # 或者用模板字符串
|
||||
```
|
||||
|
||||
并建议把 `_tests/test_admin_js.py` 接进 CI 的必过项 —— 它**确实抓到了**
|
||||
(这次全量回归里 3d 阶段报 FAIL),说明这个测试是有效的。
|
||||
|
||||
---
|
||||
|
||||
## 三、🔴 高:`--relay` / `--relay-status` 命令行崩溃
|
||||
|
||||
### 复现
|
||||
|
||||
```bash
|
||||
python _tests/test_verify_relay_cli.py
|
||||
```
|
||||
|
||||
### 证据
|
||||
|
||||
```
|
||||
═══ 1. cli_relay_status()(不在 try 里)═══
|
||||
它打印的内容:
|
||||
中转地址:https://relay.example.invalid
|
||||
正在测…
|
||||
🔴 未捕获异常: TypeError: RelayLink.start() missing 1 required positional argument: 'url'
|
||||
File "C:\...\windows\run_agent.py", line 338, in cli_relay_status
|
||||
|
||||
═══ 2. cli_relay(url)(在 try 里)═══
|
||||
✗ 测试失败:RelayLink.start() missing 1 required positional argument: 'url'
|
||||
返回值: 1
|
||||
```
|
||||
|
||||
### 根因
|
||||
|
||||
`relay.py:247` 的签名是:
|
||||
|
||||
```python
|
||||
def start(self, url: str) -> bool: # url 必填,没有默认值
|
||||
```
|
||||
|
||||
但 `run_agent.py` 有两处**不传参数**:
|
||||
|
||||
* `run_agent.py:338`(`cli_relay_status`)—— **不在 try 里** → 未捕获异常,报栈
|
||||
* `run_agent.py:296`(`cli_relay`)—— 在 try 里 → 被吞成"测试失败"
|
||||
|
||||
### 影响
|
||||
|
||||
* `ykxmz.exe --relay-status` → **直接抛栈**
|
||||
* `ykxmz.exe --relay https://...` → 地址**存下去了**(第 281 行先存后测),
|
||||
但用户每次都看到"✗ 测试失败:…missing 1 required positional argument: 'url'"。
|
||||
**用户会以为自己填的地址不对**,反复试别的地址,实际是代码的锅。
|
||||
|
||||
### 为什么没被测出来
|
||||
|
||||
全仓库搜索 `--relay` / `cli_relay` 的测试引用:
|
||||
|
||||
```
|
||||
_tests\ 下只有我新写的 test_verify_relay_cli.py 提到 cli_relay
|
||||
verify_all.ps1 的 35 个阶段里没有任何一个碰 --relay / --relay-status
|
||||
```
|
||||
|
||||
**这两条 CLI 路径零覆盖。**
|
||||
|
||||
### 建议修法
|
||||
|
||||
* 最小修:两处都改成 `link.start(store.get("relay_url") or "")`
|
||||
(`cli_relay` 里直接用刚存下的 `url` 更合适)
|
||||
* 补测试:在 `verify_all.ps1` 里加一个阶段,真调 `run_agent.cli_relay_status()`
|
||||
并把"不抛异常 + 返回值合理"钉住
|
||||
|
||||
---
|
||||
|
||||
## 四、🟡 中:`deploy.sh --prefix` 沙箱泄漏
|
||||
|
||||
### 复现
|
||||
|
||||
```bash
|
||||
# 在设备上(用垫片 PATH 保证安全,垫片只记日志不执行)
|
||||
PATH=/tmp/sbxim:$PATH bash /root/dep/deploy.sh --prefix /tmp/sbx --skip-tunnel
|
||||
```
|
||||
|
||||
### 证据
|
||||
|
||||
`deploy.sh:212-213` 的承诺:
|
||||
|
||||
```bash
|
||||
# 测试时 --prefix /tmp/xxx,所有写入都关在沙箱里,不碰真系统。
|
||||
```
|
||||
|
||||
**实测调用记录**(垫片日志,各命令带调用者):
|
||||
|
||||
```
|
||||
[caller: bash /root/dep/deploy.sh --prefix /tmp/sbxroot2 --skip-tunnel] systemctl daemon-reload
|
||||
[caller: bash /root/dep/deploy.sh --prefix /tmp/sbxroot2 --skip-tunnel] systemctl restart lanremote-relay
|
||||
[caller: bash /root/dep/deploy.sh --prefix /tmp/sbxroot2 --skip-tunnel] systemctl enable lanremote-nettune
|
||||
[caller: bash /root/dep/deploy.sh --prefix /tmp/sbxroot2 --skip-tunnel] systemctl start lanremote-nettune
|
||||
[caller: bash /root/dep/deploy.sh --prefix /tmp/sbxroot2 --skip-tunnel] systemctl enable lanremote-watchdog.timer
|
||||
|
||||
[caller: bash /tmp/sbxroot2/root/lanremote-relay/net-tune.sh] sysctl -w net.ipv4.tcp_mtu_probing=1
|
||||
[caller: bash /tmp/sbxroot2/root/lanremote-relay/net-tune.sh] iptables -t mangle -A POSTROUTING ... --set-mss 1360
|
||||
[caller: ... net-tune.sh] iptables ... ← 共 213 条
|
||||
```
|
||||
|
||||
### 根因
|
||||
|
||||
两条独立的泄漏:
|
||||
|
||||
1. **`deploy.sh` 自己的 `systemctl` 没有 prefix 守卫**
|
||||
(`deploy.sh:606`、`758-780`、`868-871`):
|
||||
```bash
|
||||
if command -v systemctl >/dev/null 2>&1; then # 只判断"有没有 systemctl"
|
||||
systemctl daemon-reload
|
||||
systemctl restart lanremote-relay # ← 真系统
|
||||
```
|
||||
2. **`deploy-tune.sh` 收到了 `--prefix` 但没往下传**
|
||||
(`deploy.sh:448` 传了,`deploy-tune.sh:100-106` 调 `net-tune.sh` 时没传),
|
||||
而 **`net-tune.sh` 完全不支持 `--prefix`**(第 81、109、122 行无条件改真实系统)。
|
||||
|
||||
### 影响
|
||||
|
||||
**在线上设备上执行 `deploy.sh --prefix /tmp/test`(一个很自然的"先试跑一下"动作)会:**
|
||||
|
||||
1. `systemctl restart lanremote-relay` → **重启正在服务的中转**,
|
||||
清空内存里的设备注册和会话,所有客户端要几秒才重连
|
||||
2. `sysctl -w net.ipv4.tcp_mtu_probing=1` → 改真实内核参数
|
||||
3. `iptables -A mangle POSTROUTING --set-mss 1360` → 往真实防火墙**追加**规则
|
||||
|
||||
本次取证因为用了垫片,真实系统**未被改动**(逐项比对 md5 全部一致),
|
||||
所以这条是**静态+动态取证确认的潜在破坏**,不是已经发生的破坏。
|
||||
|
||||
### 建议修法
|
||||
|
||||
* `net-tune.sh` 加 `--prefix` 支持;**加 `DRY_RUN`/`--dry`** 更彻底
|
||||
* `deploy-tune.sh` 把 `--prefix` 透传下去
|
||||
* `deploy.sh` 里所有 `systemctl` 调用加守卫:
|
||||
```bash
|
||||
if [ -n "$PREFIX" ]; then
|
||||
say " [沙箱] 跳过 systemctl ..."
|
||||
elif command -v systemctl >/dev/null 2>&1; then
|
||||
...
|
||||
fi
|
||||
```
|
||||
* 把这个守卫本身写进测试(用垫片 PATH 是可行的办法,`_build/test_deploy_sandbox.sh` 可直接复用)
|
||||
|
||||
---
|
||||
|
||||
## 五、🟡 中:`test_net_tune.py` 断言过时,和实现故意反转的决策矛盾
|
||||
|
||||
### 证据
|
||||
|
||||
```python
|
||||
# _tests/test_net_tune.py:53
|
||||
check("不把接口 MTU 调小(写回了 1500)",
|
||||
"mtu 1500" in text, "接口 MTU 降到 1400 实测会掉到 50%")
|
||||
```
|
||||
|
||||
```bash
|
||||
# relay/net-tune.sh:29-31(实现已故意反转,并写了更正说明)
|
||||
# 为什么接口 MTU 也要压到 1400(早期结论是错的,这里更正)
|
||||
# 早先这里写的是「不要改接口 MTU,实测降到 1400 反而掉到 50%」。
|
||||
# 那个结论只考虑了 TCP,**漏掉了 QUIC**:
|
||||
# * MSS 钳制走的是 iptables 的 TCPMSS 目标,**只对 TCP 生效**;
|
||||
# * 隧道用 QUIC 时跑在 UDP 上,整套 TCPMSS 规则对它毫无作用。
|
||||
|
||||
# net-tune.sh:54
|
||||
MTU_VALUE=${MTU_VALUE:-1400}
|
||||
```
|
||||
|
||||
运行结果:
|
||||
|
||||
```
|
||||
[FAIL] 不把接口 MTU 调小(写回了 1500) 接口 MTU 降到 1400 实测会掉到 50%
|
||||
通过 20 项,失败 1 项
|
||||
```
|
||||
|
||||
### 影响
|
||||
|
||||
这个测试钉死了一个**已被新证据推翻的旧结论**。后果有两层:
|
||||
|
||||
1. 它**永久失败** → 每次都贡献一条噪声,真失败容易被淹
|
||||
2. 更糟:后人为了让测试过,可能**把真正的改进改回 1500** ——
|
||||
而那正是 QUIC 建连不稳(`failed to dial to edge with quic:
|
||||
timeout: no recent network activity`)的根因之一
|
||||
|
||||
### 建议修法
|
||||
|
||||
把这个断言**反过来**,并保留它作为"防回归"的价值:
|
||||
|
||||
```python
|
||||
check("接口 MTU 压到 1400(QUIC 用 UDP,MSS 钳制管不到它)",
|
||||
"mtu \"$MTU_VALUE\"" in text or "MTU_VALUE=1400" in text
|
||||
or "MTU_VALUE:-1400" in text,
|
||||
"QUIC 的包超路径 MTU 会被静默丢弃,而 UDP 不回 ICMP")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、🟡 中:`test_admin_device_control.py` 的注释描述了它并不验证的规则
|
||||
|
||||
### 方法:变异测试
|
||||
|
||||
```bash
|
||||
python _tests/test_verify_mutation.py # 注入变异,看现有测试抓不抓得到
|
||||
python _tests/test_verify_coverage_gap.py # 证明盲区是真 bug,不是理论问题
|
||||
```
|
||||
|
||||
### 证据
|
||||
|
||||
对 `relay/relay.py` 注入 4 个变异,跑现有 `test_admin_device_control.py`(23 项):
|
||||
|
||||
| 变异 | 现有测试是否抓到 |
|
||||
|---|---|
|
||||
| A. `known_device` 改成按**配对码**判断 | ❌ **23 项全部通过 —— 没抓到** |
|
||||
| B. 改码后不删旧码(留孤儿) | ✅ 抓到 |
|
||||
| C. 改码不踢会话 | ✅ 抓到 |
|
||||
| D. 改码不校验占用 | ✅ 抓到 |
|
||||
|
||||
**而测试文件第 11-12 行的注释白纸黑字写着它就是要防 A:**
|
||||
|
||||
```python
|
||||
# 4. 关掉之后**已登记过**的设备仍然能上线
|
||||
# —— 这一条最容易写错:如果按配对码判断,
|
||||
# 管理员给老设备改过码之后,老设备会被自己的开关挡在外面
|
||||
```
|
||||
|
||||
### 为什么没抓到 + 证明盲区是真 bug
|
||||
|
||||
它的用例里设备用的还是**原来那个码**,而 `self.codes[那个码] == device_id`,
|
||||
所以 `code in self.codes` 也为真 → 变异在这个路径上**表现不出来**。
|
||||
|
||||
变异真正现形的路径是:「管理员改过码 + **设备配置里还是旧码** + 开关已关」。
|
||||
`test_verify_coverage_gap.py` 构造了这个组合,对照跑原版/变异版:
|
||||
|
||||
```
|
||||
原版「带旧码重连」: 成功
|
||||
变异「带旧码重连」: 失败
|
||||
|
||||
✅ 结论:变异确实造成真实功能退化(原版成功、变异失败),
|
||||
而现有 test_admin_device_control.py 的 23 项**全部通过**。
|
||||
```
|
||||
|
||||
**所以"23 项测试通过"这句自述是真的,但它不等于"这条规则被验证了"。**
|
||||
|
||||
### 建议修法
|
||||
|
||||
在 `test_admin_device_control.py` 里补一条用例:
|
||||
|
||||
```
|
||||
注册(码 A)-> 管理员改成码 B -> 关掉开关 -> 设备带着码 A 重连 -> 必须成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、🟢 低:`deviceId` 接受控制字符
|
||||
|
||||
### 证据
|
||||
|
||||
```
|
||||
[PASS] deviceId 超长 200 字符 应拒绝
|
||||
[PASS] deviceId 超长 81 字符 应拒绝
|
||||
[PASS] deviceId 正好 80 字符 应接受
|
||||
· 控制字符 NUL -> 接受
|
||||
· 控制字符 \r\n -> 接受
|
||||
· HTML 标签 -> 接受
|
||||
```
|
||||
|
||||
(第一版测试我用同一个配对码试了 5 种非法值,后面几种其实是被"码已占用"拒的,
|
||||
**归因是错的**;第二版每次用全新码才拿到干净结论。)
|
||||
|
||||
### 影响
|
||||
|
||||
* **不是 XSS**:`admin.py` 的 `_esc()` 转义到位。实测渲染产物里是
|
||||
`<img src=x onerror=alert("...")>`,没有原始标签。
|
||||
* **是日志伪造**:`deviceId` 里的 `\r\n` 会被写进日志,
|
||||
可以伪造出整行"看起来像系统输出"的日志。
|
||||
对一台只给自己用的中转站危害有限,但属于输入校验缺失。
|
||||
|
||||
### 建议修法
|
||||
|
||||
`register()` 里给 `device_id` 加白名单:
|
||||
|
||||
```python
|
||||
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,80}", device_id):
|
||||
return {"ok": False, "error": "deviceId 不合法"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、🟢 低:`enable_relay()` 第二次调用谎报成功(潜伏)
|
||||
|
||||
### 证据
|
||||
|
||||
```
|
||||
第 1 次 enable_relay() -> False
|
||||
第 2 次 enable_relay() -> True ← relay 实际没在跑(candidates=[])
|
||||
```
|
||||
|
||||
### 根因
|
||||
|
||||
`server.py:321-322`:
|
||||
|
||||
```python
|
||||
if self._relay is not None:
|
||||
return True
|
||||
self._relay = RelayLink(...) # 先赋值
|
||||
started = self._relay.start(target)
|
||||
```
|
||||
|
||||
第一次调用时 `start("")` 提前返回 False(不起线程),但 `self._relay`
|
||||
**已经被赋值成对象了**。第二次调用就撞上 `is not None` → 直接 `return True`。
|
||||
|
||||
### 影响
|
||||
|
||||
**当前无调用方触发** —— 全仓库只有 `server.py:185` 调一次,且忽略返回值。
|
||||
所以我如实标注为**潜伏**而非现实问题。
|
||||
|
||||
但它是脆的:将来任何"用户在设置里填了中转地址、想当场启用"的路径
|
||||
都会命中这个 `return True`,**用户以为启用成功,实际什么都没连**。
|
||||
|
||||
### 建议修法
|
||||
|
||||
```python
|
||||
if self._relay is not None and self._relay.url:
|
||||
return True
|
||||
```
|
||||
|
||||
或者把赋值挪到 `start()` 成功之后。
|
||||
|
||||
---
|
||||
|
||||
## 九、⚪ 提示:打包 EXE 过期(e2e 失败的真正原因)
|
||||
|
||||
```
|
||||
dist_exe/ykxmz/ykxmz.exe 16:17:02 构建
|
||||
|
||||
更晚被改动的源文件:
|
||||
17:10:22 windows/lanremote/discovery.py
|
||||
17:18:13 windows/run_agent.py
|
||||
17:22:29 windows/lanremote/server.py
|
||||
17:35:56 windows/lanremote/relay.py
|
||||
17:42:17 windows/lanremote/instance.py
|
||||
```
|
||||
|
||||
全量回归里 `端到端(打包 EXE)` 和 `Java <-> 打包后的 EXE` 两项失败,
|
||||
**根因是 EXE 没跟着源文件重建**,不是代码缺陷。重建后应复跑。
|
||||
|
||||
---
|
||||
|
||||
## 十、验证过、**没有**发现问题的攻击面
|
||||
|
||||
这些我都真打过,结论是干净的,列出来是为了让"没提"不等于"没查":
|
||||
|
||||
| 攻击面 | 做法 | 结果 |
|
||||
|---|---|---|
|
||||
| **admin.py 的 `%` 格式化撞车** | 扫源文件字面量 + 真调 `render_admin()` | ✅ 干净。`ADMIN_CSS` 结尾是 `""" % {}`(第 231 行)**在导入时就被格式化**,`%%` 正确变 `%`;`ADMIN_JS` 不参与格式化,单个 `%` 无害 |
|
||||
| **管理页 XSS(deviceId/name)** | 注册 `<img onerror=...>` / `<script>...`,抓页面 | ✅ 干净,`_esc()` 转义到位(含 data 属性里的引号) |
|
||||
| **设备名里的 `%s` / `%d` 把格式化搞崩** | 造 `name = "百分号 %s %d %% 测试"` | ✅ 干净,原样保留,没有二次格式化 |
|
||||
| **管理接口鉴权绕过** | 不带 cookie / 不带 `X-Admin` 打 4 个接口 | ✅ 全部 401/403 |
|
||||
| **改码后 codes/devices/state.json 自洽** | 连续改码 A→B→C,查三处 | ✅ 一台设备只占一个码,旧码被释放,state.json 只有一条绑定 |
|
||||
| **并发改码** | 两线程同时改同一台设备 | ✅ 无异常,设备还在,最终码是其中之一,"输掉"的码没变孤儿 |
|
||||
| **超时清理的顺序** | 检查 codes 与 devices 的成对清理 | ✅ 成对清理,没有"清一个留一个"的中间态 |
|
||||
| **空设备列表时管理页** | `delete_all` 后抓页面 | ✅ 200,无 traceback |
|
||||
| **allowNew 重启后是否保留** | 关掉 → 重启中转 → 查 | ✅ 保留(持久化生效) |
|
||||
| **资源泄漏 / 崩溃** | 上述所有场景跑完检查 500 | ✅ 没有 500 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、我自己在验证过程中犯的错(如实记录)
|
||||
|
||||
测试自己也会骗人,这几处我修正后才拿到干净结论:
|
||||
|
||||
1. **场景 4 的归因错误**:五种非法 deviceId 用了**同一个配对码**,
|
||||
第一个成功注册后,后面几种其实是被"码已被占用"拒的,不是"deviceId 非法"。
|
||||
→ 改成每次用全新码才测出真实行为(控制字符被接受)。
|
||||
|
||||
2. **把"已格式化的值"当成"源文件"来扫**:我扫 `admin.ADMIN_CSS`(导入后
|
||||
的值,`%%` 已变 `%`),把合法的单个 `%` 误判成"漏写转义"。
|
||||
→ 改成扫源文件三引号字面量,并检查定义后有没有 `%` 运算。
|
||||
|
||||
3. **`render_admin` 的 `downloads_fn` 形状传错**:我传了元组列表,
|
||||
实际要 `[{"name":..., "size":...}]`,报 `TypeError` 是**我的错**。
|
||||
→ 修正后 20/20 通过。
|
||||
|
||||
4. **变异盲区的触发条件构造错**:第一次我让设备带**新码**重连,
|
||||
两版都成功,看起来"变异无害"。实际分歧点是设备带**旧码**重连
|
||||
(它不知道管理员在服务端改了码)。→ 修正后才证明盲区是真 bug。
|
||||
|
||||
---
|
||||
|
||||
## 十二、我新增的验证测试
|
||||
|
||||
都在 `_tests/` 下,可单独运行,也可接进 `verify_all.ps1`:
|
||||
|
||||
| 文件 | 覆盖 |
|
||||
|---|---|
|
||||
| `test_verify_relay_edges.py` | 状态机边界:TTL 锁死 / 改码孤儿 / 奇怪 deviceId / 并发改码 / 鉴权 / 空列表 / 持久化 |
|
||||
| `test_verify_relay_xss.py` | 修正归因后的非法输入 + 管理页 XSS + deviceId 撞车 + 改码边界 |
|
||||
| `test_verify_ttl_lockout.py` | **钉死 TTL 锁死 bug**(含"重启能不能救"这一问) |
|
||||
| `test_verify_admin_format.py` | admin.py 的 `%` 格式化 + 转义 + 极端输入 |
|
||||
| `test_verify_mutation.py` | **变异测试**:验证现有 3e 测试能不能抓到 bug |
|
||||
| `test_verify_coverage_gap.py` | 证明变异盲区是真 bug |
|
||||
| `test_verify_js_syntax.py` | 精确指出管理页 JS 的裸换行位置 |
|
||||
| `test_verify_relay_cli.py` | **钉死 `--relay` / `--relay-status` 崩溃** |
|
||||
| `test_verify_blank_relay.py` | 默认地址清空后的回归面 |
|
||||
|
||||
辅助脚本(在 `_build/`):`test_deploy_sandbox.sh`(垫片 PATH 测沙箱泄漏)、
|
||||
`test_deploy_leak.sh`(带调用者归因的取证)。
|
||||
|
||||
---
|
||||
|
||||
## 十三、没做到 / 无法验证的部分
|
||||
|
||||
* **真机管理页的浏览器行为**:本机没有 node,`test_admin_js.py` 的
|
||||
`--check` 被跳过了。我是用自写的字符串状态机证明 JS 有裸换行的,
|
||||
没有用真正的 JS 引擎跑过。**建议在有 node 的环境复跑一次确认。**
|
||||
* **设备上的真实 TTL 行为**:我用 `RELAY_DEVICE_TTL=2` 在本地加速复现,
|
||||
没有在设备上等 6 小时。逻辑路径是同一条,但真机没验证。
|
||||
* **`deploy.sh` 在真机上的实际破坏**:我用垫片 PATH 拦截了所有危险命令
|
||||
(这正是为了不破坏线上),所以"会造成破坏"是**取证推断**,
|
||||
不是"已经破坏过"的实测。
|
||||
* **稳定性/性能数值**:本次任务聚焦正确性,没有对中转做吞吐/延迟压测
|
||||
(那是另一条线的任务)。
|
||||
|
||||
---
|
||||
|
||||
## 十四、给出建议的修复优先级
|
||||
|
||||
1. **先修 #1(TTL 锁死)** —— 影响面最大、触发最自然、后果不可自愈
|
||||
2. **再修 #2(管理页 JS)** —— 修完管理页才真的能用(现在按钮全废)
|
||||
3. **修 #3(CLI 崩溃)** —— 改动最小(两处加参数),收益直接
|
||||
4. **修 #5(过时测试)** —— 不然它会一直掩盖真失败,还诱导错误修改
|
||||
5. **修 #4(沙箱泄漏)** —— 至少先把 `systemctl` 加守卫,避免误重启线上
|
||||
6. 补 #6 的用例;#7/#8 按余量处理
|
||||
7. 重建 EXE 后复跑全量回归(#9)
|
||||
Reference in New Issue
Block a user