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,且与直连回环逐项行为一致
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Contract test for the generated `client.js`.
|
||||
*
|
||||
* Loads the artifact in a sandbox with a stub `window.__ModuleLoader__` and a
|
||||
* stub React, then checks the slot registration, the localization wiring, the
|
||||
* loading render, the ready render, and that the route the panel fetches is the
|
||||
* one the host half exports.
|
||||
*
|
||||
* Run: `node scripts/test-client.mjs`
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import vm from 'node:vm'
|
||||
import { SUMMARY_PATH } from '../index.js'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const source = readFileSync(join(root, 'client.js'), 'utf8')
|
||||
|
||||
const results = []
|
||||
function check(label, condition, detail) {
|
||||
results.push({ label, ok: Boolean(condition) })
|
||||
console.log(`${condition ? 'PASS' : 'FAIL'} ${label}${condition || detail === undefined ? '' : ` — ${detail}`}`)
|
||||
}
|
||||
|
||||
// ── load the artifact the way the browser kernel does ───────────────────────
|
||||
let captured
|
||||
const sandbox = {
|
||||
window: { __ModuleLoader__: { load: (options) => { captured = options } } },
|
||||
console,
|
||||
TextEncoder,
|
||||
setTimeout,
|
||||
navigator: {},
|
||||
}
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(source, sandbox)
|
||||
|
||||
check('artifact registers exactly once', captured !== undefined)
|
||||
check('module id equals the package name', captured?.id === '@sutong/dsh-lan-access', captured?.id)
|
||||
check('factory is a function', typeof captured?.factory === 'function')
|
||||
|
||||
// ── stub React ──────────────────────────────────────────────────────────────
|
||||
let useStateCalls = 0
|
||||
let stateFixture
|
||||
const React = {
|
||||
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
|
||||
useState: (initial) => {
|
||||
useStateCalls += 1
|
||||
if (useStateCalls === 1 && stateFixture !== undefined) return [stateFixture, () => {}]
|
||||
return [typeof initial === 'function' ? initial() : initial, () => {}]
|
||||
},
|
||||
useEffect: () => {},
|
||||
}
|
||||
const requireStub = (name) => {
|
||||
if (name === 'react') return React
|
||||
throw new Error(`unexpected require(${JSON.stringify(name)})`)
|
||||
}
|
||||
|
||||
const plugin = captured.factory(requireStub)
|
||||
check('plugin exposes an inject list', Array.isArray(plugin?.inject), JSON.stringify(plugin?.inject))
|
||||
check('plugin injects slots and locale', plugin.inject?.includes('slots') && plugin.inject?.includes('locale'))
|
||||
check('plugin exposes apply', typeof plugin?.apply === 'function')
|
||||
|
||||
// ── drive apply against a fake client context ───────────────────────────────
|
||||
let dictionaries
|
||||
const registrations = []
|
||||
let injectedOwner
|
||||
const slots = {
|
||||
inject(owner, callback) {
|
||||
injectedOwner = owner
|
||||
callback()
|
||||
return () => {}
|
||||
},
|
||||
register(options, component) {
|
||||
registrations.push({ options, component })
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
const locale = {
|
||||
register(ns, dicts) {
|
||||
dictionaries = { ns, dicts }
|
||||
return () => {}
|
||||
},
|
||||
bind: () => (key) => dictionaries?.dicts.zh?.[key] ?? key,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
const effects = []
|
||||
const ctx = {
|
||||
get: (name) => (name === 'slots' ? slots : name === 'locale' ? locale : undefined),
|
||||
effect: (callback, label) => {
|
||||
effects.push(label)
|
||||
const disposer = callback()
|
||||
return () => {
|
||||
if (typeof disposer === 'function') disposer()
|
||||
}
|
||||
},
|
||||
}
|
||||
plugin.apply(ctx)
|
||||
|
||||
check('locale namespace registered', dictionaries?.ns === 'dsh-lan-access', dictionaries?.ns)
|
||||
check('locale carries zh and en', dictionaries?.dicts?.zh !== undefined && dictionaries?.dicts?.en !== undefined)
|
||||
check(
|
||||
'zh and en cover the same keys',
|
||||
JSON.stringify(Object.keys(dictionaries.dicts.zh).sort()) === JSON.stringify(Object.keys(dictionaries.dicts.en).sort()),
|
||||
'dictionary key sets differ',
|
||||
)
|
||||
check('registered into the Settings nav seat', injectedOwner === 'settings.section', injectedOwner)
|
||||
check('exactly one slot registration', registrations.length === 1, `count ${registrations.length}`)
|
||||
check('registration id is the section key', registrations[0]?.options?.id === 'lan-access', registrations[0]?.options?.id)
|
||||
check('registration names its slot', registrations[0]?.options?.name === 'settings.section')
|
||||
check('label thunk projects localized text', registrations[0]?.options?.label?.() === '局域网访问', registrations[0]?.options?.label?.())
|
||||
|
||||
// ── loading render ──────────────────────────────────────────────────────────
|
||||
useStateCalls = 0
|
||||
const loading = registrations[0].component({})
|
||||
check('loading render returns an element', loading?.type === 'div' && loading.props.className === 'dla-root')
|
||||
check('loading render mentions reading', JSON.stringify(loading.children).includes('正在读取'))
|
||||
|
||||
// ── ready render with the host's payload shape ──────────────────────────────
|
||||
stateFixture = {
|
||||
status: 'ready',
|
||||
data: {
|
||||
enabled: true,
|
||||
listen: { host: '0.0.0.0', port: 3081 },
|
||||
target: { host: '127.0.0.1', port: 3080 },
|
||||
lan: [
|
||||
{ address: '192.168.1.5', url: 'http://192.168.1.5:3081/', tokenUrl: 'http://192.168.1.5:3081/?token=abc' },
|
||||
{ address: '10.0.0.7', url: 'http://10.0.0.7:3081/', tokenUrl: 'http://10.0.0.7:3081/?token=abc' },
|
||||
],
|
||||
},
|
||||
}
|
||||
useStateCalls = 0
|
||||
const ready = registrations[0].component({})
|
||||
const readyJson = JSON.stringify(ready)
|
||||
check('ready render returns the panel', ready?.type === 'div' && ready.props.className === 'dla-root')
|
||||
check('ready render lists both addresses', readyJson.includes('192.168.1.5') && readyJson.includes('10.0.0.7'))
|
||||
check('ready render embeds a QR svg', readyJson.includes('"svg"'))
|
||||
check('ready render shows the token url', readyJson.includes('token=abc'))
|
||||
check('ready render warns about the token', readyJson.includes('launch token'))
|
||||
check('qr caption targets the selected address', readyJson.includes('192.168.1.5 的二维码'))
|
||||
|
||||
// ── a failed bind must be visible, not dressed up as a live QR ──────────────
|
||||
stateFixture = {
|
||||
status: 'ready',
|
||||
data: {
|
||||
enabled: true,
|
||||
listening: false,
|
||||
bindError: 'listen EADDRINUSE: address already in use 0.0.0.0:3081',
|
||||
listen: { host: '0.0.0.0', port: 3081 },
|
||||
target: { host: '127.0.0.1', port: 3080 },
|
||||
lan: [{ address: '192.168.1.5', url: 'http://192.168.1.5:3081/', tokenUrl: 'http://192.168.1.5:3081/?token=abc' }],
|
||||
},
|
||||
}
|
||||
useStateCalls = 0
|
||||
const failed = registrations[0].component({})
|
||||
const failedJson = JSON.stringify(failed)
|
||||
check('bind failure is surfaced in the panel', failedJson.includes('EADDRINUSE'))
|
||||
check('bind failure names the target', failedJson.includes('0.0.0.0:3081'))
|
||||
|
||||
// ── the two halves agree on the route ───────────────────────────────────────
|
||||
check('client fetches the host route', source.includes(SUMMARY_PATH), SUMMARY_PATH)
|
||||
|
||||
const failures = results.filter((entry) => !entry.ok)
|
||||
console.log(`\n${results.length - failures.length}/${results.length} checks passed`)
|
||||
process.exit(failures.length === 0 ? 0 : 1)
|
||||
Reference in New Issue
Block a user