ykxmz 1.5.0:修复手表端 TLS 证书校验 + MTU 修复网络稳定性

被控端
- 修复 MTU 问题:夹 MSS + 路径 MTU 探测(公网可用率 48% -> 100%)
- 复用 HTTP 连接(免掉每次新建 TLS 的 30% 握手失败)
- 每分钟回头找局域网,自动切回快 400 倍的路径
- 画面心跳帧:静止时省流量,客户端仍知道流是活的
- 锁屏时给出可操作的提示

手表端
- 内置 GTS Root R4 / GlobalSign Root CA,修 SSLHandshakeException
- 连接错误翻译成人话(证书/时间/解析/超时分别提示)
- 经中转连上后用服务器报的地址刷新已配对记录
- 新增局域网连接页面、服务器延迟检测

服务器端
- 支持 chunked 请求体(修每 16 秒一次的连接错乱与大面积 502)
- 网络调优脚本 net-tune.sh(开机自动应用)
- 管理页:批量删除、网络体检、下载区
- 隧道改 HTTP/2 + 双连接

文档
- 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
This commit is contained in:
xmz7891
2026-10-06 19:01:05 +08:00
commit b62ac865ea
3195 changed files with 203292 additions and 0 deletions
@@ -0,0 +1,449 @@
package com.lanremote.watch;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketTimeoutException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
/**
* 局域网自动发现的**纯 Java 核心**(不含任何 Android 依赖)。
*
* 两层查找策略:
* 1. UDP 广播探测(快,前提是广播没被拦)
* 2. 定向广播 + 子网 TCP 扫描(慢一点,但广播被拦时仍然管用)
*
* 与 Windows 端 lanremote/discovery.py 用同一套报文格式。
*/
public final class DiscoveryCore {
public static final int PORT = 8766;
private static final String PROBE =
"{\"lanremote\":\"LANREMOTE_DISCOVER\",\"proto\":1}";
private static final String REPLY_MAGIC = "LANREMOTE_REPLY";
private static final String SELFCHECK_MAGIC = "LANREMOTE_SELFCHECK";
private static final String ACK_MAGIC = "LANREMOTE_ACK";
private DiscoveryCore() {
}
public static class Found {
public final String host;
public final int port;
public final String name;
public final String version;
/** 来源:broadcast(广播) / directed(定向广播) / scan(端口扫描) */
public final String via;
public Found(String host, int port, String name, String version, String via) {
this.host = host;
this.port = port;
this.name = name;
this.version = version;
this.via = via;
}
@Override
public String toString() {
return (name == null || name.isEmpty() ? host : name)
+ " " + host + ":" + port + " [" + via + "]";
}
}
// ==================================================================
// 地址计算(纯函数,可单独验证)
// ==================================================================
public static long ipToLong(String ip) {
String[] parts = ip.split("\\.");
if (parts.length != 4) {
return -1;
}
long value = 0;
for (String part : parts) {
int octet;
try {
octet = Integer.parseInt(part.trim());
} catch (NumberFormatException e) {
return -1;
}
if (octet < 0 || octet > 255) {
return -1;
}
value = (value << 8) | octet;
}
return value & 0xFFFFFFFFL;
}
public static String longToIp(long value) {
return ((value >> 24) & 0xFF) + "." + ((value >> 16) & 0xFF) + "."
+ ((value >> 8) & 0xFF) + "." + (value & 0xFF);
}
/**
* 由本机 IP + 前缀长度算出**定向广播地址**。
*
* 例:192.168.10.104/24 -> 192.168.10.255
*
* 之前这里是个 bug:代码里写死了猜 192.168.255.255,
* 对 /24 网段来说那根本不是本网段的广播地址,
* 很多路由器和安卓版本不会转发它,导致广播探测收不到任何回复。
*/
public static String directedBroadcast(String ip, int prefix) {
long addr = ipToLong(ip);
if (addr < 0 || prefix < 0 || prefix > 32) {
return null;
}
if (prefix == 32) {
return ip; // 单机,没有广播
}
long mask = (0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL;
long network = addr & mask;
long broadcast = (network | (~mask & 0xFFFFFFFFL)) & 0xFFFFFFFFL;
return longToIp(broadcast);
}
/** 列出该网段内所有可分配主机地址(跳过网络号与广播号,最多 maxHosts 个)。 */
public static List<String> subnetHosts(String ip, int prefix, int maxHosts) {
List<String> hosts = new ArrayList<String>();
long addr = ipToLong(ip);
if (addr < 0 || prefix < 0 || prefix > 30) {
return hosts;
}
long mask = (0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL;
long network = addr & mask;
long broadcast = (network | (~mask & 0xFFFFFFFFL)) & 0xFFFFFFFFL;
for (long host = network + 1; host < broadcast && hosts.size() < maxHosts; host++) {
hosts.add(longToIp(host));
}
return hosts;
}
// ==================================================================
// 查找
// ==================================================================
public static List<Found> search(int timeoutMs) {
return search(timeoutMs, null);
}
/**
* 广播搜索。会阻塞 timeoutMs 毫秒,必须在后台线程调用。
*
* @param extraBroadcast 定向广播地址,如 192.168.10.255(可为 null)
*/
public static List<Found> search(int timeoutMs, String extraBroadcast) {
List<Found> results = new ArrayList<Found>();
Set<String> seen = new LinkedHashSet<String>();
DatagramSocket socket = null;
try {
socket = new DatagramSocket();
socket.setBroadcast(true);
socket.setSoTimeout(400);
byte[] payload = PROBE.getBytes("UTF-8");
List<String> targets = new ArrayList<String>();
targets.add("255.255.255.255"); // 受限广播
if (extraBroadcast != null && extraBroadcast.length() > 0) {
targets.add(extraBroadcast); // 本网段定向广播(关键)
}
for (String target : targets) {
try {
socket.send(new DatagramPacket(payload, payload.length,
InetAddress.getByName(target), PORT));
} catch (Exception ignored) {
// 该广播地址不被允许时跳过
}
}
long deadline = System.currentTimeMillis() + timeoutMs;
byte[] buffer = new byte[2048];
while (System.currentTimeMillis() < deadline) {
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
try {
socket.receive(packet);
} catch (SocketTimeoutException timeout) {
continue;
} catch (Exception broken) {
break;
}
Found found = parse(packet.getData(), packet.getLength(),
packet.getAddress().getHostAddress(), "broadcast");
if (found == null) {
continue;
}
String key = found.host + ":" + found.port;
if (seen.add(key)) {
results.add(found);
}
socket.setSoTimeout(250);
}
} catch (Exception ignored) {
// 网络不可用时返回已收集到的结果
} finally {
if (socket != null) {
socket.close();
}
}
return results;
}
/**
* 子网 TCP 端口扫描。
*
* 用途:广播被路由器/防火墙拦掉时,这是唯一还能找到被控端的办法。
* 直接对本网段 254 个地址并发试连目标端口。
*/
public static List<String> scanSubnet(String ip, int prefix, int port,
int connectTimeoutMs, int maxHosts) {
List<String> hosts = subnetHosts(ip, prefix, maxHosts);
List<String> open = Collections.synchronizedList(new ArrayList<String>());
if (hosts.isEmpty()) {
return open;
}
int threads = Math.min(48, Math.max(8, hosts.size()));
ExecutorService pool = Executors.newFixedThreadPool(threads);
List<Future<?>> tasks = new ArrayList<Future<?>>();
try {
for (final String host : hosts) {
tasks.add(pool.submit(new Callable<Void>() {
@Override
public Void call() {
if (ping(host, port, connectTimeoutMs)) {
open.add(host);
}
return null;
}
}));
}
for (Future<?> task : tasks) {
try {
task.get(connectTimeoutMs + 1500L, TimeUnit.MILLISECONDS);
} catch (Exception ignored) {
// 单个地址超时不影响整体
}
}
} finally {
pool.shutdownNow();
}
Collections.sort(open, new java.util.Comparator<String>() {
@Override
public int compare(String a, String b) {
long la = ipToLong(a);
long lb = ipToLong(b);
return la < lb ? -1 : (la > lb ? 1 : 0);
}
});
return open;
}
/**
* 请电脑打开「网络自检」。
*
* 走 UDP 广播是有意的:连不上的典型原因就是 TCP 被防火墙拦了,
* 而 UDP 广播往往仍然通(那正是「能搜到但连不上」的原因)。
* 所以只要能广播出去,就能让电脑把自检窗口弹出来。
*
* @return 电脑名;没有回应返回 null
*/
public static String requestSelfCheck(String extraBroadcast, int timeoutMs) {
DatagramSocket socket = null;
try {
socket = new DatagramSocket();
socket.setBroadcast(true);
socket.setSoTimeout(500);
byte[] payload = ("{\"lanremote\":\"" + SELFCHECK_MAGIC
+ "\",\"proto\":1}").getBytes("UTF-8");
List<String> targets = new ArrayList<String>();
targets.add("255.255.255.255");
if (extraBroadcast != null && extraBroadcast.length() > 0) {
targets.add(extraBroadcast);
}
// 连发三轮,丢一两个包也能到
for (int round = 0; round < 3; round++) {
for (String target : targets) {
try {
socket.send(new DatagramPacket(payload, payload.length,
InetAddress.getByName(target), PORT));
} catch (Exception ignored) {
// 某个广播地址不被允许时跳过
}
}
try {
Thread.sleep(120);
} catch (InterruptedException stop) {
Thread.currentThread().interrupt();
break;
}
}
long deadline = System.currentTimeMillis() + timeoutMs;
byte[] buffer = new byte[2048];
while (System.currentTimeMillis() < deadline) {
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
try {
socket.receive(packet);
} catch (SocketTimeoutException timeout) {
continue;
} catch (Exception broken) {
break;
}
String text = new String(packet.getData(), 0, packet.getLength(), "UTF-8");
if (text.indexOf(ACK_MAGIC) < 0) {
continue;
}
try {
Map<String, Object> obj = Json.parseObject(text);
String name = Json.str(obj, "name", "");
String note = Json.str(obj, "note", "");
if (name != null && name.length() > 0) {
// 电脑那边现在会把诊断结论一起回过来(它自己不弹窗口),
// 用换行分隔:第一行是电脑名,后面是结论。
return note != null && note.length() > 0
? name + "\n" + note : name;
}
} catch (Exception ignored) {
// 解析失败就用来源 IP
}
return packet.getAddress().getHostAddress();
}
} catch (Exception ignored) {
// 网络不可用
} finally {
if (socket != null) {
socket.close();
}
}
return null;
}
/** 对扫描到的地址做一次单播发现,拿到电脑名和真实端口。 */
public static Found probeHost(String host, int timeoutMs) {
DatagramSocket socket = null;
try {
socket = new DatagramSocket();
socket.setSoTimeout(Math.max(300, timeoutMs));
byte[] payload = PROBE.getBytes("UTF-8");
InetAddress target = InetAddress.getByName(host);
socket.send(new DatagramPacket(payload, payload.length, target, PORT));
byte[] buffer = new byte[2048];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
socket.receive(packet);
return parse(packet.getData(), packet.getLength(),
packet.getAddress().getHostAddress(), "unicast");
} catch (Exception e) {
return null;
} finally {
if (socket != null) {
socket.close();
}
}
}
/** TCP 探测结果 —— 关键是区分「拒绝」和「超时」,两者含义完全不同。 */
public static class PingResult {
public final boolean ok;
public final String reason; // ok / refused / timeout / unreachable / other
public final long ms;
public PingResult(boolean ok, String reason, long ms) {
this.ok = ok;
this.reason = reason;
this.ms = ms;
}
/** 给人看的解释。 */
public String describe() {
if (ok) {
return "成功(" + ms + " ms)";
}
if ("refused".equals(reason)) {
return "被拒绝(" + ms + " ms)—— 主机在线,但**没有程序在监听这个端口**";
}
if ("timeout".equals(reason)) {
return "超时(" + ms + " ms)—— 数据包没有到达或被丢弃"
+ "(防火墙拦截 / 不在同一网络 / 主机离线)";
}
if ("unreachable".equals(reason)) {
return "网络不可达(" + ms + " ms)—— 不同网段或没有路由";
}
return "失败:" + reason + "(" + ms + " ms)";
}
}
/**
* 带原因区分的 TCP 探测。
*
* 为什么必须区分:
* * 被拒绝(RST) = 包到了对方,对方说"这个端口没人听" -> 被控端没运行
* * 超时 = 包根本没到,或中途被丢 -> 防火墙 / 不同网络 / 对方离线
* 这两种情况的排查方向完全相反。
*/
public static PingResult pingDetailed(String host, int port, int timeoutMs) {
long start = System.currentTimeMillis();
java.net.Socket socket = new java.net.Socket();
try {
socket.connect(new InetSocketAddress(host, port), timeoutMs);
return new PingResult(true, "ok", System.currentTimeMillis() - start);
} catch (java.net.SocketTimeoutException e) {
return new PingResult(false, "timeout", System.currentTimeMillis() - start);
} catch (java.net.NoRouteToHostException e) {
return new PingResult(false, "unreachable", System.currentTimeMillis() - start);
} catch (java.net.ConnectException e) {
String msg = String.valueOf(e.getMessage()).toLowerCase();
String reason = msg.contains("refused") || msg.contains("拒绝")
? "refused" : "unreachable";
return new PingResult(false, reason, System.currentTimeMillis() - start);
} catch (Exception e) {
return new PingResult(false, e.getClass().getSimpleName(),
System.currentTimeMillis() - start);
} finally {
try {
socket.close();
} catch (Exception ignored) {
// 忽略关闭异常
}
}
}
/** 简单版:只要通不通。 */
public static boolean ping(String host, int port, int timeoutMs) {
return pingDetailed(host, port, timeoutMs).ok;
}
static Found parse(byte[] data, int length, String fromHost, String via) {
try {
String text = new String(data, 0, length, "UTF-8");
if (text.indexOf(REPLY_MAGIC) < 0) {
return null;
}
Map<String, Object> obj = Json.parseObject(text);
if (!REPLY_MAGIC.equals(Json.str(obj, "lanremote", ""))) {
return null;
}
int port = Json.integer(obj, "port", 0);
if (port <= 0 || port > 65535) {
return null;
}
return new Found(fromHost, port,
Json.str(obj, "name", ""), Json.str(obj, "version", ""), via);
} catch (Exception e) {
return null;
}
}
}