修「经中转几次后永久哑掉」——「按一下没反应」的真因

== 现象 ==

用户报:
  * 局域网能搜到电脑,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:
xmz7891
2026-10-07 17:59:39 +08:00
parent b2bfa6ffbf
commit 8880e7033d
290 changed files with 13216 additions and 1159 deletions
+8 -6
View File
@@ -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 完全不受保护,包一大就被中间设备悄悄丢掉。
+2
View File
@@ -0,0 +1,2 @@
passed=34 failed=0
+2
View File
@@ -0,0 +1,2 @@
passed=18 failed=0
+35
View File
@@ -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 项
==============================================================
-47
View File
@@ -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 阶段。
+164
View File
@@ -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 矛盾;按实测改成「两种协议同时在跑(故意的)」
+55
View File
@@ -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 项
==============================================================
+55
View File
@@ -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
View File
@@ -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
View File
-16
View File
@@ -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
+27
View File
@@ -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 项
==============================================================
+128
View File
@@ -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(" 实际:见上面。凡是出现硬编码地址的地方都要改成空字符串。")
+85
View File
@@ -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/^/ /'
+58
View File
@@ -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/^/ /'
+9
View File
@@ -0,0 +1,9 @@
═══ 1. cli_relay_status()(不在 try 里)═══
它打印的内容:
(没有抛异常)
═══ 2. cli_relay(url)(在 try 里)═══
它打印的内容:
返回值: 1
═══ 3. 结论 ═══
+64
View File
@@ -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())
+130
View File
@@ -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())
+272
View File
@@ -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())
+183
View File
@@ -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())
+162
View File
@@ -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())
+101
View File
@@ -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)
+90
View File
@@ -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())
+286
View File
@@ -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())
+175
View File
@@ -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())
+205
View File
@@ -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())
+96
View File
@@ -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())
+180
View File
@@ -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())
+29
View File
@@ -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
════ 被控端日志 ════
+262
View File
@@ -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())
+54
View File
@@ -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(" 改动:保留临时目录 + 第四节前打印中转存活状态")
+50
View File
@@ -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
View File
@@ -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 会把错误翻译成人话:
+57
View File
@@ -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/^/ /'
+246
View File
@@ -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.
+207
View File
@@ -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())
+35
View File
@@ -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 项
+58
View File
@@ -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/^/ /'
+159
View File
@@ -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% 的部分
(待填)
+67
View File
@@ -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 " 已清理"
+38
View File
@@ -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/^/ /'
+177
View File
@@ -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"
+57
View File
@@ -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/^/ /'
+103
View File
@@ -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())
+88
View File
@@ -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
1 time code ms
2 2026-10-07 17:29:20 200 1150
3 2026-10-07 17:29:36 200 1404
4 2026-10-07 17:29:52 200 1319
5 2026-10-07 17:30:09 200 1421
6 2026-10-07 17:30:25 200 1503
7 2026-10-07 17:30:41 200 1125
8 2026-10-07 17:30:58 200 1136
9 2026-10-07 17:31:14 200 1039
10 2026-10-07 17:31:30 200 1350
11 2026-10-07 17:31:46 200 1418
12 2026-10-07 17:32:03 200 1752
13 2026-10-07 17:32:20 200 1383
14 2026-10-07 17:32:36 200 1316
15 2026-10-07 17:32:52 200 1347
16 2026-10-07 17:33:09 200 1450
17 2026-10-07 17:33:25 200 1393
18 2026-10-07 17:33:41 200 1019
19 2026-10-07 17:33:57 200 1159
20 2026-10-07 17:34:13 200 1132
21 2026-10-07 17:34:30 200 1152
22 2026-10-07 17:34:46 200 1113
23 2026-10-07 17:35:02 200 1488
24 2026-10-07 17:35:18 200 1167
25 2026-10-07 17:35:34 200 1174
26 2026-10-07 17:35:51 200 1429
27 2026-10-07 17:36:07 200 1337
28 2026-10-07 17:36:24 200 1305
29 2026-10-07 17:36:39 200 878
30 2026-10-07 17:36:55 200 850
31 2026-10-07 17:37:11 200 813
32 2026-10-07 17:37:28 200 1854
33 2026-10-07 17:37:44 200 1172
34 2026-10-07 17:38:00 200 837
35 2026-10-07 17:38:16 200 1050
36 2026-10-07 17:38:32 200 876
37 2026-10-07 17:38:48 200 911
38 2026-10-07 17:39:05 200 1850
39 2026-10-07 17:39:21 200 953
40 2026-10-07 17:39:36 200 871
41 2026-10-07 17:39:52 200 848
42 2026-10-07 17:40:08 200 845
43 2026-10-07 17:40:46 200 22347
44 2026-10-07 17:41:01 200 723
45 2026-10-07 17:41:39 200 22449
46 2026-10-07 17:41:55 200 1748
47 2026-10-07 17:42:32 200 22048
48 2026-10-07 17:43:03 200 15949
49 2026-10-07 17:43:19 200 881
50 2026-10-07 17:43:35 200 929
51 2026-10-07 17:43:51 200 858
52 2026-10-07 17:44:08 200 1490
53 2026-10-07 17:44:24 200 1351
54 2026-10-07 17:44:40 200 1125
55 2026-10-07 17:44:56 200 1030
56 2026-10-07 17:45:12 200 1285
57 2026-10-07 17:45:28 200 1101
58 2026-10-07 17:45:45 200 1327
59 2026-10-07 17:46:01 200 1540
60 2026-10-07 17:46:18 200 1340
61 2026-10-07 17:46:35 200 2030
62 2026-10-07 17:46:51 200 1050
63 2026-10-07 17:47:07 200 1079
64 2026-10-07 17:47:23 200 1126
65 2026-10-07 17:47:39 200 977
66 2026-10-07 17:47:55 200 1414
67 2026-10-07 17:48:12 200 1145
68 2026-10-07 17:48:28 200 1311
69 2026-10-07 17:48:44 200 1292
70 2026-10-07 17:49:01 200 1383
71 2026-10-07 17:49:17 200 1013
72 2026-10-07 17:49:33 200 1378
73 2026-10-07 17:49:49 200 1373
74 2026-10-07 17:50:05 200 1013
75 2026-10-07 17:50:21 200 1168
76 2026-10-07 17:50:38 200 1238
77 2026-10-07 17:50:54 200 1140
78 2026-10-07 17:51:10 200 1281
79 2026-10-07 17:51:26 200 1131
80 2026-10-07 17:52:04 200 22346
81 2026-10-07 17:52:41 200 22329
82 2026-10-07 17:53:04 200 7874
83 2026-10-07 17:53:20 200 1221
84 2026-10-07 17:53:36 200 863
85 2026-10-07 17:53:53 200 1721
86 2026-10-07 17:54:08 200 844
87 2026-10-07 17:54:24 200 775
88 2026-10-07 17:54:40 200 830
+100
View File
@@ -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"
+91
View File
@@ -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"
+47
View File
@@ -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
+33
View File
@@ -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 " (无)"
+69
View File
@@ -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"
+135
View File
@@ -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
+136
View File
@@ -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"
+160
View File
@@ -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"
+24
View File
@@ -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
+193
View File
@@ -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"
+108
View File
@@ -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)"
+57
View File
@@ -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 小时)"
+42
View File
@@ -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/^/ /'
+58
View File
@@ -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/^/ /'
+61
View File
@@ -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/^/ 次数: /'
+50
View File
@@ -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"
+55
View File
@@ -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/^/ /'
+135
View File
@@ -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
+317
View File
@@ -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"
+73
View File
@@ -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"
+51
View File
@@ -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
+69
View File
@@ -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/^/ /'
+40
View File
@@ -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 无响应,并只重启它)"
+79
View File
@@ -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
+54
View File
@@ -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())
+37
View File
@@ -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
+28
View File
@@ -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
+57
View File
@@ -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 后才能算)"
+56
View File
@@ -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"
+52
View File
@@ -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}'
+284
View File
@@ -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
+94
View File
@@ -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
+127
View File
@@ -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
View File
@@ -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
+613
View File
@@ -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()` 转义到位。实测渲染产物里是
`&lt;img src=x onerror=alert(&quot;...&quot;)&gt;`,没有原始标签。
* **是日志伪造**:`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)