f158377742
终端在 Windows 上起不来 (打包规则漏解包)
- 根因: build.asarUnpack 写的是 "**/node_modules/@lydell/node-pty-*/*", 只匹配一层,
于是 prebuilds/<plat>/ 下的文件没被解包, 留在 app.asar 里
- Windows 上这些必须是磁盘上的真实文件: conpty.dll (OS loader 加载)、
OpenConsole.exe (CreateProcess)、以及 node-pty 用 worker_threads.Worker 起的
conoutSocketWorker.js —— node-pty 本身就写了
__dirname.replace('node_modules.asar', 'node_modules.asar.unpacked') 来适配
- Linux 上看不出来: 只需要一个 pty.node, Electron 的 asar 层能 dlopen 归档里的 .node
- 修复: 规则递归化为 "**/node_modules/@lydell/node-pty-*/**/*"
- 验证: 重新打包后 pty.node 已落到 app.asar.unpacked, 安装版真实 spawn 成功
Windows 保留设备名 / 尾部点空格导致落盘写错
- 新增 src/main/safe-name.ts 的 safeFileName()
- Win32 把 CON/PRN/AUX/NUL/COM1-9/LPT1-9 (带扩展名也算) 当设备, open() 打开的不是文件;
还会悄悄吃掉文件名结尾的点和空格, 导致 existsSync 判断和实际落盘对不上
- 两处落盘都接上: file-server.ts 的 targetPath()、drive.ts 的 downloadDriveFile()
(后者名字来自对端共享目录, 属不可信输入)
- 策略是"最小必要": 非法字符全平台都清, 保留名和尾点空格只在 win32 上改写,
避免动到 Linux 上合法的文件名
其他
- 终端 cwd: Windows 优先用 USERPROFILE (装了 Git Bash 时 HOME 可能是 POSIX 路径, Win32 下不存在)
- USB 面板空列表提示按平台给, 不再一律说"Linux 用户需要在 dialout 组"
分析文档与自测
- 新增 docs/windows-compat.md: 各原生模块的 N-API 情况、已有 win32 分支的位置、
已知限制 (ConPTY 需 Win10 1809+、防火墙首次弹窗、USB 需 WinUSB 驱动等)、
以及一份 Windows 真机验证清单
- 自测新增 S1-S8 组: 保留名改写、尾部点空格、非法字符、跨平台不过度清洗、
超长截断、默认 shell/cwd、asarUnpack 规则回归护栏、上传落盘 + 路径穿越
- 全部 38 条用例通过
800 lines
37 KiB
TypeScript
800 lines
37 KiB
TypeScript
// LocalNetMsg 远程功能自测 (terminal / forward / usb)
|
|
// 在一个进程里跑两个虚拟节点 (A = 控制端, B = 被控端), 走真实的
|
|
// ChatServer + ChatClient + 三个 remote manager, 接线方式与 src/main/ipc.ts 完全一致.
|
|
//
|
|
// 用法:
|
|
// npm run selftest # 全部
|
|
// npm run selftest -- terminal # 只跑某些用例
|
|
import { spawn, ChildProcess } from 'node:child_process'
|
|
import { existsSync, mkdirSync, rmSync, openSync, closeSync, readSync, writeSync, writeFileSync, readFileSync } from 'node:fs'
|
|
import * as fs from 'node:fs'
|
|
import { join, dirname } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
import { connect } from 'node:net'
|
|
import { createServer, Server, request as httpRequest } from 'node:http'
|
|
import { createHash } from 'node:crypto'
|
|
|
|
import { ChatServer } from '../../src/main/chat-server'
|
|
import { ChatClient } from '../../src/main/chat-client'
|
|
import { FileServer } from '../../src/main/file-server'
|
|
import { listDrive, downloadDriveFile, collectDriveFiles } from '../../src/main/drive'
|
|
import { computeWsState } from '../../src/main/ws-state'
|
|
import { safeFileName } from '../../src/main/safe-name'
|
|
import { defaultCwd, defaultShell } from '../../src/main/remote/shell'
|
|
import { TerminalManager } from '../../src/main/remote/terminal'
|
|
import { ForwardManager } from '../../src/main/remote/forward'
|
|
import { UsbBridgeManager, toUsbControlSetup } from '../../src/main/remote/usb'
|
|
import { updateSettings } from '../../src/main/settings'
|
|
import { listAudit } from '../../src/main/db'
|
|
import type { DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, UsbFrame } from '../../src/main/protocol'
|
|
|
|
// ============ 小型测试框架 ============
|
|
interface CaseResult { name: string; ok: boolean; detail: string; ms: number }
|
|
const results: CaseResult[] = []
|
|
let current = ''
|
|
|
|
function assert(cond: any, msg: string) {
|
|
if (!cond) throw new Error(msg)
|
|
}
|
|
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)) }
|
|
|
|
async function runCase(name: string, fn: () => Promise<void>) {
|
|
current = name
|
|
const t0 = Date.now()
|
|
try {
|
|
await fn()
|
|
results.push({ name, ok: true, detail: '', ms: Date.now() - t0 })
|
|
console.log(` \x1b[32mPASS\x1b[0m ${name} (${Date.now() - t0}ms)`)
|
|
} catch (e: any) {
|
|
const detail = String(e?.message || e)
|
|
results.push({ name, ok: false, detail, ms: Date.now() - t0 })
|
|
console.log(` \x1b[31mFAIL\x1b[0m ${name} (${Date.now() - t0}ms): ${detail}`)
|
|
}
|
|
}
|
|
|
|
// ============ 虚拟节点 ============
|
|
interface UiEvent { kind: string; sessionId?: string; peerId?: string; data?: string; text?: string; reason?: string }
|
|
|
|
class Node {
|
|
info: DeviceInfo
|
|
server: ChatServer
|
|
client: ChatClient
|
|
fileServer: FileServer
|
|
terminal: TerminalManager
|
|
forward: ForwardManager
|
|
usb: UsbBridgeManager
|
|
events: UiEvent[] = []
|
|
driveDir = ''
|
|
driveToken = ''
|
|
|
|
constructor(id: string, name: string) {
|
|
this.info = {
|
|
deviceId: id, name, hostname: name, platform: 'linux', version: 1,
|
|
appVersion: '0.1.0', address: '127.0.0.1', chatPort: 0, filePort: 0, ts: Date.now(),
|
|
}
|
|
this.server = new ChatServer(0)
|
|
this.client = new ChatClient(this.info)
|
|
this.fileServer = new FileServer(0, join(tmpdir(), `lnm-selftest-dl-${id.slice(-4)}`), {
|
|
getDriveRoot: () => this.driveDir,
|
|
getDriveToken: () => this.driveToken,
|
|
isDriveEnabled: () => true,
|
|
})
|
|
this.terminal = new TerminalManager(this.client, id)
|
|
this.forward = new ForwardManager(this.client)
|
|
this.usb = new UsbBridgeManager(this.client)
|
|
}
|
|
|
|
async start() {
|
|
const port = await this.server.start()
|
|
this.info.chatPort = port
|
|
const filePort = await this.fileServer.start()
|
|
this.info.filePort = filePort
|
|
// 网盘目录: 放几个测试文件
|
|
this.driveDir = join(tmpdir(), `lnm-selftest-drive-${this.info.deviceId.slice(-4)}`)
|
|
rmSync(this.driveDir, { recursive: true, force: true })
|
|
mkdirSync(join(this.driveDir, 'sub', 'deep'), { recursive: true })
|
|
writeFileSync(join(this.driveDir, 'hello.txt'), 'HELLO-FROM-DRIVE')
|
|
writeFileSync(join(this.driveDir, 'sub', 'nested.txt'), 'NESTED-FROM-DRIVE')
|
|
writeFileSync(join(this.driveDir, 'sub', 'deep', 'deeper.txt'), 'DEEPER-FROM-DRIVE')
|
|
this.driveToken = `tok-${this.info.deviceId.slice(-4)}`
|
|
this.info.driveToken = this.driveToken
|
|
|
|
// ---- 与 src/main/ipc.ts 完全一致的帧路由 ----
|
|
const TRACE = !!process.env.LNM_TRACE
|
|
const route = (peer: DeviceInfo, frame: WsFrame) => {
|
|
if (TRACE) {
|
|
const p: any = (frame as any).payload
|
|
console.log(`[trace ${this.info.name}] <- ${frame.type}${p ? `/${p.type}` : ''} ${p?.dir || ''} ${p?.sessionId?.slice(0, 8) || ''} ${p?.data ? `(${Buffer.from(p.data, 'base64').length}B)` : ''}`)
|
|
}
|
|
if (frame.type === 'terminal') {
|
|
this.terminal.handleIncoming(peer, frame.payload as TerminalFrame)
|
|
.catch((e) => console.warn(`[${this.info.name}] terminal handle error:`, e?.message))
|
|
} else if (frame.type === 'forward') {
|
|
this.forward.handleIncoming(peer, frame.payload as ForwardFrame)
|
|
} else if (frame.type === 'usb') {
|
|
this.usb.handleIncoming(peer, frame.payload as UsbFrame)
|
|
}
|
|
}
|
|
this.server.on('frame', route)
|
|
this.client.on('message', route)
|
|
|
|
// ---- 渲染层能看到的事件 (对应 ipc.ts 里 broadcastToRenderer) ----
|
|
this.terminal.on('output', (sessionId, bytes, peerId) =>
|
|
this.events.push({ kind: 'terminal:output', sessionId, peerId, data: bytes.toString('base64') }))
|
|
this.terminal.on('sessionOpened', (sessionId, peerId) =>
|
|
this.events.push({ kind: 'terminal:opened', sessionId, peerId }))
|
|
this.terminal.on('sessionClosed', (sessionId, peerId, reason) =>
|
|
this.events.push({ kind: 'terminal:closed', sessionId, peerId, reason }))
|
|
this.terminal.on('approvalRequested', (req) =>
|
|
this.events.push({ kind: 'approval', peerId: req.peerId, text: req.detail }))
|
|
this.forward.on('sessionOpened', (i: any) =>
|
|
this.events.push({ kind: 'forward:opened', sessionId: i.sessionId, peerId: i.peerId }))
|
|
this.forward.on('sessionClosed', (i: any) =>
|
|
this.events.push({ kind: 'forward:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason }))
|
|
this.usb.on('output', (sessionId, bytes, peerId) =>
|
|
this.events.push({ kind: 'usb:output', sessionId, peerId, data: bytes.toString('base64') }))
|
|
this.usb.on('sessionOpened', (i: any) =>
|
|
this.events.push({ kind: 'usb:opened', sessionId: i.sessionId, peerId: i.peerId }))
|
|
this.usb.on('sessionClosed', (i: any) =>
|
|
this.events.push({ kind: 'usb:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason }))
|
|
this.usb.on('sessionError', (i: any) =>
|
|
this.events.push({ kind: 'usb:error', sessionId: i.sessionId, peerId: i.peerId, text: i.error }))
|
|
|
|
this.client.on('error', (_peer, err) => console.warn(`[${this.info.name}] ws error: ${err.message}`))
|
|
|
|
// 追踪本端发出的帧
|
|
if (TRACE) {
|
|
const origSend = this.client.send.bind(this.client)
|
|
;(this.client as any).send = (frame: WsFrame, deviceId?: string) => {
|
|
const p: any = (frame as any).payload
|
|
console.log(`[trace ${this.info.name}] -> ${frame.type}${p ? `/${p.type}` : ''} ${p?.dir || ''} ${p?.sessionId?.slice(0, 8) || ''} ${p?.data ? `(${Buffer.from(p.data, 'base64').length}B)` : ''}`)
|
|
return origSend(frame, deviceId)
|
|
}
|
|
}
|
|
}
|
|
|
|
connectTo(other: Node) {
|
|
this.client.connectTo({ ...other.info })
|
|
}
|
|
|
|
// 该节点收到的某个 kind 事件 (最近 n 秒内)
|
|
hasEvent(kind: string, pred?: (e: UiEvent) => boolean) {
|
|
return this.events.some(e => e.kind === kind && (!pred || pred(e)))
|
|
}
|
|
findEvents(kind: string) { return this.events.filter(e => e.kind === kind) }
|
|
clearEvents() { this.events = [] }
|
|
|
|
async stop() {
|
|
try { await this.server.stop() } catch {}
|
|
try { this.client.stop() } catch {}
|
|
try { this.terminal.stop() } catch {}
|
|
try { this.forward.stop() } catch {}
|
|
try { this.usb.stop() } catch {}
|
|
}
|
|
}
|
|
|
|
function decode(b64: string) { return Buffer.from(b64, 'base64').toString('utf8') }
|
|
|
|
// ============ 小工具 ============
|
|
function startHttpServer(port: number, body: string): Promise<Server> {
|
|
return new Promise((resolve, reject) => {
|
|
const s = createServer((req, res) => {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
res.end(body)
|
|
})
|
|
s.on('error', reject)
|
|
s.listen(port, '127.0.0.1', () => resolve(s))
|
|
})
|
|
}
|
|
|
|
// 发起 HTTP 请求, 收到完整响应后**保持连接** (隧道是单连接设计, 连接断了整个转发就结束)
|
|
function httpGet(port: number, path = '/', timeoutMs = 5000): Promise<{ body: string; sock: Socket }> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = connect({ host: '127.0.0.1', port }, () => {
|
|
req.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: keep-alive\r\n\r\n`)
|
|
})
|
|
let buf = ''
|
|
let settled = false
|
|
const finish = () => {
|
|
if (settled) return
|
|
settled = true
|
|
clearTimeout(timer)
|
|
resolve({ body: buf, sock: req })
|
|
}
|
|
// 目标服务是 keep-alive, 连接不会自己关 —— 按 Content-Length 判完整, 别等 'end'
|
|
const tryComplete = () => {
|
|
const idx = buf.indexOf('\r\n\r\n')
|
|
if (idx < 0) return
|
|
const m = /content-length:\s*(\d+)/i.exec(buf.slice(0, idx))
|
|
const need = m ? Number(m[1]) : 0
|
|
if (buf.length >= idx + 4 + need) finish()
|
|
}
|
|
const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error(`http 超时 (收到 ${buf.length}B): ${buf.slice(0, 120)}`)) } }, timeoutMs)
|
|
req.on('data', (d) => { buf += d.toString('utf8'); tryComplete() })
|
|
req.on('end', () => finish())
|
|
req.on('error', (e) => { if (!settled) { settled = true; clearTimeout(timer); reject(e) } })
|
|
})
|
|
}
|
|
|
|
function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 50): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const t0 = Date.now()
|
|
const tick = () => {
|
|
if (pred()) return resolve()
|
|
if (Date.now() - t0 > timeoutMs) return reject(new Error('等待超时'))
|
|
setTimeout(tick, stepMs)
|
|
}
|
|
tick()
|
|
})
|
|
}
|
|
|
|
// 造一个 socat 串口对: 一头是 PTY (给被控端当真实串口), 另一头我们直接读写
|
|
async function startSocatSerial(linkDir: string): Promise<{ proc: ChildProcess; userPath: string; internalPath: string }> {
|
|
const userPath = join(linkDir, 'user')
|
|
const internalPath = join(linkDir, 'internal')
|
|
const proc = spawn('socat', [
|
|
'-d', '-d',
|
|
`pty,raw,echo=0,link=${userPath}`,
|
|
`pty,raw,echo=0,link=${internalPath}`,
|
|
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
let err = ''
|
|
proc.stderr?.on('data', (b) => { err += b.toString() })
|
|
const t0 = Date.now()
|
|
while (!(existsSync(userPath) && existsSync(internalPath))) {
|
|
if (Date.now() - t0 > 5000) { proc.kill(); throw new Error('socat 起不来: ' + err) }
|
|
await sleep(100)
|
|
}
|
|
return { proc, userPath, internalPath }
|
|
}
|
|
|
|
// 打开 PTY 的一端做非阻塞读写
|
|
function openPty(path: string) {
|
|
const fd = openSync(path, fs.constants.O_RDWR | fs.constants.O_NONBLOCK)
|
|
return {
|
|
fd,
|
|
write: (s: string | Buffer) => writeSync(fd, s),
|
|
read: (): string => {
|
|
const buf = Buffer.alloc(4096)
|
|
try {
|
|
const n = readSync(fd, buf, 0, buf.length, null)
|
|
return n > 0 ? buf.slice(0, n).toString('utf8') : ''
|
|
} catch (e: any) {
|
|
if (e?.code === 'EAGAIN' || e?.code === 'EWOULDBLOCK') return ''
|
|
throw e
|
|
}
|
|
},
|
|
close: () => { try { closeSync(fd) } catch {} },
|
|
}
|
|
}
|
|
|
|
// ============ 主流程 ============
|
|
async function main() {
|
|
const filter = process.argv.slice(2).filter(a => !a.startsWith('-'))
|
|
const want = (name: string) => !filter.length || filter.some(f => name.toLowerCase().includes(f.toLowerCase()))
|
|
const step = (m: string) => console.log(`[step] ${m}`)
|
|
|
|
step('建节点 A')
|
|
const A = new Node('peer-aaaaaaaa-0000-4000-8000-00000000000a', '控制端A')
|
|
step('建节点 B')
|
|
const B = new Node('peer-bbbbbbbb-0000-4000-8000-00000000000b', '被控端B')
|
|
step('启动 A')
|
|
await A.start()
|
|
step('启动 B')
|
|
await B.start()
|
|
step(`节点就绪 A(:${A.info.chatPort}) B(:${B.info.chatPort})`)
|
|
|
|
// 双向连通 (模拟 discovery 找到对方后双方都连上)
|
|
step('建立 WS 连接')
|
|
A.connectTo(B)
|
|
B.connectTo(A)
|
|
await waitFor(() => A.client.isOpen(B.info.deviceId) && B.client.isOpen(A.info.deviceId), 8000)
|
|
console.log(`\n两个节点已连通: A(:${A.info.chatPort}) <-> B(:${B.info.chatPort})\n`)
|
|
|
|
// 授权: 双方互相放行, 避免弹窗 (等价于设置里勾了"允许")
|
|
updateSettings({
|
|
remoteEnabled: true,
|
|
remoteAllowPeers: {
|
|
[A.info.deviceId]: { terminal: true, forward: true, usb: true },
|
|
[B.info.deviceId]: { terminal: true, forward: true, usb: true },
|
|
},
|
|
})
|
|
|
|
// ============ 终端 ============
|
|
console.log('\x1b[1m[终端]\x1b[0m')
|
|
let termSessionId = ''
|
|
const TERM_MARK = 'LNM_TERM_SELFTEST'
|
|
|
|
if (want('terminal') || want('term')) {
|
|
await runCase('T1 打开终端: 控制端收到 ack ok', async () => {
|
|
const r = await A.terminal.openOnPeer(B.info.deviceId, { rows: 24, cols: 80 })
|
|
assert(r.ok, `open 被拒: ${r.reason}`)
|
|
assert(!!r.sessionId, '没有 sessionId')
|
|
termSessionId = r.sessionId!
|
|
})
|
|
|
|
await runCase('T2 控制端能收到终端输出 (提示符/回显)', async () => {
|
|
const got = await waitFor(() => A.hasEvent('terminal:output'), 6000).then(() => true).catch(() => false)
|
|
assert(got, '控制端 6s 内没有收到任何 terminal:output 事件 —— 终端画面是空的')
|
|
const text = A.findEvents('terminal:output').map(e => decode(e.data!)).join('')
|
|
assert(text.length > 0, '收到的输出为空')
|
|
})
|
|
|
|
await runCase('T3 输入命令, 输出能回显到控制端', async () => {
|
|
A.clearEvents()
|
|
const ok = A.terminal.sendInput(termSessionId, `echo ${TERM_MARK}\n`)
|
|
assert(ok, 'sendInput 失败 (会话不存在?)')
|
|
await waitFor(() => A.findEvents('terminal:output').map(e => decode(e.data!)).join('').includes(TERM_MARK), 8000)
|
|
.catch(() => { throw new Error('8s 内没有看到命令回显 ' + TERM_MARK) })
|
|
})
|
|
|
|
await runCase('T4 窗口尺寸 resize 能传到被控端', async () => {
|
|
A.terminal.resize(termSessionId, 40, 120)
|
|
await sleep(300)
|
|
const sess = B.terminal.listServerSessions().find(s => s.sessionId === termSessionId)
|
|
assert(!!sess, '被控端没有这个 session')
|
|
assert(sess!.rows === 40 && sess!.cols === 120, `被控端尺寸不对: ${sess!.rows}x${sess!.cols}`)
|
|
})
|
|
|
|
await runCase('T5 关闭终端: 被控端 session 被回收', async () => {
|
|
A.terminal.close(termSessionId, 'selftest')
|
|
await waitFor(() => B.terminal.listServerSessions().every(s => s.sessionId !== termSessionId), 5000)
|
|
.catch(() => { throw new Error('被控端 session 没有被回收') })
|
|
})
|
|
}
|
|
|
|
// ============ 端口转发 ============
|
|
console.log('\n\x1b[1m[端口转发]\x1b[0m')
|
|
if (want('forward') || want('for')) {
|
|
const targetPort = 47881 // 被控端的目标服务
|
|
const listenPort = 47882 // 控制端的本地监听
|
|
const peerListenPort = 47883 // 被控端的监听 (self-in)
|
|
const ctrlTargetPort = 47884 // 控制端的目标服务 (self-in)
|
|
const peerTarget = await startHttpServer(targetPort, 'HELLO-FROM-PEER')
|
|
const ctrlTarget = await startHttpServer(ctrlTargetPort, 'HELLO-FROM-CONTROL')
|
|
const openSocks: Socket[] = []
|
|
try {
|
|
await runCase('F1 self-out: 控制端 127.0.0.1:47882 拿到被控端服务内容', async () => {
|
|
const r = await A.forward.openOnPeer(B.info.deviceId, {
|
|
direction: 'self-out', listenPort, targetHost: '127.0.0.1', targetPort, ttlSec: 120,
|
|
})
|
|
assert(r.ok, `open 被拒: ${r.reason}`)
|
|
const { body, sock } = await httpGet(listenPort)
|
|
openSocks.push(sock)
|
|
assert(body.includes('HELLO-FROM-PEER'), `拿到的是: ${body.slice(0, 120)}`)
|
|
})
|
|
|
|
await runCase('F2 self-in: 被控端 127.0.0.1:47883 拿到控制端服务内容', async () => {
|
|
const r = await A.forward.openOnPeer(B.info.deviceId, {
|
|
direction: 'self-in', listenPort: peerListenPort, targetHost: '127.0.0.1', targetPort: ctrlTargetPort, ttlSec: 120,
|
|
})
|
|
assert(r.ok, `open 被拒: ${r.reason}`)
|
|
const { body, sock } = await httpGet(peerListenPort)
|
|
openSocks.push(sock)
|
|
assert(body.includes('HELLO-FROM-CONTROL'), `拿到的是: ${body.slice(0, 120)}`)
|
|
})
|
|
|
|
await runCase('F3 拒绝把本机自身端口 (47900) 作为转发目标', async () => {
|
|
const r = await A.forward.openOnPeer(B.info.deviceId, {
|
|
direction: 'self-out', listenPort: 47890, targetHost: '127.0.0.1', targetPort: 47900, ttlSec: 60,
|
|
})
|
|
assert(!r.ok, '自身端口竟然被放行了')
|
|
})
|
|
|
|
await runCase('F4 转发字节计数在两端都有记录', async () => {
|
|
const cs = A.forward.listClientSessions()
|
|
assert(cs.length > 0, '控制端没有转发 session')
|
|
const s = cs[0]
|
|
assert(s.bytesIn > 0 && s.bytesOut > 0, `字节计数不对: in=${s.bytesIn} out=${s.bytesOut}`)
|
|
})
|
|
|
|
await runCase('F5 关掉"对方发起的"转发: 对端 session 和监听端口都要释放', async () => {
|
|
// 由 B 发起: B bind 47885, A 作为 server 侧连 B 填的目标
|
|
const r = await B.forward.openOnPeer(A.info.deviceId, {
|
|
direction: 'self-out', listenPort: 47885, targetHost: '127.0.0.1', targetPort: targetPort, ttlSec: 300,
|
|
})
|
|
assert(r.ok, `B 发起转发失败: ${r.reason}`)
|
|
const serverSess = A.forward.listServerSessions().find(s => s.sessionId === r.sessionId)
|
|
assert(!!serverSess, 'A 侧没有对应的 server session (对方发起的)')
|
|
assert(B.forward.listClientSessions().some(s => s.sessionId === r.sessionId), 'B 侧 client session 不存在')
|
|
|
|
// A 在 UI 上点"关闭" (对方发起的) —— 走 closeSession, 要能关掉 server 侧
|
|
const closed = A.forward.closeSession(r.sessionId!, 'user')
|
|
assert(closed, 'closeSession 没关掉任何东西')
|
|
assert(A.forward.listServerSessions().every(s => s.sessionId !== r.sessionId), 'A 侧 server session 还在')
|
|
|
|
// 关键: close 帧要发到对端, B 的 client session 及其监听端口要一起回收
|
|
await waitFor(() => B.forward.listClientSessions().every(s => s.sessionId !== r.sessionId), 5000)
|
|
.catch(() => { throw new Error('对端的 client session 没被回收 (close 帧没发出去?)') })
|
|
let portStillOpen = true
|
|
try {
|
|
await new Promise<void>((res, rej) => {
|
|
const sock = connect({ host: '127.0.0.1', port: 47885 }, () => { sock.destroy(); res() })
|
|
sock.on('error', () => rej(new Error('refused')))
|
|
setTimeout(() => { sock.destroy(); rej(new Error('timeout')) }, 2000)
|
|
})
|
|
} catch { portStillOpen = false }
|
|
assert(!portStillOpen, '对端的监听端口 47885 还占着, 没释放')
|
|
})
|
|
} finally {
|
|
for (const s of openSocks) { try { s.destroy() } catch {} }
|
|
peerTarget.close(); ctrlTarget.close()
|
|
}
|
|
}
|
|
|
|
// ============ USB / 串口 ============
|
|
console.log('\n\x1b[1m[USB / 串口]\x1b[0m')
|
|
if (want('usb')) {
|
|
await runCase('U1 列对方设备: 请求-响应链路通', async () => {
|
|
const r = await A.usb.listOnPeer(B.info.deviceId)
|
|
assert(r.ok, `list 失败: ${r.reason}`)
|
|
assert(Array.isArray(r.devices), 'devices 不是数组')
|
|
})
|
|
|
|
await runCase('U2 USB 模式设备枚举 (libusb) 能列出真机设备', async () => {
|
|
const devs = await B.usb.listLocal()
|
|
const usbDevs = devs.filter(d => d.kind === 'usb')
|
|
console.log(` (枚举到 ${devs.length} 个设备, 其中 usb ${usbDevs.length} 个)`)
|
|
assert(usbDevs.length > 0, '一个 libusb 设备都没枚举到 —— USB 模式不可用')
|
|
})
|
|
|
|
await runCase('U2b 内核占用的设备: attach 失败原因能正确回传到控制端', async () => {
|
|
const devs = await B.usb.listLocal()
|
|
const usbDevs = devs.filter(d => d.kind === 'usb')
|
|
assert(usbDevs.length > 0, '没有可用的 usb 设备')
|
|
// 这台机器上的设备都被内核驱动占着 (键鼠/摄像头/蓝牙), 测试里不让它抢驱动
|
|
const pick = usbDevs.find(d => /hub/i.test(d.product || '')) || usbDevs[0]
|
|
console.log(` (选用设备 ${pick.busId} ${pick.product || ''})`)
|
|
const r = await A.usb.attach(B.info.deviceId, {
|
|
busId: pick.busId, direction: 'self-out',
|
|
config: { kind: 'usb', configurationValue: 1, interfaceNumber: 0, detachKernelDriver: false },
|
|
})
|
|
// 期望: 要么成功, 要么失败原因能传回来 (而不是超时/静默)
|
|
if (!r.ok) {
|
|
console.log(` (按预期被内核占用挡住: ${r.reason})`)
|
|
assert(!!r.reason && r.reason.length > 0, '失败时没有给出原因')
|
|
}
|
|
})
|
|
|
|
await runCase('U2c 控制传输 setup 翻译正确, 真机 GET_DESCRIPTOR 能拿到数据', async () => {
|
|
const usbMod: any = require('usb')
|
|
const devs = await usbMod.webusb.loadDevices()
|
|
assert(devs.length > 0, 'libusb 枚举不到设备')
|
|
const dev = devs.find((d: any) => d.vendorId === 0x0c45 && d.productId === 0x6366) || devs[0]
|
|
await dev.open()
|
|
try {
|
|
// 用应用里同一套翻译函数 (bmRequestType 数值 -> node-usb v3 字符串枚举)
|
|
const setup = toUsbControlSetup({ requestType: 0x80, request: 0x06, value: 0x0100, index: 0x0000 })
|
|
const buf = await dev.nativeControlTransferIn(setup, 3000, 18)
|
|
assert(!!buf && buf.length === 18, `描述符长度不对: ${buf?.length}`)
|
|
const hexd = Buffer.from(buf).toString('hex')
|
|
console.log(` (descriptor: ${hexd})`)
|
|
assert(hexd.startsWith('1201'), '不是合法的设备描述符')
|
|
} finally {
|
|
await dev.close().catch(() => {})
|
|
}
|
|
})
|
|
|
|
// 串口双向字节流: 用 socat 造一个 PTY 对冒充"真串口"
|
|
const vcomDir = join(tmpdir(), `lnm-selftest-vcom-${Date.now()}`)
|
|
mkdirSync(vcomDir, { recursive: true })
|
|
let socatProc: ChildProcess | null = null
|
|
let devEnd: ReturnType<typeof openPty> | null = null
|
|
try {
|
|
const pair = await startSocatSerial(vcomDir)
|
|
socatProc = pair.proc
|
|
// 被控端持有 "user" 端 (当真实串口), 测试这边拿 "internal" 端当外部设备
|
|
B.usb.listLocal = async () => ([{
|
|
busId: `serial:${pair.userPath}`, vid: 0, pid: 0, deviceClass: 0x02, deviceSubclass: 0x02,
|
|
product: 'selftest-pty', manufacturer: 'socat', kind: 'serial', serialPath: pair.userPath, baudRate: 115200,
|
|
}] as any)
|
|
|
|
await runCase('U3 attach 串口: 控制端能拿到 attached ok', async () => {
|
|
const r = await A.usb.attach(B.info.deviceId, {
|
|
busId: `serial:${pair.userPath}`, direction: 'self-out',
|
|
config: { kind: 'serial', baudRate: 115200 },
|
|
})
|
|
assert(r.ok, `attach 被拒: ${r.reason}`)
|
|
})
|
|
|
|
await runCase('U4 串口双向字节流: 控制端 -> 设备', async () => {
|
|
const sess = A.usb.listClientSessions()[0]
|
|
assert(!!sess, '控制端没有 usb session')
|
|
devEnd = openPty(pair.internalPath)
|
|
const ok = await A.usb.sendSerialBytes(sess.sessionId, Buffer.from('HELLO-FROM-CONTROL\n'))
|
|
assert(ok, 'sendSerialBytes 失败')
|
|
// 设备端应该收到
|
|
let seen = ''
|
|
for (let i = 0; i < 50 && !seen.includes('HELLO-FROM-CONTROL'); i++) {
|
|
await sleep(100)
|
|
seen += devEnd.read()
|
|
}
|
|
assert(seen.includes('HELLO-FROM-CONTROL'), `设备端没收到: ${JSON.stringify(seen)}`)
|
|
})
|
|
|
|
await runCase('U5 串口双向字节流: 设备 -> 控制端', async () => {
|
|
assert(!!devEnd, '设备端没准备好')
|
|
A.clearEvents()
|
|
devEnd.write('HELLO-FROM-DEVICE\n')
|
|
await waitFor(() => A.findEvents('usb:output').map(e => decode(e.data!)).join('').includes('HELLO-FROM-DEVICE'), 6000)
|
|
.catch(() => { throw new Error('控制端没收到设备发来的字节') })
|
|
})
|
|
} finally {
|
|
try { devEnd?.close() } catch {}
|
|
try { socatProc?.kill() } catch {}
|
|
try { rmSync(vcomDir, { recursive: true, force: true }) } catch {}
|
|
}
|
|
}
|
|
|
|
// ============ 网盘 ============
|
|
console.log('\n\x1b[1m[网盘]\x1b[0m')
|
|
if (want('drive') || want('网盘')) {
|
|
await runCase('D1 列对方网盘根目录', async () => {
|
|
const r = await listDrive(B.info, '')
|
|
assert(r.ok, `列目录失败: ${r.reason}`)
|
|
const names = (r.entries || []).map(e => e.name)
|
|
assert(names.includes('hello.txt'), `没看到 hello.txt, 实际: ${names.join(',')}`)
|
|
assert(names.includes('sub'), `没看到子目录 sub, 实际: ${names.join(',')}`)
|
|
const f = (r.entries || []).find(e => e.name === 'hello.txt')!
|
|
assert(f.size === 'HELLO-FROM-DRIVE'.length, `大小不对: ${f.size}`)
|
|
assert(!f.isDir, '文件被标成目录了')
|
|
})
|
|
|
|
await runCase('D2 进子目录再列一次', async () => {
|
|
const r = await listDrive(B.info, 'sub')
|
|
assert(r.ok, `列子目录失败: ${r.reason}`)
|
|
const names = (r.entries || []).map(e => e.name)
|
|
assert(names.includes('nested.txt'), `实际: ${names.join(',')}`)
|
|
})
|
|
|
|
await runCase('D3 下载文件: 内容一致', async () => {
|
|
const dest = join(tmpdir(), `lnm-selftest-dl-${Date.now()}`)
|
|
mkdirSync(dest, { recursive: true })
|
|
const r = await downloadDriveFile({ peer: B.info, relPath: 'hello.txt', destDir: dest })
|
|
assert(r.ok, `下载失败: ${r.reason}`)
|
|
const content = readFileSync(r.path!, 'utf8')
|
|
assert(content === 'HELLO-FROM-DRIVE', `内容不对: ${content}`)
|
|
assert(r.size === 'HELLO-FROM-DRIVE'.length, `大小不对: ${r.size}`)
|
|
})
|
|
|
|
await runCase('D4 下载子目录里的文件', async () => {
|
|
const dest = join(tmpdir(), `lnm-selftest-dl2-${Date.now()}`)
|
|
mkdirSync(dest, { recursive: true })
|
|
const r = await downloadDriveFile({ peer: B.info, relPath: 'sub/nested.txt', destDir: dest })
|
|
assert(r.ok, `下载失败: ${r.reason}`)
|
|
assert(readFileSync(r.path!, 'utf8') === 'NESTED-FROM-DRIVE', '内容不对')
|
|
})
|
|
|
|
await runCase('D5 目录穿越被挡住 (.. 逃不出网盘根)', async () => {
|
|
const r = await listDrive(B.info, '../../../../etc')
|
|
assert(!r.ok, '竟然列出了网盘之外的目录!')
|
|
const r2 = await listDrive(B.info, '..')
|
|
assert(!r2.ok, '竟然能列出上级目录!')
|
|
})
|
|
|
|
await runCase('D6 token 错误 -> 拒绝访问', async () => {
|
|
const bad = { ...B.info, driveToken: 'wrong-token' }
|
|
const r = await listDrive(bad, '')
|
|
assert(!r.ok, '错误 token 竟然能访问!')
|
|
assert(/令牌/.test(r.reason || ''), `错误信息不对: ${r.reason}`)
|
|
})
|
|
|
|
await runCase('D7 对方没开网盘 (无 token) -> 明确提示不支持', async () => {
|
|
const r = await listDrive({ ...B.info, driveToken: undefined }, '')
|
|
assert(!r.ok, '无 token 竟然能访问!')
|
|
assert(/不支持/.test(r.reason || ''), `错误信息不对: ${r.reason}`)
|
|
})
|
|
|
|
await runCase('D8 递归收集目录下的全部文件', async () => {
|
|
const r = await collectDriveFiles(B.info, 'sub')
|
|
assert(r.ok, `收集失败: ${r.reason}`)
|
|
const names = r.files.map(f => f.relPath).sort()
|
|
assert(names.includes('sub/nested.txt'), `缺 nested.txt: ${names.join(',')}`)
|
|
assert(names.includes('sub/deep/deeper.txt'), `缺深层文件: ${names.join(',')}`)
|
|
})
|
|
|
|
await runCase('D9 整目录下载: 保持层级', async () => {
|
|
const dest = join(tmpdir(), `lnm-selftest-dl3-${Date.now()}`)
|
|
mkdirSync(dest, { recursive: true })
|
|
const collected = await collectDriveFiles(B.info, 'sub')
|
|
assert(collected.ok, '收集失败')
|
|
for (const f of collected.files) {
|
|
const relInside = f.relPath.slice('sub'.length).replace(/^[/\\]+/, '')
|
|
const dirInside = relInside ? dirname(relInside) : ''
|
|
const destSubPath = dirInside && dirInside !== '.' ? join('sub', dirInside) : 'sub'
|
|
const r = await downloadDriveFile({ peer: B.info, relPath: f.relPath, destDir: dest, destSubPath })
|
|
assert(r.ok, `下载 ${f.relPath} 失败: ${r.reason}`)
|
|
}
|
|
assert(existsSync(join(dest, 'sub', 'nested.txt')), '层级不对: 少了 sub/nested.txt')
|
|
assert(existsSync(join(dest, 'sub', 'deep', 'deeper.txt')), '层级不对: 少了 sub/deep/deeper.txt')
|
|
assert(readFileSync(join(dest, 'sub', 'deep', 'deeper.txt'), 'utf8') === 'DEEPER-FROM-DRIVE', '内容不对')
|
|
})
|
|
}
|
|
|
|
// ============ 消息通道状态 ============
|
|
// 回归: 启动时主进程先连上对端, 渲染层后订阅 -> open 事件被丢掉, UI 永久显示"等待重连"
|
|
// 所以状态必须是"拉取式"的: 任何时刻去查都能查到当前真实状态
|
|
if (want('ws') || want('state')) {
|
|
console.log('\n\x1b[1m[通道状态]\x1b[0m')
|
|
|
|
await runCase('W1 通道状态可随时拉取 (连接早已建好, 现在才查也查得到)', async () => {
|
|
assert(A.client.isOpen(B.info.deviceId), '前提不成立: A->B 的 outgoing 没开')
|
|
// 模拟"渲染层晚订阅": 此刻才去查状态
|
|
const st = computeWsState([B.info.deviceId], A.client.getStatus(), A.server)
|
|
assert(st[B.info.deviceId] === true, `状态不对: ${JSON.stringify(st)}`)
|
|
})
|
|
|
|
await runCase('W2 只有 inbound 通 (对方连我, 我没主动连) 也算通道可用', async () => {
|
|
const C = new Node('peer-cccccccc-0000-4000-8000-00000000000c', '只入站C')
|
|
await C.start()
|
|
try {
|
|
B.connectTo(C) // 只有 B -> C 这一条腿
|
|
await waitFor(() => C.server.isConnected(B.info.deviceId), 8000)
|
|
assert(!C.client.isOpen(B.info.deviceId), '前提不成立: C 不该有 outgoing 连接')
|
|
const st = computeWsState([B.info.deviceId], C.client.getStatus(), C.server)
|
|
assert(st[B.info.deviceId] === true, `只有 inbound 通时不该判死: ${JSON.stringify(st)}`)
|
|
} finally {
|
|
await C.stop()
|
|
}
|
|
})
|
|
|
|
await runCase('W3 两条腿都断了 -> 状态回落 false', async () => {
|
|
A.client.remove(B.info.deviceId)
|
|
B.client.remove(A.info.deviceId)
|
|
await waitFor(() => !A.server.isConnected(B.info.deviceId), 5000)
|
|
await waitFor(() => !B.server.isConnected(A.info.deviceId), 5000)
|
|
const st = computeWsState([B.info.deviceId], A.client.getStatus(), A.server)
|
|
assert(st[B.info.deviceId] === false, `断了还报可用: ${JSON.stringify(st)}`)
|
|
})
|
|
}
|
|
|
|
// ============ 跨平台兼容 ============
|
|
if (want('win') || want('compat') || want('platform')) {
|
|
console.log('\n\x1b[1m[跨平台兼容]\x1b[0m')
|
|
|
|
await runCase('S1 Windows 保留设备名被改写 (CON/NUL/COM1 带扩展名也算)', async () => {
|
|
for (const n of ['CON', 'con.txt', 'NUL', 'AUX', 'COM1', 'LPT1', 'clock$.log']) {
|
|
const out = safeFileName(n, { platform: 'win32' })
|
|
assert(!/^(CON|NUL|AUX|COM1|LPT1|CLOCK\$)/i.test(out), `"${n}" 没被处理: ${out}`)
|
|
assert(out.startsWith('_'), `"${n}" 应该加前缀: ${out}`)
|
|
}
|
|
})
|
|
|
|
await runCase('S2 Windows 尾部点/空格被去掉 (Win32 会悄悄吞掉它们)', async () => {
|
|
assert(safeFileName('report.', { platform: 'win32' }) === 'report', '尾部点没去掉')
|
|
assert(safeFileName('report ', { platform: 'win32' }) === 'report', '尾部空格没去掉')
|
|
assert(safeFileName('a b ', { platform: 'win32' }) === 'a b', '不该动中间的空格')
|
|
})
|
|
|
|
await runCase('S3 非法字符全平台都清 (含路径分隔符)', async () => {
|
|
for (const plat of ['win32', 'linux', 'darwin'] as NodeJS.Platform[]) {
|
|
const out = safeFileName('a/b\\c:d*e?f"g<h>i|j', { platform: plat })
|
|
assert(!/[/\\:*?"<>|]/.test(out), `${plat} 上还有非法字符: ${out}`)
|
|
}
|
|
})
|
|
|
|
await runCase('S4 Linux/macOS 不乱改合法文件名 (CON 在 Linux 上是合法文件)', async () => {
|
|
assert(safeFileName('CON', { platform: 'linux' }) === 'CON', 'Linux 上不该改 CON')
|
|
assert(safeFileName('a:b', { platform: 'darwin' }) === 'a_b', '冒号还是该清')
|
|
})
|
|
|
|
await runCase('S5 超长文件名被截断且不超上限', async () => {
|
|
const out = safeFileName('x'.repeat(500) + '.txt', { platform: 'linux' })
|
|
assert(out.length <= 200, `没截断: ${out.length}`)
|
|
assert(out.endsWith('.txt'), '扩展名丢了')
|
|
})
|
|
|
|
await runCase('S6 默认 shell / cwd 可用', async () => {
|
|
const sh = defaultShell()
|
|
assert(sh && sh.file, '没选到 shell')
|
|
const cwd = defaultCwd()
|
|
assert(cwd && existsSync(cwd), `cwd 不存在: ${cwd}`)
|
|
})
|
|
|
|
await runCase('S7 打包配置: node-pty 平台包必须递归解包 (否则 Windows 终端起不来)', async () => {
|
|
// 打包后 __dirname 在 scripts/selftest/.out, 向上找到带 build.asarUnpack 的 package.json
|
|
let dir = __dirname
|
|
let pkg: any = null
|
|
for (let i = 0; i < 6; i++) {
|
|
const f = join(dir, 'package.json')
|
|
if (existsSync(f)) {
|
|
try {
|
|
const j = JSON.parse(readFileSync(f, 'utf8'))
|
|
if (j?.build?.asarUnpack) { pkg = j; break }
|
|
} catch {}
|
|
}
|
|
dir = dirname(dir)
|
|
}
|
|
assert(pkg, '没找到带 build.asarUnpack 的 package.json')
|
|
const pats: string[] = pkg.build.asarUnpack || []
|
|
const ptyPat = pats.find(p => p.includes('@lydell/node-pty-*'))
|
|
assert(ptyPat, `asarUnpack 里没有 node-pty 平台包的规则: ${pats.join(' | ')}`)
|
|
assert(
|
|
ptyPat.endsWith('/**/*'),
|
|
`node-pty 平台包规则必须递归 (/**/*), 否则 prebuilds/<plat>/ 里的 .node/.dll/.exe 会留在 asar 里: ${ptyPat}`,
|
|
)
|
|
})
|
|
await runCase('S8 上传落盘: 文件名按本机平台清洗, 且不逃出接收目录', async () => {
|
|
const root = join(tmpdir(), `lnm-selftest-up-${Date.now()}`)
|
|
mkdirSync(root, { recursive: true })
|
|
const fsrv = new FileServer(0, root, {
|
|
getDriveRoot: () => '', getDriveToken: () => '', isDriveEnabled: () => false,
|
|
})
|
|
const port = await fsrv.start()
|
|
const upload = async (name: string, body: string) => {
|
|
const buf = Buffer.from(body)
|
|
return new Promise<any>((resolve, reject) => {
|
|
const req = httpRequest({
|
|
host: '127.0.0.1', port, path: '/upload', method: 'POST',
|
|
headers: {
|
|
'x-file-id': `selftest-up-${Math.random().toString(36).slice(2)}`,
|
|
'x-file-name': encodeURIComponent(name),
|
|
'x-file-size': String(buf.length),
|
|
'x-file-mime': 'text/plain',
|
|
'Content-Length': String(buf.length),
|
|
},
|
|
}, r => {
|
|
let b = ''
|
|
r.on('data', d => (b += d))
|
|
r.on('end', () => { try { resolve(JSON.parse(b)) } catch (e) { reject(e) } })
|
|
})
|
|
req.on('error', reject)
|
|
req.end(buf)
|
|
})
|
|
}
|
|
try {
|
|
// 1) Windows 保留设备名: 在 Linux 上是合法文件名, 不该被改;
|
|
// 在 Windows 上必须被改写 (S1 已覆盖 win32 分支)。这里校验"落盘名 == 清洗结果"。
|
|
const r1 = await upload('CON.txt', 'UPLOAD_BODY')
|
|
assert(r1?.ok, `上传失败: ${JSON.stringify(r1)}`)
|
|
const saved1: string = r1.savedPath
|
|
assert(existsSync(saved1), `文件没落盘: ${saved1}`)
|
|
assert(readFileSync(saved1, 'utf8') === 'UPLOAD_BODY', '内容不对')
|
|
const expect1 = safeFileName('CON.txt', { platform: process.platform })
|
|
assert((saved1.split(/[\\/]/).pop() || '') === expect1,
|
|
`落盘名与清洗结果不一致: ${saved1} vs ${expect1}`)
|
|
|
|
// 2) 路径穿越: 文件名里带 ../ 必须被清洗掉, 不能写到接收目录外面
|
|
const r2 = await upload('../../evil.txt', 'EVIL')
|
|
assert(r2?.ok, `上传失败: ${JSON.stringify(r2)}`)
|
|
const saved2: string = r2.savedPath
|
|
assert(existsSync(saved2), `文件没落盘: ${saved2}`)
|
|
const rel = saved2.slice(root.length).replace(/^[\\/]+/, '')
|
|
assert(!rel.split(/[\\/]/).includes('..'), `路径穿越没挡住: ${saved2}`)
|
|
assert(!saved2.includes(`${root}..`), `路径穿越没挡住: ${saved2}`)
|
|
} finally {
|
|
await fsrv.stop()
|
|
try { rmSync(root, { recursive: true, force: true }) } catch {}
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============ 审计 ============
|
|
if (want('audit')) {
|
|
console.log('\n\x1b[1m[审计]\x1b[0m')
|
|
await runCase('A1 关键操作都写进了 audit_log', async () => {
|
|
const rows = listAudit({ limit: 200 })
|
|
const actions = new Set(rows.map(r => r.action))
|
|
const need = ['terminal.open', 'forward.open']
|
|
for (const n of need) assert(actions.has(n), `audit 里没有 ${n} (现有: ${[...actions].join(', ')})`)
|
|
})
|
|
}
|
|
|
|
// ============ 汇总 ============
|
|
await A.stop()
|
|
await B.stop()
|
|
|
|
const pass = results.filter(r => r.ok).length
|
|
const fail = results.length - pass
|
|
console.log(`\n\x1b[1m结果: ${pass} 通过 / ${fail} 失败\x1b[0m`)
|
|
if (fail) {
|
|
console.log('\n失败用例:')
|
|
for (const r of results.filter(x => !x.ok)) console.log(` - ${r.name}\n ${r.detail}`)
|
|
}
|
|
process.exit(fail ? 1 : 0)
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('自测脚本异常:', e)
|
|
process.exit(2)
|
|
})
|