diff --git a/_build/_c18.txt b/_build/_c18.txt new file mode 100644 index 0000000..970b3d4 --- /dev/null +++ b/_build/_c18.txt @@ -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 分钟对照确认。 diff --git a/_build/ab_tunnel.sh b/_build/ab_tunnel.sh new file mode 100644 index 0000000..f8cb9e3 --- /dev/null +++ b/_build/ab_tunnel.sh @@ -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}')" diff --git a/_build/ab_tunnel_long.sh b/_build/ab_tunnel_long.sh new file mode 100644 index 0000000..d35b34c --- /dev/null +++ b/_build/ab_tunnel_long.sh @@ -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" diff --git a/_build/ab_v2.sh b/_build/ab_v2.sh new file mode 100644 index 0000000..2e94c92 --- /dev/null +++ b/_build/ab_v2.sh @@ -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" diff --git a/_build/diag_502_deep.sh b/_build/diag_502_deep.sh new file mode 100644 index 0000000..9b72ab0 --- /dev/null +++ b/_build/diag_502_deep.sh @@ -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)" diff --git a/_build/diag_tunnel_opt.sh b/_build/diag_tunnel_opt.sh new file mode 100644 index 0000000..150a018 --- /dev/null +++ b/_build/diag_tunnel_opt.sh @@ -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 diff --git a/_build/fix_lan_hijack.py b/_build/fix_lan_hijack.py new file mode 100644 index 0000000..25928ec --- /dev/null +++ b/_build/fix_lan_hijack.py @@ -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 明确要求"先走局域网"。 + * + *

用户点「局域网连接」填了 IP,或者点首页已配对电脑时, + * 应该先按他给的地址直连。原来的实现只看中转地址填没填 —— + * 填了就一律走中转,**完全无视用户填的局域网 IP**, + * 而中转超时 40 秒,用户点完只看到「正在连接…」,以为没反应。 + * + *

设 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 reply; + if (!relay.isEmpty()) {""" + +NEW_BRANCH = """ final String relay = relayUrl(); + Map 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()) diff --git a/_build/fix_watch_stale_ip.py b/_build/fix_watch_stale_ip.py new file mode 100644 index 0000000..4abcefd --- /dev/null +++ b/_build/fix_watch_stale_ip.py @@ -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 网段。 + * + *

读不到手表自己的网卡时返回 true(保守:宁可按原地址试一次, + * 也不要因为拿不到权限就误判成"过期")。 + */ + private boolean hostInMySubnet(String host) { + if (host == null || host.isEmpty()) { + return true; + } + java.util.List 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()) diff --git a/_build/poll60_patch.py b/_build/poll60_patch.py new file mode 100644 index 0000000..2830515 --- /dev/null +++ b/_build/poll60_patch.py @@ -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" + " *

必须显著大于服务端的 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()) diff --git a/_build/poll60_patch2.py b/_build/poll60_patch2.py new file mode 100644 index 0000000..69f60d3 --- /dev/null +++ b/_build/poll60_patch2.py @@ -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" + " *

