package com.battery.device.bts; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.ReentrantLock; /** * 新威 BTS 对外接口的**远程命名管道**传输(ADR-0068)。 * *
现场实测的三条关键事实决定了这个类的形状: * *
权限:调用方需管理员身份,且 SMB 会话用的是那台机器的本地管理员账号—— * 口令从本地凭据文件取({@link com.battery.device.api.credentials.LocalCredentials}),不进数据库、不进仓库。 * 会话由本类用 {@code net.exe use} 建立:Java 无法给命名管道传凭据, * 只有先有一条已认证的会话,路径才打得开。会话按"主机 + 账号 + 口令"缓存,进程内每条只建一次。 * *
只读边界:出站请求先过 {@link NewareBtsReadOnlyCommands} 的白名单, * 非只读命令在写进管道之前就被拒绝。 * *
读超时:{@code RandomAccessFile} 没有读超时(管道挂住就是永久阻塞),
* 因此读取放在单线程执行器里、由调用方带超时等待;超时从另一个线程关掉在途句柄把读打断,
* 使"挂住"变成一次可分类的失败,而不是把调用线程钉死。
*/
public final class NewareBtsPipeTransport implements BtsTransport
{
/** 服务端命名管道的名字;跨机时路径为 {@code \\<主机>\pipe\<名字>}。 */
private static final String PIPE_NAME = "NewareBtsAPI";
/** 实测的报文结束符(协议文档的 {@code #\r\n} 在本接口上无效)。 */
private static final byte[] FRAME_END = "\n\n".getBytes(StandardCharsets.UTF_8);
private static final int DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
private static final int MAX_SESSION_TIMEOUT_MS = 30_000;
private static final int MAX_RESPONSE_TIMEOUT_MS = 120_000;
private static final int READ_BUFFER_BYTES = 64 * 1024;
/** 已建立的 SMB 会话;键为「主机 + 账号 + 口令」的摘要,进程内复用。 */
private static final Set 前置条件:请求只含只读白名单内的命令。并发调用由锁串行化(本接口一条连接只服务一条请求)。
* SMB 会话首次使用时建立;管道打开或读取失败时重建会话并重试一次,仍失败则归类抛出。
*
* @param request 完整请求报文(不含结束符)
* @return 应答原文(已去掉 {@code \n\n})
* @throws BtsTransportException 请求越权,或会话、管道、读取失败
*/
@Override
public String exchange(String request)
{
NewareBtsReadOnlyCommands.requireAllowed(request);
requestLock.lock();
try
{
ensureSession(false);
try
{
return perform(request);
}
catch (BtsTransportException failure)
{
// 会话可能已被对端或系统回收(长时间空闲、重启上位机):重建一次再试,只试一次。
if (!isSessionRecoverable(failure.reason())) throw failure;
ensureSession(true);
return perform(request);
}
}
finally
{
requestLock.unlock();
}
}
private String perform(String request)
{
Future 返回值:成功返回输出;失败时若输出表明"该主机的会话已存在"(错误 1219/多重连接)
* 也按成功处理——会话已经在了,管道照常可开。其余失败返回空值。
* 建立与撤销都失败不抛出,交给后续管道打开去报错,避免凭据问题被当成会话问题。
*/
private String runNet(String... arguments)
{
String[] command = new String[arguments.length + 1];
command[0] = netExecutable();
System.arraycopy(arguments, 0, command, 1, arguments.length);
try
{
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
if (!process.waitFor(sessionTimeoutMs, TimeUnit.MILLISECONDS))
{
process.destroyForcibly();
return null;
}
if (process.exitValue() == 0) return output;
String normalized = output.toLowerCase(Locale.ROOT);
if (normalized.contains("1219") || normalized.contains("多重连接") || normalized.contains("multiple connections"))
return output;
return null;
}
catch (IOException ex)
{
return null;
}
catch (InterruptedException ex)
{
Thread.currentThread().interrupt();
return null;
}
}
private static String netExecutable()
{
String systemRoot = System.getenv("SystemRoot");
return (systemRoot == null || systemRoot.isBlank() ? "C:\\Windows" : systemRoot) + "\\System32\\net.exe";
}
/** 会话或连接类失败值得重建会话再试一次;协议与内容类失败重试没有意义。 */
private static boolean isSessionRecoverable(BtsTransportException.Reason reason)
{
return reason == BtsTransportException.Reason.CONNECTION_FAILED
|| reason == BtsTransportException.Reason.DISCONNECTED
|| reason == BtsTransportException.Reason.IO_FAILURE;
}
/** 关掉在途句柄:这是唯一能打断阻塞中 ReadFile 的手段。 */
private void closeInFlight()
{
RandomAccessFile pipe = inFlight;
if (pipe == null) return;
try { pipe.close(); } catch (IOException ignored) { }
}
/**
* 在独立线程里关掉在途句柄,用来打断阻塞中的读。
*
* 为什么不自己关:实测关闭一条"服务端不作答的连接"**本身会阻塞**(关闭要等 SMB 层回收),
* 那样超时就白设了——调用线程照样被钉死。而这个实例是串行执行的(BTS 一条连接只服务一条请求,
* 注册表也按实例串行),一条不响应的命令足以把该实例之后的所有请求堵住。
* 关闭线程是守护线程且不等它结束:最坏情况是每次超时留一个挂着的关闭线程,
* 而超时本身应当罕见——真出现"某客户端不支持某命令",它的表现正是每次都无应答。
*/
private void interruptRead()
{
RandomAccessFile pipe = inFlight;
if (pipe == null) return;
Thread closer = new Thread(() -> {
try { pipe.close(); } catch (IOException ignored) { }
}, "neware-bts-pipe-close");
closer.setDaemon(true);
closer.start();
}
@Override
public void close()
{
requestLock.lock();
try
{
closeInFlight();
}
finally
{
requestLock.unlock();
}
reader.shutdownNow();
}
private static String requireText(String value, String field)
{
String normalized = Objects.requireNonNull(value, field).trim();
if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank");
return normalized;
}
private static int timeout(Duration value, int maximumMs, String field)
{
int millis = Math.toIntExact(Objects.requireNonNull(value, field).toMillis());
if (millis < 1) throw new IllegalArgumentException(field + " timeout must be positive");
if (millis > maximumMs) throw new IllegalArgumentException(field + " timeout must not exceed " + maximumMs + " ms");
return millis;
}
private static final class FrameTooLargeException extends IOException
{
private static final long serialVersionUID = 1L;
}
private static final class InvalidUtf8Exception extends IOException
{
private static final long serialVersionUID = 1L;
private InvalidUtf8Exception(Throwable cause)
{
super(cause);
}
}
}