dsh-remote 0.4.0: edge auth – slaves log in to an auth proxy in front of the master

- lib/edge-auth.mjs: basic / bearer / oauth2 client_credentials (Authentik M2M) /
  custom headers (Cloudflare Access), proxy session cookie jar, retry with fresh
  token, clear edge-refusal vs DSH-refusal errors
- link + enroll go through the edge; master.edgeAuth stored at pair time,
  secrets as DSH credential refs only; config edgeAuth override
- /dsh-remote/edge-auth: show/change/probe; Settings UI section + pair form
- pair.mjs / install-mac.sh: --edge … flags, secrets prompted via /dev/tty
- tests: fake Authentik-like proxy for all modes + WebSocket through the edge
This commit is contained in:
lyuksovannyy committed 2026-10-11 12:19:36 +00:00
1 parent 5dff41a3d0
commit 78fce8e1ce
12 files changed
+833 -40

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// Screenshot Settings → Remote devices (headless Chromium via DevTools protocol).
// node deploy/e2e-settings.mjs <chrome> <out.png> [port]
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { cookieName, mintCookie } from '../lib/auth-cookie.mjs'
const [chrome, out, port = '3081'] = process.argv.slice(2)
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
const secret = fs.readFileSync(path.join(home, '.credentials.yaml'), 'utf8').match(/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{43})/)[1]
const authority = `127.0.0.1:${port}`
const prof = fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-set-'))
const proc = spawn(chrome, ['--headless=new', '--no-sandbox', '--disable-gpu', `--user-data-dir=${prof}`, '--remote-debugging-port=0', '--window-size=1400,1300', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
const wsUrl = await new Promise((resolve, reject) => {
let buf = ''
proc.stderr.on('data', (d) => { buf += d; const m = buf.match(/DevTools listening on (ws:\/\/\S+)/); if (m) resolve(m[1]) })
setTimeout(() => reject(new Error('chrome did not start')), 15000)
})
const page = (await (await fetch(wsUrl.replace(/^ws/, 'http').replace(/\/devtools\/browser\/.*/, '/json/list'))).json()).find((t) => t.type === 'page')
const ws = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((r) => ws.addEventListener('open', r))
let id = 0
const pending = new Map()
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } })
const send = (method, params = {}) => new Promise((resolve) => { const i = ++id; pending.set(i, resolve); ws.send(JSON.stringify({ id: i, method, params })) })
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const evalJs = async (expr) => (await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable')
await send('Network.setCookie', { name: cookieName(authority), value: mintCookie(authority, secret, 1).split('=').slice(1).join('='), domain: '127.0.0.1', path: '/', httpOnly: true })
await send('Page.navigate', { url: `http://${authority}/#/settings/dsh-remote` })
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)
})()`))
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 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'))
console.log('screenshot', out)
ws.close(); proc.kill('SIGKILL')
try { fs.rmSync(prof, { recursive: true, force: true }) } catch { /* chrome may still hold files */ }
process.exit(0)
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
// Test helper: an Authentik-like auth proxy in front of a DSH master.
// node deploy/fake-edge.mjs <listenPort> <upstream http://host:port> <user> <password>
// Unauthenticated → 302 to a fake login page; Basic user:password → session cookie +
// pass-through (HTTP and WebSocket upgrades); session cookie → pass-through.
import crypto from 'node:crypto'
import http from 'node:http'
import net from 'node:net'
const [port, upstream, user, pass] = process.argv.slice(2)
const up = new URL(upstream)
const sessions = new Set()
const want = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`
const stats = { redirects: 0, credentialLogins: 0, cookieHits: 0, upgrades: 0 }
function auth(req) {
const sid = /edge_session=([a-f0-9]+)/.exec(req.headers.cookie || '')?.[1]
if (sid && sessions.has(sid)) { stats.cookieHits++; return { ok: true } }
if (req.headers.authorization === want) {
stats.credentialLogins++
const id = crypto.randomBytes(12).toString('hex')
sessions.add(id)
return { ok: true, setCookie: `edge_session=${id}; Path=/; HttpOnly; Max-Age=86400` }
}
if (req.headers.authorization) return { ok: false, status: 401 }
return { ok: false, status: 302 }
}
const strip = (h) => { const o = { ...h }; delete o.authorization; return o }
const srv = http.createServer((req, res) => {
if (req.url === '/__stats') { res.end(JSON.stringify(stats)); return }
const a = auth(req)
if (!a.ok) {
if (a.status === 302) stats.redirects++
res.writeHead(a.status, a.status === 302 ? { location: 'https://auth.example/application/o/authorize/?client_id=x' } : {})
return res.end()
}
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]
res.writeHead(r.statusCode, h)
r.pipe(res)
})
p.on('error', () => { res.writeHead(502); res.end() })
req.pipe(p)
})
srv.on('upgrade', (req, socket, head) => {
const a = auth(req)
if (!a.ok) {
if (a.status === 302) stats.redirects++
socket.end(a.status === 302 ? 'HTTP/1.1 302 Found\r\nLocation: https://auth.example/login\r\nContent-Length: 0\r\n\r\n' : 'HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n')
return
}
stats.upgrades++
const s = net.connect(Number(up.port), up.hostname, () => {
const lines = [`${req.method} ${req.url} HTTP/1.1`]
for (const [k, v] of Object.entries({ ...strip(req.headers), host: up.host })) for (const one of [].concat(v)) lines.push(`${k}: ${one}`)
s.write(lines.join('\r\n') + '\r\n\r\n')
if (head?.length) s.write(head)
s.pipe(socket); socket.pipe(s)
})
s.on('error', () => socket.destroy())
socket.on('error', () => s.destroy())
})
srv.listen(Number(port), '127.0.0.1', () => console.log(`fake edge on 127.0.0.1:${port} → ${upstream}`))
+8 -2
View File
@@ -13,10 +13,14 @@
# --port PORT local DSH port (default 3081)
# --accept LIST comma list of secrets to accept from the master (default OMNI_API_KEY,FORGE_CREDENTIALS)
# --no-service don't install the LaunchAgent (run `dsh web` yourself)
# --edge MODE … the master sits behind an auth proxy: basic|bearer|oauth2|headers, with
# --edge-user, --edge-token-url, --edge-client-id, --edge-client-secret,
# --edge-header "Name: value" (secrets are prompted for, never in argv)
# Re-running is safe: it updates Node/DSH/dsh-remote and keeps the existing pairing.
set -euo pipefail
MASTER="" CODE="" NAME="" PORT=3081 ACCEPT="OMNI_API_KEY,FORGE_CREDENTIALS" SERVICE=1
EDGE_ARGS=()
NODE_MAJOR=22
REPO_TGZ="https://git.rizzy.dev/DSH-Plugins/dsh-remote/archive/main.tar.gz"
while [ $# -gt 0 ]; do
@@ -27,7 +31,9 @@ while [ $# -gt 0 ]; do
--port) PORT="$2"; shift 2 ;;
--accept) ACCEPT="$2"; shift 2 ;;
--no-service) SERVICE=0; shift ;;
-h|--help) sed -n '2,17p' "$0" 2>/dev/null || true; exit 0 ;;
--edge|--edge-user|--edge-token-url|--edge-client-id|--edge-scope|--edge-header) EDGE_ARGS+=("$1" "$2"); shift 2 ;;
--edge-client-secret) EDGE_ARGS+=("$1"); shift ;;
-h|--help) sed -n '2,19p' "$0" 2>/dev/null || true; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
@@ -100,7 +106,7 @@ if [ -f "$STATE" ] && grep -q '"deviceId"' "$STATE"; then
else
[ -n "$CODE" ] || die "--code is required for the first pairing (master → Settings → Remote devices → Create pairing code)"
say "Pairing with $MASTER as \"$NAME\""
node "$PLUGIN_DIR/deploy/pair.mjs" --master "$MASTER" --code "$CODE" --name "$NAME" --state "$STATE"
node "$PLUGIN_DIR/deploy/pair.mjs" --master "$MASTER" --code "$CODE" --name "$NAME" --state "$STATE" ${EDGE_ARGS[@]+"${EDGE_ARGS[@]}"}
fi
# 7. LaunchAgent: dsh web on 127.0.0.1:$PORT, at login, restarted on exit.
+29
View File
@@ -0,0 +1,29 @@
// Helpers for deploy/pair.mjs (kept separate so they are unit-testable).
/**
* Set `refs.<ref>` in the text of a DSH `.credentials.yaml`, preserving everything else
* (records, other refs, comments). Values are written as JSON strings (valid YAML scalars).
*/
export function setRefIn(text, ref, value) {
if (!/^[A-Z][A-Z0-9_]*$/.test(ref)) throw new Error(`bad ref name ${ref}`)
const q = JSON.stringify(String(value))
const lines = (text || 'version: 1\n').split('\n')
if (lines[lines.length - 1] === '') lines.pop()
let refsAt = lines.findIndex((l) => /^refs:\s*(\{\})?\s*$/.test(l))
if (refsAt === -1) { lines.push('refs:'); refsAt = lines.length - 1 } else lines[refsAt] = 'refs:'
let end = refsAt + 1
while (end < lines.length && /^\s+\S/.test(lines[end])) end++
const existing = lines.slice(refsAt + 1, end).findIndex((l) => new RegExp(`^\\s+${ref}:`).test(l))
if (existing >= 0) lines[refsAt + 1 + existing] = ` ${ref}: ${q}`
else lines.splice(end, 0, ` ${ref}: ${q}`)
return lines.join('\n') + '\n'
}
/** Read `refs.<ref>` from `.credentials.yaml` text (plain, quoted or JSON-quoted scalars). */
export function getRefIn(text, ref) {
const m = String(text || '').match(new RegExp(`^\\s+${ref}:\\s*(.+)$`, 'm'))
if (!m) return undefined
const v = m[1].trim()
if (v.startsWith('"')) { try { return JSON.parse(v) } catch { return v.slice(1, -1) } }
return v.replace(/^'|'$/g, '')
}
+102 -15
View File
@@ -1,48 +1,135 @@
#!/usr/bin/env node
// Pair this machine with a dsh-remote master WITHOUT a running local DSH: exchange a
// one-time pairing code for a device key and write the slave link state.
// node deploy/pair.mjs --master http://192.168.0.111:3080 --code ABCD-... --name my-mac [--state <file>]
// The state file defaults to $DSH_HOME/profiles/web/dsh-remote/state.json. Restart DSH after.
//
// node deploy/pair.mjs --master https://dsh.example.com --code ABCD-... --name my-mac
// [--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 headers --edge-header "CF-Access-Client-Id: …" --edge-header "CF-Access-Client-Secret: …"]
// [--state <file>]
//
// Edge = an auth proxy (Authentik, oauth2-proxy, Cloudflare Access, Basic) in front of the
// master. Its secrets are stored in $DSH_HOME/.credentials.yaml (refs, 0600) — the same store
// DSH's credentials service reads — and only their ref names go into the link state.
// Restart DSH afterwards.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import readline from 'node:readline'
import tty from 'node:tty'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const { createEdgeAuth } = await import(path.join(here, '..', 'lib', 'edge-auth.mjs'))
const { setRefIn, getRefIn } = await import(path.join(here, 'pair-lib.mjs'))
const args = process.argv.slice(2)
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 ? args[i + 1] : d }
const opts = (k) => args.flatMap((a, i) => (a === `--${k}` ? [args[i + 1]] : []))
const master = String(opt('master', '')).replace(/\/+$/, '')
const code = opt('code', '')
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>]')
console.error('usage: pair.mjs --master <http(s)://…> --code <pairing code> [--name <device name>] [--edge basic|bearer|oauth2|headers …]')
process.exit(2)
}
// ---- credentials.yaml refs (the store DSH's credentials service reads) ----------------
const credFile = path.join(home, '.credentials.yaml')
function setRef(ref, value) {
let text = ''
try { text = fs.readFileSync(credFile, 'utf8') } catch { text = '' }
fs.mkdirSync(home, { recursive: true, mode: 0o700 })
const tmp = `${credFile}.${process.pid}.tmp`
fs.writeFileSync(tmp, setRefIn(text, ref, value), { mode: 0o600 })
fs.renameSync(tmp, credFile)
}
function getRef(ref) {
if (process.env[ref]) return process.env[ref]
try { return getRefIn(fs.readFileSync(credFile, 'utf8'), ref) } catch { return undefined }
}
function prompt(question) {
// `curl … | bash` makes stdin the script; read from the terminal instead.
let input = process.stdin
let output = process.stdout
if (!process.stdin.isTTY) {
try {
const fd = fs.openSync('/dev/tty', 'r+')
input = new tty.ReadStream(fd)
output = new tty.WriteStream(fd)
} catch { return Promise.resolve('') }
}
return new Promise((resolve) => {
const rl = readline.createInterface({ input, output, terminal: true })
rl._writeToOutput = (s) => { if (s.includes(question)) output.write(s) } // hide typed secret
rl.question(question, (a) => { rl.close(); output.write('\n'); if (input !== process.stdin) input.destroy(); resolve(a.trim()) })
})
}
// ---- edge auth spec ----------------------------------------------------------------------
const edgeMode = opt('edge', 'none')
let edgeAuth = { mode: 'none' }
async function secretRef(ref, what) {
let v = getRef(ref)
if (!v) v = await prompt(`${what}: `)
if (!v) { console.error(`missing ${what} (set ${ref} or answer the prompt)`); process.exit(2) }
setRef(ref, v)
return ref
}
if (edgeMode === 'basic') {
edgeAuth = { mode: 'basic', username: opt('edge-user', ''), passwordRef: await secretRef('DSH_REMOTE_EDGE_PASSWORD', `Password / app password for "${opt('edge-user', '')}" at the auth proxy`) }
} else if (edgeMode === 'bearer') {
edgeAuth = { mode: 'bearer', tokenRef: await secretRef('DSH_REMOTE_EDGE_TOKEN', 'Bearer token for the auth proxy') }
} else if (edgeMode === 'oauth2') {
edgeAuth = { mode: 'oauth2', tokenUrl: opt('edge-token-url'), clientId: opt('edge-client-id'), username: opt('edge-user'), scope: opt('edge-scope') }
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 === 'headers') {
edgeAuth = { mode: 'headers', headers: {} }
let i = 0
for (const h of opts('edge-header')) {
const at = h.indexOf(':')
if (at <= 0) { console.error(`bad --edge-header "${h}" (want "Name: value")`); process.exit(2) }
const ref = `DSH_REMOTE_EDGE_HEADER_${++i}`
setRef(ref, h.slice(at + 1).trim())
edgeAuth.headers[h.slice(0, at).trim()] = `ref:${ref}`
}
} else if (edgeMode !== 'none') {
console.error(`unknown --edge mode ${edgeMode}`)
process.exit(2)
}
// ---- enroll -----------------------------------------------------------------------------------
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'device'
let state = {}
try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')) } catch { state = {} }
state.self ||= { id: slug(name), name, createdAt: Date.now() }
state.self.name = name
const r = await fetch(`${master}/dsh-remote/enroll`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: code.trim().toUpperCase(), id: state.self.id, name, platform: `${process.platform}-${process.arch}` }),
redirect: 'manual',
})
let j = {}
try { j = await r.json() } catch { j = {} }
if (r.status >= 300 && r.status < 400) {
console.error(`pairing failed: ${master} redirected (${r.headers.get('location')?.slice(0, 80)}…). A login proxy (e.g. Authentik) is in front of /dsh-remote/enroll; use the LAN URL or exempt that path.`)
const edge = createEdgeAuth(edgeAuth, async (ref) => getRef(ref))
let r
try {
r = await edge.edgeFetch(`${master}/dsh-remote/enroll`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: code.trim().toUpperCase(), id: state.self.id, name, platform: `${process.platform}-${process.arch}` }),
})
} catch (e) {
console.error(`pairing failed: ${e.message}${e.hint ? `\n hint: ${e.hint}` : ''}`)
process.exit(1)
}
let j = {}
try { j = await r.json() } catch { j = {} }
if (!r.ok) { console.error(`pairing failed: ${j.error || `HTTP ${r.status}`}`); process.exit(1) }
state.master = { url: master, deviceId: j.deviceId, key: j.key, master: j.master, pairedAt: Date.now() }
state.master = { url: master, deviceId: j.deviceId, key: j.key, master: j.master, edgeAuth, pairedAt: Date.now() }
state.self.id = j.deviceId
fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 })
const tmp = `${stateFile}.${process.pid}.tmp`
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 })
fs.renameSync(tmp, stateFile)
console.log(`paired as "${j.deviceId}" with master ${j.master?.name || master}`)
console.log(`paired as "${j.deviceId}" with master ${j.master?.name || master}${edgeAuth.mode !== 'none' ? ` (through ${edgeAuth.mode} edge auth)` : ''}`)