Files
ykxmz/android/src/com/lanremote/watch/RelayTransport.java
T
xmz7891 b62ac865ea 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 + 双连接

文档
- 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
2026-10-06 19:01:05 +08:00

527 lines
18 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}
}