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:
2026-09-27 03:49:12 +08:00
commit ced05e06a3
17 changed files with 3174 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
/**
* Client half of @sutong/dsh-lan-access: a Plugins-settings tab that shows the
* LAN addresses the host listener publishes, with a scannable QR code for the
* address you pick.
*
* This file is bundled into the single browser artifact `client.js` by
* `scripts/build-client.mjs`; it is never loaded directly by the browser.
*
* @module @sutong/dsh-lan-access/panel
*/
import { encodeQR } from './qrcode.js'
/** Locale namespace for this plugin's visible text. */
const NS = 'dsh-lan-access'
/** Host route (behind the official `/api` fence and browser authentication). */
const SUMMARY_URL = '/api/dsh-lan-access/summary'
/**
* The seat this panel occupies: its own entry in the Settings navigation.
* Registering here (rather than inside the Plugins section's tab strip) makes
* the page reachable directly from Settings; `dsh-opencode-go` sets the same
* precedent for a plugin-owned settings page.
*/
const SLOT = 'settings.section'
/** Nav/section key for this panel. */
const SECTION_ID = 'lan-access'
/** Simplified-Chinese dictionary; the key source. */
const zh = {
title: '局域网访问',
description: '通过独立反向代理把本机 dsh Web UI 发布到局域网,保留官方 launch token 登录。',
loading: '正在读取局域网地址…',
failed: '无法读取局域网信息:{message}',
forward: '转发到',
addresses: '局域网地址',
noAddresses: '未找到非内部 IPv4 地址,局域网内暂时无法访问。',
scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。',
copy: '复制',
copied: '已复制',
open: '打开',
warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。',
disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。',
notListening: '监听器未能绑定 {target}:{message}',
qr: '{address} 的二维码',
}
/** English dictionary; must cover every key above. */
const en = {
title: 'LAN Access',
description: 'Publishes this machine’s dsh Web UI on the LAN through a standalone reverse proxy while keeping the official launch-token login.',
loading: 'Reading LAN addresses…',
failed: 'Could not read LAN information: {message}',
forward: 'forwards to',
addresses: 'LAN addresses',
noAddresses: 'No non-internal IPv4 address was found, so nothing is reachable from the LAN yet.',
scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).',
copy: 'Copy',
copied: 'Copied',
open: 'Open',
warning: 'That link carries the launch token and grants full dsh control, shell included. Share it only on a trusted network.',
disabled: 'The LAN listener is configured off in this profile.',
notListening: 'The listener could not bind {target}: {message}',
qr: 'QR code for {address}',
}
/** Substitute `{name}` placeholders. */
function interpolate(template, params) {
if (params === undefined) return template
return template.replaceAll(/\{(\w+)\}/g, (match, key) => (key in params ? String(params[key]) : match))
}
/**
* Bind the locale service, falling back to the Chinese dictionary when the
* service is absent or its contract drifted.
* @param locale - the live `locale` service, when present.
* @returns a translate function taking a key and optional `{name}` parameters.
*/
function makeTranslator(locale) {
if (locale !== undefined && typeof locale.bind === 'function') {
try {
const bound = locale.bind(NS)
if (typeof bound === 'function') return (key, params) => interpolate(bound(key), params)
} catch {
/* fall through to the local dictionary */
}
}
return (key, params) => interpolate(zh[key] ?? key, params)
}
/**
* The active translator. `apply` replaces it once the locale service is bound
* and re-binds it on every locale change, so components read it at render time.
*/
let translate = (key, params) => interpolate(zh[key] ?? key, params)
/** Stylesheet, scoped under `dla-` and expressed only in theme tokens. */
const CSS = `
.dla-root { display: flex; flex-direction: column; gap: 16px; padding: 4px 2px 24px; color: var(--dsw-alias-label-primary); }
.dla-head { display: flex; flex-direction: column; gap: 6px; }
.dla-title { font-size: 15px; font-weight: 600; }
.dla-desc { font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); }
.dla-facts { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
.dla-fact b { color: var(--dsw-alias-label-primary); font-weight: 500; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dla-body { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; }
.dla-list { display: flex; flex-direction: column; gap: 8px; flex: 1 1 320px; min-width: 280px; }
.dla-section { font-size: 12px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; color: var(--dsw-alias-label-secondary); }
.dla-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 8px 10px; background: var(--dsw-alias-bg-layer-1); }
.dla-row[data-selected="true"] { border-color: var(--dsw-alias-brand-primary); }
.dla-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.dla-ip { font-size: 12px; color: var(--dsw-alias-label-secondary); }
.dla-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dla-btn { flex: none; font: inherit; font-size: 12px; line-height: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); cursor: pointer; }
.dla-btn:hover { border-color: var(--dsw-alias-border-l2); }
.dla-btn[data-primary="true"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
.dla-qr { display: flex; flex-direction: column; gap: 8px; align-items: center; }
.dla-qr-card { padding: 10px; border-radius: 10px; background: #ffffff; border: 1px solid var(--dsw-alias-border-l1); }
.dla-qr-cap { font-size: 12px; color: var(--dsw-alias-label-secondary); max-width: 200px; text-align: center; }
.dla-hint { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); }
.dla-warn { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-state-warn-primary); }
.dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); }
`
/**
* Render a QR matrix as a crisp SVG.
* @param symbol - `{ size, modules }` from the encoder.
* @param label - accessible label.
* @returns a React element.
*/
function qrElement(React, symbol, label, caption) {
const h = React.createElement
const { size, modules } = symbol
let path = ''
for (let row = 0; row < size; row += 1) {
for (let col = 0; col < size; col += 1) {
if (modules[row * size + col]) path += `M${col} ${row}h1v1h-1z`
}
}
return h(
'div',
{ className: 'dla-qr' },
h(
'div',
{ className: 'dla-qr-card' },
h(
'svg',
{
width: 168,
height: 168,
viewBox: `0 0 ${size} ${size}`,
role: 'img',
'aria-label': label,
shapeRendering: 'crispEdges',
},
h('rect', { width: size, height: size, fill: '#ffffff' }),
h('path', { d: path, fill: '#000000' }),
),
),
caption === undefined ? null : h('div', { className: 'dla-qr-cap' }, caption),
)
}
/**
* The Plugins-settings tab: read the host summary and present the LAN links.
* @param props - slot props; this seat supplies none.
* @returns the panel element.
*/
function Panel() {
const h = React.createElement
const [state, setState] = React.useState({ status: 'loading' })
const [selected, setSelected] = React.useState(0)
const [copied, setCopied] = React.useState('')
React.useEffect(() => {
let live = true
fetch(SUMMARY_URL, { headers: { accept: 'application/json' } })
.then(async (response) => {
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`)
return response.json()
})
.then((data) => {
if (live) setState({ status: 'ready', data })
})
.catch((error) => {
if (live) setState({ status: 'error', message: String(error?.message ?? error) })
})
return () => {
live = false
}
}, [])
const t = translate
const children = [h('style', { key: 'style' }, CSS)]
if (state.status === 'loading') {
children.push(h('div', { className: 'dla-hint', key: 'loading' }, t('loading')))
return h('div', { className: 'dla-root' }, children)
}
if (state.status === 'error') {
children.push(h('div', { className: 'dla-error', key: 'error' }, t('failed', { message: state.message })))
return h('div', { className: 'dla-root' }, children)
}
const { data } = state
const lan = Array.isArray(data.lan) ? data.lan : []
const active = lan[Math.min(selected, Math.max(lan.length - 1, 0))]
children.push(
h(
'div',
{ className: 'dla-head', key: 'head' },
h('div', { className: 'dla-title' }, t('title')),
h('div', { className: 'dla-desc' }, t('description')),
h(
'div',
{ className: 'dla-facts' },
h('span', null, `${data.listen.host}:${String(data.listen.port)}`, ' ', t('forward'), ' ', h('b', null, `${data.target.host}:${String(data.target.port)}`)),
),
),
)
if (data.enabled === false) {
children.push(h('div', { className: 'dla-warn', key: 'disabled' }, t('disabled')))
}
if (data.listening === false) {
// Never advertise an address that is not actually bound.
children.push(
h(
'div',
{ className: 'dla-error', key: 'not-listening' },
t('notListening', {
target: `${data.listen.host}:${String(data.listen.port)}`,
message: data.bindError ?? 'unknown error',
}),
),
)
}
if (lan.length === 0) {
children.push(h('div', { className: 'dla-hint', key: 'none' }, t('noAddresses')))
return h('div', { className: 'dla-root' }, children)
}
const copy = (url) => {
const done = () => {
setCopied(url)
globalThis.setTimeout(() => setCopied(''), 1500)
}
if (globalThis.navigator?.clipboard?.writeText !== undefined) {
globalThis.navigator.clipboard.writeText(url).then(done, done)
} else {
done()
}
}
const rows = lan.map((entry, index) =>
h(
'div',
{
className: 'dla-row',
key: entry.address,
'data-selected': String(index === selected),
onClick: () => setSelected(index),
},
h(
'div',
{ className: 'dla-row-main' },
h('div', { className: 'dla-ip' }, entry.address),
h('div', { className: 'dla-url', title: entry.tokenUrl }, entry.tokenUrl),
),
h(
'button',
{
type: 'button',
className: 'dla-btn',
onClick: (event) => {
event.stopPropagation()
copy(entry.tokenUrl)
},
},
copied === entry.tokenUrl ? t('copied') : t('copy'),
),
h(
'a',
{ className: 'dla-btn', href: entry.tokenUrl, target: '_blank', rel: 'noreferrer', onClick: (event) => event.stopPropagation() },
t('open'),
),
),
)
let symbol
try {
symbol = encodeQR(active.tokenUrl)
} catch {
symbol = undefined
}
children.push(
h(
'div',
{ className: 'dla-body', key: 'body' },
h(
'div',
{ className: 'dla-list' },
h('div', { className: 'dla-section' }, t('addresses')),
...rows,
h('div', { className: 'dla-hint' }, t('scanHint')),
),
symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address),
),
h('div', { className: 'dla-warn', key: 'warn' }, t('warning')),
)
return h('div', { className: 'dla-root' }, children)
}
/** Services this client half reads. */
export const inject = ['slots', 'locale']
/**
* Register the locale dictionaries and the Plugins-settings tab.
* @param ctx - the restricted client context.
*/
export function apply(ctx) {
const locale = ctx.get('locale')
if (locale !== undefined) {
if (typeof locale.register === 'function') {
try {
const dispose = locale.register(NS, { zh, en })
if (typeof dispose === 'function') ctx.effect(() => dispose, 'dsh-lan-access: locale dictionaries')
} catch (error) {
console.error('dsh-lan-access: locale registration failed', error)
}
}
translate = makeTranslator(locale)
if (typeof locale.subscribe === 'function') {
const unsubscribe = locale.subscribe(() => {
translate = makeTranslator(locale)
})
if (typeof unsubscribe === 'function') ctx.effect(() => unsubscribe, 'dsh-lan-access: locale subscription')
}
}
const slots = ctx.get('slots')
if (slots === undefined) {
console.error('dsh-lan-access: the slots service is unavailable; the LAN panel is not mounted')
return
}
const t = makeTranslator(locale)
slots.inject(SLOT, () =>
slots.register(
{
name: SLOT,
id: SECTION_ID,
order: 50,
label: () => t('title'),
},
Panel,
),
)
}