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
+50
-8
@@ -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()],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,16 @@ export class Store {
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- UI-managed node settings ---------------------------------------------
|
||||
/** { nodeType?: 'master'|'slave', authMiddleware?: boolean } — set from Settings → Remote devices. */
|
||||
settings() { return (this.data.settings ||= {}) }
|
||||
|
||||
setSettings(patch) {
|
||||
this.data.settings = { ...this.settings(), ...patch }
|
||||
this.save()
|
||||
return this.data.settings
|
||||
}
|
||||
|
||||
// ---- slave side: link to master ------------------------------------------
|
||||
get master() { return this.data.master || null }
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ export function connect(url, { headers = {}, timeoutMs = 15000, rejectUnauthoriz
|
||||
const err = new Error(`upgrade refused: HTTP ${res.statusCode}${loc} ${res.headers.location ? '' : body.trim().slice(0, 200)}`.trim())
|
||||
err.status = res.statusCode
|
||||
err.location = res.headers.location
|
||||
err.contentType = String(res.headers['content-type'] || '')
|
||||
err.body = body
|
||||
reject(err)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user