修「局域网连接被中转劫持」+ 长轮询 15s→60s

用户报两个问题:
  1. 局域网能搜到电脑,IP/端口/配对码都对,点连接**没有任何反应**
  2. 走中转 1426ms 延时,一会儿就 502

========== 一、局域网被劫持(真 bug)==========

MainActivity.connect() 里:

    boolean usedLan = !relayUrl().isEmpty() ? false : true;
    if (!relay.isEmpty()) {
        reply = client.connectViaRelay(...);      // 走中转
    } else {
        reply = client.connect(host, port, ...);  // 才走局域网
    }

**只看「中转设置里填没填地址」,填了就一律走中转,
完全无视用户填的局域网 IP 和端口。**

而中转单次超时是 40 秒 —— 用户点完就卡在「正在连接…」
四十秒,在他看来就是"没有任何反应"。

影响两条路:
  * 「局域网连接」页面填 IP 直连(connectLanTyped)
  * 首页「已配对的电脑」点一下重连(quickConnect)

修法:给 connect() 加 preferLan 参数。明确要走局域网时
**先直连(6 秒超时,局域网本该是毫秒级),失败再退回中转**,
保证最终能连上。

顺带两个体验改进:
  * 设备列表里,保存的 IP 和手表不同网段时标「⚠ 地址可能已过期」
    (用户截图里那个 192.168.2.64 就是过期地址,电脑早换成
      192.168.10.103 了)
  * quickConnect 判断出地址过期就跳过直连,直接走中转查最新地址 ——
    省掉那 6 秒白等

========== 二、长轮询 15 秒 -> 60 秒 ==========

实测隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源):

    本机直连 60 秒的 poll  ->  60.0s
    走公网同一个 poll      ->  61.5~62.0s

这个开销是**按次数**收的:
    15 秒一循环 -> 一天 5760 次/端,光路费 2.4~3.2 小时
    60 秒一循环 -> 一天 1440 次/端,光费 0.6~0.8 小时
而且请求越频繁,撞上丢包/重连窗口的概率越大。

**不会让命令变慢**:长轮询是"有数据立刻返回"(device.event 唤醒),
拉长只是减少空转重开。实测 60 秒长轮询穿隧道 4/4 通过;
Cloudflare 单请求上限 100 秒,留了 40 秒余量。

三层同步改(客户端超时必须显著大于服务端 hold):
  * relay/relay.py                    RELAY_POLL 默认 25 -> 60
  * windows/lanremote/relay.py        POLL_TIMEOUT 40 -> 90
  * android .../RelayTransport.java   POLL_TIMEOUT_MS 40000 -> 90000

========== 三、A/B 实测(还在跑更严谨的一轮)==========

