mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-19 04:50:43 +00:00
被控端 - 修复 MTU 问题:夹 MSS + 路径 MTU 探测(公网可用率 48% -> 100%) - 复用 HTTP 连接(免掉每次新建 TLS 的 30% 握手失败) - 每分钟回头找局域网,自动切回快 400 倍的路径 - 画面心跳帧:静止时省流量,客户端仍知道流是活的 - 锁屏时给出可操作的提示 手表端 - 内置 GTS Root R4 / GlobalSign Root CA,修 SSLHandshakeException - 连接错误翻译成人话(证书/时间/解析/超时分别提示) - 经中转连上后用服务器报的地址刷新已配对记录 - 新增局域网连接页面、服务器延迟检测 服务器端 - 支持 chunked 请求体(修每 16 秒一次的连接错乱与大面积 502) - 网络调优脚本 net-tune.sh(开机自动应用) - 管理页:批量删除、网络体检、下载区 - 隧道改 HTTP/2 + 双连接 文档 - 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
450 lines
18 KiB
Java
450 lines
18 KiB
Java
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;
|
||
}
|
||
}
|
||
}
|