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:
commit
8872e73e76
21 files changed
+2914
No files matched your search
@@ -0,0 +1,58 @@
|
||||
// Mint DSH's own browser-session cookie for a given authority.
|
||||
//
|
||||
// Format (dsh-client-connection BrowserAuth): cookie name `dsh-auth-` +
|
||||
// base64url(sha256(authority)), value `v1.<b64u(json)>.<b64u(hmac-sha256(secret, body))>`
|
||||
// with json {version:1, authority, issuedAt, expiresAt}. The 32-byte secret is the
|
||||
// credential record `client-connection/browser-session` (kind grant, payload.secret).
|
||||
//
|
||||
// The target DSH accepts proxied requests only when Host matches the cookie's authority,
|
||||
// so the proxy rewrites Host to the loopback authority it forwards to and attaches a
|
||||
// cookie minted for exactly that authority. The browser never sees this cookie.
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const DAY = 86400000
|
||||
const b64u = (b) => Buffer.from(b).toString('base64url')
|
||||
|
||||
export function cookieName(authority) {
|
||||
return 'dsh-auth-' + b64u(crypto.createHash('sha256').update(authority).digest())
|
||||
}
|
||||
|
||||
export function mintCookie(authority, secretB64u, maxAgeDays = 1) {
|
||||
const secret = Buffer.from(String(secretB64u || ''), 'base64url')
|
||||
if (secret.length !== 32) return null
|
||||
const issuedAt = Date.now()
|
||||
const body = b64u(JSON.stringify({ version: 1, authority, issuedAt, expiresAt: issuedAt + maxAgeDays * DAY }))
|
||||
const sig = b64u(crypto.createHmac('sha256', secret).update(body).digest())
|
||||
return `${cookieName(authority)}=v1.${body}.${sig}`
|
||||
}
|
||||
|
||||
/** Read the browser-session signing secret through ctx.credentials (no file access). */
|
||||
export async function readBrowserSecret(ctx) {
|
||||
let creds
|
||||
try { creds = typeof ctx.get === 'function' ? ctx.get('credentials') : ctx.credentials } catch { creds = undefined }
|
||||
if (!creds || typeof creds.readRecord !== 'function') return ''
|
||||
try {
|
||||
const rec = await creds.readRecord('client-connection/browser-session')
|
||||
return rec?.payload?.secret || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Small cache: re-mint a cookie per authority once an hour. */
|
||||
export class CookieJar {
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx
|
||||
this.cache = new Map()
|
||||
this.secret = ''
|
||||
}
|
||||
|
||||
async get(authority) {
|
||||
const hit = this.cache.get(authority)
|
||||
if (hit && hit.at > Date.now() - 3600000) return hit.cookie
|
||||
if (!this.secret) this.secret = await readBrowserSecret(this.ctx)
|
||||
const cookie = mintCookie(authority, this.secret, 1)
|
||||
if (cookie) this.cache.set(authority, { cookie, at: Date.now() })
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
// Stream multiplexer over one WsConn.
|
||||
//
|
||||
// Frames:
|
||||
// text = JSON control message { t: <type>, ...fields }
|
||||
// binary = data frame [u32 stream id][payload]
|
||||
//
|
||||
// Streams are opened with a control message `{t:'open', id, kind, meta}` and carry raw
|
||||
// bytes in both directions. They implement backpressure-lite: a stream pauses its local
|
||||
// source while the underlying socket buffer is above HIGH_WATER.
|
||||
//
|
||||
// Request/response control calls: `{t:'call', cid, method, params}` →
|
||||
// `{t:'reply', cid, ok, result | error}`. Unsolicited `{t:'event', name, data}`.
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
const HIGH_WATER = 8 * 1024 * 1024
|
||||
|
||||
export class Mux extends EventEmitter {
|
||||
/**
|
||||
* @param ws WsConn
|
||||
* @param {{ odd: boolean, handlers?: Record<string, Function>, onStream?: Function }} opts
|
||||
* odd: this side allocates odd stream ids (master = even, slave = odd) so ids never collide.
|
||||
*/
|
||||
constructor(ws, { odd, handlers = {}, onStream } = {}) {
|
||||
super()
|
||||
this.ws = ws
|
||||
this.nextId = odd ? 1 : 2
|
||||
this.nextCid = 1
|
||||
this.streams = new Map()
|
||||
this.calls = new Map()
|
||||
this.handlers = handlers
|
||||
this.onStream = onStream
|
||||
this.closed = false
|
||||
this.lastSeen = Date.now()
|
||||
ws.on('message', (data, binary) => {
|
||||
this.lastSeen = Date.now()
|
||||
if (binary) this._data(data)
|
||||
else this._control(data)
|
||||
})
|
||||
ws.on('pong', () => { this.lastSeen = Date.now() })
|
||||
ws.on('close', (code, reason) => this._teardown(code, reason))
|
||||
this.pinger = setInterval(() => {
|
||||
if (Date.now() - this.lastSeen > 45000) return ws.destroy(1001, 'ping timeout')
|
||||
ws.ping()
|
||||
}, 15000)
|
||||
this.pinger.unref?.()
|
||||
}
|
||||
|
||||
_teardown(code, reason) {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
clearInterval(this.pinger)
|
||||
for (const s of this.streams.values()) s._remoteEnd(new Error('link closed'))
|
||||
this.streams.clear()
|
||||
for (const { reject, timer } of this.calls.values()) { clearTimeout(timer); reject(new Error('link closed')) }
|
||||
this.calls.clear()
|
||||
this.emit('close', code, reason)
|
||||
}
|
||||
|
||||
close(code, reason) { this.ws.close(code, reason) }
|
||||
|
||||
sendControl(msg) {
|
||||
if (this.closed) return false
|
||||
return this.ws.send(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
_control(text) {
|
||||
let msg
|
||||
try { msg = JSON.parse(text) } catch { return }
|
||||
switch (msg.t) {
|
||||
case 'open': {
|
||||
const s = new Stream(this, msg.id, msg.kind, msg.meta || {})
|
||||
this.streams.set(msg.id, s)
|
||||
Promise.resolve()
|
||||
.then(() => (this.onStream ? this.onStream(s) : s.fail('no stream handler')))
|
||||
.catch((e) => s.fail(String(e?.message ?? e)))
|
||||
return
|
||||
}
|
||||
case 'head': return this.streams.get(msg.id)?._head(msg.meta || {})
|
||||
case 'end': return this.streams.get(msg.id)?._remoteEnd()
|
||||
case 'fail': return this.streams.get(msg.id)?._remoteEnd(new Error(msg.error || 'stream failed'))
|
||||
case 'pause': return this.streams.get(msg.id)?.emit('remote-pause')
|
||||
case 'resume': return this.streams.get(msg.id)?.emit('remote-resume')
|
||||
case 'call': return this._call(msg)
|
||||
case 'reply': {
|
||||
const c = this.calls.get(msg.cid)
|
||||
if (!c) return
|
||||
this.calls.delete(msg.cid)
|
||||
clearTimeout(c.timer)
|
||||
if (msg.ok) c.resolve(msg.result)
|
||||
else c.reject(Object.assign(new Error(msg.error || 'remote error'), { status: msg.status }))
|
||||
return
|
||||
}
|
||||
case 'event': return this.emit('event', msg.name, msg.data)
|
||||
default: return this.emit('control', msg)
|
||||
}
|
||||
}
|
||||
|
||||
async _call(msg) {
|
||||
const h = this.handlers[msg.method]
|
||||
try {
|
||||
if (!h) throw Object.assign(new Error(`unknown method ${msg.method}`), { status: 404 })
|
||||
const result = await h(msg.params ?? {}, this)
|
||||
this.sendControl({ t: 'reply', cid: msg.cid, ok: true, result: result ?? null })
|
||||
} catch (e) {
|
||||
this.sendControl({ t: 'reply', cid: msg.cid, ok: false, error: String(e?.message ?? e), status: e?.status })
|
||||
}
|
||||
}
|
||||
|
||||
call(method, params = {}, timeoutMs = 30000) {
|
||||
if (this.closed) return Promise.reject(new Error('link closed'))
|
||||
const cid = this.nextCid++
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.calls.delete(cid)
|
||||
reject(new Error(`call ${method} timed out`))
|
||||
}, timeoutMs)
|
||||
timer.unref?.()
|
||||
this.calls.set(cid, { resolve, reject, timer })
|
||||
this.sendControl({ t: 'call', cid, method, params })
|
||||
})
|
||||
}
|
||||
|
||||
event(name, data) { this.sendControl({ t: 'event', name, data }) }
|
||||
|
||||
_data(buf) {
|
||||
if (buf.length < 4) return
|
||||
const id = buf.readUInt32BE(0)
|
||||
this.streams.get(id)?._remoteData(buf.subarray(4))
|
||||
}
|
||||
|
||||
/** Open an outgoing stream. */
|
||||
open(kind, meta = {}) {
|
||||
const id = this.nextId
|
||||
this.nextId += 2
|
||||
const s = new Stream(this, id, kind, meta)
|
||||
this.streams.set(id, s)
|
||||
this.sendControl({ t: 'open', id, kind, meta })
|
||||
// The opener attaches listeners synchronously; deliver from the next tick on.
|
||||
queueMicrotask(() => s.start())
|
||||
return s
|
||||
}
|
||||
|
||||
_writeData(id, chunk) {
|
||||
const header = Buffer.alloc(4)
|
||||
header.writeUInt32BE(id, 0)
|
||||
// Split large chunks so one stream cannot monopolise the link.
|
||||
const MAX = 256 * 1024
|
||||
let ok = true
|
||||
for (let off = 0; off < chunk.length; off += MAX) {
|
||||
ok = this.ws.send(Buffer.concat([header, chunk.subarray(off, off + MAX)])) && ok
|
||||
}
|
||||
return ok && this.ws.bufferedAmount < HIGH_WATER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One bidirectional byte stream. Events: 'head'(meta), 'data'(buf), 'end', 'error'(err),
|
||||
* 'drain'. Writers should stop on write()===false and wait for 'drain'.
|
||||
*/
|
||||
export class Stream extends EventEmitter {
|
||||
constructor(mux, id, kind, meta) {
|
||||
super()
|
||||
this.mux = mux
|
||||
this.id = id
|
||||
this.kind = kind
|
||||
this.meta = meta
|
||||
this.localEnded = false
|
||||
this.remoteEnded = false
|
||||
this.done = false
|
||||
// Incoming events are queued until the consumer attaches its listeners and calls
|
||||
// start(): handlers often await something (cookie, upstream connect) first, and
|
||||
// data/end arriving in the meantime must not be lost.
|
||||
this.paused = true
|
||||
this.queue = []
|
||||
}
|
||||
|
||||
/** Begin delivering queued + future incoming events. Idempotent. */
|
||||
start() {
|
||||
if (!this.paused) return this
|
||||
this.paused = false
|
||||
const q = this.queue
|
||||
this.queue = []
|
||||
for (const [ev, arg] of q) this._deliver(ev, arg)
|
||||
return this
|
||||
}
|
||||
|
||||
_deliver(ev, arg) {
|
||||
if (this.paused) { this.queue.push([ev, arg]); return }
|
||||
if (ev === 'head') this.emit('head', arg)
|
||||
else if (ev === 'data') this.emit('data', arg)
|
||||
else if (ev === 'end') { this.emit('end'); this._maybeDone() }
|
||||
else if (ev === 'error') { if (this.listenerCount('error')) this.emit('error', arg); this._finish() }
|
||||
}
|
||||
|
||||
head(meta) { this.mux.sendControl({ t: 'head', id: this.id, meta }) }
|
||||
|
||||
write(chunk) {
|
||||
if (this.localEnded || this.mux.closed) return false
|
||||
const ok = this.mux._writeData(this.id, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
if (!ok && !this._drainWait) {
|
||||
this._drainWait = true
|
||||
const sock = this.mux.ws.socket
|
||||
sock.once('drain', () => { this._drainWait = false; this.emit('drain') })
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
end() {
|
||||
if (this.localEnded) return
|
||||
this.localEnded = true
|
||||
this.mux.sendControl({ t: 'end', id: this.id })
|
||||
this._maybeDone()
|
||||
}
|
||||
|
||||
fail(error) {
|
||||
if (this.done) return
|
||||
this.mux.sendControl({ t: 'fail', id: this.id, error: String(error) })
|
||||
this.localEnded = true
|
||||
this.remoteEnded = true
|
||||
this._finish()
|
||||
}
|
||||
|
||||
_head(meta) { this._deliver('head', meta) }
|
||||
_remoteData(buf) { if (!this.remoteEnded) this._deliver('data', buf) }
|
||||
|
||||
_remoteEnd(err) {
|
||||
if (this.remoteEnded) return
|
||||
this.remoteEnded = true
|
||||
if (err) {
|
||||
this.localEnded = true
|
||||
this._deliver('error', err)
|
||||
return
|
||||
}
|
||||
this._deliver('end')
|
||||
}
|
||||
|
||||
_maybeDone() { if (this.localEnded && this.remoteEnded) this._finish() }
|
||||
|
||||
_finish() {
|
||||
if (this.done) return
|
||||
this.done = true
|
||||
this.mux.streams.delete(this.id)
|
||||
this.emit('close')
|
||||
}
|
||||
}
|
||||
+199
@@ -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()
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
// Persistent state for dsh-remote: device identity, paired devices (master) or master
|
||||
// link credentials (slave), sync bookkeeping. One JSON file, written atomically, 0600.
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
let PROFILE_DIR = ''
|
||||
|
||||
/** Set once from ctx.profileContext.dir at plugin start (authoritative). */
|
||||
export function setProfileDir(dir) { if (dir) PROFILE_DIR = dir }
|
||||
|
||||
export function profileDir() {
|
||||
if (PROFILE_DIR) return PROFILE_DIR
|
||||
if (process.env.DSH_PROFILE_DIR) return process.env.DSH_PROFILE_DIR
|
||||
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
||||
return path.join(home, 'profiles', process.env.DSH_PROFILE || 'web')
|
||||
}
|
||||
|
||||
export function stateDir() {
|
||||
return path.join(profileDir(), 'dsh-remote')
|
||||
}
|
||||
|
||||
const b64u = (buf) => Buffer.from(buf).toString('base64url')
|
||||
export const randomId = (bytes = 16) => b64u(crypto.randomBytes(bytes))
|
||||
export const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex')
|
||||
|
||||
export function safeEqual(a, b) {
|
||||
const x = Buffer.from(String(a))
|
||||
const y = Buffer.from(String(b))
|
||||
return x.length === y.length && crypto.timingSafeEqual(x, y)
|
||||
}
|
||||
|
||||
/** Device id: lowercase slug, safe in URL paths (/r/<id>/). */
|
||||
export function slug(s) {
|
||||
const v = String(s || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
||||
return v || 'device'
|
||||
}
|
||||
|
||||
export class Store {
|
||||
constructor(file = path.join(stateDir(), 'state.json')) {
|
||||
this.file = file
|
||||
this.data = this._load()
|
||||
}
|
||||
|
||||
_load() {
|
||||
try {
|
||||
const v = JSON.parse(fs.readFileSync(this.file, 'utf8'))
|
||||
if (v && typeof v === 'object') return v
|
||||
} catch { /* first run */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
save() {
|
||||
fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 })
|
||||
const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`
|
||||
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2), { mode: 0o600 })
|
||||
fs.renameSync(tmp, this.file)
|
||||
}
|
||||
|
||||
/** This installation's stable identity. */
|
||||
identity(defaultName) {
|
||||
if (!this.data.self) {
|
||||
const name = defaultName || os.hostname()
|
||||
this.data.self = { id: slug(name), name, createdAt: Date.now() }
|
||||
this.save()
|
||||
}
|
||||
return this.data.self
|
||||
}
|
||||
|
||||
// ---- master side: paired devices + pairing codes -------------------------
|
||||
devices() { return (this.data.devices ||= {}) }
|
||||
|
||||
pairingCodes() { return (this.data.pairing ||= {}) }
|
||||
|
||||
createPairingCode({ ttlMs = 15 * 60 * 1000, label = '' } = {}) {
|
||||
// 4 groups of 4 from an unambiguous alphabet: easy to type, ~80 bits.
|
||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
const raw = crypto.randomBytes(16)
|
||||
const code = [...raw].map((b) => alphabet[b % alphabet.length]).join('').replace(/(.{4})(?=.)/g, '$1-')
|
||||
this.pairingCodes()[sha256(code)] = { expiresAt: Date.now() + ttlMs, label, createdAt: Date.now() }
|
||||
this._prunePairing()
|
||||
this.save()
|
||||
return { code, expiresAt: Date.now() + ttlMs }
|
||||
}
|
||||
|
||||
consumePairingCode(code) {
|
||||
this._prunePairing()
|
||||
const key = sha256(String(code || '').trim().toUpperCase())
|
||||
const rec = this.pairingCodes()[key]
|
||||
if (!rec) return null
|
||||
delete this.pairingCodes()[key]
|
||||
this.save()
|
||||
return rec
|
||||
}
|
||||
|
||||
_prunePairing() {
|
||||
const now = Date.now()
|
||||
for (const [k, v] of Object.entries(this.pairingCodes())) if (!v || v.expiresAt < now) delete this.pairingCodes()[k]
|
||||
}
|
||||
|
||||
/** Register (or re-register) a device; returns its secret key (shown once). */
|
||||
addDevice({ id, name, platform }) {
|
||||
const devices = this.devices()
|
||||
let devId = slug(id || name)
|
||||
// Keep ids unique; a re-pair of the same machine replaces its key.
|
||||
const secret = randomId(32)
|
||||
devices[devId] = {
|
||||
id: devId,
|
||||
name: name || devId,
|
||||
platform: platform || '',
|
||||
keyHash: sha256(secret),
|
||||
createdAt: devices[devId]?.createdAt || Date.now(),
|
||||
revoked: false,
|
||||
sync: devices[devId]?.sync || { enabled: true, allow: [], deny: [], secrets: [] },
|
||||
}
|
||||
this.save()
|
||||
return { id: devId, secret }
|
||||
}
|
||||
|
||||
verifyDevice(id, secret) {
|
||||
const d = this.devices()[id]
|
||||
if (!d || d.revoked) return null
|
||||
return safeEqual(d.keyHash, sha256(String(secret || ''))) ? d : null
|
||||
}
|
||||
|
||||
revokeDevice(id) {
|
||||
const d = this.devices()[id]
|
||||
if (!d) return false
|
||||
d.revoked = true
|
||||
d.revokedAt = Date.now()
|
||||
this.save()
|
||||
return true
|
||||
}
|
||||
|
||||
removeDevice(id) {
|
||||
if (!this.devices()[id]) return false
|
||||
delete this.devices()[id]
|
||||
this.save()
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- slave side: link to master ------------------------------------------
|
||||
get master() { return this.data.master || null }
|
||||
|
||||
setMaster(m) {
|
||||
this.data.master = m
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
// Plugin + config sync (master → slaves).
|
||||
//
|
||||
// Manifest (built on the master, served over the link):
|
||||
// {
|
||||
// version, builtAt, master,
|
||||
// bundles: [{ name, spec, kind: 'registry'|'local', version, hash }],
|
||||
// patch: [ ...cordis patch entries from the master's cordis.patch.yml selected for sync ],
|
||||
// secrets: [ names of credential refs the master offers for opt-in sync ]
|
||||
// }
|
||||
//
|
||||
// Slave application:
|
||||
// * registry bundles -> ctx.pluginManager.installBundle(`${name}@${version}`) unless already
|
||||
// installed at that version, or excluded by the slave's deny list.
|
||||
// * local (link:) bundles -> fetched as a tarball over the link, unpacked to
|
||||
// <profile>/dsh-remote/plugins/<name>/, installed as `file:` spec.
|
||||
// * patch entries -> written to <profile>/dsh-remote/managed-bundle/cordis.patch.yml,
|
||||
// a bundle the slave enables BEFORE its own cordis.patch.yml, so
|
||||
// anything the slave sets locally keeps precedence.
|
||||
// * secrets -> only the names the slave explicitly opted into are requested;
|
||||
// values travel over the authenticated link and are written with
|
||||
// ctx.credentials.set(ref, value).
|
||||
import crypto from 'node:crypto'
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { profileDir, stateDir } from './store.mjs'
|
||||
|
||||
export const MANAGED_BUNDLE = '@dsh-remote/managed'
|
||||
|
||||
/** Patch entry ids never synced: they are machine-local by nature. */
|
||||
const LOCAL_ONLY_IDS = new Set(['dsh-remote', 'dsh-lanmode', 'host-webserver', 'webserver', 'client-connection'])
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, args, { maxBuffer: 64 * 1024 * 1024, ...opts }, (err, stdout, stderr) => {
|
||||
resolve({ code: err ? (err.code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DSH's own entry-list YAML dialect (JSON schema + `!!js` expressions), loaded from the
|
||||
* running installation so synced patches keep `!!js` nodes. Over the wire a `!!js` node is
|
||||
* `{__jsExpr: "<code>"}`; dumping with this schema writes it back as `!!js`.
|
||||
*/
|
||||
let yamlCache = null
|
||||
async function loadYaml() {
|
||||
if (yamlCache) return yamlCache
|
||||
// Resolve DSH's own copies (not whatever the plugin dir could see): the dsh install's
|
||||
// node_modules, found by following the real path of the running `dsh` bin.
|
||||
const nm = dshNodeModules()
|
||||
if (!nm) throw new Error('cannot locate the DSH installation (node_modules)')
|
||||
const incPkg = JSON.parse(fs.readFileSync(path.join(nm, '@deepseek-ai', 'cordis-plugin-include', 'package.json'), 'utf8'))
|
||||
const incMain = typeof incPkg.exports === 'string' ? incPkg.exports
|
||||
: incPkg.exports?.['.']?.default || incPkg.exports?.['.']?.import || incPkg.exports?.['.'] || incPkg.main || 'lib/index.js'
|
||||
const inc = await import(path.join(nm, '@deepseek-ai', 'cordis-plugin-include', String(incMain)))
|
||||
const yaml = await import(path.join(nm, 'js-yaml', 'dist', 'js-yaml.mjs'))
|
||||
const y = yaml.default || yaml
|
||||
yamlCache = {
|
||||
load: (s) => y.load(s, { schema: inc.entryListSchema }),
|
||||
dump: (v, o = {}) => y.dump(v, { ...o, schema: inc.entryListSchema }),
|
||||
}
|
||||
return yamlCache
|
||||
}
|
||||
|
||||
export { loadYaml }
|
||||
|
||||
function readJson(file) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
|
||||
}
|
||||
|
||||
function dirHash(dir) {
|
||||
const h = crypto.createHash('sha256')
|
||||
const walk = (d) => {
|
||||
for (const ent of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (ent.name === 'node_modules' || ent.name === '.git' || ent.name.startsWith('.')) continue
|
||||
const p = path.join(d, ent.name)
|
||||
if (ent.isDirectory()) walk(p)
|
||||
else if (ent.isFile()) { h.update(path.relative(dir, p)); h.update(fs.readFileSync(p)) }
|
||||
}
|
||||
}
|
||||
walk(dir)
|
||||
return h.digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// master side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function buildManifest({ self, include = {}, offerSecrets = [] }) {
|
||||
const prof = profileDir()
|
||||
const pkg = readJson(path.join(prof, 'package.json')) || {}
|
||||
const deps = pkg.dependencies || {}
|
||||
const enabled = new Set(pkg.dsh?.profile?.bundles || [])
|
||||
const excludeBundles = new Set(include.excludeBundles || [])
|
||||
const bundles = []
|
||||
for (const [name, spec] of Object.entries(deps)) {
|
||||
if (!enabled.has(name) || excludeBundles.has(name) || name === '@local/dsh-remote' || name === MANAGED_BUNDLE) continue
|
||||
const s = String(spec)
|
||||
if (s.startsWith('link:') || s.startsWith('file:')) {
|
||||
const dir = path.resolve(prof, s.replace(/^(link|file):/, ''))
|
||||
const m = readJson(path.join(dir, 'package.json'))
|
||||
if (!m) continue
|
||||
bundles.push({ name, kind: 'local', version: m.version || '0.0.0', hash: dirHash(dir), dir })
|
||||
} else {
|
||||
const installed = readJson(path.join(prof, 'node_modules', name, 'package.json'))
|
||||
bundles.push({ name, kind: 'registry', spec: s, version: installed?.version || s.replace(/^[\^~]/, '') })
|
||||
}
|
||||
}
|
||||
|
||||
// Patch entries: everything in the master's cordis.patch.yml except machine-local ids.
|
||||
let patch = []
|
||||
let patchError = null
|
||||
try {
|
||||
const yaml = await loadYaml()
|
||||
const doc = yaml.load(fs.readFileSync(path.join(prof, 'cordis.patch.yml'), 'utf8')) || []
|
||||
const excludeIds = new Set([...(include.excludePatchIds || []), ...LOCAL_ONLY_IDS])
|
||||
patch = (Array.isArray(doc) ? doc : []).filter((e) => e && typeof e === 'object' && !excludeIds.has(e.id))
|
||||
} catch (e) {
|
||||
patch = []
|
||||
patchError = String(e?.message ?? e)
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
builtAt: Date.now(),
|
||||
master: { id: self.id, name: self.name },
|
||||
bundles: bundles.map(({ dir, ...b }) => b),
|
||||
patch,
|
||||
patchError,
|
||||
secrets: offerSecrets,
|
||||
_dirs: Object.fromEntries(bundles.filter((b) => b.dir).map((b) => [b.name, b.dir])),
|
||||
}
|
||||
}
|
||||
|
||||
/** Pack a local plugin dir into a tgz Buffer (excludes .git, node_modules). */
|
||||
export async function packLocal(dir) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-remote-pack-'))
|
||||
const out = path.join(tmp, 'pkg.tgz')
|
||||
const r = await run('tar', ['--exclude=.git', '--exclude=node_modules', '--exclude=*.bak', '-czf', out, '-C', dir, '.'])
|
||||
if (r.code !== 0) throw new Error(`tar failed: ${r.stderr.trim()}`)
|
||||
const buf = fs.readFileSync(out)
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
return buf
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// slave side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function syncDir() { return path.join(stateDir(), 'sync') }
|
||||
|
||||
export function managedBundleDir() { return path.join(stateDir(), 'managed-bundle') }
|
||||
|
||||
/** Write the managed bundle (package.json + patch) that carries synced config. */
|
||||
export async function writeManagedBundle(patch, insertedLocalPlugins = []) {
|
||||
const dir = managedBundleDir()
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
const yaml = await loadYaml()
|
||||
const header = '# Managed by dsh-remote: config synced from the master. Do not edit;\n'
|
||||
+ '# your own profile cordis.patch.yml is applied after this layer and wins.\n'
|
||||
const body = patch.length ? yaml.dump(patch, { lineWidth: 200, noRefs: true }) : '[]\n'
|
||||
fs.writeFileSync(path.join(dir, 'cordis.patch.yml'), header + body)
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: MANAGED_BUNDLE,
|
||||
version: `0.0.${Date.now()}`,
|
||||
private: true,
|
||||
description: 'dsh-remote managed layer: plugins/config synced from the master',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
dsh_remote: { localPlugins: insertedLocalPlugins },
|
||||
}, null, 2))
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/refresh <profile>/node_modules/<name> -> dir. DSH resolves bundle packages
|
||||
* from the profile's node_modules; a `link:` dependency alone is only materialised by a
|
||||
* pnpm install, so make the link ourselves (idempotent, no network).
|
||||
*/
|
||||
export function linkIntoNodeModules(name, dir) {
|
||||
const link = path.join(profileDir(), 'node_modules', ...name.split('/'))
|
||||
fs.mkdirSync(path.dirname(link), { recursive: true })
|
||||
try {
|
||||
if (fs.readlinkSync(link) === dir) return false
|
||||
} catch { /* missing or not a link */ }
|
||||
fs.rmSync(link, { recursive: true, force: true })
|
||||
fs.symlinkSync(dir, link)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Ensure the managed bundle is linked and listed last, i.e. right before the user's own patch. */
|
||||
export function ensureManagedBundleOrder() {
|
||||
const prof = profileDir()
|
||||
const file = path.join(prof, 'package.json')
|
||||
const pkg = readJson(file)
|
||||
if (!pkg) return false
|
||||
pkg.dependencies ||= {}
|
||||
const spec = `link:${managedBundleDir()}`
|
||||
let changed = linkIntoNodeModules(MANAGED_BUNDLE, managedBundleDir())
|
||||
let pkgChanged = false
|
||||
if (pkg.dependencies[MANAGED_BUNDLE] !== spec) { pkg.dependencies[MANAGED_BUNDLE] = spec; pkgChanged = true }
|
||||
const bundles = (((pkg.dsh ||= {}).profile ||= {}).bundles ||= [])
|
||||
const idx = bundles.indexOf(MANAGED_BUNDLE)
|
||||
if (idx !== bundles.length - 1) {
|
||||
if (idx !== -1) bundles.splice(idx, 1)
|
||||
bundles.push(MANAGED_BUNDLE)
|
||||
pkgChanged = true
|
||||
}
|
||||
if (pkgChanged) fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n')
|
||||
return changed || pkgChanged
|
||||
}
|
||||
|
||||
export function unpackLocal(name, version, tgz) {
|
||||
const safe = name.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
||||
const dir = path.join(syncDir(), 'plugins', safe)
|
||||
const tmp = `${dir}.new`
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
fs.mkdirSync(tmp, { recursive: true })
|
||||
const tgzFile = path.join(syncDir(), `${safe}.tgz`)
|
||||
fs.writeFileSync(tgzFile, tgz)
|
||||
return run('tar', ['-xzf', tgzFile, '-C', tmp]).then((r) => {
|
||||
fs.rmSync(tgzFile, { force: true })
|
||||
if (r.code !== 0) throw new Error(`untar ${name}: ${r.stderr.trim()}`)
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
fs.renameSync(tmp, dir)
|
||||
linkDshDeps(dir)
|
||||
return dir
|
||||
})
|
||||
}
|
||||
|
||||
/** DSH installation's own node_modules (where @deepseek-ai/* packages live). */
|
||||
export function dshNodeModules() {
|
||||
const bin = process.argv[1] || ''
|
||||
let real = bin
|
||||
try { real = fs.realpathSync(bin) } catch { /* keep argv path */ }
|
||||
const cand = [
|
||||
path.resolve(path.dirname(real), '..', 'node_modules'),
|
||||
path.resolve(path.dirname(bin), '..', 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules'),
|
||||
]
|
||||
for (const c of cand) if (fs.existsSync(path.join(c, '@deepseek-ai'))) return c
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Synced local plugins import `@deepseek-ai/*` modules that the master resolved through
|
||||
* a node_modules symlink into its DSH install. Recreate those links against this
|
||||
* machine's DSH so the plugin loads unchanged.
|
||||
*/
|
||||
export function linkDshDeps(pluginDir) {
|
||||
const nm = dshNodeModules()
|
||||
if (!nm) return []
|
||||
const wanted = new Set()
|
||||
const scan = (d, depth = 0) => {
|
||||
if (depth > 3) return
|
||||
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue
|
||||
const p = path.join(d, ent.name)
|
||||
if (ent.isDirectory()) scan(p, depth + 1)
|
||||
else if (/\.(m?js|cjs)$/.test(ent.name)) {
|
||||
const src = fs.readFileSync(p, 'utf8')
|
||||
for (const m of src.matchAll(/(?:from\s+|import\s*\(\s*|require\(\s*)['"](@deepseek-ai\/[a-z0-9-]+)/g)) wanted.add(m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
scan(pluginDir)
|
||||
const linked = []
|
||||
for (const mod of wanted) {
|
||||
const target = path.join(nm, mod)
|
||||
if (!fs.existsSync(target)) continue
|
||||
const link = path.join(pluginDir, 'node_modules', mod)
|
||||
fs.mkdirSync(path.dirname(link), { recursive: true })
|
||||
try { fs.rmSync(link, { recursive: true, force: true }) } catch { /* ignore */ }
|
||||
fs.symlinkSync(target, link)
|
||||
linked.push(mod)
|
||||
}
|
||||
return linked
|
||||
}
|
||||
|
||||
/** Decide whether a bundle passes the slave's allow/deny lists. */
|
||||
export function allowed(name, { allow = [], deny = [] } = {}) {
|
||||
const match = (pat) => pat === name || (pat.endsWith('*') && name.startsWith(pat.slice(0, -1)))
|
||||
if (deny.some(match)) return false
|
||||
if (allow.length && !allow.some(match)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Link-spec install of a local plugin dir into the profile (package.json + bundles list). */
|
||||
export function linkLocalBundle(name, dir) {
|
||||
const prof = profileDir()
|
||||
const file = path.join(prof, 'package.json')
|
||||
const pkg = readJson(file)
|
||||
if (!pkg) throw new Error('profile package.json unreadable')
|
||||
pkg.dependencies ||= {}
|
||||
const spec = `link:${dir}`
|
||||
let changed = false
|
||||
if (pkg.dependencies[name] !== spec) { pkg.dependencies[name] = spec; changed = true }
|
||||
const bundles = (((pkg.dsh ||= {}).profile ||= {}).bundles ||= [])
|
||||
if (!bundles.includes(name)) {
|
||||
// Synced plugins go before the managed layer so the master's config for them applies.
|
||||
const at = bundles.indexOf(MANAGED_BUNDLE)
|
||||
if (at === -1) bundles.push(name)
|
||||
else bundles.splice(at, 0, name)
|
||||
changed = true
|
||||
}
|
||||
if (changed) fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n')
|
||||
return linkIntoNodeModules(name, dir) || changed
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
// Minimal dependency-free WebSocket (RFC 6455) for the dsh-remote link.
|
||||
//
|
||||
// Why not `ws`: DSH plugins loaded through `link:` resolve imports from their own
|
||||
// folder, and slaves install the plugin from a tarball without node_modules. The link
|
||||
// only needs binary + text frames, ping/pong and close, so a small implementation keeps
|
||||
// the plugin installable anywhere with zero dependencies.
|
||||
import crypto from 'node:crypto'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||
const MAX_FRAME = 64 * 1024 * 1024
|
||||
|
||||
export const acceptKey = (key) => crypto.createHash('sha1').update(key + GUID).digest('base64')
|
||||
|
||||
/** One WebSocket endpoint over a raw socket (server or client role). */
|
||||
export class WsConn extends EventEmitter {
|
||||
constructor(socket, { client = false, head } = {}) {
|
||||
super()
|
||||
this.socket = socket
|
||||
this.client = client
|
||||
this.open = true
|
||||
this.buf = head && head.length ? Buffer.from(head) : Buffer.alloc(0)
|
||||
this.frag = null
|
||||
socket.setNoDelay(true)
|
||||
socket.on('data', (d) => this._onData(d))
|
||||
socket.on('close', () => this._closed(1006, 'socket closed'))
|
||||
// Never throw from an unhandled 'error' event: a reset peer must not crash the host.
|
||||
socket.on('error', (e) => { if (this.listenerCount('error')) this.emit('error', e); this._closed(1006, String(e?.message ?? e)) })
|
||||
if (this.buf.length) queueMicrotask(() => this._onData(Buffer.alloc(0)))
|
||||
}
|
||||
|
||||
get bufferedAmount() { return this.socket.writableLength }
|
||||
|
||||
_closed(code, reason) {
|
||||
if (!this.open) return
|
||||
this.open = false
|
||||
this.emit('close', code, reason)
|
||||
}
|
||||
|
||||
_onData(chunk) {
|
||||
if (chunk.length) this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk
|
||||
for (;;) {
|
||||
const b = this.buf
|
||||
if (b.length < 2) return
|
||||
const fin = (b[0] & 0x80) !== 0
|
||||
const op = b[0] & 0x0f
|
||||
const masked = (b[1] & 0x80) !== 0
|
||||
let len = b[1] & 0x7f
|
||||
let off = 2
|
||||
if (len === 126) {
|
||||
if (b.length < 4) return
|
||||
len = b.readUInt16BE(2); off = 4
|
||||
} else if (len === 127) {
|
||||
if (b.length < 10) return
|
||||
const big = b.readBigUInt64BE(2)
|
||||
if (big > BigInt(MAX_FRAME)) return this.destroy(1009, 'frame too large')
|
||||
len = Number(big); off = 10
|
||||
}
|
||||
if (len > MAX_FRAME) return this.destroy(1009, 'frame too large')
|
||||
const maskOff = off
|
||||
if (masked) off += 4
|
||||
if (b.length < off + len) return
|
||||
let payload = b.subarray(off, off + len)
|
||||
if (masked) {
|
||||
const mask = b.subarray(maskOff, maskOff + 4)
|
||||
payload = Buffer.from(payload)
|
||||
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]
|
||||
}
|
||||
this.buf = b.subarray(off + len)
|
||||
this._frame(fin, op, payload)
|
||||
if (!this.open) return
|
||||
}
|
||||
}
|
||||
|
||||
_frame(fin, op, payload) {
|
||||
if (op === 0x8) {
|
||||
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1005
|
||||
try { this._send(0x8, payload.subarray(0, 2)) } catch {}
|
||||
this.socket.end()
|
||||
return this._closed(code, payload.subarray(2).toString('utf8'))
|
||||
}
|
||||
if (op === 0x9) return this._send(0xa, payload)
|
||||
if (op === 0xa) return this.emit('pong', payload)
|
||||
if (op === 0x0) {
|
||||
if (!this.frag) return this.destroy(1002, 'unexpected continuation')
|
||||
this.frag.parts.push(payload)
|
||||
if (!fin) return
|
||||
const data = Buffer.concat(this.frag.parts)
|
||||
const binary = this.frag.op === 0x2
|
||||
this.frag = null
|
||||
return this.emit('message', binary ? data : data.toString('utf8'), binary)
|
||||
}
|
||||
if (op === 0x1 || op === 0x2) {
|
||||
if (!fin) { this.frag = { op, parts: [payload] }; return }
|
||||
return this.emit('message', op === 0x2 ? Buffer.from(payload) : payload.toString('utf8'), op === 0x2)
|
||||
}
|
||||
this.destroy(1002, 'bad opcode')
|
||||
}
|
||||
|
||||
_send(op, data) {
|
||||
if (!this.socket.writable) return false
|
||||
const payload = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
const len = payload.length
|
||||
let header
|
||||
if (len < 126) { header = Buffer.alloc(2); header[1] = len }
|
||||
else if (len < 65536) { header = Buffer.alloc(4); header[1] = 126; header.writeUInt16BE(len, 2) }
|
||||
else { header = Buffer.alloc(10); header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2) }
|
||||
header[0] = 0x80 | op
|
||||
if (this.client) {
|
||||
header[1] |= 0x80
|
||||
const mask = crypto.randomBytes(4)
|
||||
const body = Buffer.from(payload)
|
||||
for (let i = 0; i < body.length; i++) body[i] ^= mask[i & 3]
|
||||
return this.socket.write(Buffer.concat([header, mask, body]))
|
||||
}
|
||||
return this.socket.write(Buffer.concat([header, payload]))
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (!this.open) return false
|
||||
return typeof data === 'string' ? this._send(0x1, Buffer.from(data, 'utf8')) : this._send(0x2, data)
|
||||
}
|
||||
|
||||
ping(data = Buffer.alloc(0)) { if (this.open) this._send(0x9, data) }
|
||||
|
||||
close(code = 1000, reason = '') {
|
||||
if (!this.open) return
|
||||
const p = Buffer.alloc(2 + Buffer.byteLength(reason))
|
||||
p.writeUInt16BE(code, 0); p.write(reason, 2)
|
||||
try { this._send(0x8, p) } catch {}
|
||||
this.socket.end()
|
||||
setTimeout(() => this.socket.destroy(), 2000).unref()
|
||||
this._closed(code, reason)
|
||||
}
|
||||
|
||||
destroy(code = 1006, reason = '') {
|
||||
this._closed(code, reason)
|
||||
this.socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/** Complete a server-side upgrade on (req, socket, head). Returns a WsConn or null. */
|
||||
export function acceptUpgrade(req, socket, head, extraHeaders = {}) {
|
||||
const key = req.headers['sec-websocket-key']
|
||||
if (!key || String(req.headers.upgrade).toLowerCase() !== 'websocket') {
|
||||
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
|
||||
return null
|
||||
}
|
||||
const lines = [
|
||||
'HTTP/1.1 101 Switching Protocols',
|
||||
'Upgrade: websocket',
|
||||
'Connection: Upgrade',
|
||||
`Sec-WebSocket-Accept: ${acceptKey(key)}`,
|
||||
...Object.entries(extraHeaders).map(([k, v]) => `${k}: ${v}`),
|
||||
]
|
||||
socket.write(lines.join('\r\n') + '\r\n\r\n')
|
||||
return new WsConn(socket, { head })
|
||||
}
|
||||
|
||||
export function rejectUpgrade(socket, status, message = '') {
|
||||
const text = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', 502: 'Bad Gateway' }[status] || 'Error'
|
||||
try {
|
||||
socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`)
|
||||
} catch { socket.destroy() }
|
||||
}
|
||||
|
||||
/** Client connect: resolves a WsConn, rejects with {status, body} on refusal. */
|
||||
export function connect(url, { headers = {}, timeoutMs = 15000, rejectUnauthorized = true } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url)
|
||||
const secure = u.protocol === 'wss:' || u.protocol === 'https:'
|
||||
const key = crypto.randomBytes(16).toString('base64')
|
||||
const req = (secure ? https : http).request({
|
||||
host: u.hostname,
|
||||
port: u.port || (secure ? 443 : 80),
|
||||
path: u.pathname + u.search,
|
||||
method: 'GET',
|
||||
servername: u.hostname,
|
||||
rejectUnauthorized,
|
||||
headers: {
|
||||
...headers,
|
||||
Host: u.host,
|
||||
Connection: 'Upgrade',
|
||||
Upgrade: 'websocket',
|
||||
'Sec-WebSocket-Version': '13',
|
||||
'Sec-WebSocket-Key': key,
|
||||
},
|
||||
timeout: timeoutMs,
|
||||
})
|
||||
req.on('timeout', () => { req.destroy(new Error(`connect timeout after ${timeoutMs} ms`)) })
|
||||
req.on('error', reject)
|
||||
req.on('response', (res) => {
|
||||
let body = ''
|
||||
res.setEncoding('utf8')
|
||||
res.on('data', (d) => { if (body.length < 4096) body += d })
|
||||
res.on('end', () => {
|
||||
const err = new Error(`upgrade refused: HTTP ${res.statusCode} ${body.trim()}`)
|
||||
err.status = res.statusCode
|
||||
err.body = body
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
req.on('upgrade', (res, socket, head) => {
|
||||
if (res.headers['sec-websocket-accept'] !== acceptKey(key)) {
|
||||
socket.destroy()
|
||||
return reject(new Error('bad Sec-WebSocket-Accept'))
|
||||
}
|
||||
socket.setTimeout(0)
|
||||
resolve(new WsConn(socket, { client: true, head }))
|
||||
})
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user