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

+117 -10
View File
@@ -33,6 +33,7 @@ import { CookieJar } from './lib/auth-cookie.mjs'
import { ownHttp, ownUpgrade, viewHttp, viewUpgrade } from './lib/proxy.mjs'
import * as sync from './lib/sync.mjs'
import * as ops from './lib/agent-ops.mjs'
import { createEdgeAuth } from './lib/edge-auth.mjs'
export const name = 'dsh-remote'
export const inject = {
@@ -421,12 +422,15 @@ export function apply(ctx, config = {}) {
url.protocol = url.protocol === 'https:' ? 'wss:' : url.protocol === 'http:' ? 'ws:' : url.protocol
url.pathname = url.pathname.replace(/\/+$/, '') + LINK_PATH
try {
const edge = edgeFor(m)
const ws = await connect(url.href, {
headers: {
...(await edge.headers()),
'x-dsh-remote-device': m.deviceId,
'x-dsh-remote-key': m.key,
'x-dsh-remote-platform': `${process.platform}-${process.arch}`,
},
onHeaders: (h) => edge.absorb(h),
})
const mux = new Mux(ws, { odd: true, handlers, onStream: serveStream })
mux.deviceId = 'master'
@@ -454,11 +458,43 @@ export function apply(ctx, config = {}) {
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
} catch (e) {
masterLink = null
if (e.status === 401) { log.warn(`dsh-remote: master refused key (${e.message}); re-pair this device`); lastLinkError = e.message; return }
lastLinkError = e.message
// DSH itself refuses an unknown/revoked key with 401 + this exact text; anything else
// that is a redirect/401/403 came from an auth proxy in front of the master.
const deviceRefused = e.status === 401 && /unknown or revoked device/.test(e.body || '')
if (deviceRefused) { log.warn(`dsh-remote: master refused key (${e.message}); re-pair this device`); lastLinkError = e.message; return }
const edgeRefused = (e.status >= 300 && e.status < 400) || e.status === 401 || e.status === 403
if (edgeRefused) {
const mode = edgeFor(m).mode
lastLinkError = mode === 'none'
? `auth proxy in front of ${url.host} blocked the link (${e.status}); configure edgeAuth for this master (Settings → Remote devices)`
: `auth proxy in front of ${url.host} rejected the edgeAuth credentials (${e.status})`
edgeFor(m).reset()
backoff = Math.max(backoff, 15000)
} else lastLinkError = e.message
log.warn(`dsh-remote: link to master failed: ${lastLinkError}`)
schedule()
}
}
// Edge auth (auth proxy in front of the master). Config: master.edgeAuth stored at pair
// time, overridden by `edgeAuth` in this plugin's config. Secrets resolve via credentials.
let edgeCache = null
function edgeConfigFor(m) {
return config.edgeAuth?.mode ? config.edgeAuth : (m?.edgeAuth || { mode: 'none' })
}
function edgeFor(m) {
const cfg = edgeConfigFor(m)
const key = JSON.stringify(cfg)
if (edgeCache?.key !== key) edgeCache = { key, client: createEdgeAuth(cfg, resolveSecretRef) }
return edgeCache.client
}
async function resolveSecretRef(ref) {
const creds = svc(ctx, 'credentials')
if (creds) {
try { const r = await creds.resolve(ref); if (r?.value) return r.value } catch { /* fall through */ }
}
return process.env[ref]
}
let lastLinkError = null
let timer = null
function schedule() {
@@ -598,7 +634,7 @@ export function apply(ctx, config = {}) {
route('/dsh-remote/state', ['GET'], async () => ({
role,
self,
master: role === 'slave' ? { url: store.master?.url || null, linked: !!masterLink?.mux, info: masterLink?.info || null, error: masterLink ? null : lastLinkError } : null,
master: role === 'slave' ? { url: store.master?.url || null, linked: !!masterLink?.mux, info: masterLink?.info || null, error: masterLink ? null : lastLinkError, edgeAuth: store.master ? edgeFor(store.master).describe() : null } : null,
devices: devicesView(),
publicUrl: config.publicUrl || null,
lanUrl: config.lanUrl || null,
@@ -634,19 +670,90 @@ export function apply(ctx, config = {}) {
},
}), 'dsh-remote: enroll')
/**
* Normalise an edgeAuth spec from the UI/API. Secret values arrive inline once and are
* written to DSH credentials under a ref; only the ref names are kept in state.
*/
async function storeEdgeAuth(spec) {
if (!spec || !spec.mode || spec.mode === 'none') return { mode: 'none' }
const creds = svc(ctx, 'credentials')
const put = async (ref, value) => {
if (value === undefined || value === null || value === '') return ref
if (!creds) throw httpErr(503, 'credentials service unavailable to store the edge secret')
await creds.set(ref, String(value))
return ref
}
const S = (s) => (s ? String(s).trim() : undefined)
const out = { mode: spec.mode }
if (spec.mode === 'basic') {
out.username = S(spec.username) || ''
out.passwordRef = await put(S(spec.passwordRef) || 'DSH_REMOTE_EDGE_PASSWORD', spec.password)
} else if (spec.mode === 'bearer') {
out.tokenRef = await put(S(spec.tokenRef) || 'DSH_REMOTE_EDGE_TOKEN', spec.token)
} else if (spec.mode === 'oauth2') {
Object.assign(out, { tokenUrl: S(spec.tokenUrl), clientId: S(spec.clientId), username: S(spec.username), scope: S(spec.scope) })
if (!out.tokenUrl || !out.clientId) throw httpErr(400, 'oauth2 edgeAuth needs tokenUrl and clientId')
if (spec.password || spec.passwordRef) out.passwordRef = await put(S(spec.passwordRef) || 'DSH_REMOTE_EDGE_PASSWORD', spec.password)
if (spec.clientSecret || spec.clientSecretRef) out.clientSecretRef = await put(S(spec.clientSecretRef) || 'DSH_REMOTE_EDGE_CLIENT_SECRET', spec.clientSecret)
} else if (spec.mode === 'headers') {
out.headers = {}
let i = 0
for (const [k, v] of Object.entries(spec.headers || {})) {
if (!/^[A-Za-z0-9-]+$/.test(k)) throw httpErr(400, `bad header name ${k}`)
const val = String(v ?? '')
// Values are treated as secrets unless already a ref.
out.headers[k] = val.startsWith('ref:') ? val : `ref:${await put(`DSH_REMOTE_EDGE_HEADER_${++i}`, val)}`
}
} else throw httpErr(400, `unknown edgeAuth mode ${spec.mode}`)
return out
}
route('/dsh-remote/pair', ['POST'], async (req) => {
const body = await readJson(req)
const masterUrl = String(body.masterUrl || '').trim().replace(/\/+$/, '')
if (!/^https?:\/\//.test(masterUrl)) throw httpErr(400, 'masterUrl must start with http:// or https://')
const r = await fetch(`${masterUrl}/dsh-remote/enroll`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: body.code, id: self.id, name: self.name, platform: `${process.platform}-${process.arch}` }),
})
const edgeAuth = await storeEdgeAuth(body.edgeAuth)
const edge = createEdgeAuth(config.edgeAuth?.mode ? config.edgeAuth : edgeAuth, resolveSecretRef)
let r
try {
r = await edge.edgeFetch(`${masterUrl}/dsh-remote/enroll`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: body.code, id: self.id, name: self.name, platform: `${process.platform}-${process.arch}` }),
})
} catch (e) {
throw httpErr(e.status && e.status < 600 ? (e.status >= 300 && e.status < 400 ? 502 : e.status) : 502, `${e.message}${e.hint ? ` – ${e.hint}` : ''}`)
}
const j = await r.json().catch(() => ({}))
if (!r.ok) throw httpErr(r.status, j.error || `enroll failed (HTTP ${r.status})`)
store.setMaster({ url: masterUrl, deviceId: j.deviceId, key: j.key, master: j.master, pairedAt: Date.now() })
return { ok: true, deviceId: j.deviceId, master: j.master, restartRequired: role !== 'slave' }
store.setMaster({ url: masterUrl, deviceId: j.deviceId, key: j.key, master: j.master, edgeAuth, pairedAt: Date.now() })
edgeCache = null
return { ok: true, deviceId: j.deviceId, master: j.master, edgeAuth: edge.describe(), restartRequired: role !== 'slave' }
})
// Change / test edge auth of an already paired slave without re-pairing.
route('/dsh-remote/edge-auth', ['GET', 'POST'], async (req) => {
if (!store.master) throw httpErr(400, 'not paired with a master')
if (req.method === 'POST') {
const body = await readJson(req)
const edgeAuth = await storeEdgeAuth(body.edgeAuth)
store.setMaster({ ...store.master, edgeAuth })
edgeCache = null
masterLink?.mux?.close(1000, 'edge auth changed')
backoff = 1000
if (!masterLink) { clearTimeout(timer); connectMaster() }
}
// Probe: an unauthenticated GET of the link path through the edge must reach DSH, which
// answers a plain GET on an upgrade-only path with 404/426 (not a login redirect).
const edge = edgeFor(store.master)
let probe
try {
const r = await edge.edgeFetch(`${store.master.url}/dsh-remote/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
probe = { reachedDsh: true, status: r.status }
} catch (e) {
probe = { reachedDsh: false, error: e.message, hint: e.hint }
}
return { master: store.master.url, override: !!config.edgeAuth?.mode, edgeAuth: edge.describe(), probe, linked: !!masterLink?.mux, linkError: lastLinkError }
})
route('/dsh-remote/unpair', ['POST'], async () => {