Files
dsh-lan-access/index.js
T
sutong dbfdfda60d refactor: 包名定为 @sutong/dsh-lan-access,元数据指向自建 Gitea
主办从 GitHub 迁到自建 Gitea(git.nixus.top/sutong/dsh-lan-access),
包名随之从 @wishesl 改回 @sutong,与仓库归属一致。
repository/bugs/homepage 指向 git.nixus.top;LICENSE 版权主体同步。
因包名已做成单一来源,本次只需改 package.json 并重建。
2026-09-27 04:18:46 +08:00

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,
}