Files
sutong ced05e06a3 feat: dsh-lan-access — 局域网访问 dsh Web UI
用独立反向代理把 dsh Web UI 发布到局域网。官方 CLI 主动拒绝
`dsh web --host 0.0.0.0`(会把宿主机远程代码执行暴露到网络),
因此本插件让官方服务器保持只绑回环,另开监听并转发。

宿主端(index.js)
- 在 0.0.0.0:3082 监听,把 HTTP 与 WebSocket 转发到 127.0.0.1:<webServer 端口>
- 上游 Host/Origin 重写为回环,使官方 /api 信任围栏「通过」而不是被绕过
- 不改动 Set-Cookie:浏览器按请求 URL 建立 host-only cookie,转发层无需干预
- 监听端 DNS 重绑定防护;targetHost 仅接受回环地址(不会变成开放代理)
- /api/dsh-lan-access/summary 复用官方 connection.admit() 做围栏与浏览器认证
- 启动横幅打印带 launch token 的局域网链接
- 端口被占用时只告警不抛出,并在面板上报 listening:false,不展示不可用的二维码
- ownsHostCompat(默认关):仅为非回环页面声明 ownsHost,恢复官方设置界面

客户端(src/ 经 scripts/build-client.mjs 生成 client.js)
- Settings → 局域网访问:局域网地址列表、复制/打开、选中地址的二维码
- 零依赖二维码编码器(byte 模式 / ECC M / version 1–10)

组合层
- 只 insert 一行,不覆盖任何 shipped row:停用本插件只会移除局域网监听,
  回环 Web UI 不受影响

验证
- 宿主转发契约 28/28;客户端契约 25/25
- 二维码编码器与 npm qrcode 参考实现在全版本 × 全 8 掩码下逐模块比对 240/240 一致
- 端到端:局域网 token 首访 303 并铸 cookie → 应用 200;无凭证 401;
  WebSocket 升级 101,且与直连回环逐项行为一致
2026-09-27 03:49:12 +08:00

257 lines
11 KiB
JavaScript

