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 subnetHosts(String ip, int prefix, int maxHosts) { List hosts = new ArrayList(); 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 search(int timeoutMs) { return search(timeoutMs, null); } /** * 广播搜索。会阻塞 timeoutMs 毫秒,必须在后台线程调用。 * * @param extraBroadcast 定向广播地址,如 192.168.10.255(可为 null) */ public static List search(int timeoutMs, String extraBroadcast) { List results = new ArrayList(); Set seen = new LinkedHashSet(); DatagramSocket socket = null; try { socket = new DatagramSocket(); socket.setBroadcast(true); socket.setSoTimeout(400); byte[] payload = PROBE.getBytes("UTF-8"); List targets = new ArrayList(); 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 scanSubnet(String ip, int prefix, int port, int connectTimeoutMs, int maxHosts) { List hosts = subnetHosts(ip, prefix, maxHosts); List open = Collections.synchronizedList(new ArrayList()); if (hosts.isEmpty()) { return open; } int threads = Math.min(48, Math.max(8, hosts.size())); ExecutorService pool = Executors.newFixedThreadPool(threads); List> tasks = new ArrayList>(); try { for (final String host : hosts) { tasks.add(pool.submit(new Callable() { @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() { @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 targets = new ArrayList(); 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 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 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; } } }