mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-19 10:40:44 +00:00
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:
@@ -0,0 +1,526 @@
|
||||
package com.lanremote.watch;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 走中转服务器的传输层(跨网络用)。
|
||||
*
|
||||
* 原理:
|
||||
* 手表没法直连电脑(两边都在 NAT 后面),所以两边都主动连出去:
|
||||
*
|
||||
* 手表 --HTTPS--> 中转服务器 <--HTTPS-- 电脑
|
||||
*
|
||||
* 中转只按配对码把两条连接对上,中间传的是**原始字节**,它不认识内容。
|
||||
* 所以电脑那边的配对、令牌校验、权限判断全部继续生效 ——
|
||||
* 中转被攻破也进不了用户的电脑。
|
||||
*
|
||||
* 为什么用长轮询而不是 WebSocket:
|
||||
* 长轮询在任何代理 / 4G 网络下都最稳,而且 Android 自带
|
||||
* HttpURLConnection 就能做,不需要引任何第三方库
|
||||
* (这个项目手表端是**零第三方依赖**的纯 Java)。
|
||||
*
|
||||
* 数据流:
|
||||
* 上行(手表->电脑):write() 攒进缓冲区 -> 发送线程 POST /v1/client/send
|
||||
* 下行(电脑->手表):轮询线程 GET 长轮询 -> 塞进 inbox -> read() 取走
|
||||
*/
|
||||
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;
|
||||
/** 发送/接收的单次请求超时 */
|
||||
private static final int SEND_TIMEOUT_MS = 30000;
|
||||
/** 网络出错后的退避 */
|
||||
private static final int RETRY_MIN_MS = 1500;
|
||||
private static final int RETRY_MAX_MS = 15000;
|
||||
/** 单次读到的字节上限 */
|
||||
private static final int MAX_BODY = 8 * 1024 * 1024;
|
||||
|
||||
private final String baseUrl;
|
||||
private final String pairingCode;
|
||||
private final String clientName;
|
||||
|
||||
private volatile String session = "";
|
||||
private volatile String deviceName = "";
|
||||
private volatile boolean closed;
|
||||
/** 是否被另一台手表顶替(这种情况下不该重连) */
|
||||
private volatile boolean replaced;
|
||||
private volatile String lastError = "";
|
||||
private volatile boolean online;
|
||||
|
||||
/** 收到的字节,按块排队,read() 按需取 */
|
||||
private final LinkedBlockingQueue<byte[]> inbox =
|
||||
new LinkedBlockingQueue<byte[]>();
|
||||
private byte[] current;
|
||||
private int currentOffset;
|
||||
|
||||
/** 待发送的字节 */
|
||||
private final ByteArrayOutputStream pending = new ByteArrayOutputStream();
|
||||
private final Object pendingLock = new Object();
|
||||
|
||||
private Thread pollThread;
|
||||
private Thread sendThread;
|
||||
private final Object closingLock = new Object();
|
||||
|
||||
public RelayTransport(String baseUrl, String pairingCode, String clientName) {
|
||||
String url = baseUrl == null ? "" : baseUrl.trim();
|
||||
while (url.endsWith("/")) {
|
||||
url = url.substring(0, url.length() - 1);
|
||||
}
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "https://" + url;
|
||||
}
|
||||
this.baseUrl = url;
|
||||
this.pairingCode = pairingCode == null ? "" : pairingCode.trim();
|
||||
this.clientName = clientName == null || clientName.isEmpty()
|
||||
? "手表" : clientName;
|
||||
}
|
||||
|
||||
public String getDeviceName() {
|
||||
return deviceName;
|
||||
}
|
||||
|
||||
public String getLastError() {
|
||||
return lastError;
|
||||
}
|
||||
|
||||
public boolean isOnline() {
|
||||
return online && !closed;
|
||||
}
|
||||
|
||||
/** 是不是被另一台手表接管了。界面据此提示"已被接管"而不是"连接失败"。 */
|
||||
public boolean isReplaced() {
|
||||
return replaced;
|
||||
}
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Transport
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public InputStream input() {
|
||||
return new InputStream() {
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
byte[] one = new byte[1];
|
||||
int got = read(one, 0, 1);
|
||||
return got <= 0 ? -1 : (one[0] & 0xFF);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer, int offset, int length)
|
||||
throws IOException {
|
||||
if (length <= 0) {
|
||||
return 0;
|
||||
}
|
||||
while (current == null || currentOffset >= current.length) {
|
||||
if (closed) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
current = inbox.poll(500, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return -1;
|
||||
}
|
||||
currentOffset = 0;
|
||||
}
|
||||
int available = current.length - currentOffset;
|
||||
int take = Math.min(available, length);
|
||||
System.arraycopy(current, currentOffset, buffer, offset, take);
|
||||
currentOffset += take;
|
||||
if (currentOffset >= current.length) {
|
||||
current = null;
|
||||
currentOffset = 0;
|
||||
}
|
||||
return take;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int available() {
|
||||
int total = 0;
|
||||
if (current != null) {
|
||||
total += current.length - currentOffset;
|
||||
}
|
||||
for (byte[] piece : inbox) {
|
||||
total += piece.length;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream output() {
|
||||
return new OutputStream() {
|
||||
@Override
|
||||
public void write(int value) {
|
||||
write(new byte[]{(byte) value}, 0, 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int length) {
|
||||
if (length <= 0) {
|
||||
return;
|
||||
}
|
||||
synchronized (pendingLock) {
|
||||
pending.write(buffer, offset, length);
|
||||
if (pending.size() >= FLUSH_BYTES) {
|
||||
pendingLock.notifyAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClosed() {
|
||||
return closed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (closingLock) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
}
|
||||
// 尽量把手里的东西发出去,再把会话摘掉
|
||||
flushNow();
|
||||
if (!session.isEmpty()) {
|
||||
try {
|
||||
request("/v1/client/detach", new byte[0], true, 8000);
|
||||
} catch (IOException ignored) {
|
||||
// 断网时摘不掉也没关系,中转会自己清
|
||||
}
|
||||
}
|
||||
if (pollThread != null) {
|
||||
pollThread.interrupt();
|
||||
}
|
||||
if (sendThread != null) {
|
||||
sendThread.interrupt();
|
||||
}
|
||||
synchronized (pendingLock) {
|
||||
pendingLock.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String describe() {
|
||||
return "中转服务器";
|
||||
}
|
||||
|
||||
public String getSession() {
|
||||
return session;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 连接
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** 接入中转。成功返回 true。 */
|
||||
/** 被控端上报的局域网 IP(可能多个网卡)。判断同网段用。 */
|
||||
private java.util.List<String> lanIps = new java.util.ArrayList<String>();
|
||||
|
||||
/** 被控端监听端口。0 表示它没报(那就没法直连)。 */
|
||||
private int lanPort;
|
||||
|
||||
public java.util.List<String> getLanIps() {
|
||||
return lanIps;
|
||||
}
|
||||
|
||||
public int getLanPort() {
|
||||
return lanPort;
|
||||
}
|
||||
|
||||
public boolean attach() {
|
||||
try {
|
||||
String body = "{\"code\":\"" + Json.escape(pairingCode)
|
||||
+ "\",\"name\":\"" + Json.escape(clientName) + "\"}";
|
||||
HttpResult result = request("/v1/client/attach",
|
||||
body.getBytes(RemoteClient.UTF8), false, 30000);
|
||||
if (result.code != 200) {
|
||||
lastError = parseError(result.body, "接入被拒(" + result.code + ")");
|
||||
return false;
|
||||
}
|
||||
java.util.Map<String, Object> reply =
|
||||
Json.parseObject(new String(result.body, RemoteClient.UTF8));
|
||||
if (!Json.bool(reply, "ok", false)) {
|
||||
lastError = Json.str(reply, "error", "接入被拒");
|
||||
return false;
|
||||
}
|
||||
session = Json.str(reply, "session", "");
|
||||
deviceName = Json.str(reply, "deviceName", "");
|
||||
// 中转顺便告诉我们「这台电脑在哪个局域网、开在哪个端口」。
|
||||
// 手表拿它判断要不要切成局域网直连。
|
||||
lanIps = Json.stringList(reply, "lanIps");
|
||||
lanPort = Json.integer(reply, "port", 0);
|
||||
if (session.isEmpty()) {
|
||||
lastError = "中转没给会话令牌";
|
||||
return false;
|
||||
}
|
||||
online = true;
|
||||
lastError = "";
|
||||
} catch (Exception exc) {
|
||||
lastError = "连不上中转:" + exc.getClass().getSimpleName()
|
||||
+ (exc.getMessage() == null ? "" : " " + exc.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
pollThread = new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
pollLoop();
|
||||
}
|
||||
}, "relay-poll");
|
||||
pollThread.setDaemon(true);
|
||||
pollThread.start();
|
||||
|
||||
sendThread = new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
sendLoop();
|
||||
}
|
||||
}, "relay-send");
|
||||
sendThread.setDaemon(true);
|
||||
sendThread.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 轮询:电脑 -> 手表
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private void pollLoop() {
|
||||
int backoff = RETRY_MIN_MS;
|
||||
while (!closed) {
|
||||
try {
|
||||
HttpResult result = request("/v1/client/poll", new byte[0], true,
|
||||
POLL_TIMEOUT_MS);
|
||||
if (result.code == 409) {
|
||||
// 被另一台手表接管了。这里**必须断开**,不能重连 ——
|
||||
// 否则两台手表会互相把对方顶掉,永远握不上手。
|
||||
online = false;
|
||||
lastError = "已被另一台手表接管";
|
||||
replaced = true;
|
||||
closed = true;
|
||||
return;
|
||||
}
|
||||
if (result.code == 401 || result.code == 403) {
|
||||
// 会话过期(中转重启等)—— 这种可以自动重连
|
||||
online = false;
|
||||
lastError = "会话已失效,正在重新接入";
|
||||
if (!reattach()) {
|
||||
sleep(backoff);
|
||||
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
|
||||
continue;
|
||||
}
|
||||
backoff = RETRY_MIN_MS;
|
||||
continue;
|
||||
}
|
||||
if (result.code != 200) {
|
||||
online = false;
|
||||
lastError = "轮询返回 " + result.code;
|
||||
sleep(backoff);
|
||||
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
|
||||
continue;
|
||||
}
|
||||
online = true;
|
||||
lastError = "";
|
||||
backoff = RETRY_MIN_MS;
|
||||
if (result.body.length > 0) {
|
||||
inbox.offer(result.body);
|
||||
}
|
||||
} catch (Exception exc) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
online = false;
|
||||
lastError = "轮询出错:" + exc.getClass().getSimpleName();
|
||||
sleep(backoff);
|
||||
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean reattach() {
|
||||
try {
|
||||
String body = "{\"code\":\"" + Json.escape(pairingCode)
|
||||
+ "\",\"name\":\"" + Json.escape(clientName) + "\"}";
|
||||
HttpResult result = request("/v1/client/attach",
|
||||
body.getBytes(RemoteClient.UTF8), false, 30000);
|
||||
java.util.Map<String, Object> reply =
|
||||
Json.parseObject(new String(result.body, RemoteClient.UTF8));
|
||||
String fresh = Json.str(reply, "session", "");
|
||||
if (Json.bool(reply, "ok", false) && !fresh.isEmpty()) {
|
||||
session = fresh;
|
||||
deviceName = Json.str(reply, "deviceName", deviceName);
|
||||
return true;
|
||||
}
|
||||
lastError = Json.str(reply, "error", "重新接入失败");
|
||||
return false;
|
||||
} catch (Exception exc) {
|
||||
lastError = "重新接入出错:" + exc.getClass().getSimpleName();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 发送:手表 -> 电脑
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private void sendLoop() {
|
||||
while (!closed) {
|
||||
byte[] chunk = takePending();
|
||||
if (chunk == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
HttpResult result = request("/v1/client/send", chunk, true,
|
||||
SEND_TIMEOUT_MS);
|
||||
if (result.code != 200) {
|
||||
lastError = "发送返回 " + result.code;
|
||||
}
|
||||
} catch (Exception exc) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
lastError = "发送出错:" + exc.getClass().getSimpleName();
|
||||
sleep(1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] takePending() {
|
||||
synchronized (pendingLock) {
|
||||
if (pending.size() == 0) {
|
||||
try {
|
||||
pendingLock.wait(FLUSH_MILLIS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
}
|
||||
if (pending.size() == 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
byte[] chunk = pending.toByteArray();
|
||||
pending.reset();
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private void flushNow() {
|
||||
byte[] chunk;
|
||||
synchronized (pendingLock) {
|
||||
if (pending.size() == 0) {
|
||||
return;
|
||||
}
|
||||
chunk = pending.toByteArray();
|
||||
pending.reset();
|
||||
}
|
||||
try {
|
||||
request("/v1/client/send", chunk, true, SEND_TIMEOUT_MS);
|
||||
} catch (Exception ignored) {
|
||||
// 关闭时发不出去就算了
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// HTTP
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static final class HttpResult {
|
||||
final int code;
|
||||
final byte[] body;
|
||||
|
||||
HttpResult(int code, byte[] body) {
|
||||
this.code = code;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
private HttpResult request(String path, byte[] body, boolean withSession,
|
||||
int timeoutMs) throws IOException {
|
||||
// 走 TlsCompat:老手表缺根证书时用内置的那两个补上。
|
||||
// 不这么改会报 SSLHandshakeException: Chain validation failed
|
||||
// (廉价手表的系统根证书库常缺 GTS Root R4)。
|
||||
HttpURLConnection connection = TlsCompat.open(baseUrl + path);
|
||||
try {
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setDoOutput(body.length > 0);
|
||||
connection.setConnectTimeout(Math.min(15000, timeoutMs));
|
||||
connection.setReadTimeout(timeoutMs);
|
||||
connection.setUseCaches(false);
|
||||
connection.setRequestProperty("Content-Type",
|
||||
"application/octet-stream");
|
||||
connection.setRequestProperty("User-Agent", "LanRemote-Watch/1.5");
|
||||
if (withSession) {
|
||||
connection.setRequestProperty("X-Session", session);
|
||||
}
|
||||
if (body.length > 0) {
|
||||
connection.setFixedLengthStreamingMode(body.length);
|
||||
OutputStream out = connection.getOutputStream();
|
||||
out.write(body);
|
||||
out.flush();
|
||||
out.close();
|
||||
}
|
||||
int code = connection.getResponseCode();
|
||||
InputStream stream = code >= 400
|
||||
? connection.getErrorStream() : connection.getInputStream();
|
||||
byte[] payload = stream == null ? new byte[0] : readAll(stream);
|
||||
return new HttpResult(code, payload);
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readAll(InputStream stream) throws IOException {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] piece = new byte[16384];
|
||||
int total = 0;
|
||||
int got;
|
||||
while ((got = stream.read(piece)) > 0) {
|
||||
total += got;
|
||||
if (total > MAX_BODY) {
|
||||
break;
|
||||
}
|
||||
buffer.write(piece, 0, got);
|
||||
}
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
private static String parseError(byte[] body, String fallback) {
|
||||
try {
|
||||
java.util.Map<String, Object> reply =
|
||||
Json.parseObject(new String(body, RemoteClient.UTF8));
|
||||
String error = Json.str(reply, "error", "");
|
||||
return error.isEmpty() ? fallback : error;
|
||||
} catch (Exception ignored) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static void sleep(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user