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

+50 -8
View File
@@ -74,6 +74,24 @@ export class CookieJar {
}
clear() { this.cookies.clear() }
/**
* Seed from a pasted cookie string: "a=1; b=2", a "Cookie: a=1; b=2" header line, or one
* "name=value" per line (as copied from browser dev tools). The master DSH's own
* `dsh-auth-*` cookie is dropped (the slave authenticates to DSH with its device key).
*/
seed(text) {
const body = String(text || '').trim().replace(/^cookie\s*:\s*/i, '')
for (const part of body.split(/;|\r?\n/)) {
const p = part.trim()
const eq = p.indexOf('=')
if (eq <= 0) continue
const name = p.slice(0, eq).trim()
if (/^dsh-auth-/.test(name) || /\s/.test(name)) continue
this.cookies.set(name, { value: p.slice(eq + 1).trim().replace(/^"|"$/g, ''), expires: null })
}
return this.cookies.size
}
}
/**
@@ -81,10 +99,17 @@ export class CookieJar {
* @param cfg edgeAuth config ({mode, ...})
* @param resolveSecret async (refName) => string|undefined (DSH credentials, then env)
*/
export function createEdgeAuth(cfg, resolveSecret) {
export function createEdgeAuth(cfg, resolveSecret, { persistCookie } = {}) {
const mode = cfg?.mode || 'none'
const jar = new CookieJar()
let token = null // { value, expiresAt }
let persisted = ''
/** Cookie mode: write rotated proxy cookies back so the session keeps extending itself. */
function persist() {
if (mode !== 'cookie' || !persistCookie) return
const h = jar.header()
if (h && h !== persisted) { persisted = h; Promise.resolve(persistCookie(h)).catch(() => {}) }
}
const secret = async (ref, what) => {
if (!ref) throw new EdgeAuthError(`edgeAuth: ${what} is not configured`)
@@ -128,6 +153,11 @@ export function createEdgeAuth(cfg, resolveSecret) {
for (const [k, v] of Object.entries(cfg.headers || {})) {
h[k.toLowerCase()] = typeof v === 'string' && v.startsWith('ref:') ? await secret(v.slice(4), k) : String(v)
}
} else if (mode === 'cookie') {
// A session cookie copied from a browser that already logged in through the proxy
// (e.g. Authentik's `authentik_proxy_<hash>`). Seeded once; cookies the proxy rotates
// afterwards replace it in the jar for the life of this process.
if (!jar.cookies.size) jar.seed(await secret(cfg.cookieRef, 'proxy session cookie'))
} else if (mode !== 'none') {
throw new EdgeAuthError(`edgeAuth: unknown mode "${mode}"`)
}
@@ -137,9 +167,12 @@ export function createEdgeAuth(cfg, resolveSecret) {
}
/** Classify a response that came back from the edge instead of DSH. */
function edgeBlocked(status, location) {
function edgeBlocked(status, location, contentType = '') {
if (status >= 300 && status < 400 && location && !/\/dsh-remote\//.test(location)) return `redirected to ${String(location).slice(0, 90)}`
if (status === 401 || status === 403) return `HTTP ${status}`
// Some proxies answer with the login page inline (HTTP 200 text/html); DSH's own
// endpoints answer JSON / plain text, never HTML.
if (status === 200 && /text\/html/i.test(contentType)) return 'answered with an HTML page (sign-in page?)'
return null
}
@@ -153,16 +186,18 @@ export function createEdgeAuth(cfg, resolveSecret) {
const h = { ...(init.headers || {}), ...(await headers({ forceRefresh: attempt > 0 })) }
const r = await fetch(url, { ...init, headers: h, redirect: 'manual' })
jar.absorb(r.headers)
const blocked = edgeBlocked(r.status, r.headers.get('location'))
persist()
const ct = r.headers.get('content-type') || ''
const blocked = edgeBlocked(r.status, r.headers.get('location'), ct)
if (!blocked) return r
// A DSH JSON error (e.g. invalid pairing code → 403) is not an edge refusal.
if ((r.status === 401 || r.status === 403) && /json/.test(r.headers.get('content-type') || '')) return r
if ((r.status === 401 || r.status === 403) && /json/.test(ct)) return r
if (attempt === 0 && mode !== 'none') { jar.clear(); token = null; continue }
throw new EdgeAuthError(`the auth proxy in front of ${new URL(url).host} refused the request (${blocked})`, {
status: r.status,
hint: mode === 'none'
? 'configure edgeAuth (basic / bearer / oauth2 / headers) for this master, or exempt /dsh-remote/link and /dsh-remote/enroll on the proxy'
: 'check the edgeAuth credentials and that this identity may access the application',
? 'sign in through the auth proxy (Settings → Remote devices), configure edgeAuth, or exempt /dsh-remote/link and /dsh-remote/enroll on the proxy'
: 'the proxy rejected the stored credentials or cookie; sign in again or check the edgeAuth settings',
})
}
throw new EdgeAuthError('unreachable')
@@ -175,8 +210,15 @@ export function createEdgeAuth(cfg, resolveSecret) {
edgeFetch,
edgeBlocked,
/** Feed upgrade-response cookies back (WebSocket handshake). */
absorb: (h) => jar.absorb(h),
absorb: (h) => { jar.absorb(h); persist() },
reset() { jar.clear(); token = null },
describe() { return { mode, ...(mode === 'basic' ? { username: cfg.username } : {}), ...(mode === 'oauth2' ? { tokenUrl: cfg.tokenUrl, clientId: cfg.clientId, username: cfg.username, tokenValidUntil: token ? new Date(token.expiresAt).toISOString() : null } : {}), cookies: [...jar.cookies.keys()] } },
describe() {
return {
mode,
...(mode === 'basic' ? { username: cfg.username } : {}),
...(mode === 'oauth2' ? { tokenUrl: cfg.tokenUrl, clientId: cfg.clientId, username: cfg.username, tokenValidUntil: token ? new Date(token.expiresAt).toISOString() : null } : {}),
cookies: [...jar.cookies.keys()],
}
},
}
}