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 + 双连接 文档 - 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
527 lines
18 KiB
Java
527 lines
18 KiB
Java
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();
|
||
}
|
||
}
|
||
}
|