ced05e06a3
用独立反向代理把 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,且与直连回环逐项行为一致
432 lines
15 KiB
JavaScript
432 lines
15 KiB
JavaScript
/**
|
|
* @sutong/dsh-lan-access — host half.
|
|
*
|
|
* Publishes the loopback dsh Web UI on the LAN through a standalone reverse
|
|
* proxy. `dsh web --host 0.0.0.0` is refused by the shipped CLI on purpose
|
|
* (it would expose remote code execution to the network), so this plugin keeps
|
|
* the official server on loopback and adds a second listener that forwards to
|
|
* it. The official browser-trust fence and the launch-token login stay in
|
|
* force; nothing about the composed application is overridden.
|
|
*
|
|
* @module @sutong/dsh-lan-access
|
|
*/
|
|
|
|
import { createServer, request as httpRequest } from 'node:http'
|
|
import { connect as netConnect, isIPv4 } from 'node:net'
|
|
import { networkInterfaces } from 'node:os'
|
|
import z from '@deepseek-ai/schemastery'
|
|
|
|
/** Stable Cordis plugin name. */
|
|
export const name = 'dsh-lan-access'
|
|
|
|
/** Services required before the LAN listener resolves its upstream. */
|
|
export const inject = ['webServer']
|
|
|
|
/**
|
|
* Configuration for the LAN listener. Every value also lives in the profile's
|
|
* `cordis.patch.yml` row config, so it survives plugin upgrades.
|
|
*/
|
|
export const Config = z.object({
|
|
/** Whether the LAN listener runs at all. @default true */
|
|
enabled: z.boolean().default(true),
|
|
/** Listener address; `0.0.0.0` publishes on every interface. @default '0.0.0.0' */
|
|
host: z.string().default('0.0.0.0'),
|
|
/**
|
|
* Listener port. The default avoids 3081, which the community
|
|
* `dsh-lan-proxy` plugin also claims by default, so the two can coexist.
|
|
* @default 3082
|
|
*/
|
|
port: z.number().default(3082),
|
|
/**
|
|
* Upstream host. Only a loopback authority is accepted, so the plugin can
|
|
* never become an open forward proxy (SSRF guard). @default '127.0.0.1'
|
|
*/
|
|
targetHost: z.string().default('127.0.0.1'),
|
|
/**
|
|
* Upstream port, or 0 to follow the live `webServer` port. @default 0
|
|
*/
|
|
targetPort: z.number().default(0),
|
|
/** Print the LAN URLs (with launch token) at startup. @default true */
|
|
printBanner: z.boolean().default(true),
|
|
/**
|
|
* Declare the page-side topology fact `ownsHost` for non-loopback pages.
|
|
*
|
|
* The official client derives `isLoopback` from `location.hostname`, and the
|
|
* durable settings surface is loopback-only by that rule: on a LAN page the
|
|
* settings mirror stays in `memory` mode, so the Models/provider pages fail
|
|
* with "settings are unavailable in this browser" and the config-file action
|
|
* disappears. The Host/Origin fence and the launch-token login are unaffected
|
|
* by this fact, so enabling it restores the settings UI rather than granting
|
|
* new server-side authority — but that UI reads and writes server settings,
|
|
* credentials included. Keep it off unless every device on this network is
|
|
* trusted. @default false
|
|
*/
|
|
ownsHostCompat: z.boolean().default(false),
|
|
})
|
|
|
|
/** Exact route the Web UI panel reads its LAN facts from. */
|
|
export const SUMMARY_PATH = '/api/dsh-lan-access/summary'
|
|
|
|
/** Connection-owned hop-by-hop headers this proxy never forwards. */
|
|
const HOP_BY_HOP = new Set([
|
|
'connection',
|
|
'keep-alive',
|
|
'proxy-authenticate',
|
|
'proxy-authorization',
|
|
'te',
|
|
'trailer',
|
|
'transfer-encoding',
|
|
'upgrade',
|
|
])
|
|
|
|
/** Names that always mean the local machine. */
|
|
const LOOPBACK_NAMES = new Set(['localhost', '::1', '0:0:0:0:0:0:0:1'])
|
|
|
|
/**
|
|
* Whether a host names the local loopback authority.
|
|
* @param host - a bare hostname or IP literal, without a port.
|
|
* @returns true for localhost, IPv6 loopback, or any 127/8 address.
|
|
*/
|
|
function isLoopbackHost(host) {
|
|
if (typeof host !== 'string' || host === '') return false
|
|
const bare = host.toLowerCase().replaceAll('[', '').replaceAll(']', '')
|
|
if (LOOPBACK_NAMES.has(bare)) return true
|
|
return isIPv4(bare) && bare.startsWith('127.')
|
|
}
|
|
|
|
/**
|
|
* Strip the port from an HTTP `Host` / authority value.
|
|
* @param authority - `host`, `host:port`, or `[v6]:port`.
|
|
* @returns the bare host, or the input when it cannot be split.
|
|
*/
|
|
function hostnameOf(authority) {
|
|
if (typeof authority !== 'string') return ''
|
|
const value = authority.trim()
|
|
if (value.startsWith('[')) {
|
|
const end = value.indexOf(']')
|
|
return end === -1 ? value : value.slice(1, end)
|
|
}
|
|
const colon = value.indexOf(':')
|
|
return colon === -1 ? value : value.slice(0, colon)
|
|
}
|
|
|
|
/**
|
|
* Every non-internal IPv4 address this machine currently holds. These are the
|
|
* addresses a peer on the same network can reach.
|
|
* @returns addresses in interface order, link-local autoconf excluded.
|
|
*/
|
|
function lanIPv4Addresses() {
|
|
const found = []
|
|
for (const entries of Object.values(networkInterfaces())) {
|
|
for (const entry of entries ?? []) {
|
|
if (entry.family !== 'IPv4' || entry.internal) continue
|
|
if (entry.address.startsWith('169.254.')) continue
|
|
found.push(entry.address)
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
/**
|
|
* Rewrite one outbound request's headers for the loopback upstream.
|
|
*
|
|
* `Host` must name a loopback authority because the official `/api` fence
|
|
* accepts only loopback or declared authorities, and the browser cookie is
|
|
* authority-bound. `Origin` must then match the rewritten `Host`, or the fence
|
|
* rejects an otherwise same-origin request. The browser keeps its own cookie
|
|
* jar keyed by the address it actually requested, so cookies need no rewriting
|
|
* on the way back.
|
|
* @param headers - incoming request headers.
|
|
* @param authority - the upstream `host:port` to present.
|
|
* @param preserve - lowercase names to keep even though they are hop-by-hop (the WebSocket handshake).
|
|
* @returns a new header record safe to send upstream.
|
|
*/
|
|
function upstreamHeaders(headers, authority, preserve = EMPTY_PRESERVE) {
|
|
const out = {}
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (value === undefined) continue
|
|
const lower = key.toLowerCase()
|
|
if (HOP_BY_HOP.has(lower) && !preserve.has(lower)) continue
|
|
out[key] = value
|
|
}
|
|
out.host = authority
|
|
if (typeof out.origin === 'string') out.origin = `http://${authority}`
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Hop-by-hop names the WebSocket handshake itself must keep: stripping them
|
|
* would downgrade the forwarded request to a plain GET, so the upstream would
|
|
* answer 200 with a body instead of switching protocols.
|
|
*/
|
|
const UPGRADE_PRESERVE = new Set(['connection', 'upgrade'])
|
|
|
|
/** Shared empty set, so the common path allocates nothing. */
|
|
const EMPTY_PRESERVE = new Set()
|
|
|
|
/**
|
|
* Forward one upgraded (WebSocket) socket to the upstream server.
|
|
*
|
|
* The handshake is re-emitted byte-for-byte with only `Host`/`Origin`
|
|
* rewritten, so `permessage-deflate` and every other extension negotiate
|
|
* end-to-end between the browser and the upstream — this proxy never
|
|
* decompresses a frame.
|
|
* @param req - the incoming upgrade request.
|
|
* @param clientSocket - the browser-side socket, owned on return.
|
|
* @param head - bytes already read past the handshake.
|
|
* @param options - `authority` plus the upstream `host`/`port` to dial.
|
|
*/
|
|
function forwardUpgrade(req, clientSocket, head, options) {
|
|
const { authority, host, port } = options
|
|
const headers = upstreamHeaders(req.headers, authority, UPGRADE_PRESERVE)
|
|
let raw = `${req.method ?? 'GET'} ${req.url ?? '/'} HTTP/1.1\r\n`
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
for (const item of Array.isArray(value) ? value : [value]) raw += `${key}: ${item}\r\n`
|
|
}
|
|
raw += '\r\n'
|
|
|
|
const upstream = netConnect(port, host, () => {
|
|
upstream.write(raw)
|
|
if (head !== undefined && head.length > 0) upstream.write(head)
|
|
upstream.pipe(clientSocket)
|
|
clientSocket.pipe(upstream)
|
|
})
|
|
const bail = () => {
|
|
upstream.destroy()
|
|
clientSocket.destroy()
|
|
}
|
|
upstream.on('error', bail)
|
|
clientSocket.on('error', bail)
|
|
}
|
|
|
|
/**
|
|
* Head script that declares `ownsHost` for a non-loopback page only.
|
|
*
|
|
* It stays inert on a loopback page, where the official client already treats
|
|
* the browser as local, and mutates nothing but the one topology flag.
|
|
*/
|
|
export const OWNS_HOST_COMPAT_SCRIPT = [
|
|
'(function () {',
|
|
' try {',
|
|
' var host = globalThis.location && globalThis.location.hostname;',
|
|
' if (!host) return;',
|
|
' if (host === "localhost" || host === "[::1]" || host.indexOf("127.") === 0) return;',
|
|
' var transport = globalThis.__DSH_TRANSPORT__ || {};',
|
|
' transport.ownsHost = true;',
|
|
' globalThis.__DSH_TRANSPORT__ = transport;',
|
|
' } catch (error) { /* leave the page topology untouched */ }',
|
|
'})();',
|
|
].join('\n')
|
|
|
|
/**
|
|
* Insert an inline classic script immediately before `</head>`, so it runs
|
|
* before the deferred module bundle reads `__DSH_TRANSPORT__`.
|
|
* @param html - the raw index document.
|
|
* @param text - script body; must not contain `</script`.
|
|
* @returns the document with the script inserted.
|
|
*/
|
|
function withHeadScript(html, text) {
|
|
const tag = `<script>${text}</script>`
|
|
const at = html.indexOf('</head>')
|
|
return at === -1 ? `${tag}${html}` : `${html.slice(0, at)}${tag}${html.slice(at)}`
|
|
}
|
|
|
|
/**
|
|
* Mount the LAN listener, the panel's data route, and the startup banner.
|
|
* @param ctx - plugin context carrying the web server and connection services.
|
|
* @param config - validated {@link Config}.
|
|
*/
|
|
export function apply(ctx, config) {
|
|
const settings = { ...config }
|
|
|
|
if (settings.enabled === false) return
|
|
if (!isLoopbackHost(settings.targetHost)) {
|
|
console.warn(
|
|
`dsh-lan-access: refusing targetHost ${JSON.stringify(settings.targetHost)}; only a loopback upstream is allowed (this plugin is not an open proxy)`,
|
|
)
|
|
return
|
|
}
|
|
if (!Number.isInteger(settings.port) || settings.port <= 0 || settings.port > 65535) {
|
|
console.warn(`dsh-lan-access: refusing invalid port ${String(settings.port)}`)
|
|
return
|
|
}
|
|
|
|
/** Resolve the live upstream port; `targetPort: 0` follows the web server. */
|
|
const upstreamPort = () => {
|
|
if (Number.isInteger(settings.targetPort) && settings.targetPort > 0) return settings.targetPort
|
|
const live = ctx.get('webServer')?.port
|
|
return typeof live === 'number' && live > 0 ? live : 3080
|
|
}
|
|
const upstreamAuthority = () => `${settings.targetHost}:${String(upstreamPort())}`
|
|
|
|
/** LAN facts for the banner and the Web UI panel. */
|
|
const summary = () => {
|
|
const connection = ctx.get('connection')
|
|
const port = settings.port
|
|
return {
|
|
enabled: true,
|
|
listening: listened,
|
|
...(bindError === undefined ? {} : { bindError }),
|
|
listen: { host: settings.host, port },
|
|
target: { host: settings.targetHost, port: upstreamPort() },
|
|
lan: lanIPv4Addresses().map((address) => {
|
|
const url = `http://${address}:${String(port)}/`
|
|
return {
|
|
address,
|
|
url,
|
|
tokenUrl: connection === undefined ? url : connection.authenticatedUrl(url),
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
let listened = false
|
|
let announced = false
|
|
/** Set when the listener could not bind; surfaced to the panel. */
|
|
let bindError
|
|
const announce = (connection) => {
|
|
if (announced || !listened || settings.printBanner === false) return
|
|
announced = true
|
|
console.log(
|
|
`dsh-lan-access: 0.0.0.0:${String(settings.port)} → http://${upstreamAuthority()} (LAN listener)`,
|
|
)
|
|
const addresses = lanIPv4Addresses()
|
|
if (addresses.length === 0) {
|
|
console.log('dsh-lan-access: no non-internal IPv4 address found; no LAN URL to print')
|
|
}
|
|
for (const address of addresses) {
|
|
const url = `http://${address}:${String(settings.port)}/`
|
|
const tokenUrl = connection === undefined ? url : connection.authenticatedUrl(url)
|
|
console.log(`dsh-lan-access: ${tokenUrl}`)
|
|
}
|
|
console.log(
|
|
'dsh-lan-access: that link carries the launch token and grants full dsh control (shell included) — keep it on a trusted network',
|
|
)
|
|
}
|
|
|
|
// The browser URL the panel is served from may be the loopback one, so the
|
|
// data route is reachable both directly and through the listener.
|
|
ctx.inject(['webServer', 'connection'], (routeCtx) => {
|
|
routeCtx.effect(
|
|
() =>
|
|
routeCtx.webServer.register({
|
|
kind: 'exact',
|
|
path: SUMMARY_PATH,
|
|
handler: (req, res) => {
|
|
const admission = routeCtx.connection.admit(req)
|
|
if ('rejection' in admission) {
|
|
res.writeHead(admission.rejection, { 'content-type': 'text/plain; charset=utf-8' })
|
|
res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
return
|
|
}
|
|
const body = JSON.stringify(summary())
|
|
res.writeHead(200, {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'cache-control': 'no-store',
|
|
'content-length': Buffer.byteLength(body),
|
|
})
|
|
res.end(body)
|
|
},
|
|
}),
|
|
'dsh-lan-access: LAN summary route',
|
|
)
|
|
})
|
|
|
|
if (settings.ownsHostCompat) {
|
|
ctx.inject(['webServer'], (compatCtx) => {
|
|
compatCtx.effect(
|
|
() => compatCtx.webServer.tapIndex((html) => withHeadScript(html, OWNS_HOST_COMPAT_SCRIPT)),
|
|
'dsh-lan-access: ownsHost compat index tap',
|
|
)
|
|
})
|
|
}
|
|
|
|
const server = createServer((req, res) => {
|
|
const authority = upstreamAuthority()
|
|
const upstream = httpRequest(
|
|
{
|
|
host: settings.targetHost,
|
|
port: upstreamPort(),
|
|
method: req.method,
|
|
path: req.url,
|
|
headers: upstreamHeaders(req.headers, authority),
|
|
},
|
|
(up) => {
|
|
const out = {}
|
|
for (const [key, value] of Object.entries(up.headers)) {
|
|
if (value === undefined) continue
|
|
if (HOP_BY_HOP.has(key.toLowerCase())) continue
|
|
out[key] = value
|
|
}
|
|
res.writeHead(up.statusCode ?? 502, out)
|
|
up.pipe(res)
|
|
},
|
|
)
|
|
upstream.on('error', (error) => {
|
|
if (!res.headersSent) {
|
|
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
}
|
|
res.end(`dsh-lan-access: upstream error: ${error.message}`)
|
|
})
|
|
req.pipe(upstream)
|
|
})
|
|
|
|
server.on('upgrade', (req, socket, head) => {
|
|
const authority = upstreamAuthority()
|
|
const host = hostnameOf(req.headers.host)
|
|
const isLocal =
|
|
host === '' ||
|
|
host === settings.host ||
|
|
isLoopbackHost(host) ||
|
|
lanIPv4Addresses().includes(host)
|
|
if (!isLocal) {
|
|
// DNS-rebinding guard: only address-literal/localhost authorities may
|
|
// reach the upstream, so a hostile name cannot point here.
|
|
socket.destroy()
|
|
return
|
|
}
|
|
forwardUpgrade(req, socket, head, {
|
|
authority,
|
|
host: settings.targetHost,
|
|
port: upstreamPort(),
|
|
})
|
|
})
|
|
|
|
server.on('error', (error) => {
|
|
bindError = error.message
|
|
const hint =
|
|
error.code === 'EADDRINUSE'
|
|
? ' — another process or plugin already holds that port (the community dsh-lan-proxy plugin defaults to 3081)'
|
|
: ''
|
|
console.warn(
|
|
`dsh-lan-access: LAN listener unavailable on ${settings.host}:${String(settings.port)} because ${error.message}${hint}`,
|
|
)
|
|
})
|
|
|
|
ctx.effect(
|
|
() => () => {
|
|
server.closeAllConnections?.()
|
|
server.close()
|
|
},
|
|
'dsh-lan-access: LAN listener',
|
|
)
|
|
|
|
server.listen(settings.port, settings.host, () => {
|
|
listened = true
|
|
announce(ctx.get('connection'))
|
|
})
|
|
|
|
ctx.inject(['connection'], (connectionCtx) => {
|
|
announce(connectionCtx.connection)
|
|
})
|
|
}
|
|
|
|
/** Test hooks; production never mutates them. */
|
|
export const internals = {
|
|
isLoopbackHost,
|
|
hostnameOf,
|
|
lanIPv4Addresses,
|
|
upstreamHeaders,
|
|
withHeadScript,
|
|
}
|