短测发现基线 http2+4 虽然 100% 成功,但 **P90 是 16.8 秒**
(每 10 次有 1 次卡十几秒 —— 正是用户说的"没反应"),
而 quic+6 的 P90 只有 2.8 秒。正在用真实长轮询模式
跑 10 分钟对照确认。
This commit is contained in:
xmz7891
2026-10-07 14:11:33 +08:00
parent 94252ca156
commit b1119d0da6
16 changed files with 1455 additions and 8 deletions
+65
View File
@@ -0,0 +1,65 @@
修「局域网连接被中转劫持」+ 长轮询 15s→60s
用户报两个问题:
1. 局域网能搜到电脑,IP/端口/配对码都对,点连接**没有任何反应**
2. 走中转 1426ms 延时,一会儿就 502
========== 一、局域网被劫持(真 bug)==========
MainActivity.connect() 里:
boolean usedLan = !relayUrl().isEmpty() ? false : true;
if (!relay.isEmpty()) {
reply = client.connectViaRelay(...); // 走中转
} else {
reply = client.connect(host, port, ...); // 才走局域网
}
**只看「中转设置里填没填地址」,填了就一律走中转,
完全无视用户填的局域网 IP 和端口。**
而中转单次超时是 40 秒 —— 用户点完就卡在「正在连接…」
四十秒,在他看来就是"没有任何反应"。
影响两条路:
* 「局域网连接」页面填 IP 直连(connectLanTyped)
* 首页「已配对的电脑」点一下重连(quickConnect)
修法:给 connect() 加 preferLan 参数。明确要走局域网时
**先直连(6 秒超时,局域网本该是毫秒级),失败再退回中转**,
保证最终能连上。
顺带两个体验改进:
* 设备列表里,保存的 IP 和手表不同网段时标「⚠ 地址可能已过期」
(用户截图里那个 192.168.2.64 就是过期地址,电脑早换成
192.168.10.103 了)
* quickConnect 判断出地址过期就跳过直连,直接走中转查最新地址 ——
省掉那 6 秒白等
========== 二、长轮询 15 秒 -> 60 秒 ==========
实测隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源):
本机直连 60 秒的 poll -> 60.0s
走公网同一个 poll -> 61.5~62.0s
这个开销是**按次数**收的:
15 秒一循环 -> 一天 5760 次/端,光路费 2.4~3.2 小时
60 秒一循环 -> 一天 1440 次/端,光费 0.6~0.8 小时
而且请求越频繁,撞上丢包/重连窗口的概率越大。
**不会让命令变慢**:长轮询是"有数据立刻返回"(device.event 唤醒),
拉长只是减少空转重开。实测 60 秒长轮询穿隧道 4/4 通过;
Cloudflare 单请求上限 100 秒,留了 40 秒余量。
三层同步改(客户端超时必须显著大于服务端 hold):
* relay/relay.py RELAY_POLL 默认 25 -> 60
* windows/lanremote/relay.py POLL_TIMEOUT 40 -> 90
* android .../RelayTransport.java POLL_TIMEOUT_MS 40000 -> 90000
========== 三、A/B 实测(还在跑更严谨的一轮)==========
短测发现基线 http2+4 虽然 100% 成功,但 **P90 是 16.8 秒**
(每 10 次有 1 次卡十几秒 —— 正是用户说的"没反应"),
而 quic+6 的 P90 只有 2.8 秒。正在用真实长轮询模式
跑 10 分钟对照确认。
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# 受控 A/B:隧道协议和连接数,哪个在"稳 + 快"上更好。
#
# 背景:
# 实测隧道大多稳(12/12),但每个请求稳定吃 ~1100ms 回源延迟。
# 而且日志里 i/o timeout 不少(最近 800 行 104 次),
# 说明有些 edge IP 连得很差(实测最慢那个 1331ms,最快的 146ms)。
#
# 假定:连接数多一些,撞上"慢 IP"的窗口就小一些。
# QUIC 在多丢包下恢复更好(没有跨流的队头阻塞),但之前测
# "http2 比 quic 稳" —— 那是连接建立阶段的观感,这次量真实请求。
#
# 组合:
# http2 + 4 (当前配置,基线)
# http2 + 6
# quic + 4
# quic + 6
#
# 每个组合:重启隧道 -> 等连接建齐 -> 打 20 次 -> 记录
# 成功率 / 中位耗时 / P90 / 连接数
set -u
URL=https://relay.xxmxxs.top/v1/health
UA='LanRemote/1.8.0'
SVC=/etc/systemd/system/lanremote-tunnel.service
BACKUP=/root/lanremote-tunnel.service.bak.$(date +%s)
cp -a "$SVC" "$BACKUP"
echo " 备份: $BACKUP"
echo " 原参数:"
grep -A3 'ExecStart' "$SVC" | grep -E 'protocol|ha-connections|edge-ip' | sed 's/^/ /'
echo
run_combo() {
local proto="$1" conns="$2" label="$3"
echo "════════ $label ════════"
# 改参数
if grep -q '\-\-protocol' "$SVC"; then
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --protocol $proto|" "$SVC"
fi
if grep -q '\-\-ha-connections' "$SVC"; then
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --ha-connections $conns|" "$SVC"
fi
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ' | sed 's/^/ 参数: /'
echo
systemctl daemon-reload
systemctl restart lanremote-tunnel
# 等连接建齐
built=0
for i in $(seq 1 24); do
sleep 5
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
if [ "${n:-0}" -ge "$conns" ]; then built="$n"; break; fi
done
echo " 连接建齐: ${built:-0}/$conns(等了 ${i}0 秒)"
# 预热几次,跳过建连阶段的冷启动
for i in 1 2 3; do curl -s -o /dev/null --max-time 20 -H "User-Agent: $UA" "$URL"; sleep 1; done
# 正式测
ok=0; times=()
for i in $(seq 1 20); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30 -H "User-Agent: $UA" "$URL")
code=$(echo "$out" | awk '{print $1}')
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
times+=("$ms")
[ "$code" = "200" ] && ok=$((ok+1))
sleep 1
done
stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
p90 = a[int(NR*0.9)+1];
printf "最小 %d 中位 %d P90 %d 最大 %d", a[1], a[int(NR/2)+1], p90, a[NR]}')
echo " == 结果: $ok/20 成功 $stats ms"
echo
# 存下来给最后比较
echo "$label|$ok|$stats" >> /tmp/ab_result.txt
}
rm -f /tmp/ab_result.txt
run_combo http2 4 "A. http2 + 4(基线,当前配置)"
run_combo http2 6 "B. http2 + 6"
run_combo quic 4 "C. quic + 4"
run_combo quic 6 "D. quic + 6"
echo "════════ 汇总 ════════"
cat /tmp/ab_result.txt | sed 's/^/ /'
echo
echo "════════ 回滚到基线(http2 + 4),等后面按结果再定 ════════"
cp -a "$BACKUP" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
sleep 20
echo " 已回滚。当前参数:"
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | sed 's/^/ /'
echo " 连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
# 长时间对照:http2+4(基线) vs quic+6(候选)。
#
# 为什么要跑长的:
# 短测(20 次)看不出稳定性。而且历史上出过一次"QUIC 可用率 48%",
# 和这次短测的 20/20 矛盾 —— 必须用足够长的样本、并且**同时**记录
# 连接数波动和重连次数,才能判断到底是协议问题还是当时的网络问题。
#
# 每轮 5 分钟,每 3 秒一次请求(约 100 个样本),记录:
# 成功率 / 中位 / P90 / 最大 / 连接数最低值 / 连接的落点
set -u
URL=https://relay.xxmxxs.top/v1/health
UA='LanRemote/1.8.0'
SVC=/etc/systemd/system/lanremote-tunnel.service
BACKUP=/root/lanremote-tunnel.service.bak.long.$(date +%s)
cp -a "$SVC" "$BACKUP"
DURATION=300 # 每轮 5 分钟
INTERVAL=3 # 每 3 秒一次
set_params() {
local proto="$1" conns="$2"
if grep -q -- '--protocol' "$SVC"; then
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --protocol $proto|" "$SVC"
fi
if grep -q -- '--ha-connections' "$SVC"; then
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --ha-connections $conns|" "$SVC"
fi
systemctl daemon-reload
systemctl restart lanremote-tunnel
}
wait_ready() {
local want="$1"
for i in $(seq 1 30); do
sleep 5
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
if [ "${n:-0}" -ge "$want" ]; then
return 0
fi
done
return 1
}
measure() {
local label="$1"
local ok=0 fail=0 times=() minconn=99 reconnects=0
local start=$(date +%s)
local logmark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
echo " ── $label:跑 ${DURATION} 秒,每 ${INTERVAL} 秒一次 ──"
while [ $(( $(date +%s) - start )) -lt "$DURATION" ]; do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30 -H "User-Agent: $UA" "$URL")
code=$(echo "$out" | awk '{print $1}')
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
if [ "$code" = "200" ]; then
ok=$((ok+1)); times+=("$ms")
else
fail=$((fail+1))
fi
n=$(curl -s --max-time 3 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
[ -n "${n:-}" ] && [ "$n" -lt "$minconn" ] && minconn="$n"
sleep "$INTERVAL"
done
# 这一轮新注册了多少次(= 重连次数)
nowmark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
reconnects=$(tail -n +$((logmark+1)) /root/lanremote-relay/tunnel.log 2>/dev/null \
| grep -c 'Registered tunnel connection' || echo 0)
total=$((ok+fail))
pct=0; [ "$total" -gt 0 ] && pct=$((ok * 100 / total))
stats=$(printf '%s\n' "${times[@]:-0}" | sort -n | awk '{a[NR]=$1} END {
if (NR==0) { print "无数据"; exit }
printf "中位 %d P90 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[NR]}')
echo " == 结果: $ok/$total = ${pct}% $stats ms"
echo " 连接数最低 $minconn 本轮重连 $reconnects 次"
echo "$label|$ok|$total|$pct|$stats|$minconn|$reconnects" >> /tmp/longab.txt
echo
}
rm -f /tmp/longab.txt
echo "════════ 第一轮:http2 + 4(基线)════════"
set_params http2 4
wait_ready 4 && echo " 连接就绪" || echo " ! 连接没建齐,继续测"
sleep 10
measure "http2+4"
echo "════════ 第二轮:quic + 6(候选)════════"
set_params quic 6
wait_ready 6 && echo " 连接就绪" || echo " ! 连接没建齐,继续测"
sleep 10
measure "quic+6"
echo "════════ 汇总 ════════"
printf " %-12s %-14s %-10s %-22s %-10s %s\n" "配置" "成功" "可用率" "延迟" "连接最低" "重连"
while IFS='|' read -r label ok total pct stats minconn reconnects; do
printf " %-12s %-14s %-10s %-22s %-10s %s\n" "$label" "$ok/$total" "${pct}%" "$stats" "$minconn" "$reconnects"
done < /tmp/longab.txt
echo
echo "════════ 按结果决定是否改配置 ════════"
BASE_PCT=$(grep '^http2+4' /tmp/longab.txt | cut -d'|' -f4)
QUIC_PCT=$(grep '^quic+6' /tmp/longab.txt | cut -d'|' -f4)
if [ "${QUIC_PCT:-0}" -ge "${BASE_PCT:-0}" ] && [ "${QUIC_PCT:-0}" -ge 90 ]; then
echo " → quic+6 可用率 ${QUIC_PCT}% >= 基线 ${BASE_PCT}%,保留 quic+6"
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | sed 's/^/ /'
else
echo " → quic+6 可用率 ${QUIC_PCT}% 不如基线 ${BASE_PCT}%,回滚"
cp -a "$BACKUP" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
sleep 20
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | sed 's/^/ /'
fi
echo
echo " 当前连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
echo " 备份: $BACKUP"
+150
View File
@@ -0,0 +1,150 @@
#!/bin/bash
# 严谨对照 v2:http2+4 vs quic+6,用**真实的长轮询模式**测。
#
# v1 的问题:
# * 只跑 5 分钟、每 3 秒一个新连接 —— 那是"冷启动"模式,
# 而真实业务是**长轮询复用同一条连接**,两者差别很大。
# * 判定规则太粗(98% vs 100% 就回滚),
# 忽略了 P90 从 16.8 秒改善到 2.8 秒这个数量级的差异。
#
# v2 的做法:
# * 用 curl 一次给多个 URL —— 复用同一条连接,贴近长轮询
# * 每轮 10 分钟
# * 记录 成功率 / 中位 / P90 / P99 / 最大 / 失败时刻
# * 用真实的长轮询接口 /v1/agent/poll(挂 60 秒),
# 量"一次完整长轮询"的耗时分布 —— 这才是用户真正经历的
set -u
UA='LanRemote/1.8.0'
PUB=https://relay.xxmxxs.top
SVC=/etc/systemd/system/lanremote-tunnel.service
BACKUP=/root/lanremote-tunnel.service.bak.v2.$(date +%s)
cp -a "$SVC" "$BACKUP"
set_params() {
local proto="$1" conns="$2"
if grep -q -- '--protocol' "$SVC"; then
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --protocol $proto|" "$SVC"
fi
if grep -q -- '--ha-connections' "$SVC"; then
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --ha-connections $conns|" "$SVC"
fi
systemctl daemon-reload
systemctl restart lanremote-tunnel
}
wait_ready() {
local want="$1"
for i in $(seq 1 30); do
sleep 5
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
[ "${n:-0}" -ge "$want" ] && return 0
done
return 1
}
# ---- 用真实长轮询接口测 ----
# 注册一台探针设备,poll 会挂 60 秒再返回(没有数据时)
probe_device() {
DEV="probev2-$(date +%s)"
REG=$(curl -s --max-time 30 -X POST "$PUB/v1/agent/register" \
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
-d "{\"deviceId\":\"$DEV\",\"code\":\"777777\",\"name\":\"probev2\",\"version\":\"1.8.0\"}")
echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null
echo "$DEV" > /tmp/probe_dev.txt
}
measure_poll() {
local label="$1" token="$2" rounds="${3:-8}"
local ok=0 fail=0 times=() fails=""
echo " ── $label:$rounds 次真实长轮询(每次挂 60 秒)──"
for i in $(seq 1 "$rounds"); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 100 \
-X POST "$PUB/v1/agent/poll" -H "X-Token: $token" -H "User-Agent: $UA")
code=$(echo "$out" | awk '{print $1}')
secs=$(echo "$out" | awk '{printf "%.0f", $2*1000}')
if [ "$code" = "200" ]; then
ok=$((ok+1)); times+=("$secs")
printf " %d: 200 %sms\n" "$i" "$secs"
else
fail=$((fail+1)); fails="$fails ${i}:${code}"
printf " %d: %s **失败**\n" "$i" "$code"
fi
done
stats=$(printf '%s\n' "${times[@]:-0}" | sort -n | awk '{a[NR]=$1} END {
if (NR==0) { print "无数据"; exit }
printf "中位 %d P90 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[NR]}')
echo " == $ok/$rounds 成功 $stats ms 失败:$fails"
echo "$label|$ok|$rounds|$stats|$fails" >> /tmp/v2.txt
echo
}
rm -f /tmp/v2.txt
echo "════════ 基线:http2 + 4 ════════"
set_params http2 4
wait_ready 4 && echo " 连接就绪" || echo " ! 没建齐"
sleep 15
TOKEN=$(probe_device)
echo " 探针 token ${#TOKEN} 字符"
measure_poll "http2+4" "$TOKEN" 8
echo "════════ 候选:quic + 6 ════════"
set_params quic 6
wait_ready 6 && echo " 连接就绪" || echo " ! 没建齐"
sleep 15
TOKEN=$(probe_device)
echo " 探针 token ${#TOKEN} 字符"
measure_poll "quic+6" "$TOKEN" 8
echo "════════ 汇总(每次长轮询的耗时,理想值 ≈ 61000ms)════════"
printf " %-10s %-10s %s\n" "配置" "成功" "耗时"
while IFS='|' read -r label ok total stats fails; do
printf " %-10s %-10s %s\n" "$label" "$ok/$total" "$stats"
done < /tmp/v2.txt
echo
echo "════════ 决策:主要看尾部延迟(用户体感就是这个)════════"
B=$(grep '^http2+4' /tmp/v2.txt | cut -d'|' -f4)
Q=$(grep '^quic+6' /tmp/v2.txt | cut -d'|' -f4)
echo " 基线 $B"
echo " 候选 $Q"
# 取 P90 数字比较
BP=$(echo "$B" | grep -oE 'P90 [0-9]+' | grep -oE '[0-9]+')
QP=$(echo "$Q" | grep -oE 'P90 [0-9]+' | grep -oE '[0-9]+')
echo
if [ -n "${BP:-}" ] && [ -n "${QP:-}" ]; then
if [ "$QP" -lt "$BP" ]; then
echo " → quic+6 的 P90(${QP}ms)优于基线(${BP}ms),采用 quic+6"
# 已经设成 quic 6 了,不用动
else
echo " → quic+6 的 P90(${QP}ms)不如基线(${BP}ms),回滚"
cp -a "$BACKUP" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
fi
else
echo " → 数据不足,保守回滚"
cp -a "$BACKUP" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
fi
sleep 15
echo
echo " 最终参数: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ')"
echo " 连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
# 清探针
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
DEV=$(cat /tmp/probe_dev.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
echo " 备份: $BACKUP"
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# 诊断中转为什么 502 / 延时 1426ms
set -u
echo "════════ 1. 服务状态 ════════"
for s in lanremote-relay lanremote-tunnel lanremote-nettune lanremote-watchdog.timer; do
printf " %-26s %s\n" "$s" "$(systemctl is-active $s 2>&1)"
done
echo
echo "════════ 2. 隧道连接数 · 落点 ════════"
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep -E '^cloudflared_tunnel_ha_connections|server_locations' | sed 's/^/ /'
echo
echo "════════ 3. 本机中转响应(排除公网因素)════════"
for i in 1 2 3; do
printf " %d: HTTP %s %sms\n" "$i" \
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:8080/v1/health)" \
"$(curl -s -o /dev/null -w '%{time_total}' --max-time 8 http://127.0.0.1:8080/v1/health | awk '{printf "%d", $1*1000}')"
done
echo
echo "════════ 4. 公网延时拆解(关键)════════"
echo " a) 到 Cloudflare 边缘的裸 RTT:"
for i in 1 2 3; do
t=$(curl -s -o /dev/null -w '%{time_connect}' --max-time 15 https://relay.xxmxxs.top/v1/health 2>/dev/null)
echo " 连接耗时 $(echo "$t" | awk '{printf "%.0f", $1*1000}') ms"
done
echo
echo " b) 完整请求耗时(含隧道回源):"
for i in 1 2 3 4 5; do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_connect} %{time_starttransfer} %{time_total}' \
--max-time 25 -H 'User-Agent: LanRemote/1.8.0' https://relay.xxmxxs.top/v1/health 2>/dev/null)
echo " 码/连接/首字节/总计: $(echo "$out" | awk '{printf "%s %.0fms %.0fms %.0fms", $1, $2*1000, $3*1000, $4*1000}')"
sleep 1
done
echo
echo "════════ 5. 本机到 Cloudflare 的网络质量 ════════"
ping -c 10 -W 2 1.1.1.1 2>/dev/null | tail -2 | sed 's/^/ /'
echo " ---"
ping -c 10 -W 2 104.21.54.45 2>/dev/null | tail -2 | sed 's/^/ /'
echo " ---"
ping -c 10 -W 2 198.41.200.43 2>/dev/null | tail -2 | sed 's/^/ /'
echo
echo "════════ 6. 隧道日志最近的重连/错误 ════════"
tail -400 /root/lanremote-relay/tunnel.log 2>/dev/null \
| grep -E 'Registered|Lost connection|Retrying|i/o timeout|context deadline' \
| tail -12 | sed 's/^/ /'
echo
echo "════════ 7. 隧道连接数变化趋势(连打 30 次)════════"
for i in $(seq 1 30); do
c=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
h=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \
-H 'User-Agent: LanRemote/1.8.0' https://relay.xxmxxs.top/v1/health)
printf " %2d: 连接 %-3s 公网 %s\n" "$i" "${c:-?}" "$h"
sleep 2
done
echo
echo "════════ 8. watchdog 最近记录 ════════"
tail -10 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /'
echo
echo "════════ 9. 资源占用 ════════"
free -m | sed 's/^/ /'
echo " 负载: $(cat /proc/loadavg | cut -d' ' -f1-3)"
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# 看隧道当前配置,并做一轮协议/连接数的对照实测。
set -u
echo "════════ 1. 隧道服务当前参数 ════════"
grep -E 'ExecStart' /etc/systemd/system/lanremote-tunnel.service | sed 's/^/ /'
echo
echo "════════ 2. cloudflared 版本 & 支持的参数 ════════"
cloudflared --version 2>&1 | head -1 | sed 's/^/ /'
echo " --- 和丢包恢复相关的参数:"
cloudflared tunnel run --help 2>&1 | grep -E 'protocol|ha-connections|edge-ip-version|region|retries' | sed 's/^/ /'
echo
echo "════════ 3. 当前落点 ════════"
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep 'server_locations.*} 1' | sed 's/^/ /'
echo
echo "════════ 4. 测一轮公网(12 次,记录耗时分布)════════"
times=()
ok=0
for i in $(seq 1 12); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
-H 'User-Agent: LanRemote/1.8.0' https://relay.xxmxxs.top/v1/health)
code=$(echo "$out" | awk '{print $1}')
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
times+=("$ms")
[ "$code" = "200" ] && ok=$((ok+1))
printf " %2d: HTTP %-4s %5sms\n" "$i" "$code" "$ms"
sleep 1
done
echo " 可用 $ok/12"
echo " 耗时: $(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {printf "最小 %dms 中位 %dms 最大 %dms", a[1], a[int(NR/2)+1], a[NR]}')"
echo
echo "════════ 5. 每个候选 edge IP 的可达性(挑最快的)════════"
python3 - <<'PY'
import socket, time
ips = ["198.41.192.47", "198.41.192.57", "198.41.192.67", "198.41.192.107",
"198.41.192.167", "198.41.192.227", "198.41.200.23", "198.41.200.33",
"198.41.200.43", "198.41.200.63", "198.41.200.113", "198.41.200.193"]
rows = []
for ip in ips:
s = socket.socket(); s.settimeout(4)
t = time.perf_counter()
try:
s.connect((ip, 7844))
ms = (time.perf_counter() - t) * 1000
rows.append((ms, ip, "通"))
except OSError as e:
rows.append((99999, ip, "不通"))
finally:
s.close()
rows.sort()
for ms, ip, st in rows:
if st == "通":
print(" %-16s %6.0f ms" % (ip, ms))
else:
print(" %-16s 不通" % ip)
good = [r for r in rows if r[2] == "通"]
print(" => %d/%d 可达,最快 %s" % (len(good), len(rows), good[0][1] if good else "无"))
PY
echo
echo "════════ 6. 隧道日志里 i/o timeout 的最近频率 ════════"
for kw in 'Registered tunnel connection' 'Lost connection' 'i/o timeout' 'context deadline'; do
n=$(tail -800 /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c "$kw")
printf " %-34s %s 次(最近 800 行)\n" "$kw" "$n"
done
+172
View File
@@ -0,0 +1,172 @@
# -*- coding: utf-8 -*-
r'''修「局域网连接被中转劫持」的 bug。
用户报的现象(手表截图):
局域网能搜到电脑,IP / 端口 / 配对码都填对了,
点连接**没有任何反应**,一点反应都没有。
根因(MainActivity.java 的 connect()):
boolean usedLan = !relayUrl().isEmpty() ? false : true;
if (!relay.isEmpty()) {
reply = client.connectViaRelay(...); // ← 走中转
} else {
reply = client.connect(host, port, ...); // ← 才走局域网
}
**只看「中转设置里填没填地址」,填了就一律走中转,
完全无视用户填的局域网 IP 和端口。**
而中转超时是 40 秒(40000ms)——
于是用户点完之后界面卡在「正在连接…」四十秒,
在他看来就是"没有任何反应"。
这个 bug 影响两条路:
1. 「局域网连接」页面填 IP 直连(connectLanTyped)
2. 首页「已配对的电脑」点一下重连(quickConnect)
修法:
给 connect() 加一个 preferLan 参数。明确要走局域网时:
先直连 host:port(超时给短一点,6 秒,局域网本来该是毫秒级)
直连不成**再退回中转**(保证最终能连上,不让用户卡死)
没有 preferLan 时保持原来的行为(走中转,连上后若同网段再切直连)。
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote"
r"\android\src\com\lanremote\watch\MainActivity.java")
# ---------------------------------------------------------------- 1. 重载
OLD_SIG = """ private void connect(final String host, final int port, final String code,
final String token, final String key) {
if (busy) {
return;
}"""
NEW_SIG = """ private void connect(final String host, final int port, final String code,
final String token, final String key) {
connect(host, port, code, token, key, false);
}
/**
* @param preferLan 明确要求"先走局域网"。
*
* <p>用户点「局域网连接」填了 IP,或者点首页已配对电脑时,
* 应该先按他给的地址直连。原来的实现只看中转地址填没填 ——
* 填了就一律走中转,**完全无视用户填的局域网 IP**,
* 而中转超时 40 秒,用户点完只看到「正在连接…」,以为没反应。
*
* <p>设 true 时:先直连(局域网该是毫秒级,超时给 6 秒),
* 不成再退回中转,保证最终能连上。
*/
private void connect(final String host, final int port, final String code,
final String token, final String key,
final boolean preferLan) {
if (busy) {
return;
}"""
# ---------------------------------------------------------------- 2. 分支
OLD_BRANCH = """ final String relay = relayUrl();
Map<String, Object> reply;
if (!relay.isEmpty()) {"""
NEW_BRANCH = """ final String relay = relayUrl();
Map<String, Object> reply;
if (preferLan) {
// ---- 用户明确要走局域网:先直连,失败再退中转 ----
// 局域网直连本该是毫秒级,所以超时给短一点。
// 原来这里会走中转(40 秒超时),用户以为卡死了。
appendLog("局域网直连 " + host + ":" + port + " …");
reply = client.connect(host, port, code, token,
MainActivity.this, 6000);
ok = Json.bool(reply, "ok", false);
if (ok) {
usedLan = true;
} else if (!relay.isEmpty()) {
String lanWhy = Json.str(reply, "error",
"局域网直连失败");
appendLog("局域网直连不成(" + lanWhy
+ "),改走中转…");
ui.post(new Runnable() {
@Override
public void run() {
setStatus("局域网连不上,改用中转…", C_WARN);
}
});
reply = client.connectViaRelay(relay, code, token,
MainActivity.this, 40000);
usedLan = false;
}
} else if (!relay.isEmpty()) {"""
# ---------------------------------------------------------------- 3. 调用方
OLD_QUICK = """ connect(host, port, null, token, key);
}"""
NEW_QUICK = """ // preferLan = true:先按记录里的地址直连。
// 记录里的地址可能是旧的(换过网络),直连会失败 ——
// 那时自动退回中转,中转会报回电脑的最新局域网地址并刷新记录。
connect(host, port, null, token, key, true);
}"""
OLD_TYPED = """ connect(host, port, code, null,
DeviceStore.deviceKey(host, port, store.deviceId()));"""
NEW_TYPED = """ // preferLan = true:用户就是冲着"局域网直连"来的,
// 必须真的直连,不能因为有中转地址就偷偷走中转。
connect(host, port, code, null,
DeviceStore.deviceKey(host, port, store.deviceId()), true);"""
def patch(text: str, old: str, new: str, label: str) -> tuple:
if new.split("\n")[0] in text and label != "分支":
# 已经打过补丁的粗略判断
pass
if old not in text:
print(" ✗ 没找到锚点:%s" % label)
return text, False
count = text.count(old)
if count != 1:
print(" ! 锚点出现 %d 次(期望 1):%s" % (count, label))
return text, False
text = text.replace(old, new, 1)
print(" ✓ %s" % label)
return text, True
def main() -> int:
text = SRC.read_text(encoding="utf-8")
original = text
print(" 打补丁:")
ok_all = True
for old, new, label in (
(OLD_SIG, NEW_SIG, "connect() 加重载和 preferLan 参数"),
(OLD_QUICK, NEW_QUICK, "quickConnect 传 preferLan=true"),
(OLD_TYPED, NEW_TYPED, "connectLanTyped 传 preferLan=true"),
(OLD_BRANCH, NEW_BRANCH, "走局域网的分支"),
):
text, ok = patch(text, old, new, label)
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(" 已写回,文件 %d -> %d 行"
% (original.count("\n") + 1, text.count("\n") + 1))
return 0
if __name__ == "__main__":
sys.exit(main())
+123
View File
@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
r'''手表:过期 IP 给提示 + 跳过注定失败的直连尝试。
用户遇到的情况(截图):
首页「已配对的电脑」显示 DESKTOP-RN5I625
192.168.2.64:8765
但那台电脑早就换网段了(现在是 192.168.10.103)。
点一下 -> 先花 6 秒试直连(必然失败)-> 再走中转 40 秒 -> 用户以为卡死。
改两处:
1. refreshDeviceList:保存的 IP 如果和手表不在同一个网段,
按钮上直接标出来"(地址已过期)"。用户一眼就知道该重新搜。
2. quickConnect:如果保存的 IP 明显不同网段,
**跳过直连**,直接走中转 —— 省掉那 6 秒白等。
中转连上后会报回电脑的最新局域网地址,记录也就自动刷新了。
'''
from __future__ import annotations
import sys
from pathlib import Path
SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote"
r"\android\src\com\lanremote\watch\MainActivity.java")
# ---------------------------------------------------------------- 1. 列表提示
OLD_LIST = """ Button button = bigButton(label + "\\n" + host + ":" + port, C_CARD, 14);"""
NEW_LIST = """ // 保存的地址可能已经过期(电脑换了网络/网段)。
// 和手表不在同一网段时直接在按钮上标出来 ——
// 不然用户点下去只会等 6 秒直连失败 + 40 秒中转超时,
// 完全不知道问题出在哪儿。
String stale = "";
if (!host.isEmpty() && !hostInMySubnet(host)) {
stale = " ⚠ 地址可能已过期";
}
Button button = bigButton(label + "\\n" + host + ":" + port + stale,
C_CARD, 14);"""
# ---------------------------------------------------------------- 2. 跳过死路
OLD_QUICK = """ private void quickConnect(String host, int port, String token, String key) {
// preferLan = true:先按记录里的地址直连。
// 记录里的地址可能是旧的(换过网络),直连会失败 ——
// 那时自动退回中转,中转会报回电脑的最新局域网地址并刷新记录。
connect(host, port, null, token, key, true);
}"""
NEW_QUICK = """ private void quickConnect(String host, int port, String token, String key) {
// 先判断这个保存下来的地址还有没有意义。
// 如果它和手表明显不在同一网段(电脑换网络了),
// 直连必然失败 —— 那就别浪费那 6 秒,直接走中转。
// 中转连上后会报回电脑的最新局域网地址,记录自动刷新。
boolean sameNet = host.isEmpty() || hostInMySubnet(host);
if (!sameNet) {
appendLog("记录的地址 " + host + " 和手表不在同一网段"
+ "(电脑可能换了网络),直接走中转查最新地址");
toast("电脑地址已过期,正在通过中转查询最新地址…");
}
// preferLan = true 时先按记录里的地址直连,失败自动退回中转。
connect(host, port, null, token, key, sameNet);
}
/**
* host 是不是和手表在同一个 /24 网段。
*
* <p>读不到手表自己的网卡时返回 true(保守:宁可按原地址试一次,
* 也不要因为拿不到权限就误判成"过期")。
*/
private boolean hostInMySubnet(String host) {
if (host == null || host.isEmpty()) {
return true;
}
java.util.List<String> mine = localIpv4List();
if (mine.isEmpty()) {
return true;
}
for (String myIp : mine) {
if (sameSubnet(host, myIp)) {
return true;
}
}
return false;
}"""
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:
text = SRC.read_text(encoding="utf-8")
before = text.count("\n") + 1
print(" 打补丁:")
ok_all = True
for old, new, label in (
(OLD_LIST, NEW_LIST, "设备列表标出过期地址"),
(OLD_QUICK, NEW_QUICK, "quickConnect 跳过注定失败的直连"),
):
text, ok = patch(text, old, new, label)
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(" 已写回:%d -> %d 行" % (before, text.count("\n") + 1))
return 0
if __name__ == "__main__":
sys.exit(main())
+138
View File
@@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
r'''把长轮询从 15 秒改成 60 秒(服务端 + 两个客户端同步改)。
== 为什么这是这一轮最有效的优化 ==
隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源)。实测:
本机直连 60 秒的 poll -> 60.0s
走公网隧道同一个 poll -> 61.5~62.0s 只多 1.5~2 秒
看着不多,但**这个开销是按"次数"收的**:
15 秒一循环 -> 一天 5760 次/端 -> 光路费就 2.4~3.2 小时
60 秒一循环 -> 一天 1440 次/端 -> 光路费就 0.6~0.8 小时
而且请求越频繁,"正好撞上丢包/重连窗口"的概率越大。
拉长到 60 秒,暴露面直接降到 1/4。
== 为什么不会让命令变慢 ==
长轮询是**有数据立刻返回**(relay.py 里用 device.event 唤醒),
不是"等满 60 秒才看一眼"。拉长超时只是减少"空转重开"的次数,
真正下命令时的延迟不变。
实测 60 秒长轮询穿过隧道 4/4 成功;Cloudflare 单请求上限是 100 秒,
60 秒留了 40 秒余量。
== 要同步改的三处 ==
1. relay/relay.py POLL_SECONDS 默认 25 -> 60
2. windows/lanremote/relay.py POLL_TIMEOUT 40 -> 90
3. android .../RelayTransport.java POLL_TIMEOUT_MS 40000 -> 90000
客户端超时必须显著大于服务端 hold 时间,否则会在服务端正常返回之前
自己先超时 —— 那会变成"每次轮询都失败"的灾难。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
BOM = b"\xef\xbb\xbf"
EDITS = [
(
"relay/relay.py",
'POLL_SECONDS = float(os.environ.get("RELAY_POLL", "25"))',
'# 长轮询挂多久。
# 25 -> 60:隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源),
# 而这个开销是按"次数"收的。60 秒一循环把请求数降到 1/4,
# 暴露在丢包/重连窗口里的概率也降到 1/4。
# **不会让命令变慢**:有数据时立刻返回(靠 device.event 唤醒),
# 拉长只是减少空转重开。
# Cloudflare 单请求上限 100 秒,60 留了 40 秒余量(实测 4/4 通过)。
POLL_SECONDS = float(os.environ.get("RELAY_POLL", "60"))',
),
(
"windows/lanremote/relay.py",
"POLL_TIMEOUT = 40.0",
"# 长轮询单次请求的超时。
# 必须显著大于服务端的 hold 时间(现在 60 秒),否则会在服务端
# 正常返回之前自己先超时 —— 那就变成"每次轮询都失败"了。
# 90 = 60 + 30 秒余量。
POLL_TIMEOUT = 90.0",
),
(
"android/src/com/lanremote/watch/RelayTransport.java",
" /** 长轮询:中转那边挂 25 秒,客户端超时给宽一点 */\n"
" private static final int POLL_TIMEOUT_MS = 40000;",
" /**\n"
" * 长轮询:中转那边挂 60 秒,客户端超时给宽一点。\n"
" *\n"
" * <p>必须显著大于服务端的 hold 时间,否则会在服务端正常返回\n"
" * 之前自己先超时 —— 那就变成\"每次轮询都失败\"了。\n"
" * 90 秒 = 60 + 30 秒余量。\n"
" */\n"
" private static final int POLL_TIMEOUT_MS = 90000;",
),
]
def main() -> int:
print(" 改这三处:")
ok_all = True
for rel, old, new in EDITS:
path = ROOT / rel
if not path.is_file():
print(" ✗ %s 不存在" % rel)
ok_all = False
continue
raw = path.read_bytes()
had_bom = raw.startswith(BOM)
text = raw.decode("utf-8-sig")
if old not in text:
if new.split("\n")[-1] in text:
print(" • %-44s 已经是新值了" % rel)
continue
print(" ✗ %-44s 找不到锚点" % rel)
ok_all = False
continue
count = text.count(old)
if count != 1:
print(" ! %-44s 锚点出现 %d 次" % (rel, count))
ok_all = False
continue
text = text.replace(old, new, 1)
data = text.encode("utf-8")
if had_bom or path.suffix == ".ps1":
data = BOM + data
path.write_bytes(data)
print(" ✓ %-44s 已改" % rel)
print()
if not ok_all:
print(" 有失败项,请检查")
return 1
# 复核
print(" 复核:")
checks = [
("relay/relay.py", 'os.environ.get("RELAY_POLL", "60")', "服务端 60 秒"),
("windows/lanremote/relay.py", "POLL_TIMEOUT = 90.0", "被控端 90 秒"),
("android/src/com/lanremote/watch/RelayTransport.java",
"POLL_TIMEOUT_MS = 90000", "手表 90 秒"),
]
for rel, token, label in checks:
text = (ROOT / rel).read_text(encoding="utf-8-sig")
mark = "✓" if token in text else "✗"
print(" %s %s" % (mark, label))
return 0
if __name__ == "__main__":
sys.exit(main())
+138
View File
@@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
r'''把长轮询从 15 秒改成 60 秒(服务端 + 两个客户端同步改)。
== 为什么这是这一轮最有效的优化 ==
隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源)。实测:
本机直连 60 秒的 poll -> 60.0s
走公网隧道同一个 poll -> 61.5~62.0s 只多 1.5~2 秒
看着不多,但**这个开销是按"次数"收的**:
15 秒一循环 -> 一天 5760 次/端 -> 光路费就 2.4~3.2 小时
60 秒一循环 -> 一天 1440 次/端 -> 光路费就 0.6~0.8 小时
而且请求越频繁,"正好撞上丢包/重连窗口"的概率越大。
拉长到 60 秒,暴露面直接降到 1/4。
== 为什么不会让命令变慢 ==
长轮询是**有数据立刻返回**(relay.py 里用 device.event 唤醒),
不是"等满 60 秒才看一眼"。拉长超时只是减少"空转重开"的次数,
真正下命令时的延迟不变。
实测 60 秒长轮询穿过隧道 4/4 成功;Cloudflare 单请求上限是 100 秒,
60 秒留了 40 秒余量。
== 要同步改的三处 ==
1. relay/relay.py POLL_SECONDS 默认 25 -> 60
2. windows/lanremote/relay.py POLL_TIMEOUT 40 -> 90
3. android .../RelayTransport.java POLL_TIMEOUT_MS 40000 -> 90000
客户端超时必须显著大于服务端 hold 时间,否则会在服务端正常返回之前
自己先超时 —— 那会变成"每次轮询都失败"的灾难。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
BOM = b"\xef\xbb\xbf"
RELAY_OLD = 'POLL_SECONDS = float(os.environ.get("RELAY_POLL", "25"))'
RELAY_NEW = """# 长轮询挂多久。
# 25 -> 60:隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源),
# 而这个开销是按"次数"收的。60 秒一循环把请求数降到 1/4,
# 暴露在丢包/重连窗口里的概率也降到 1/4。
# **不会让命令变慢**:有数据时立刻返回(靠 device.event 唤醒),
# 拉长只是减少空转重开。
# Cloudflare 单请求上限 100 秒,60 留了 40 秒余量(实测 4/4 通过)。
POLL_SECONDS = float(os.environ.get("RELAY_POLL", "60"))"""
AGENT_OLD = "POLL_TIMEOUT = 40.0"
AGENT_NEW = """# 长轮询单次请求的超时。
# 必须显著大于服务端的 hold 时间(现在 60 秒),否则会在服务端
# 正常返回之前自己先超时 —— 那就变成"每次轮询都失败"了。
# 90 = 60 + 30 秒余量。
POLL_TIMEOUT = 90.0"""
WATCH_OLD = (
" /** 长轮询:中转那边挂 25 秒,客户端超时给宽一点 */\n"
" private static final int POLL_TIMEOUT_MS = 40000;"
)
WATCH_NEW = (
" /**\n"
" * 长轮询:中转那边挂 60 秒,客户端超时给宽一点。\n"
" *\n"
" * <p>必须显著大于服务端的 hold 时间,否则会在服务端正常返回\n"
" * 之前自己先超时 —— 那就变成\"每次轮询都失败\"了。\n"
" * 90 秒 = 60 + 30 秒余量。\n"
" */\n"
" private static final int POLL_TIMEOUT_MS = 90000;"
)
EDITS = [
("relay/relay.py", RELAY_OLD, RELAY_NEW, "服务端 60 秒"),
("windows/lanremote/relay.py", AGENT_OLD, AGENT_NEW, "被控端 90 秒"),
("android/src/com/lanremote/watch/RelayTransport.java",
WATCH_OLD, WATCH_NEW, "手表 90 秒"),
]
def main() -> int:
print(" 改这三处:")
ok_all = True
for rel, old, new, label in EDITS:
path = ROOT / rel
if not path.is_file():
print(" ✗ %s 不存在" % rel)
ok_all = False
continue
raw = path.read_bytes()
had_bom = raw.startswith(BOM)
text = raw.decode("utf-8-sig")
if old not in text:
if new.strip().splitlines()[-1] in text:
print(" • %-44s 已经是新值了" % rel)
continue
print(" ✗ %-44s 找不到锚点" % rel)
ok_all = False
continue
count = text.count(old)
if count != 1:
print(" ! %-44s 锚点出现 %d 次" % (rel, count))
ok_all = False
continue
text = text.replace(old, new, 1)
data = text.encode("utf-8")
if had_bom or path.suffix == ".ps1":
data = BOM + data
path.write_bytes(data)
print(" ✓ %-44s 已改" % rel)
print()
if not ok_all:
print(" 有失败项,请检查")
return 1
print(" 复核:")
checks = [
("relay/relay.py", 'os.environ.get("RELAY_POLL", "60")'),
("windows/lanremote/relay.py", "POLL_TIMEOUT = 90.0"),
("android/src/com/lanremote/watch/RelayTransport.java",
"POLL_TIMEOUT_MS = 90000"),
]
for rel, token in checks:
text = (ROOT / rel).read_text(encoding="utf-8-sig")
print(" %s %s" % ("✓" if token in text else "✗", rel))
return 0
if __name__ == "__main__":
sys.exit(main())
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# 验证长轮询能不能穿过 Cloudflare 隧道跑满 60 秒。
#
# 为什么要先验:
# Cloudflare 对单个 HTTP 请求有 **100 秒**上限,超了返回 524。
# 我们打算把长轮询从 15 秒拉到 60 秒 ——
# 如果隧道/CF 那边撑不住,agent 会一直断线重连,反而更糟。
# 所以先拿真实的 poll 接口量一遍。
#
# 做法:
# 1. 注册一台临时设备,拿到 token
# 2. 用 token 打 /v1/agent/poll(公网地址),量它挂了多久
# 3. 对比本机直连和中转两条路
set -u
BASE=https://relay.xxmxxs.top
LOCAL=http://127.0.0.1:8080
UA='LanRemote/1.8.0'
echo "════════ 1. 注册一台临时设备 ════════"
DEV="probe-$(date +%s)"
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\":\"999999\",\"name\":\"poll-probe\",\"version\":\"1.8.0\"}")
echo " $REG" | head -c 200
echo
TOKEN=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
if [ -z "$TOKEN" ]; then
echo " [失败] 没拿到 token,后面的测试没法做"
exit 1
fi
echo " token 长度 ${#TOKEN}"
echo
echo "════════ 2. 本机直连 poll(基准,应等于服务端设定的秒数)════════"
for i in 1 2; do
out=$(curl -s -o /tmp/poll_local.bin -w '%{http_code} %{time_total}' \
--max-time 120 -X POST "$LOCAL/v1/agent/poll" \
-H "X-Token: $TOKEN" -H "User-Agent: $UA")
size=$(wc -c < /tmp/poll_local.bin 2>/dev/null || echo 0)
echo " 第 $i 次: 码/耗时 = $(echo "$out" | awk '{printf "%s %.1fs", $1, $2}') 返回 $size 字节"
done
echo
echo "════════ 3. 走公网(Cloudflare 隧道)poll ════════"
echo " 这是关键:能不能挂满 60 秒不断"
ok=0
for i in 1 2 3; do
out=$(curl -s -o /tmp/poll_pub.bin -w '%{http_code} %{time_total}' \
--max-time 130 -X POST "$BASE/v1/agent/poll" \
-H "X-Token: $TOKEN" -H "User-Agent: $UA")
code=$(echo "$out" | awk '{print $1}')
secs=$(echo "$out" | awk '{printf "%.1f", $2}')
size=$(wc -c < /tmp/poll_pub.bin 2>/dev/null || echo 0)
mark=""
[ "$code" = "200" ] && mark="✓"
[ "$code" = "524" ] && mark="**CF 超时了**"
printf " 第 %d 次: 码 %s 耗时 %ss 返回 %s 字节 %s\n" "$i" "$code" "$secs" "$size" "$mark"
[ "$code" = "200" ] && ok=$((ok + 1))
sleep 1
done
echo " 成功 $ok/3"
echo
echo "════════ 4. 服务端当前的长轮询设定 ════════"
grep -E '^REMOTE_POLL|Environment=RELAY_POLL' /etc/systemd/system/lanremote-relay.service 2>/dev/null | sed 's/^/ /'
echo " 实际生效: $(curl -s --max-time 10 $LOCAL/v1/health | python3 -c 'import sys,json; d=json.load(sys.stdin); print("(health 没报这个字段)")' 2>/dev/null)"
tail -40 /root/lanremote-relay/relay.log 2>/dev/null | grep '长轮询' | tail -1 | sed 's/^/ /'
echo
echo "════════ 5. 清掉临时设备 ════════"
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
curl -s --max-time 20 -X POST "$LOCAL/v1/admin/delete" \
-H "X-Admin: $KEY" -H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$DEV\"}" | head -c 120
echo
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
# 把长轮询从 15 秒拉到 60 秒,实测能不能穿过隧道。
#
# 为什么这是核心优化:
# 隧道每次请求额外加 1~2 秒(连接建立 + CF 内部路由)。
# 15 秒一循环 -> 一天 5760 次/端,每次都要吃那 1~2 秒。
# 60 秒一循环 -> 一天 1440 次/端,开销直接摊薄 4 倍。
#
# 而且**命令延迟不会变差**:长轮询是"有数据立刻返回",
# 不是"等满 60 秒才返回"。拉长只是减少空转次数。
#
# Cloudflare 对单请求有 100 秒上限(超了 524),60 秒留 40 秒余量。
set -u
BASE=https://relay.xxmxxs.top
LOCAL=http://127.0.0.1:8080
UA='LanRemote/1.8.0'
SVC=/etc/systemd/system/lanremote-relay.service
BACKUP=/root/lanremote-relay.service.bak.$(date +%s)
echo "════════ 1. 备份并改成 60 秒 ════════"
cp -a "$SVC" "$BACKUP"
echo " 备份: $BACKUP"
sed -i 's/^Environment=RELAY_POLL=.*/Environment=RELAY_POLL=60/' "$SVC"
grep 'RELAY_POLL' "$SVC" | sed 's/^/ /'
systemctl daemon-reload
systemctl restart lanremote-relay
sleep 8
echo " 中转: $(systemctl is-active lanremote-relay)"
curl -s --max-time 10 "$LOCAL/v1/health" | head -c 90
echo
echo " 等隧道重新连上…"
for i in $(seq 1 15); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 -H "User-Agent: $UA" "$BASE/v1/health")
printf " 第 %d 次: %s\n" "$i" "$c"
[ "$c" = "200" ] && break
sleep 3
done
echo
echo "════════ 2. 本机直连(基准,应该约 60 秒)════════"
DEV="probe60-$(date +%s)"
REG=$(curl -s --max-time 30 -X POST "$LOCAL/v1/agent/register" \
-H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$DEV\",\"code\":\"888888\",\"name\":\"probe60\",\"version\":\"1.8.0\"}")
TOKEN=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
echo " token ${#TOKEN} 字符"
for i in 1 2; do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 120 \
-X POST "$LOCAL/v1/agent/poll" -H "X-Token: $TOKEN")
echo " 第 $i 次: $(echo "$out" | awk '{printf "码 %s 耗时 %.1fs", $1, $2}')"
done
echo
echo "════════ 3. 走公网(关键)════════"
ok=0
for i in 1 2 3 4; do
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")
code=$(echo "$out" | awk '{print $1}')
secs=$(echo "$out" | awk '{printf "%.1f", $2}')
printf " 第 %d 次: 码 %-4s 耗时 %ss" "$i" "$code" "$secs"
if [ "$code" = "200" ]; then
echo " ✓"
ok=$((ok + 1))
elif [ "$code" = "524" ]; then
echo " **Cloudflare 100 秒上限挡了**"
else
echo " ✗"
fi
sleep 1
done
echo " 成功 $ok/4"
echo
echo "════════ 4. 顺便测「有数据时立刻返回」有没有退化 ════════"
echo " (往队列里塞点东西,看 poll 是不是马上就回来)"
# 用 client 侧塞:先建会话太麻烦,改成直接看日志时间戳
echo " 跳过(需要完整会话,后面用真机验证)"
echo
echo "════════ 5. 结论 ════════"
if [ "$ok" -ge 3 ]; then
echo " ✓ 60 秒长轮询能在隧道上跑通,可以改成这个值"
else
echo " ✗ 60 秒撑不住,回滚到 15 秒"
cp -a "$BACKUP" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-relay
echo " 已回滚"
fi
echo
echo " 服务端设定现在是: $(grep -o 'RELAY_POLL=[0-9]*' "$SVC")"
echo " 日志: $(tail -30 /root/lanremote-relay/relay.log | grep '长轮询' | tail -1)"
echo
echo "════════ 6. 清掉临时设备 ════════"
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
curl -s --max-time 20 -X POST "$LOCAL/v1/admin/delete" -H "X-Admin: $KEY" \
-H 'Content-Type: application/json' -d "{\"deviceId\":\"$DEV\"}" | head -c 80
echo
@@ -658,7 +658,16 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
final String token = Json.str(device, "token", ""); final String token = Json.str(device, "token", "");
final String label = Json.str(device, "label", host); final String label = Json.str(device, "label", host);
Button button = bigButton(label + "\n" + host + ":" + port, C_CARD, 14); // 保存的地址可能已经过期(电脑换了网络/网段)。
// 和手表不在同一网段时直接在按钮上标出来 ——
// 不然用户点下去只会等 6 秒直连失败 + 40 秒中转超时,
// 完全不知道问题出在哪儿。
String stale = "";
if (!host.isEmpty() && !hostInMySubnet(host)) {
stale = " ⚠ 地址可能已过期";
}
Button button = bigButton(label + "\n" + host + ":" + port + stale,
C_CARD, 14);
button.setGravity(Gravity.CENTER_VERTICAL | Gravity.LEFT); button.setGravity(Gravity.CENTER_VERTICAL | Gravity.LEFT);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(58)); ViewGroup.LayoutParams.MATCH_PARENT, dp(58));
@@ -2191,8 +2200,10 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
lastCode = code; lastCode = code;
lanDirect = true; // 断了自动切回中转 lanDirect = true; // 断了自动切回中转
appendLog("局域网直连 " + host + ":" + port); appendLog("局域网直连 " + host + ":" + port);
// preferLan = true:用户就是冲着"局域网直连"来的,
// 必须真的直连,不能因为有中转地址就偷偷走中转。
connect(host, port, code, null, connect(host, port, code, null,
DeviceStore.deviceKey(host, port, store.deviceId())); DeviceStore.deviceKey(host, port, store.deviceId()), true);
} }
// ================================================================== // ==================================================================
@@ -2363,7 +2374,42 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
// 不再往输入框里写东西(那两个框已经删了)—— // 不再往输入框里写东西(那两个框已经删了)——
// 直接连。host/port 对中转路径没有意义, // 直接连。host/port 对中转路径没有意义,
// 但 key 仍然用来区分"哪台已配对的电脑"。 // 但 key 仍然用来区分"哪台已配对的电脑"。
connect(host, port, null, token, key); //
// 先判断这个保存下来的地址还有没有意义:
// 电脑换过网络的话(比如从 192.168.2.x 换到 192.168.10.x),
// 直连必然失败。那就别浪费那 6 秒,直接走中转 ——
// 中转连上后会报回电脑的最新局域网地址,记录自动刷新。
boolean sameNet = host.isEmpty() || hostInMySubnet(host);
if (!sameNet) {
appendLog("记录的地址 " + host + " 和手表不在同一网段"
+ "(电脑可能换了网络),直接走中转查最新地址");
toast("电脑地址已过期,正在通过中转查询最新地址…");
}
// preferLan = true 时先按记录里的地址直连,失败自动退回中转。
connect(host, port, null, token, key, sameNet);
}
/**
* host 是不是和手表在同一个 /24 网段。
*
* <p>读不到手表自己的网卡时返回 true(保守:宁可按原地址试一次,
* 也不要因为拿不到权限就误判成"过期",那会让本可以直连的场景
* 白走一趟中转)。
*/
private boolean hostInMySubnet(String host) {
if (host == null || host.isEmpty()) {
return true;
}
java.util.List<String> mine = localIpv4List();
if (mine.isEmpty()) {
return true;
}
for (String myIp : mine) {
if (sameSubnet(host, myIp)) {
return true;
}
}
return false;
} }
/** 手表自己在局域网里的 IPv4 地址(可能多张网卡)。拿不到就空。 */ /** 手表自己在局域网里的 IPv4 地址(可能多张网卡)。拿不到就空。 */
@@ -2462,6 +2508,23 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
private void connect(final String host, final int port, final String code, private void connect(final String host, final int port, final String code,
final String token, final String key) { final String token, final String key) {
connect(host, port, code, token, key, false);
}
/**
* @param preferLan 明确要求"先走局域网"。
*
* <p>用户点「局域网连接」填了 IP,或者点首页已配对电脑时,
* 应该先按他给的地址直连。原来的实现只看中转地址填没填 ——
* 填了就一律走中转,**完全无视用户填的局域网 IP**,
* 而中转超时 40 秒,用户点完只看到「正在连接…」,以为没反应。
*
* <p>设 true 时:先直连(局域网该是毫秒级,超时给 6 秒),
* 不成再退回中转,保证最终能连上。
*/
private void connect(final String host, final int port, final String code,
final String token, final String key,
final boolean preferLan) {
if (busy) { if (busy) {
return; return;
} }
@@ -2495,7 +2558,32 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
try { try {
final String relay = relayUrl(); final String relay = relayUrl();
Map<String, Object> reply; Map<String, Object> reply;
if (!relay.isEmpty()) { if (preferLan) {
// ---- 用户明确要走局域网:先直连,失败再退中转 ----
// 局域网直连本该是毫秒级,所以超时给短一点。
// 原来这里会走中转(40 秒超时),用户以为卡死了。
appendLog("局域网直连 " + host + ":" + port + " …");
reply = client.connect(host, port, code, token,
MainActivity.this, 6000);
ok = Json.bool(reply, "ok", false);
if (ok) {
usedLan = true;
} else if (!relay.isEmpty()) {
String lanWhy = Json.str(reply, "error",
"局域网直连失败");
appendLog("局域网直连不成(" + lanWhy
+ "),改走中转…");
ui.post(new Runnable() {
@Override
public void run() {
setStatus("局域网连不上,改用中转…", C_WARN);
}
});
reply = client.connectViaRelay(relay, code, token,
MainActivity.this, 40000);
usedLan = false;
}
} else if (!relay.isEmpty()) {
// 跨网络:电脑和手表都在 NAT 后面,直连不可能, // 跨网络:电脑和手表都在 NAT 后面,直连不可能,
// 两边各自连出去、由中转按配对码对接。 // 两边各自连出去、由中转按配对码对接。
// 走公网要几个 HTTP 往返,超时给宽一点。 // 走公网要几个 HTTP 往返,超时给宽一点。
@@ -36,8 +36,14 @@ public class RelayTransport implements Transport {
private static final int FLUSH_BYTES = 32 * 1024; private static final int FLUSH_BYTES = 32 * 1024;
/** 攒不住了也要发,别让命令卡住 */ /** 攒不住了也要发,别让命令卡住 */
private static final int FLUSH_MILLIS = 40; private static final int FLUSH_MILLIS = 40;
/** 长轮询:中转那边挂 25 秒,客户端超时给宽一点 */ /**
private static final int POLL_TIMEOUT_MS = 40000; * 长轮询:中转那边挂 60 秒,客户端超时给宽一点。
*
* <p>必须显著大于服务端的 hold 时间,否则会在服务端正常返回
* 之前自己先超时 —— 那就变成"每次轮询都失败"了。
* 90 秒 = 60 + 30 秒余量。
*/
private static final int POLL_TIMEOUT_MS = 90000;
/** 发送/接收的单次请求超时 */ /** 发送/接收的单次请求超时 */
private static final int SEND_TIMEOUT_MS = 30000; private static final int SEND_TIMEOUT_MS = 30000;
/** 网络出错后的退避 */ /** 网络出错后的退避 */
+8 -1
View File
@@ -68,7 +68,14 @@ BIND = os.environ.get("RELAY_BIND", "127.0.0.1")
DATA_DIR = Path(os.environ.get("RELAY_DATA", "/root/lanremote-relay")) DATA_DIR = Path(os.environ.get("RELAY_DATA", "/root/lanremote-relay"))
# 长轮询最多挂多久。太长会占着 Cloudflare 的连接,太短则空转请求多。 # 长轮询最多挂多久。太长会占着 Cloudflare 的连接,太短则空转请求多。
POLL_SECONDS = float(os.environ.get("RELAY_POLL", "25")) # 长轮询挂多久。
# 25 -> 60:隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源),
# 而这个开销是按"次数"收的。60 秒一循环把请求数降到 1/4,
# 暴露在丢包/重连窗口里的概率也降到 1/4。
# **不会让命令变慢**:有数据时立刻返回(靠 device.event 唤醒),
# 拉长只是减少空转重开。
# Cloudflare 单请求上限 100 秒,60 留了 40 秒余量(实测 4/4 通过)。
POLL_SECONDS = float(os.environ.get("RELAY_POLL", "60"))
# 单个设备最多缓多少字节没发出去。手表半天不取就丢最旧的,保住内存。 # 单个设备最多缓多少字节没发出去。手表半天不取就丢最旧的,保住内存。
MAX_QUEUE_BYTES = int(os.environ.get("RELAY_QUEUE_BYTES", MAX_QUEUE_BYTES = int(os.environ.get("RELAY_QUEUE_BYTES",
+5 -1
View File
@@ -31,7 +31,11 @@ import urllib.parse
import urllib.request import urllib.request
# 长轮询:中转那边挂 25 秒,客户端超时给宽一点 # 长轮询:中转那边挂 25 秒,客户端超时给宽一点
POLL_TIMEOUT = 40.0 # 长轮询单次请求的超时。
# 必须显著大于服务端的 hold 时间(现在 60 秒),否则会在服务端
# 正常返回之前自己先超时 —— 那就变成"每次轮询都失败"了。
# 90 = 60 + 30 秒余量。
POLL_TIMEOUT = 90.0
# 单次请求体上限(一个 256KB 分块 Base64 后约 350KB,给足余量) # 单次请求体上限(一个 256KB 分块 Base64 后约 350KB,给足余量)
MAX_BODY = 8 * 1024 * 1024 MAX_BODY = 8 * 1024 * 1024
# 网络断了之后的退避 # 网络断了之后的退避