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

+164 -11
View File
@@ -89,7 +89,11 @@ export function apply(ctx, config = {}) {
self.name = config.deviceName
store.save()
}
const role = config.role === 'master' ? 'master' : config.role === 'slave' ? 'slave' : (store.master ? 'slave' : 'master')
// Node type: Settings → Remote devices (stored in state) beats the config file; "auto"
// (nothing chosen) = slave once paired with a master, else master.
const uiSettings = store.settings()
const configuredRole = ['master', 'slave'].includes(uiSettings.nodeType) ? uiSettings.nodeType : config.role
const role = configuredRole === 'master' ? 'master' : configuredRole === 'slave' ? 'slave' : (store.master ? 'slave' : 'master')
const log = ctx.logger ?? console
const cookies = new CookieJar(ctx)
const localTarget = () => ({ host: '127.0.0.1', port: ctx.webServer.port })
@@ -415,7 +419,14 @@ export function apply(ctx, config = {}) {
// ------------------------------------------------------------------ slave: keep link up
let stopped = false
let backoff = 1000
async function connectMaster() {
let connecting = null
/** One connect attempt at a time (retry-link, the backoff timer and edge changes can race). */
function connectMaster() {
if (connecting) return connecting
connecting = connectMasterOnce().finally(() => { connecting = null })
return connecting
}
async function connectMasterOnce() {
const m = store.master
if (!m || stopped) return
const url = new URL(m.url)
@@ -434,11 +445,12 @@ export function apply(ctx, config = {}) {
})
const mux = new Mux(ws, { odd: true, handlers, onStream: serveStream })
mux.deviceId = 'master'
masterLink = { mux, info: null, peers: [] }
const link = { mux, info: null, peers: [] }
masterLink = link
backoff = 1000
mux.on('event', (n, d) => onLinkEvent(n, d, 'master'))
mux.on('close', (code, reason) => {
masterLink = null
if (masterLink === link) masterLink = null
notify({ type: 'devices' })
if (code === 4401) {
lastLinkError = `master rejected this device (${reason || 'revoked'}); re-pair it`
@@ -449,15 +461,16 @@ export function apply(ctx, config = {}) {
if (!stopped) schedule()
})
const info = await mux.call('hello', { id: self.id, name: self.name })
masterLink.info = { id: info.id, name: info.name }
link.info = { id: info.id, name: info.name }
// Only devices that are online on the master are reachable through its relay.
masterLink.peers = (await mux.call('devices', {})).filter((d) => d.online && !d.revoked).map(({ id, name, role: r }) => ({ id, name, role: r }))
link.peers = (await mux.call('devices', {})).filter((d) => d.online && !d.revoked).map(({ id, name, role: r }) => ({ id, name, role: r }))
notify({ type: 'devices' })
lastLinkError = null
authRequired = null
log.info?.(`dsh-remote: linked to master ${info.name} (${m.url})`)
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
} catch (e) {
masterLink = null
if (masterLink && !masterLink.info) masterLink = null // never clear a link that finished its handshake
// 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 || '')
@@ -466,17 +479,33 @@ export function apply(ctx, config = {}) {
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} blocked the link (${e.status}); sign in through the auth proxy (Settings → Remote devices)`
: `auth proxy in front of ${url.host} rejected the edgeAuth credentials (${e.status})`
edgeFor(m).reset()
backoff = Math.max(backoff, 15000)
raiseAuthRequired(m, { status: e.status, location: e.location, reason: lastLinkError })
} else if (e.status === 200 || /text\/html/i.test(e.contentType || '')) {
// Upgrade answered with a normal page instead of 101: a login page served inline.
lastLinkError = `auth proxy in front of ${url.host} answered the link with a web page; sign in through the auth proxy (Settings → Remote devices)`
raiseAuthRequired(m, { status: e.status, reason: lastLinkError })
} 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
// The master sits behind an auth middleware that wants an interactive sign-in. Surfaced in
// /dsh-remote/state so the UI can open the sign-in popup when "auth middleware" is on.
let authRequired = null
function raiseAuthRequired(m, { status, location, reason }) {
if (store.settings().authMiddleware === false) return
const loginUrl = (() => {
try { return new URL(location || '', m.url).href } catch { return null }
})()
authRequired = { at: Date.now(), status, reason, loginUrl, masterUrl: m.url, signInUrl: `${m.url}/dsh-remote/enroll` }
notify({ type: 'auth-required', ...authRequired })
}
// time, overridden by `edgeAuth` in this plugin's config. Secrets resolve via credentials.
let edgeCache = null
function edgeConfigFor(m) {
@@ -485,7 +514,7 @@ export function apply(ctx, config = {}) {
function edgeFor(m) {
const cfg = edgeConfigFor(m)
const key = JSON.stringify(cfg)
if (edgeCache?.key !== key) edgeCache = { key, client: createEdgeAuth(cfg, resolveSecretRef) }
if (edgeCache?.key !== key) edgeCache = { key, client: createEdgeAuth(cfg, resolveSecretRef, { persistCookie: (c) => persistProxyCookie(cfg, c) }) }
return edgeCache.client
}
async function resolveSecretRef(ref) {
@@ -495,6 +524,11 @@ export function apply(ctx, config = {}) {
}
return process.env[ref]
}
/** Cookie mode: write the (possibly rotated) proxy session cookie back to its credential ref. */
async function persistProxyCookie(cfg, cookieHeader) {
const creds = svc(ctx, 'credentials')
if (creds && cfg?.cookieRef) await creds.set(cfg.cookieRef, cookieHeader)
}
let lastLinkError = null
let timer = null
function schedule() {
@@ -615,6 +649,10 @@ export function apply(ctx, config = {}) {
setTimeout(() => attempt(0), 3000).unref?.()
}
// Restart this DSH from the UI (node type changes only take effect at boot). Relies on the
// service manager (systemd Restart=always / LaunchAgent KeepAlive), like restartOnSync.
const isManaged = () => !!(process.env.INVOCATION_ID || process.env.XPC_SERVICE_NAME || process.env.JOURNAL_STREAM)
// ------------------------------------------------------------------ HTTP API
const route = (path, methods, handler) => ctx.effect(() => ctx.webServer.register({
kind: 'exact',
@@ -634,7 +672,8 @@ 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, edgeAuth: store.master ? edgeFor(store.master).describe() : null } : 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, authRequired } : null,
node: nodeView(false),
devices: devicesView(),
publicUrl: config.publicUrl || null,
lanUrl: config.lanUrl || null,
@@ -695,6 +734,9 @@ export function apply(ctx, config = {}) {
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 === 'cookie') {
// Session cookie pasted from a browser that is already signed in through the proxy.
out.cookieRef = await put(S(spec.cookieRef) || 'DSH_REMOTE_EDGE_COOKIE', spec.cookie)
} else if (spec.mode === 'headers') {
out.headers = {}
let i = 0
@@ -762,6 +804,117 @@ export function apply(ctx, config = {}) {
return { ok: true }
})
/**
* Node settings from Settings → Remote devices:
* nodeType master | slave (restart needed when it changes: it decides which
* endpoints/links this instance starts)
* masterOrigin slave: origin of the master (https://dsh.example.com), pairs with it
* authMiddleware true: an auth proxy sits in front of the master; when the link meets a
* non-DSH answer the UI opens a sign-in popup, then calls retry-link
* false: behave as before (no popup)
*/
route('/dsh-remote/node', ['GET', 'POST'], async (req) => {
if (req.method === 'POST') {
const body = await readJson(req)
const patch = {}
if (body.nodeType !== undefined) {
if (!['master', 'slave'].includes(body.nodeType)) throw httpErr(400, 'nodeType must be master or slave')
patch.nodeType = body.nodeType
}
if (body.authMiddleware !== undefined) patch.authMiddleware = !!body.authMiddleware
const before = store.settings()
store.setSettings(patch)
if (patch.authMiddleware === false) authRequired = null
if (body.masterOrigin !== undefined && store.master) {
const origin = originOf(body.masterOrigin)
if (origin !== store.master.url) throw httpErr(400, 'to change the master, re-pair (Pair with a master)')
}
return nodeView(before.nodeType !== patch.nodeType && patch.nodeType !== undefined)
}
return nodeView(false)
})
const originOf = (u) => {
const url = new URL(String(u || '').trim())
if (!/^https?:$/.test(url.protocol)) throw httpErr(400, 'origin must start with http:// or https://')
return url.origin + url.pathname.replace(/\/+$/, '')
}
function nodeView(restartRequired) {
const s = store.settings()
return {
nodeType: s.nodeType || null, // what the user chose (null = auto)
effectiveNodeType: role, // what this process is running as
restartRequired: !!restartRequired || (!!s.nodeType && s.nodeType !== role),
// master: the origin slaves should use (own origin as the browser sees it is shown by the UI);
// slave: the paired master's origin (read-only once paired).
masterOrigin: store.master?.url || null,
authMiddleware: s.authMiddleware === true,
authRequired,
}
}
// Retry the master link now (after the user signed in through the popup).
route('/dsh-remote/retry-link', ['POST'], async () => {
if (role !== 'slave' || !store.master) throw httpErr(400, 'not a paired slave')
authRequired = null
backoff = 1000
edgeFor(store.master).reset()
clearTimeout(timer)
await connectMaster()
return { linked: !!masterLink?.mux, error: masterLink ? null : lastLinkError, authRequired }
})
route('/dsh-remote/restart', ['POST'], async () => {
if (!isManaged()) throw httpErr(409, 'this DSH is not running under a service manager (systemd / launchd); restart it yourself')
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 800).unref?.()
return { ok: true, restarting: true }
})
// ---- sign-in hand-over page (served by the MASTER, reachable only through its auth proxy)
// A slave UI opens this in a popup. The browser signs in to the auth proxy (Authentik, …),
// lands back here, and — after an explicit click — this page postMessages the proxy
// session cookie it received to the slave UI that opened it, so the slave's DSH can replay
// it on the link. No DSH auth is required (the proxy is the gate), and it only ever returns
// the caller's own cookies to an origin the user approves; origins are limited to
// loopback / private-network hosts or `signInOrigins` in the config.
const isPrivateHost = (h) => /^(localhost|\[::1\]|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|[^.]+\.local)$/i.test(h)
const originAllowed = (o) => {
try {
const u = new URL(o)
return isPrivateHost(u.hostname) || (config.signInOrigins || []).includes(u.origin)
} catch { return false }
}
ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: '/dsh-remote/signin',
handler: (req, res) => {
const url = new URL(req.url || '/', 'http://x')
const origin = url.searchParams.get('origin') || ''
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
const cookies = String(req.headers.cookie || '').split(';').map((s) => s.trim()).filter((s) => s && !/^dsh-auth-/.test(s))
const proxyCookies = cookies.filter((c) => /^(authentik_proxy|_oauth2_proxy|cf_authorization|authelia_session|keycloak|kc-|_forward_auth)/i.test(c))
const send = proxyCookies.length ? proxyCookies : cookies
const ok = originAllowed(origin)
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'x-frame-options': 'DENY', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-ancestors 'none'" })
res.end(`<!doctype html><meta charset=utf-8><title>dsh-remote sign-in</title>
<body style="font:15px system-ui;max-width:34em;margin:3em auto;padding:0 1em;background:#111;color:#ddd">
<h2>Signed in to ${esc(self.name)}</h2>
${!ok ? `<p>The device that opened this window (<code>${esc(origin || 'unknown')}</code>) is not on this machine's trusted list, so nothing will be sent.</p>`
: send.length ? `<p>This browser is signed in through the auth proxy. Send that session to the DSH at <code>${esc(origin)}</code> so it can reach this master?</p>
<p><button id=go style="font:inherit;padding:.5em 1.2em;border-radius:8px;border:0;background:#2f81f7;color:#fff;cursor:pointer">Send session</button> <button onclick="window.close()" style="font:inherit;padding:.5em 1.2em;border-radius:8px;border:1px solid #555;background:none;color:#ddd;cursor:pointer">Cancel</button></p>
<p id=msg style="opacity:.7"></p>
<script>
const cookie=${JSON.stringify(send.join('; ')).replace(/</g, '\\u003c')};
document.getElementById('go').onclick=()=>{
if(!window.opener){document.getElementById('msg').textContent='No opener window: close this and use the Sign in button again.';return}
window.opener.postMessage({type:'dsh-remote-signin',cookie,master:location.origin},${JSON.stringify(origin)});
document.getElementById('msg').textContent='Sent. You can close this window.';setTimeout(()=>window.close(),700)}
</script>`
: `<p>You are signed in, but the auth proxy did not forward its session cookie to DSH, so it cannot be handed over automatically. Copy it from your browser (DevTools → Application → Cookies → this site) and paste it into <b>Settings → Remote devices → Auth proxy → Session cookie</b>.</p>`}
</body>`)
},
}), 'dsh-remote: sign-in page')
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/dsh-remote/devices',