dsh-remote 0.1.0: master/slave remote control for DSH

- authenticated WebSocket link (dependency-free RFC6455 + stream mux), pairing
  codes, per-device keys (hashed), revoke/remove
- /r/<device>/ proxy of the full DSH UI (HTTP, Remote WebSocket, SSE) with
  per-device cookie minting; slave->slave relay through the master
- Devices sidebar panel (all devices' sessions, new session on device),
  Remote devices settings page, finished-session toasts
- plugin + config sync master->slave: registry bundles via pluginManager,
  link: plugins as tarballs, cordis.patch.yml into a managed bundle layer
  (!!js preserved), allow/deny, opt-in per-secret credential sync
- deploy helpers + e2e probes, unit tests
This commit is contained in:
lyuksovannyy committed 2026-10-11 08:23:19 +00:00
commit 8872e73e76
21 files changed
+2914

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
// HTTP + WebSocket tunnelling between a browser-facing webserver and a remote DSH over
// the link mux.
//
// viewer side (where the browser is): /r/<device>/<rest> ──stream "http"|"ws"──►
// owner side (where the session runs): forward to 127.0.0.1:<own port>/<rest>
//
// Owner side rewrites Host/Origin to its own loopback authority and attaches a freshly
// minted DSH auth cookie, so DSH's /api trust fence and cookie check pass. The viewer's
// own DSH cookie is stripped before forwarding (it would be meaningless remotely).
import http from 'node:http'
const HOP = new Set([
'connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade', 'te', 'trailer',
'proxy-authorization', 'proxy-authenticate', 'host', 'content-length',
])
export function stripDshCookies(cookieHeader) {
return String(cookieHeader || '')
.split(';')
.map((s) => s.trim())
.filter((s) => s && !/^dsh-auth-/.test(s) && !/^dsh_auth_session=/.test(s))
.join('; ')
}
function forwardHeaders(headers) {
const out = {}
for (const [k, v] of Object.entries(headers || {})) {
if (HOP.has(k.toLowerCase())) continue
out[k] = v
}
return out
}
// ---------------------------------------------------------------------------
// viewer side
// ---------------------------------------------------------------------------
/**
* Pipe one browser HTTP request through `openStream()` (returns a mux Stream).
* `rest` is the path+query to request on the remote side ("/api/remote/…").
* `basePath` is the local mount ("/r/ai-pc") used to rewrite redirects/cookies.
*/
export function viewHttp(req, res, openStream, { rest, basePath, extra = {} }) {
const headers = forwardHeaders(req.headers)
headers.cookie = stripDshCookies(req.headers.cookie)
if (!headers.cookie) delete headers.cookie
const stream = openStream('http', { method: req.method, path: rest, headers, ...extra })
let responded = false
stream.on('head', (meta) => {
responded = true
const outHeaders = { ...meta.headers }
// Keep relative redirects inside the mount (DSH answers "./" which is already relative).
if (typeof outHeaders.location === 'string' && outHeaders.location.startsWith('/')) {
outHeaders.location = basePath + outHeaders.location
}
// The remote never sets cookies the browser should keep under our authority.
delete outHeaders['set-cookie']
try { res.writeHead(meta.status || 502, outHeaders) } catch { /* headers already sent */ }
})
stream.on('data', (chunk) => {
if (!res.write(chunk)) {
stream.mux.sendControl({ t: 'pause', id: stream.id })
res.once('drain', () => stream.mux.sendControl({ t: 'resume', id: stream.id }))
}
})
stream.on('end', () => res.end())
stream.on('error', (err) => {
if (!responded) {
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
res.end(`dsh-remote: ${err.message}\n`)
} else {
res.destroy()
}
})
res.on('close', () => { if (!stream.done) stream.fail('client closed') })
req.on('data', (chunk) => {
if (!stream.write(chunk)) {
req.pause()
stream.once('drain', () => req.resume())
}
})
req.on('end', () => stream.end())
req.on('error', () => stream.fail('request error'))
}
/** Tunnel a browser WebSocket upgrade (raw socket) through a mux stream. */
export function viewUpgrade(req, socket, head, openStream, { rest, extra = {} }) {
const headers = forwardHeaders(req.headers)
// Keep the WebSocket handshake headers (forwardHeaders dropped connection/upgrade).
headers.cookie = stripDshCookies(req.headers.cookie)
if (!headers.cookie) delete headers.cookie
const stream = openStream('ws', { path: rest, headers, ...extra })
let open = false
stream.on('head', (meta) => {
// The owner side already completed the upstream handshake; replay its 101 to the
// browser with the browser's own Sec-WebSocket-Key accepted value.
if (meta.status !== 101) {
socket.end(`HTTP/1.1 ${meta.status || 502} Upgrade Failed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
return
}
open = true
const lines = ['HTTP/1.1 101 Switching Protocols']
for (const [k, v] of Object.entries(meta.headers || {})) {
for (const one of Array.isArray(v) ? v : [v]) lines.push(`${k}: ${one}`)
}
socket.write(lines.join('\r\n') + '\r\n\r\n')
if (head && head.length) stream.write(head)
})
stream.on('data', (chunk) => { if (!socket.write(chunk)) { /* tcp backpressure handled by kernel */ } })
stream.on('end', () => socket.end())
stream.on('error', () => {
if (!open) {
try { socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') } catch { socket.destroy() }
} else socket.destroy()
})
socket.on('data', (chunk) => stream.write(chunk))
socket.on('end', () => stream.end())
socket.on('close', () => { if (!stream.done) stream.fail('client closed') })
socket.on('error', () => { if (!stream.done) stream.fail('client error') })
}
// ---------------------------------------------------------------------------
// owner side
// ---------------------------------------------------------------------------
/**
* Serve one incoming "http" stream against the local DSH.
* @param target {host, port}; authority = `${host}:${port}`
* @param cookie async () => "dsh-auth-…=v1…" for that authority
*/
export async function ownHttp(stream, { target, cookie }) {
const { method = 'GET', path = '/', headers = {} } = stream.meta
const authority = `${target.host}:${target.port}`
const h = { ...headers, host: authority }
if (h.origin) h.origin = `http://${authority}`
if (h.referer) h.referer = `http://${authority}/`
delete h['sec-fetch-site']
const c = await cookie(authority)
h.cookie = [h.cookie, c].filter(Boolean).join('; ')
const req = http.request({ host: target.host, port: target.port, method, path, headers: h })
req.on('socket', (s) => s.on('error', () => {}))
req.on('response', (res) => {
stream.head({ status: res.statusCode, headers: forwardHeaders(res.headers) })
res.on('data', (chunk) => {
if (!stream.write(chunk)) {
res.pause()
stream.once('drain', () => res.resume())
}
})
res.on('end', () => stream.end())
res.on('error', () => stream.fail('upstream response error'))
stream.on('remote-pause', () => res.pause())
stream.on('remote-resume', () => res.resume())
})
req.on('error', (err) => stream.fail(`upstream: ${err.message}`))
stream.on('data', (chunk) => req.write(chunk))
stream.on('end', () => req.end())
stream.on('error', () => req.destroy())
stream.start()
}
/** Serve one incoming "ws" stream: perform the upgrade against local DSH, then pipe. */
export async function ownUpgrade(stream, { target, cookie }) {
const { path = '/', headers = {} } = stream.meta
const authority = `${target.host}:${target.port}`
const h = { ...headers, host: authority, connection: 'Upgrade', upgrade: headers.upgrade || 'websocket' }
if (h.origin) h.origin = `http://${authority}`
delete h['sec-fetch-site']
const c = await cookie(authority)
h.cookie = [h.cookie, c].filter(Boolean).join('; ')
const req = http.request({ host: target.host, port: target.port, method: 'GET', path, headers: h })
req.on('socket', (s) => s.on('error', () => {}))
req.on('upgrade', (res, sock, head) => {
const out = {}
for (const [k, v] of Object.entries(res.headers)) out[k] = v
stream.head({ status: 101, headers: out })
if (head && head.length) stream.write(head)
sock.on('data', (chunk) => stream.write(chunk))
sock.on('end', () => stream.end())
sock.on('error', () => stream.fail('upstream socket error'))
stream.on('data', (chunk) => sock.write(chunk))
stream.on('end', () => sock.end())
stream.on('error', () => sock.destroy())
stream.on('close', () => sock.destroy())
// Browser frames sent before the upstream handshake finished were queued; flush now.
stream.start()
})
req.on('response', (res) => {
stream.head({ status: res.statusCode || 502, headers: {} })
res.resume()
stream.end()
})
req.on('error', (err) => stream.fail(`upstream: ${err.message}`))
req.end()
}