// 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) })