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,140 @@
|
||||
/**
|
||||
* Cross-check `src/qrcode.js` against the reference `qrcode` npm package.
|
||||
*
|
||||
* The reference is only a test oracle — it is never a runtime dependency. Run
|
||||
* `node scripts/test-qr.mjs`; set `QR_ORACLE_DIR` to the directory holding the
|
||||
* installed `qrcode` package (default `/tmp/qr-oracle`).
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { encodeQR } from '../src/qrcode.js'
|
||||
|
||||
const oracleDir = process.env.QR_ORACLE_DIR ?? '/tmp/qr-oracle'
|
||||
const requireFromOracle = createRequire(`${oracleDir}/package.json`)
|
||||
let oracle
|
||||
try {
|
||||
oracle = requireFromOracle('qrcode')
|
||||
} catch (error) {
|
||||
console.error(`cannot load the qrcode oracle from ${oracleDir}: ${error.message}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
/** Byte-mode capacity at error-correction level M, independent of the encoder under test. */
|
||||
const CAPACITY_M = { 1: 14, 2: 26, 3: 42, 4: 62, 5: 84, 6: 106, 7: 122, 8: 152, 9: 180, 10: 213 }
|
||||
|
||||
/** Deterministic pseudo-random byte source. */
|
||||
function makeRandom(seed) {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state * 1_664_525 + 1_013_904_223) >>> 0
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic payload of exactly `length` bytes.
|
||||
*
|
||||
* A lowercase letter is always present on purpose: the reference implementation
|
||||
* auto-selects the most compact mode, and QR alphanumeric mode covers only
|
||||
* digits, uppercase, space and `$%*+-./:`. This encoder implements byte mode
|
||||
* (what a URL needs), so the payload must rule numeric and alphanumeric modes
|
||||
* out to make the two comparable.
|
||||
*/
|
||||
function payload(length, seed) {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~:/?#[]@!$&()*+,;=%'
|
||||
const random = makeRandom(seed)
|
||||
let text = ''
|
||||
while (text.length < length) text += alphabet[random() % alphabet.length]
|
||||
text = text.slice(0, length)
|
||||
if (!/[a-z]/.test(text)) text = `${text.slice(0, -1)}a`
|
||||
return text
|
||||
}
|
||||
|
||||
let comparisons = 0
|
||||
let failures = 0
|
||||
const failureSamples = []
|
||||
|
||||
for (let version = 1; version <= 10; version += 1) {
|
||||
for (const length of [1, Math.floor(CAPACITY_M[version] / 2), CAPACITY_M[version]]) {
|
||||
const text = payload(length, version * 1000 + length)
|
||||
for (let mask = 0; mask < 8; mask += 1) {
|
||||
const mine = encodeQR(text, { version, mask })
|
||||
const reference = oracle.create(text, {
|
||||
errorCorrectionLevel: 'M',
|
||||
version,
|
||||
maskPattern: mask,
|
||||
})
|
||||
comparisons += 1
|
||||
|
||||
const size = reference.modules.size
|
||||
const expected = reference.modules.data
|
||||
let mismatch = -1
|
||||
if (mine.size !== size) {
|
||||
mismatch = -2
|
||||
} else {
|
||||
for (let i = 0; i < expected.length; i += 1) {
|
||||
if ((mine.modules[i] ? 1 : 0) !== (expected[i] ? 1 : 0)) {
|
||||
mismatch = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mismatch !== -1) {
|
||||
failures += 1
|
||||
if (failureSamples.length < 5) {
|
||||
failureSamples.push({ version, length, mask, mismatch })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`forced version+mask comparisons: ${comparisons - failures}/${comparisons} identical`)
|
||||
|
||||
// The format-information and mask choice must match too: compare the chosen
|
||||
// mask for payloads where both implementations score penalties the same way.
|
||||
let maskAgreements = 0
|
||||
let maskCases = 0
|
||||
for (let version = 1; version <= 10; version += 1) {
|
||||
const text = payload(Math.min(20, CAPACITY_M[version]), version * 7 + 3)
|
||||
const mine = encodeQR(text, { version })
|
||||
const reference = oracle.create(text, { errorCorrectionLevel: 'M', version })
|
||||
maskCases += 1
|
||||
if (mine.mask === reference.maskPattern) maskAgreements += 1
|
||||
else console.log(` mask differs at v${version}: mine=${mine.mask} reference=${reference.maskPattern}`)
|
||||
}
|
||||
console.log(`auto-mask agreement: ${maskAgreements}/${maskCases}`)
|
||||
|
||||
// UTF-8. The reference re-segments mixed text into Byte+Alphanumeric runs for
|
||||
// compactness; this encoder deliberately emits one Byte segment (there is no
|
||||
// correctness difference, only size). Compare strictly against a payload the
|
||||
// reference also encodes as a single Byte segment.
|
||||
const byteOnly = '局域网_def~令牌~abc'
|
||||
const byteOnlyAuto = encodeQR(byteOnly)
|
||||
const byteOnlyReference = oracle.create(byteOnly, {
|
||||
errorCorrectionLevel: 'M',
|
||||
version: byteOnlyAuto.version,
|
||||
maskPattern: byteOnlyAuto.mask,
|
||||
})
|
||||
const byteOnlySame =
|
||||
byteOnlyAuto.size === byteOnlyReference.modules.size &&
|
||||
byteOnlyAuto.modules.every((value, index) => (value ? 1 : 0) === (byteOnlyReference.modules.data[index] ? 1 : 0))
|
||||
console.log(
|
||||
`utf8 single-byte-segment (v${byteOnlyAuto.version}, mask ${byteOnlyAuto.mask}) byte-identical: ${byteOnlySame}`,
|
||||
)
|
||||
if (byteOnlyReference.segments.length !== 1) console.log(' note: reference did not emit one segment')
|
||||
|
||||
// A realistic URL is where the reference re-segments. Report the divergence
|
||||
// rather than treating a valid-but-larger symbol as a failure.
|
||||
const url = 'http://192.168.1.5:3081/?token=aBcD1234EfGh5678'
|
||||
const urlSymbol = encodeQR(url)
|
||||
const urlReference = oracle.create(url, { errorCorrectionLevel: 'M' })
|
||||
console.log(
|
||||
`realistic URL fits v${urlSymbol.version} (size ${urlSymbol.size}); reference used v${urlReference.version} in ${urlReference.segments.length} segment(s)`,
|
||||
)
|
||||
const urlStructurallySound = urlSymbol.size === urlSymbol.version * 4 + 17 && urlSymbol.modules.length === urlSymbol.size * urlSymbol.size
|
||||
|
||||
const ok = failures === 0 && byteOnlySame && urlStructurallySound
|
||||
if (failureSamples.length > 0) console.log('failure samples:', JSON.stringify(failureSamples))
|
||||
console.log(ok ? '\nQR encoder matches the reference implementation' : '\nQR encoder MISMATCH')
|
||||
process.exit(ok ? 0 : 1)
|
||||
Reference in New Issue
Block a user