/** * @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 ``, so it runs * before the deferred module bundle reads `__DSH_TRANSPORT__`. * @param html - the raw index document. * @param text - script body; must not contain `${text}` const at = html.indexOf('') 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, }