dsh-remote 0.5.0: node settings, session-cookie edge auth, sign-in popup

- Settings → Remote devices → Node: type master|slave (stored in state, beats
  config role; Restart DSH button under systemd/launchd), origin (read-only:
  own origin / paired master), 'Master has an auth middleware' toggle
- middleware on: a non-DSH answer on the link (login redirect, 401/403, inline
  HTML) raises auth-required; the UI opens <master>/dsh-remote/signin in a popup,
  the user signs in to the proxy, one click hands the proxy session cookie back
  (postMessage, origin-checked, trusted origins only), link retries
- edge mode 'cookie': pasted authentik_proxy_… (or a whole Cookie header),
  rotated proxy cookies persisted back; pair.mjs/install-mac --edge cookie
- connectMaster serialised; a failed attempt never clears a finished link
- tests: cookie seed/mode, HTML login wall; e2e-signin against a fake proxy
This commit is contained in:
lyuksovannyy committed 2026-10-12 09:37:21 +00:00
1 parent 78fce8e1ce
commit bfbc3c66e4
13 files changed
+491 -34

No files matched your search

+2 -5
View File
@@ -34,12 +34,9 @@ await sleep(10000)
// Open "Change" on the edge card to show the form, and switch it to Basic.
console.log(await evalJs(`(() => {
const root = document.querySelector('[data-dsh-plugin="dsh-remote"]'); if (!root) return 'no settings panel'
const btn = [...root.querySelectorAll('button')].find((b) => b.textContent === 'Change'); if (btn) btn.click()
return 'panel: ' + root.innerText.replace(/\\n+/g, ' | ').slice(0, 500)
return 'panel: ' + root.innerText.replace(/\\n+/g, ' | ').slice(0, 700)
})()`))
await sleep(800)
await evalJs(`(() => { const sel = [...document.querySelectorAll('[data-dsh-plugin="dsh-remote"] select')].pop(); if (!sel) return; const set = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set; set.call(sel, 'basic'); sel.dispatchEvent(new Event('change', { bubbles: true })) })()`)
await sleep(800)
await sleep(500)
await evalJs(`document.querySelector('[data-dsh-plugin="dsh-remote"]')?.scrollIntoView()`)
const r = await send('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(out, Buffer.from(r.result.data, 'base64'))
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
// E2E for the "auth middleware" flow, run on a slave host against a real master:
// node deploy/e2e-signin.mjs <masterHttpUrl> <pairingCode> <chromeBinary>
// 1. start fake-edge (Authentik-like, user svc / password s3cret) in front of the master
// 2. start a throw-away slave DSH (own DSH_HOME, port 3085) and pair it THROUGH the edge
// using the pasted-cookie mode
// 3. kill the edge session (edge restarts → cookie invalid) and check the slave reports
// authRequired with authMiddleware on, and nothing with it off
// 4. open the slave UI in headless Chromium, trigger the popup flow by calling the
// hand-over (postMessage) the master's /dsh-remote/signin page would send
// 5. verify the link is back up
import { spawn, execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const [master, code, chrome] = process.argv.slice(2)
const here = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(here, '..')
const H = fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-signin-'))
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const log = (...a) => console.log(...a)
const procs = []
const stop = () => { for (const p of procs) { try { p.kill('SIGKILL') } catch { /* gone */ } } }
process.on('exit', stop)
const startEdge = () => {
const p = spawn('node', [path.join(here, 'fake-edge.mjs'), '3098', master, 'svc', 's3cret'], { stdio: 'ignore' })
procs.push(p)
return p
}
const cli = (args, env = {}) => execFileSync('node', [path.join(here, 'dshr-cli.mjs'), ...args], { env: { ...process.env, DSH_HOME: H, DSH_PORT: '3085', ...env }, encoding: 'utf8', timeout: 30000 })
let edge = startEdge()
await sleep(800)
// A browser that already signed in would own this cookie: get one from the fake edge.
const r = await fetch('http://127.0.0.1:3098/dsh-remote/enroll', { method: 'POST', headers: { authorization: `Basic ${Buffer.from('svc:s3cret').toString('base64')}` } })
const edgeCookieOf = (resp) => (resp.headers.getSetCookie?.() || []).map((c) => c.split(';')[0]).find((c) => c.startsWith('edge_session=')) || ''
const cookie = edgeCookieOf(r)
log('browser-style edge cookie:', cookie.replace(/=.*/, '=…'))
// Throw-away slave profile.
const env = { ...process.env, DSH_HOME: H, PATH: process.env.PATH }
execFileSync('dsh', ['web', '--dump-config'], { env, stdio: 'ignore', timeout: 40000 })
fs.mkdirSync(path.join(H, 'plugins'), { recursive: true })
fs.cpSync(root, path.join(H, 'plugins', 'dsh-remote'), { recursive: true })
execFileSync('node', [path.join(H, 'plugins', 'dsh-remote', 'deploy', 'install-local.mjs'), '--role', 'slave', '--name', 'signin-test'], { env })
log('pair through the edge with the pasted cookie:')
process.env.DSH_REMOTE_EDGE_COOKIE = cookie
log(execFileSync('node', [path.join(here, 'pair.mjs'), '--master', 'http://127.0.0.1:3098', '--code', code, '--name', 'signin-test', '--edge', 'cookie'], { env: { ...env, DSH_REMOTE_EDGE_COOKIE: cookie }, encoding: 'utf8' }).trim())
const dsh = spawn('dsh', ['web', '--host', '127.0.0.1', '--port', '3085', '--no-open'], { env, stdio: 'ignore' })
procs.push(dsh)
for (let i = 0; i < 60; i++) { try { if ((await fetch('http://127.0.0.1:3085/')).status) break } catch { /* booting */ } await sleep(1000) }
await sleep(6000)
const state = () => JSON.parse(cli(['GET', 'state']).split('\n').slice(1).join('\n'))
let s = state()
log('linked with pasted cookie:', s.master.linked, '| cookies:', s.master.edgeAuth.cookies.join(','))
log('--- edge forgets the session (restart) → authRequired when authMiddleware is on')
cli(['POST', 'node', '{"authMiddleware":true}'])
edge.kill('SIGKILL'); await sleep(500); edge = startEdge(); await sleep(800)
cli(['POST', 'retry-link', '{}'])
await sleep(500)
s = state()
log('linked:', s.master.linked, '| authRequired:', !!s.master.authRequired, '|', s.master.authRequired?.reason?.slice(0, 90))
log('--- same with authMiddleware off → no authRequired')
cli(['POST', 'node', '{"authMiddleware":false}'])
cli(['POST', 'retry-link', '{}'])
s = state()
log('authRequired:', !!s.master.authRequired, '| error:', s.master.error?.slice(0, 80))
log('--- popup hand-over: the master /dsh-remote/signin page postMessages the cookie')
cli(['POST', 'node', '{"authMiddleware":true}'])
const fresh = await fetch('http://127.0.0.1:3098/dsh-remote/enroll', { method: 'POST', headers: { authorization: `Basic ${Buffer.from('svc:s3cret').toString('base64')}` } })
const freshCookie = edgeCookieOf(fresh)
const cookieText = `${freshCookie}; dsh-auth-xyz=ignored`
const sign = await fetch('http://127.0.0.1:3098/dsh-remote/signin?origin=' + encodeURIComponent('http://127.0.0.1:3085'), { headers: { cookie: freshCookie } })
const page = await sign.text()
log('signin page served through the edge:', sign.status, '| offers hand-over:', /Send session/.test(page), '| cookie embedded:', page.includes(freshCookie.split('=')[0]))
const untrusted = await (await fetch('http://127.0.0.1:3098/dsh-remote/signin?origin=' + encodeURIComponent('https://evil.example'), { headers: { cookie: freshCookie } })).text()
log('untrusted origin gets nothing:', /not on this machine's trusted list/.test(untrusted) && !untrusted.includes(freshCookie.split('=')[1]))
// What the UI does when the postMessage arrives:
cli(['POST', 'edge-auth', JSON.stringify({ edgeAuth: { mode: 'cookie', cookie: cookieText } })])
const retry = JSON.parse(cli(['POST', 'retry-link', '{}']).split('\n').slice(1).join('\n'))
await sleep(1500)
s = state()
log('after hand-over → linked:', s.master.linked, '| retry-link:', JSON.stringify(retry).slice(0, 100))
process.exit(s.master.linked ? 0 : 1)
+1 -1
View File
@@ -37,7 +37,7 @@ const srv = http.createServer((req, res) => {
}
const p = http.request({ host: up.hostname, port: up.port, method: req.method, path: req.url, headers: { ...strip(req.headers), host: up.host } }, (r) => {
const h = { ...r.headers }
if (a.setCookie) h['set-cookie'] = [...(h['set-cookie'] || []), a.setCookie]
if (a.setCookie) h['set-cookie'] = [a.setCookie, ...(h['set-cookie'] || [])]
res.writeHead(r.statusCode, h)
r.pipe(res)
})
+4 -1
View File
@@ -6,6 +6,7 @@
// [--edge basic --edge-user USER] # password from $DSH_REMOTE_EDGE_PASSWORD or prompt
// [--edge bearer] # token from $DSH_REMOTE_EDGE_TOKEN or prompt
// [--edge oauth2 --edge-token-url URL --edge-client-id ID [--edge-user USER] [--edge-client-secret]]
// [--edge cookie] # proxy session cookie from $DSH_REMOTE_EDGE_COOKIE or prompt
// [--edge headers --edge-header "CF-Access-Client-Id: …" --edge-header "CF-Access-Client-Secret: …"]
// [--state <file>]
//
@@ -33,7 +34,7 @@ const name = opt('name', os.hostname())
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
const stateFile = opt('state', path.join(home, 'profiles', opt('profile', 'web'), 'dsh-remote', 'state.json'))
if (!/^https?:\/\//.test(master) || !code) {
console.error('usage: pair.mjs --master <http(s)://…> --code <pairing code> [--name <device name>] [--edge basic|bearer|oauth2|headers …]')
console.error('usage: pair.mjs --master <http(s)://…> --code <pairing code> [--name <device name>] [--edge basic|bearer|oauth2|cookie|headers …]')
process.exit(2)
}
@@ -88,6 +89,8 @@ if (edgeMode === 'basic') {
if (!edgeAuth.tokenUrl || !edgeAuth.clientId) { console.error('--edge oauth2 needs --edge-token-url and --edge-client-id'); process.exit(2) }
if (edgeAuth.username) edgeAuth.passwordRef = await secretRef('DSH_REMOTE_EDGE_PASSWORD', `App password for "${edgeAuth.username}"`)
if (args.includes('--edge-client-secret')) edgeAuth.clientSecretRef = await secretRef('DSH_REMOTE_EDGE_CLIENT_SECRET', 'OAuth2 client secret')
} else if (edgeMode === 'cookie') {
edgeAuth = { mode: 'cookie', cookieRef: await secretRef('DSH_REMOTE_EDGE_COOKIE', 'Proxy session cookie (name=value, or the whole Cookie header)') }
} else if (edgeMode === 'headers') {
edgeAuth = { mode: 'headers', headers: {} }
let i = 0