必须显著大于服务端的 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()) diff --git a/_build/test_longpoll.sh b/_build/test_longpoll.sh new file mode 100644 index 0000000..f29d192 --- /dev/null +++ b/_build/test_longpoll.sh @@ -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 diff --git a/_build/test_poll60.sh b/_build/test_poll60.sh new file mode 100644 index 0000000..ef8d60c --- /dev/null +++ b/_build/test_poll60.sh @@ -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 diff --git a/android/src/com/lanremote/watch/MainActivity.java b/android/src/com/lanremote/watch/MainActivity.java index 9c5faf1..675211c 100644 --- a/android/src/com/lanremote/watch/MainActivity.java +++ b/android/src/com/lanremote/watch/MainActivity.java @@ -658,7 +658,16 @@ public class MainActivity extends Activity implements RemoteClient.Listener { final String token = Json.str(device, "token", ""); 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); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(58)); @@ -2191,8 +2200,10 @@ public class MainActivity extends Activity implements RemoteClient.Listener { lastCode = code; lanDirect = true; // 断了自动切回中转 appendLog("局域网直连 " + host + ":" + port); + // preferLan = true:用户就是冲着"局域网直连"来的, + // 必须真的直连,不能因为有中转地址就偷偷走中转。 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 对中转路径没有意义, // 但 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 网段。 + * + *

读不到手表自己的网卡时返回 true(保守:宁可按原地址试一次, + * 也不要因为拿不到权限就误判成"过期",那会让本可以直连的场景 + * 白走一趟中转)。 + */ + private boolean hostInMySubnet(String host) { + if (host == null || host.isEmpty()) { + return true; + } + java.util.List mine = localIpv4List(); + if (mine.isEmpty()) { + return true; + } + for (String myIp : mine) { + if (sameSubnet(host, myIp)) { + return true; + } + } + return false; } /** 手表自己在局域网里的 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, final String token, final String key) { + connect(host, port, code, token, key, false); + } + + /** + * @param preferLan 明确要求"先走局域网"。 + * + *

用户点「局域网连接」填了 IP,或者点首页已配对电脑时, + * 应该先按他给的地址直连。原来的实现只看中转地址填没填 —— + * 填了就一律走中转,**完全无视用户填的局域网 IP**, + * 而中转超时 40 秒,用户点完只看到「正在连接…」,以为没反应。 + * + *

设 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; } @@ -2495,7 +2558,32 @@ public class MainActivity extends Activity implements RemoteClient.Listener { try { final String relay = relayUrl(); Map 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 后面,直连不可能, // 两边各自连出去、由中转按配对码对接。 // 走公网要几个 HTTP 往返,超时给宽一点。 diff --git a/android/src/com/lanremote/watch/RelayTransport.java b/android/src/com/lanremote/watch/RelayTransport.java index a0b84fb..fae896a 100644 --- a/android/src/com/lanremote/watch/RelayTransport.java +++ b/android/src/com/lanremote/watch/RelayTransport.java @@ -36,8 +36,14 @@ public class RelayTransport implements Transport { private static final int FLUSH_BYTES = 32 * 1024; /** 攒不住了也要发,别让命令卡住 */ private static final int FLUSH_MILLIS = 40; - /** 长轮询:中转那边挂 25 秒,客户端超时给宽一点 */ - private static final int POLL_TIMEOUT_MS = 40000; + /** + * 长轮询:中转那边挂 60 秒,客户端超时给宽一点。 + * + *

必须显著大于服务端的 hold 时间,否则会在服务端正常返回 + * 之前自己先超时 —— 那就变成"每次轮询都失败"了。 + * 90 秒 = 60 + 30 秒余量。 + */ + private static final int POLL_TIMEOUT_MS = 90000; /** 发送/接收的单次请求超时 */ private static final int SEND_TIMEOUT_MS = 30000; /** 网络出错后的退避 */ diff --git a/relay/relay.py b/relay/relay.py index 98fc5eb..242b371 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -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")) # 长轮询最多挂多久。太长会占着 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", diff --git a/windows/lanremote/relay.py b/windows/lanremote/relay.py index 6d398c1..886ac39 100644 --- a/windows/lanremote/relay.py +++ b/windows/lanremote/relay.py @@ -31,7 +31,11 @@ import urllib.parse import urllib.request # 长轮询:中转那边挂 25 秒,客户端超时给宽一点 -POLL_TIMEOUT = 40.0 +# 长轮询单次请求的超时。 +# 必须显著大于服务端的 hold 时间(现在 60 秒),否则会在服务端 +# 正常返回之前自己先超时 —— 那就变成"每次轮询都失败"了。 +# 90 = 60 + 30 秒余量。 +POLL_TIMEOUT = 90.0 # 单次请求体上限(一个 256KB 分块 Base64 后约 350KB,给足余量) MAX_BODY = 8 * 1024 * 1024 # 网络断了之后的退避