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:
1 parent
78fce8e1ce
commit
bfbc3c66e4
13 files changed
+491
-34
No files matched your search
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user