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,140 @@
|
||||
// Offline tests for the link stack: WebSocket framing, mux calls/streams, HTTP + WS
|
||||
// tunnelling through the proxy helpers, cookie minting, sync allow/deny.
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import crypto from 'node:crypto'
|
||||
import http from 'node:http'
|
||||
import { acceptUpgrade, connect, rejectUpgrade } from '../lib/ws.mjs'
|
||||
import { Mux } from '../lib/mux.mjs'
|
||||
import { ownHttp, ownUpgrade, viewHttp, viewUpgrade, stripDshCookies } from '../lib/proxy.mjs'
|
||||
import { cookieName, mintCookie } from '../lib/auth-cookie.mjs'
|
||||
import { allowed } from '../lib/sync.mjs'
|
||||
|
||||
const listen = (srv) => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port)))
|
||||
|
||||
async function linkPair({ handlersA = {}, handlersB = {}, onStreamA, onStreamB } = {}) {
|
||||
const srv = http.createServer()
|
||||
let serverMux
|
||||
const ready = new Promise((resolve) => {
|
||||
srv.on('upgrade', (req, socket, head) => {
|
||||
if (req.headers['x-key'] !== 'k') return rejectUpgrade(socket, 401, 'nope')
|
||||
const ws = acceptUpgrade(req, socket, head)
|
||||
serverMux = new Mux(ws, { odd: false, handlers: handlersA, onStream: onStreamA })
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const port = await listen(srv)
|
||||
const ws = await connect(`ws://127.0.0.1:${port}/link`, { headers: { 'x-key': 'k' } })
|
||||
const clientMux = new Mux(ws, { odd: true, handlers: handlersB, onStream: onStreamB })
|
||||
await ready
|
||||
return { srv, port, serverMux, clientMux, close: () => { clientMux.close(); srv.close() } }
|
||||
}
|
||||
|
||||
test('ws: refused upgrade surfaces HTTP status', async () => {
|
||||
const srv = http.createServer()
|
||||
srv.on('upgrade', (req, socket) => rejectUpgrade(socket, 401, 'bad key'))
|
||||
const port = await listen(srv)
|
||||
await assert.rejects(connect(`ws://127.0.0.1:${port}/`), (e) => e.status === 401 && /bad key/.test(e.message))
|
||||
srv.close()
|
||||
})
|
||||
|
||||
test('mux: calls both ways, large binary stream, errors', async () => {
|
||||
const big = crypto.randomBytes(3 * 1024 * 1024 + 17)
|
||||
const p = await linkPair({
|
||||
handlersA: { echo: async (x) => ({ back: x.v }), boom: async () => { throw Object.assign(new Error('kaboom'), { status: 418 }) } },
|
||||
handlersB: { whoami: async () => 'slave' },
|
||||
onStreamA: (s) => {
|
||||
const parts = []
|
||||
s.on('data', (d) => parts.push(d))
|
||||
s.on('end', () => { const buf = Buffer.concat(parts); s.head({ size: buf.length }); s.write(buf); s.end() })
|
||||
s.start()
|
||||
},
|
||||
})
|
||||
assert.deepEqual(await p.clientMux.call('echo', { v: 42 }), { back: 42 })
|
||||
assert.equal(await p.serverMux.call('whoami'), 'slave')
|
||||
await assert.rejects(p.clientMux.call('boom'), (e) => e.status === 418 && e.message === 'kaboom')
|
||||
await assert.rejects(p.clientMux.call('missing'), /unknown method/)
|
||||
|
||||
const s = p.clientMux.open('echo', {})
|
||||
const got = await new Promise((resolve, reject) => {
|
||||
const parts = []
|
||||
s.on('data', (d) => parts.push(d))
|
||||
s.on('end', () => resolve(Buffer.concat(parts)))
|
||||
s.on('error', reject)
|
||||
s.write(big)
|
||||
s.end()
|
||||
})
|
||||
assert.equal(got.length, big.length)
|
||||
assert.ok(got.equals(big))
|
||||
p.close()
|
||||
})
|
||||
|
||||
test('proxy: HTTP + WebSocket through the link with cookie + host rewrite', async () => {
|
||||
// "Remote DSH": checks host + cookie like dsh-client-connection would.
|
||||
const secret = crypto.randomBytes(32).toString('base64url')
|
||||
const upstream = http.createServer((req, res) => {
|
||||
const authority = req.headers.host
|
||||
const ok = (req.headers.cookie || '').includes(cookieName(authority) + '=v1.')
|
||||
if (!ok) { res.writeHead(401); return res.end('auth') }
|
||||
const body = []
|
||||
req.on('data', (d) => body.push(d))
|
||||
req.on('end', () => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain', 'x-path': req.url, 'set-cookie': 'leak=1' })
|
||||
res.end(`${req.method} ${req.url} ${Buffer.concat(body).toString()} origin=${req.headers.origin || '-'} viewerCookie=${/dsh-auth-viewer/.test(req.headers.cookie) ? 'leaked' : 'stripped'}`)
|
||||
})
|
||||
})
|
||||
upstream.on('upgrade', (req, socket, head) => {
|
||||
const ok = (req.headers.cookie || '').includes(cookieName(req.headers.host) + '=v1.')
|
||||
if (!ok) return rejectUpgrade(socket, 401, 'auth')
|
||||
const ws = acceptUpgrade(req, socket, head)
|
||||
ws.on('error', () => {})
|
||||
ws.on('message', (m) => ws.send(`echo:${m}`))
|
||||
})
|
||||
const upPort = await listen(upstream)
|
||||
const target = { host: '127.0.0.1', port: upPort }
|
||||
const cookie = async (authority) => mintCookie(authority, secret)
|
||||
|
||||
const p = await linkPair({
|
||||
onStreamA: (s) => (s.kind === 'http' ? ownHttp(s, { target, cookie }) : ownUpgrade(s, { target, cookie })),
|
||||
})
|
||||
|
||||
// Viewer webserver that mounts the remote at /r/x/.
|
||||
const viewer = http.createServer((req, res) => {
|
||||
viewHttp(req, res, (kind, meta) => p.clientMux.open(kind, meta), { rest: req.url.replace(/^\/r\/x/, ''), basePath: '/r/x' })
|
||||
})
|
||||
viewer.on('upgrade', (req, socket, head) => {
|
||||
viewUpgrade(req, socket, head, (kind, meta) => p.clientMux.open(kind, meta), { rest: req.url.replace(/^\/r\/x/, '') })
|
||||
})
|
||||
const vPort = await listen(viewer)
|
||||
|
||||
const r = await fetch(`http://127.0.0.1:${vPort}/r/x/api/thing?q=1`, {
|
||||
method: 'POST',
|
||||
body: 'hello',
|
||||
headers: { cookie: 'dsh-auth-viewer=abc; other=1', origin: `http://127.0.0.1:${vPort}` },
|
||||
})
|
||||
const text = await r.text()
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.headers.get('set-cookie'), null, 'remote set-cookie must not reach the browser')
|
||||
assert.match(text, /^POST \/api\/thing\?q=1 hello/)
|
||||
assert.match(text, new RegExp(`origin=http://127.0.0.1:${upPort}`))
|
||||
assert.match(text, /viewerCookie=stripped/)
|
||||
|
||||
const ws = await connect(`ws://127.0.0.1:${vPort}/r/x/api/remote.mux`)
|
||||
const reply = await new Promise((resolve) => { ws.on('message', (m) => resolve(m)); ws.send('ping-1') })
|
||||
assert.equal(reply, 'echo:ping-1')
|
||||
ws.close()
|
||||
|
||||
p.close(); viewer.close(); upstream.close()
|
||||
})
|
||||
|
||||
test('cookie + allow/deny helpers', () => {
|
||||
const secret = crypto.randomBytes(32).toString('base64url')
|
||||
const c = mintCookie('127.0.0.1:3081', secret)
|
||||
assert.match(c, /^dsh-auth-[A-Za-z0-9_-]+=v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/)
|
||||
assert.equal(mintCookie('a', 'short'), null)
|
||||
assert.equal(stripDshCookies('dsh-auth-x=1; a=2; dsh_auth_session=3'), 'a=2')
|
||||
assert.equal(allowed('@local/foo', {}), true)
|
||||
assert.equal(allowed('@local/foo', { deny: ['@local/*'] }), false)
|
||||
assert.equal(allowed('@local/foo', { allow: ['@other/*'] }), false)
|
||||
assert.equal(allowed('@local/foo', { allow: ['@local/foo'], deny: [] }), true)
|
||||
})
|
||||
Reference in new issue
Block a user