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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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',
|
||||
|
||||
Reference in new issue
Block a user