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

+58
View File
@@ -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
View File
@@ -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
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()
}
+150
View File
@@ -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
View File
@@ -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
View File
@@ -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()
})
}