/**
* Standalone smoke test for the LAN listener. It mounts the plugin against a
* fake Cordis context, drives a real upstream HTTP + WebSocket server, and
* asserts the forwarding contract. Run: `node scripts/smoke.mjs`.
*/
import { createServer } from 'node:http'
import { connect as netConnect } from 'node:net'
import vm from 'node:vm'
import { apply, internals, OWNS_HOST_COMPAT_SCRIPT } from '../index.js'
const results = []
function check(label, condition, detail) {
results.push({ label, ok: Boolean(condition), detail })
console.log(`${condition ? 'PASS' : 'FAIL'} ${label}${detail === undefined || condition ? '' : ` — ${detail}`}`)
}
/** Bind a throwaway server on an OS-assigned loopback port. */
function listen(server, host = '127.0.0.1') {
return new Promise((resolve) => {
server.listen(0, host, () => resolve(server.address().port))
})
}
const seen = []
const upstream = createServer((req, res) => {
seen.push({ url: req.url, host: req.headers.host, origin: req.headers.origin, cookie: req.headers.cookie })
res.writeHead(200, {
'content-type': 'application/json',
'set-cookie': 'dsh-auth-test=abc123; Max-Age=60; Path=/; HttpOnly; SameSite=Strict',
'x-upstream': 'yes',
})
res.end(JSON.stringify({ url: req.url, host: req.headers.host, origin: req.headers.origin ?? null }))
})
// DSH serves HTTP and WebSocket on the same port, so the upstream carries both.
upstream.on('upgrade', (req, socket) => {
seen.push({ upgrade: true, url: req.url, host: req.headers.host, origin: req.headers.origin })
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n')
socket.write('hello-from-upstream')
})
const upstreamPort = await listen(upstream)
// ── fake Cordis context ─────────────────────────────────────────────────────
// Cordis exposes injected services both as `ctx.<name>` and through `ctx.get`.
const routes = []
let admitted = 0
const connection = {
admit: () => {
admitted += 1
return { peer: {} }
},
authenticatedUrl: (base) => `${base}?token=TESTTOKEN`,
}
const webServer = {
port: upstreamPort,
register: (route) => {
routes.push(route)
return () => {}
},
}
const ctx = {
webServer,
connection,
get(name) {
if (name === 'connection') return connection
if (name === 'webServer') return webServer
return undefined
},
inject(deps, callback) {
if (deps.every((d) => ctx.get(d) !== undefined)) callback(ctx)
return () => {}
},
effect(callback) {
callback()
return () => {}
},
}
const LAN_PORT = 39_991
apply(ctx, {
enabled: true,
host: '127.0.0.1',
port: LAN_PORT,
targetHost: '127.0.0.1',
targetPort: upstreamPort,
printBanner: false,
})
// Wait for the listener to bind.
await new Promise((resolve) => setTimeout(resolve, 250))
/** Issue a request through the LAN listener. */
function viaLan(path, headers = {}) {
return new Promise((resolve, reject) => {
const req = netConnect(LAN_PORT, '127.0.0.1', () => {
req.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${LAN_PORT}\r\nOrigin: http://127.0.0.1:${LAN_PORT}\r\nCookie: dsh-auth-test=abc123\r\nConnection: close\r\n\r\n`)
})
let raw = ''
req.on('data', (chunk) => { raw += chunk })
req.on('end', () => resolve(raw))
req.on('error', reject)
})
}
const response = await viaLan('/api/remote.mux')
check('LAN listener returns the upstream status', response.startsWith('HTTP/1.1 200'), response.split('\r\n')[0])
check('upstream sees a loopback Host', seen[0]?.host === `127.0.0.1:${upstreamPort}`, seen[0]?.host)
check('upstream sees a matching loopback Origin', seen[0]?.origin === `http://127.0.0.1:${upstreamPort}`, seen[0]?.origin)
check('upstream receives the browser cookie', seen[0]?.cookie === 'dsh-auth-test=abc123', seen[0]?.cookie)
check('Set-Cookie is forwarded untouched', /set-cookie: dsh-auth-test=abc123/i.test(response), 'no set-cookie')
check('custom upstream header survives', /x-upstream: yes/i.test(response))
// ── WebSocket upgrade ───────────────────────────────────────────────────────
const wsRaw = await new Promise((resolve, reject) => {
const socket = netConnect(LAN_PORT, '127.0.0.1', () => {
socket.write(
`GET /api/remote.mux HTTP/1.1\r\nHost: 127.0.0.1:${LAN_PORT}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nOrigin: http://127.0.0.1:${LAN_PORT}\r\n\r\n`,
)
})
let raw = ''
socket.on('data', (chunk) => { raw += chunk })
socket.on('close', () => resolve(raw))
socket.on('error', reject)
setTimeout(() => { socket.destroy(); resolve(raw) }, 900)
})
const upgrade = seen.find((entry) => entry.upgrade)
check('WebSocket upgrade reached the upstream', upgrade !== undefined)
check('upgraded request kept a loopback Host', upgrade?.host === `127.0.0.1:${upstreamPort}`, upgrade?.host)
check('upgrade response flows back to the client', /hello-from-upstream/.test(wsRaw))
// ── the panel data route ────────────────────────────────────────────────────
const route = routes.find((entry) => entry.path === '/api/dsh-lan-access/summary')
check('summary route registered as exact', route?.kind === 'exact', route?.kind)
let body = ''
const fakeRes = {
writeHead(status, headers) { this.status = status; this.headers = headers },
end(value) { body = value ?? '' },
}
route?.handler({ headers: { host: `127.0.0.1:${upstreamPort}` } }, fakeRes)
check('summary route requires admission', admitted === 1, `admit calls: ${admitted}`)
check('summary reports the listen port', JSON.parse(body).listen.port === LAN_PORT, body)
check('summary carries a tokenized LAN url', /token=TESTTOKEN/.test(body), body)
// ── pure helpers ────────────────────────────────────────────────────────────
check('isLoopbackHost accepts 127.0.0.1', internals.isLoopbackHost('127.0.0.1') === true)
check('isLoopbackHost accepts ::1', internals.isLoopbackHost('::1') === true)
check('isLoopbackHost rejects 192.168.1.5', internals.isLoopbackHost('192.168.1.5') === false)
check('hostnameOf strips the port', internals.hostnameOf('192.168.1.5:3081') === '192.168.1.5')
check('upstreamHeaders drops hop-by-hop', internals.upstreamHeaders({ connection: 'keep-alive', 'x-a': '1' }, 'h:1').connection === undefined)
// ── bind failure is reported, never thrown ──────────────────────────────────
const blocker = createServer(() => {})
const blockerPort = await listen(blocker)
const routes2 = []
const connection2 = { admit: () => ({ peer: {} }), authenticatedUrl: (base) => `${base}?token=T` }
const webServer2 = { port: upstreamPort, register: (route) => { routes2.push(route); return () => {} } }
const ctx2 = {
webServer: webServer2,
connection: connection2,
get: (name) => (name === 'connection' ? connection2 : name === 'webServer' ? webServer2 : undefined),
inject: (deps, callback) => {
if (deps.every((d) => ctx2.get(d) !== undefined)) callback(ctx2)
return () => {}
},
effect: (callback) => { callback(); return () => {} },
}
apply(ctx2, {
enabled: true,
host: '127.0.0.1',
port: blockerPort,
targetHost: '127.0.0.1',
targetPort: upstreamPort,
printBanner: false,
})
await new Promise((resolve) => setTimeout(resolve, 250))
const route2 = routes2.find((entry) => entry.path === '/api/dsh-lan-access/summary')
let body2 = ''
route2?.handler({ headers: { host: `127.0.0.1:${upstreamPort}` } }, {
writeHead(status) { this.status = status },
end(value) { body2 = value ?? '' },
})
const payload2 = JSON.parse(body2)
check('taken port is reported as listening:false', payload2.listening === false, body2)
check('taken port carries the bind error', String(payload2.bindError ?? '').includes('EADDRINUSE'), payload2.bindError)
blocker.close()
// ── ownsHost compat is opt-in and page-conditional ──────────────────────────
/**
* Mount the plugin with a given config and return the registered index taps.
* @param config - extra config over merged onto a loopback test binding.
* @returns the tap functions the plugin registered.
*/
let compatPort = 39_990
async function tapsFor(config) {
compatPort += 1
const taps = []
const records = []
const c = {
get: () => undefined,
inject: (deps, callback) => {
callback({
webServer: {
port: upstreamPort,
register: (route) => { records.push(route); return () => {} },
tapIndex: (transform) => { taps.push(transform); return () => {} },
},
effect: (callback2) => { callback2(); return () => {} },
})
return () => {}
},
effect: (callback2) => { callback2(); return () => {} },
}
apply(c, {
enabled: true,
host: '127.0.0.1',
port: compatPort,
targetHost: '127.0.0.1',
targetPort: upstreamPort,
printBanner: false,
...config,
})
await new Promise((resolve) => setTimeout(resolve, 120))
return taps
}
const offTaps = await tapsFor({ ownsHostCompat: false })
check('ownsHost compat is off by default', offTaps.length === 0, `taps: ${offTaps.length}`)
const onTaps = await tapsFor({ ownsHostCompat: true })
check('ownsHost compat registers one index tap', onTaps.length === 1, `taps: ${onTaps.length}`)
const html = '<html><head><title>t</title></head><body><script type="module" src="x.js"></script></body></html>'
const tapped = onTaps[0]?.(html) ?? html
const scriptAt = tapped.indexOf('ownsHost = true')
const bundleAt = tapped.indexOf('x.js')
check('compat script lands in the head', scriptAt !== -1 && tapped.indexOf('</head>') > scriptAt, tapped.slice(0, 80))
check('compat script precedes the app bundle', scriptAt !== -1 && scriptAt < bundleAt)
/** Run the compat script against a stubbed page location. */
function runCompat(hostname) {
const sandbox = { globalThis: undefined, location: hostname === undefined ? undefined : { hostname } }
sandbox.globalThis = sandbox
vm.createContext(sandbox)
vm.runInContext(OWNS_HOST_COMPAT_SCRIPT, sandbox)
return sandbox.__DSH_TRANSPORT__
}
check('compat forges ownsHost on a LAN page', runCompat('192.168.1.5')?.ownsHost === true)
check('compat leaves a loopback page untouched', runCompat('127.0.0.1') === undefined)
check('compat leaves localhost untouched', runCompat('localhost') === undefined)
check('compat survives a missing location', runCompat(undefined) === undefined)
upstream.close()
const failed = results.filter((entry) => !entry.ok)
console.log(`\n${results.length - failed.length}/${results.length} checks passed`)
process.exit(failed.length === 0 ? 0 : 1)