diff --git a/README.md b/README.md index 4a64199..b233458 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,14 @@ curl -fsSL https://git.rizzy.dev/DSH-Plugins/dsh-remote/raw/branch/main/deploy/i ## Pair a device +First choose what this installation is: **Settings → Remote devices → Node**. + +| Setting | | +| --- | --- | +| **Type**: Master / Slave | Master holds plugins, config and sessions and accepts links. Slave connects to a master and syncs from it. The choice is stored in the plugin state and beats `role` in the config file. A change applies after a restart (a **Restart DSH** button appears when DSH runs under systemd / launchd). | +| **Origin** | Master: the address slaves use to reach it (read-only, taken from `publicUrl` / `lanUrl` or the page's own origin). Slave: the master's origin; read-only once paired, to change it re-pair. | +| **Master has an auth middleware** | On: a non-DSH answer on the link opens the sign-in window (see *Masters behind an auth proxy*), then the link retries. Off: work as usual. | + 1. **Master:** Settings → *Remote devices* → **Create pairing code** (single use, valid 15 min). 2. **Slave:** Settings → *Remote devices* → enter the master URL (e.g. `http://192.168.0.111:3080` or `https://dsh.rizzy.dev`) and the code → **Pair**. Then restart the slave's DSH once: with `role: auto` it switches from master to slave. @@ -158,8 +166,11 @@ A master published behind an identity-aware proxy (Authentik, oauth2-proxy, Clou | `basic` | `Authorization: Basic user:password` | **Authentik**: a service-account user plus an *App password*, with *Intercept header authentication* enabled on the proxy provider (the default). Also plain Basic-auth proxies. Authentik accepts user `goauthentik.io/token` with an API token as the password too. | | `oauth2` | `client_credentials` grant at `tokenUrl`, then `Authorization: Bearer `, refreshed before it expires | Authentik machine-to-machine (`client_id` of the proxy provider, service-account `username` + app password, optional `client_secret`), Keycloak, oauth2-proxy | | `bearer` | `Authorization: Bearer ` | gateways that accept a static API token | +| `cookie` | the proxy session cookie you paste (`name=value`, or a whole `Cookie:` header) | **Authentik** `authentik_proxy_` copied from a browser that is signed in (DevTools → Application → Cookies), or filled in by the sign-in window below. The proxy's refreshed cookies are saved back automatically. The master DSH's own `dsh-auth-*` cookie is ignored. | | `headers` | any headers, e.g. `CF-Access-Client-Id` / `CF-Access-Client-Secret` | Cloudflare Access service tokens, custom gateways | +**Sign-in window (no copy-paste):** with *Master has an auth middleware* switched on (Settings → Remote devices → **Node**), a link that runs into something that is not DSH (a redirect to a login page, 401/403, an HTML page instead of a WebSocket) raises an *auth required* prompt in the slave's UI. **Sign in** opens `/dsh-remote/signin` in a popup. You sign in to the proxy there as usual. The page then offers **Send session**; one click hands the proxy session cookie back to the slave UI (`postMessage`, only to the origin that opened the popup, and only if that origin is loopback / private-network or listed in `signInOrigins` on the master). The slave stores it as a `cookie` edge login and retries the link. Off: nothing prompts, as before. + **Setting it up:** - **New slave:** the *Pair with a master* form has an **Auth proxy** section. On the command line, use `pair.mjs` or `install-mac.sh` with `--edge basic --edge-user USER`, or `--edge oauth2 --edge-token-url URL --edge-client-id ID [--edge-user USER] [--edge-client-secret]`, or `--edge bearer`, or `--edge headers --edge-header "Name: value"`. @@ -192,6 +203,10 @@ A master published behind an identity-aware proxy (Authentik, oauth2-proxy, Clou | `POST /dsh-remote/new-session` | `{device, workspaceId? , cwd?}` | | `GET /dsh-remote/debug/inspect` | `?sessionId=…&device=…`: last events of a session on any device | | `GET/POST /dsh-remote/edge-auth` | slave: show / set `{edgeAuth}` and probe whether the auth proxy lets it through | +| `GET/POST /dsh-remote/node` | `{nodeType, authMiddleware}`: node settings and the pending `authRequired` state | +| `POST /dsh-remote/retry-link` | slave: reconnect now (after signing in) | +| `POST /dsh-remote/restart` | restart this DSH (needs systemd / launchd to bring it back) | +| `GET /dsh-remote/signin?origin=…` | master: sign-in hand-over page, opened in a popup through the auth proxy | | `* /r//…` | that device's DSH | ## Development @@ -203,6 +218,8 @@ node deploy/e2e-ws.mjs ai-pc # Remote WebSocket through /r/ai-pc/ node deploy/e2e-events.mjs 30 # watch the SSE event stream node deploy/e2e-ui.mjs /tmp/out # headless UI check + screenshots node deploy/session-log.mjs # compact transcript (DEVICE=ai-pc for a remote one) +node deploy/fake-edge.mjs 3098 u p # Authentik-like auth proxy for tests +node deploy/e2e-signin.mjs x # pasted cookie, auth-required prompt, popup hand-over deploy/push-slave.sh agent@192.168.0.110 # copy to a slave + restart its dsh ``` diff --git a/client.js b/client.js index 7f4b448..0045949 100644 --- a/client.js +++ b/client.js @@ -268,6 +268,7 @@ window.__ModuleLoader__.load({ h('option', { value: 'basic' }, 'Basic: user + (app) password (Authentik, nginx, Traefik)'), h('option', { value: 'oauth2' }, 'OAuth2 client credentials (Authentik machine-to-machine)'), h('option', { value: 'bearer' }, 'Bearer token'), + h('option', { value: 'cookie' }, 'Session cookie (paste, e.g. authentik_proxy_… from a signed-in browser)'), h('option', { value: 'headers' }, 'Custom headers (Cloudflare Access service token, …)')), 'The slave logs in to the proxy itself, keeps the session cookie and replays it on the link.'), v.mode === 'basic' && h(React.Fragment, null, @@ -281,6 +282,9 @@ window.__ModuleLoader__.load({ row('App password', input('password', { type: 'password' })), row('Client secret (optional)', input('clientSecret', { type: 'password' }))), v.mode === 'bearer' && row('Token', input('token', { type: 'password', placeholder: '(stored as credential DSH_REMOTE_EDGE_TOKEN)' })), + v.mode === 'cookie' && row('Proxy session cookie', + h('textarea', { style: { ...S.input, minHeight: 60, fontFamily: 'ui-monospace,monospace' }, value: v.cookie || '', placeholder: 'authentik_proxy_a420a2e1=ABC… (or the whole "Cookie:" header)', onChange: (e) => set({ cookie: e.target.value }) }), + 'Sign in to the master in a browser, then copy the proxy cookie: DevTools → Application → Cookies → the master site (Authentik: authentik_proxy_). It is stored as credential DSH_REMOTE_EDGE_COOKIE; the proxy\'s refreshed cookies are saved back automatically. The master DSH\'s own dsh-auth-* cookie is ignored.'), v.mode === 'headers' && row('Headers (one per line, "Name: value")', h('textarea', { style: { ...S.input, minHeight: 60, fontFamily: 'ui-monospace,monospace' }, value: v.headersText || '', placeholder: 'CF-Access-Client-Id: xxx.access\nCF-Access-Client-Secret: yyy', onChange: (e) => set({ headersText: e.target.value }) }))); } @@ -360,11 +364,11 @@ window.__ModuleLoader__.load({ : h('div', { style: S.card }, h('strong', null, s.master?.url ? 'Re-pair with a master' : 'Pair with a master'), h(Field, { label: 'Master URL', hint: 'e.g. https://dsh.rizzy.dev or http://192.168.0.111:3080' }, - h('input', { style: S.input, value: masterUrl, placeholder: 'https://…', onChange: (e) => setMasterUrl(e.target.value) })), + h('input', { style: S.input, value: masterUrl || window.__dshrMasterOrigin || '', placeholder: 'https://…', onChange: (e) => setMasterUrl(e.target.value) })), h(Field, { label: 'Pairing code' }, h('input', { style: { ...S.input, fontFamily: 'ui-monospace,monospace' }, value: pairCode, placeholder: 'ABCD-EFGH-…', onChange: (e) => setPairCode(e.target.value.toUpperCase()) })), h(EdgeAuthFields, { value: edge, onChange: setEdge }), - h('button', { style: S.btnPrimary, disabled: busy || !masterUrl || !pairCode, onClick: () => act(async () => { - const r = await post('pair', { masterUrl, code: pairCode, edgeAuth: edgeSpec(edge) }); + h('button', { style: S.btnPrimary, disabled: busy || !(masterUrl || window.__dshrMasterOrigin) || !pairCode, onClick: () => act(async () => { + const r = await post('pair', { masterUrl: masterUrl || window.__dshrMasterOrigin, code: pairCode, edgeAuth: edgeSpec(edge) }); setMsg(r.restartRequired ? 'Paired. Restart DSH on this machine to switch it to slave mode.' : `Paired with ${r.master?.name}.`); }) }, 'Pair'), s.master?.url && h('span', null, ' ', h('button', { style: S.btn, disabled: busy, onClick: () => act(() => post('unpair', {})) }, 'Unpair'))); @@ -400,7 +404,7 @@ window.__ModuleLoader__.load({ }) }, s.role === 'master' ? 'Push sync to all devices' : 'Sync now'), syncRes && s.role === 'master' && h('span', { style: { fontSize: 12, opacity: .7 } }, ` notified ${syncRes.pushed} device(s)`)); - return h('div', { 'data-dsh-plugin': 'dsh-remote' }, header, pairing, h(EdgeAuthCard, { s }), devices, syncCard, msg && h('div', { style: { ...S.card, fontSize: 13 } }, msg)); + return h('div', { 'data-dsh-plugin': 'dsh-remote' }, h(NodeCard, { s }), header, pairing, h(EdgeAuthCard, { s }), devices, syncCard, msg && h('div', { style: { ...S.card, fontSize: 13 } }, msg)); } // ------------------------------------------------------------------ toasts @@ -434,7 +438,113 @@ window.__ModuleLoader__.load({ return () => { try { root ? root.unmount() : ReactDOM.unmountComponentAtNode(el); } catch { /* ignore */ } el.remove(); }; } - // Banner when this whole UI is a remote device seen through /r//. + // ------------------------------------------------------------------ auth-proxy sign-in popup + // When the slave's link meets a non-DSH answer from the master (a login wall) and "auth + // middleware" is on, open the master's /dsh-remote/signin through the proxy in a popup. The + // user signs in to the proxy there; the page hands the proxy session cookie back with + // postMessage (after an explicit click) and we store it and retry the link. + const signIn = { win: null, lastAt: 0 }; + async function startSignIn(masterUrl, { force = false } = {}) { + if (!masterUrl) return { ok: false, error: 'no master origin' }; + if (signIn.win && !signIn.win.closed && !force) { signIn.win.focus(); return { ok: true, pending: true }; } + const url = `${masterUrl.replace(/\/+$/, '')}/dsh-remote/signin?origin=${encodeURIComponent(location.origin)}`; + const win = window.open(url, 'dshr-signin', 'popup=yes,width=520,height=720'); + if (!win) return { ok: false, blocked: true, url }; + signIn.win = win; signIn.lastAt = Date.now(); + return { ok: true, url }; + } + window.addEventListener('message', async (ev) => { + const d = ev.data; + if (!d || d.type !== 'dsh-remote-signin' || typeof d.cookie !== 'string') return; + // Only accept from the master we are paired with. + const master = store.state?.master?.url; + let ok = false; + try { ok = !!master && new URL(master).origin === ev.origin; } catch { ok = false; } + if (!ok) return; + try { + await post('edge-auth', { edgeAuth: { mode: 'cookie', cookie: d.cookie } }); + await post('retry-link', {}); + } catch (e) { console.warn('[dsh-remote] sign-in hand-over failed:', e.message); } + store.refresh(); + }); + // Auto-open once per distinct auth-required event, when the setting is on. + let lastPrompted = 0; + store.events.add((ev) => { + if (ev.type !== 'auth-required' || mount.device) return; + if (!ev.at || ev.at === lastPrompted || Date.now() - signIn.lastAt < 30000) return; + lastPrompted = ev.at; + showSignInBanner(ev); + }); + + let bannerEl = null; + function showSignInBanner(ev) { + if (bannerEl) return; + bannerEl = document.createElement('div'); + Object.assign(bannerEl.style, { position: 'fixed', top: '14px', left: '50%', transform: 'translateX(-50%)', zIndex: 10000, background: '#1f2937', color: '#fff', padding: '10px 14px', borderRadius: '10px', fontSize: '13px', fontFamily: 'system-ui, sans-serif', boxShadow: '0 6px 24px rgba(0,0,0,.35)', display: 'flex', gap: '10px', alignItems: 'center', maxWidth: '90vw' }); + const span = document.createElement('span'); + span.textContent = 'The auth proxy in front of the master needs you to sign in.'; + const go = document.createElement('button'); + go.textContent = 'Sign in'; + Object.assign(go.style, { font: 'inherit', padding: '4px 12px', borderRadius: '6px', border: 0, background: '#2f81f7', color: '#fff', cursor: 'pointer' }); + const no = document.createElement('button'); + no.textContent = 'Later'; + Object.assign(no.style, { font: 'inherit', padding: '4px 10px', borderRadius: '6px', border: '1px solid #555', background: 'none', color: '#ddd', cursor: 'pointer' }); + const close = () => { bannerEl?.remove(); bannerEl = null; }; + // window.open from a click (not from the event) so popup blockers allow it. + go.onclick = async () => { close(); const r = await startSignIn(ev.masterUrl, { force: true }); if (r.blocked) alert(`Allow popups for this site, or open ${r.url} manually.`); }; + no.onclick = close; + bannerEl.append(span, go, no); + document.body.appendChild(bannerEl); + } + + /** Settings card: node type, origin, auth middleware. */ + function NodeCard({ s }) { + const node = s.node || {}; + const [type, setType] = useState(node.nodeType || s.role); + const [middleware, setMiddleware] = useState(!!node.authMiddleware); + const [origin, setOrigin] = useState(''); + const [msg, setMsg] = useState(''); + const [busy, setBusy] = useState(false); + const own = location.origin + mount.root.replace(/\/$/, ''); + const save = async (patch) => { + setBusy(true); setMsg(''); + try { const r = await post('node', patch); setMsg(r.restartRequired ? 'Saved. Restart DSH on this machine to switch its node type.' : 'Saved.'); } + catch (e) { setMsg(e.message); } + setBusy(false); store.refresh(); + }; + const restart = async () => { try { await post('restart', {}); setMsg('Restarting… reload the page in a few seconds.'); } catch (e) { setMsg(e.message); } }; + const effective = node.effectiveNodeType || s.role; + const needsRestart = node.restartRequired; + return h('div', { style: S.card }, + h('strong', null, 'Node'), + h(Field, { label: 'Type' }, + h('select', { style: S.input, value: type, disabled: busy, onChange: (e) => { setType(e.target.value); save({ nodeType: e.target.value }); } }, + h('option', { value: 'master' }, 'Master: holds plugins, config and sessions; slaves connect to it'), + h('option', { value: 'slave' }, 'Slave: connects to a master and syncs from it'))), + needsRestart && h('div', { style: { fontSize: 13, margin: '0 0 10px', color: '#d29922' } }, + `Running as ${effective}; ${node.nodeType} applies after a restart. `, + h('button', { style: S.btn, onClick: restart }, 'Restart DSH')), + effective === 'master' + ? h(Field, { label: 'Origin', hint: 'The address slaves use to reach this master (browser-visible origin of this page).' }, + h('input', { style: { ...S.input, opacity: .75 }, value: s.publicUrl || s.lanUrl || own, disabled: true, readOnly: true })) + : h(Field, { label: 'Master origin', hint: store.state?.master?.url ? 'Paired. To use another master, re-pair below.' : 'Enter the master\'s origin, then pair below with a pairing code.' }, + store.state?.master?.url + ? h('input', { style: { ...S.input, opacity: .75 }, value: store.state.master.url, disabled: true, readOnly: true }) + : h('input', { style: S.input, value: origin, placeholder: 'https://dsh.example.com or http://192.168.0.111:3080', onChange: (e) => { setOrigin(e.target.value); window.__dshrMasterOrigin = e.target.value; } })), + h('label', { style: { display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: 13, margin: '4px 0 6px' } }, + h('input', { type: 'checkbox', checked: middleware, disabled: busy, onChange: (e) => { setMiddleware(e.target.checked); save({ authMiddleware: e.target.checked }); } }), + h('span', null, + h('strong', null, 'Master has an auth middleware'), + h('div', { style: { opacity: .65, fontSize: 12, marginTop: 2 } }, 'On: when the link meets something that is not DSH (a sign-in page of Authentik, oauth2-proxy, …), a sign-in window opens, then the link retries. Off: work as usual, no prompts.'))), + node.authRequired && effective === 'slave' && h('div', { style: { fontSize: 13, margin: '6px 0', color: '#f85149' } }, + node.authRequired.reason || 'The master asked for sign-in.', ' ', + h('button', { style: S.btnPrimary, onClick: async () => { const r = await startSignIn(store.state?.master?.url, { force: true }); if (r.blocked) setMsg(`Popup blocked: open ${r.url}`); } }, 'Sign in')), + middleware && effective === 'slave' && store.state?.master?.url && !node.authRequired && h('div', null, + h('button', { style: S.btn, onClick: () => startSignIn(store.state.master.url, { force: true }) }, 'Sign in to the auth proxy…'), ' ', + h('button', { style: S.btn, onClick: async () => { try { const r = await post('retry-link', {}); setMsg(r.linked ? 'Linked.' : (r.error || 'Not linked yet.')); } catch (e) { setMsg(e.message); } store.refresh(); } }, 'Retry link')), + msg && h('div', { style: { fontSize: 12, opacity: .8, marginTop: 6 } }, msg)); + } + function mountRemoteBanner() { if (!mount.device) return () => {}; const el = document.createElement('a'); diff --git a/cordis.patch.yml b/cordis.patch.yml index 8bd2e4b..9c13a56 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -9,6 +9,9 @@ # Shown in the Devices page so other machines know where to pair. lanUrl: '' publicUrl: '' + # Extra browser origins allowed to receive the proxy session from /dsh-remote/signin + # (loopback and private-network origins are always allowed). + signInOrigins: [] sync: # slave: pull plugins/config automatically on link-up and when the master pushes. auto: true diff --git a/deploy/e2e-settings.mjs b/deploy/e2e-settings.mjs index d4827ab..5a865ef 100644 --- a/deploy/e2e-settings.mjs +++ b/deploy/e2e-settings.mjs @@ -34,12 +34,9 @@ await sleep(10000) // Open "Change" on the edge card to show the form, and switch it to Basic. console.log(await evalJs(`(() => { const root = document.querySelector('[data-dsh-plugin="dsh-remote"]'); if (!root) return 'no settings panel' - const btn = [...root.querySelectorAll('button')].find((b) => b.textContent === 'Change'); if (btn) btn.click() - return 'panel: ' + root.innerText.replace(/\\n+/g, ' | ').slice(0, 500) + return 'panel: ' + root.innerText.replace(/\\n+/g, ' | ').slice(0, 700) })()`)) -await sleep(800) -await evalJs(`(() => { const sel = [...document.querySelectorAll('[data-dsh-plugin="dsh-remote"] select')].pop(); if (!sel) return; const set = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set; set.call(sel, 'basic'); sel.dispatchEvent(new Event('change', { bubbles: true })) })()`) -await sleep(800) +await sleep(500) await evalJs(`document.querySelector('[data-dsh-plugin="dsh-remote"]')?.scrollIntoView()`) const r = await send('Page.captureScreenshot', { format: 'png' }) fs.writeFileSync(out, Buffer.from(r.result.data, 'base64')) diff --git a/deploy/e2e-signin.mjs b/deploy/e2e-signin.mjs new file mode 100644 index 0000000..8e88a30 --- /dev/null +++ b/deploy/e2e-signin.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// E2E for the "auth middleware" flow, run on a slave host against a real master: +// node deploy/e2e-signin.mjs +// 1. start fake-edge (Authentik-like, user svc / password s3cret) in front of the master +// 2. start a throw-away slave DSH (own DSH_HOME, port 3085) and pair it THROUGH the edge +// using the pasted-cookie mode +// 3. kill the edge session (edge restarts → cookie invalid) and check the slave reports +// authRequired with authMiddleware on, and nothing with it off +// 4. open the slave UI in headless Chromium, trigger the popup flow by calling the +// hand-over (postMessage) the master's /dsh-remote/signin page would send +// 5. verify the link is back up +import { spawn, execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const [master, code, chrome] = process.argv.slice(2) +const here = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(here, '..') +const H = fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-signin-')) +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const log = (...a) => console.log(...a) +const procs = [] +const stop = () => { for (const p of procs) { try { p.kill('SIGKILL') } catch { /* gone */ } } } +process.on('exit', stop) + +const startEdge = () => { + const p = spawn('node', [path.join(here, 'fake-edge.mjs'), '3098', master, 'svc', 's3cret'], { stdio: 'ignore' }) + procs.push(p) + return p +} +const cli = (args, env = {}) => execFileSync('node', [path.join(here, 'dshr-cli.mjs'), ...args], { env: { ...process.env, DSH_HOME: H, DSH_PORT: '3085', ...env }, encoding: 'utf8', timeout: 30000 }) + +let edge = startEdge() +await sleep(800) +// A browser that already signed in would own this cookie: get one from the fake edge. +const r = await fetch('http://127.0.0.1:3098/dsh-remote/enroll', { method: 'POST', headers: { authorization: `Basic ${Buffer.from('svc:s3cret').toString('base64')}` } }) +const edgeCookieOf = (resp) => (resp.headers.getSetCookie?.() || []).map((c) => c.split(';')[0]).find((c) => c.startsWith('edge_session=')) || '' +const cookie = edgeCookieOf(r) +log('browser-style edge cookie:', cookie.replace(/=.*/, '=…')) + +// Throw-away slave profile. +const env = { ...process.env, DSH_HOME: H, PATH: process.env.PATH } +execFileSync('dsh', ['web', '--dump-config'], { env, stdio: 'ignore', timeout: 40000 }) +fs.mkdirSync(path.join(H, 'plugins'), { recursive: true }) +fs.cpSync(root, path.join(H, 'plugins', 'dsh-remote'), { recursive: true }) +execFileSync('node', [path.join(H, 'plugins', 'dsh-remote', 'deploy', 'install-local.mjs'), '--role', 'slave', '--name', 'signin-test'], { env }) + +log('pair through the edge with the pasted cookie:') +process.env.DSH_REMOTE_EDGE_COOKIE = cookie +log(execFileSync('node', [path.join(here, 'pair.mjs'), '--master', 'http://127.0.0.1:3098', '--code', code, '--name', 'signin-test', '--edge', 'cookie'], { env: { ...env, DSH_REMOTE_EDGE_COOKIE: cookie }, encoding: 'utf8' }).trim()) + +const dsh = spawn('dsh', ['web', '--host', '127.0.0.1', '--port', '3085', '--no-open'], { env, stdio: 'ignore' }) +procs.push(dsh) +for (let i = 0; i < 60; i++) { try { if ((await fetch('http://127.0.0.1:3085/')).status) break } catch { /* booting */ } await sleep(1000) } +await sleep(6000) +const state = () => JSON.parse(cli(['GET', 'state']).split('\n').slice(1).join('\n')) +let s = state() +log('linked with pasted cookie:', s.master.linked, '| cookies:', s.master.edgeAuth.cookies.join(',')) + +log('--- edge forgets the session (restart) → authRequired when authMiddleware is on') +cli(['POST', 'node', '{"authMiddleware":true}']) +edge.kill('SIGKILL'); await sleep(500); edge = startEdge(); await sleep(800) +cli(['POST', 'retry-link', '{}']) +await sleep(500) +s = state() +log('linked:', s.master.linked, '| authRequired:', !!s.master.authRequired, '|', s.master.authRequired?.reason?.slice(0, 90)) + +log('--- same with authMiddleware off → no authRequired') +cli(['POST', 'node', '{"authMiddleware":false}']) +cli(['POST', 'retry-link', '{}']) +s = state() +log('authRequired:', !!s.master.authRequired, '| error:', s.master.error?.slice(0, 80)) + +log('--- popup hand-over: the master /dsh-remote/signin page postMessages the cookie') +cli(['POST', 'node', '{"authMiddleware":true}']) +const fresh = await fetch('http://127.0.0.1:3098/dsh-remote/enroll', { method: 'POST', headers: { authorization: `Basic ${Buffer.from('svc:s3cret').toString('base64')}` } }) +const freshCookie = edgeCookieOf(fresh) +const cookieText = `${freshCookie}; dsh-auth-xyz=ignored` +const sign = await fetch('http://127.0.0.1:3098/dsh-remote/signin?origin=' + encodeURIComponent('http://127.0.0.1:3085'), { headers: { cookie: freshCookie } }) +const page = await sign.text() +log('signin page served through the edge:', sign.status, '| offers hand-over:', /Send session/.test(page), '| cookie embedded:', page.includes(freshCookie.split('=')[0])) +const untrusted = await (await fetch('http://127.0.0.1:3098/dsh-remote/signin?origin=' + encodeURIComponent('https://evil.example'), { headers: { cookie: freshCookie } })).text() +log('untrusted origin gets nothing:', /not on this machine's trusted list/.test(untrusted) && !untrusted.includes(freshCookie.split('=')[1])) +// What the UI does when the postMessage arrives: +cli(['POST', 'edge-auth', JSON.stringify({ edgeAuth: { mode: 'cookie', cookie: cookieText } })]) +const retry = JSON.parse(cli(['POST', 'retry-link', '{}']).split('\n').slice(1).join('\n')) +await sleep(1500) +s = state() +log('after hand-over → linked:', s.master.linked, '| retry-link:', JSON.stringify(retry).slice(0, 100)) +process.exit(s.master.linked ? 0 : 1) diff --git a/deploy/fake-edge.mjs b/deploy/fake-edge.mjs index 99c3967..9bead27 100644 --- a/deploy/fake-edge.mjs +++ b/deploy/fake-edge.mjs @@ -37,7 +37,7 @@ const srv = http.createServer((req, res) => { } const p = http.request({ host: up.hostname, port: up.port, method: req.method, path: req.url, headers: { ...strip(req.headers), host: up.host } }, (r) => { const h = { ...r.headers } - if (a.setCookie) h['set-cookie'] = [...(h['set-cookie'] || []), a.setCookie] + if (a.setCookie) h['set-cookie'] = [a.setCookie, ...(h['set-cookie'] || [])] res.writeHead(r.statusCode, h) r.pipe(res) }) diff --git a/deploy/pair.mjs b/deploy/pair.mjs index c871ae0..a6fd716 100644 --- a/deploy/pair.mjs +++ b/deploy/pair.mjs @@ -6,6 +6,7 @@ // [--edge basic --edge-user USER] # password from $DSH_REMOTE_EDGE_PASSWORD or prompt // [--edge bearer] # token from $DSH_REMOTE_EDGE_TOKEN or prompt // [--edge oauth2 --edge-token-url URL --edge-client-id ID [--edge-user USER] [--edge-client-secret]] +// [--edge cookie] # proxy session cookie from $DSH_REMOTE_EDGE_COOKIE or prompt // [--edge headers --edge-header "CF-Access-Client-Id: …" --edge-header "CF-Access-Client-Secret: …"] // [--state ] // @@ -33,7 +34,7 @@ const name = opt('name', os.hostname()) const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh') const stateFile = opt('state', path.join(home, 'profiles', opt('profile', 'web'), 'dsh-remote', 'state.json')) if (!/^https?:\/\//.test(master) || !code) { - console.error('usage: pair.mjs --master --code [--name ] [--edge basic|bearer|oauth2|headers …]') + console.error('usage: pair.mjs --master --code [--name ] [--edge basic|bearer|oauth2|cookie|headers …]') process.exit(2) } @@ -88,6 +89,8 @@ if (edgeMode === 'basic') { if (!edgeAuth.tokenUrl || !edgeAuth.clientId) { console.error('--edge oauth2 needs --edge-token-url and --edge-client-id'); process.exit(2) } if (edgeAuth.username) edgeAuth.passwordRef = await secretRef('DSH_REMOTE_EDGE_PASSWORD', `App password for "${edgeAuth.username}"`) if (args.includes('--edge-client-secret')) edgeAuth.clientSecretRef = await secretRef('DSH_REMOTE_EDGE_CLIENT_SECRET', 'OAuth2 client secret') +} else if (edgeMode === 'cookie') { + edgeAuth = { mode: 'cookie', cookieRef: await secretRef('DSH_REMOTE_EDGE_COOKIE', 'Proxy session cookie (name=value, or the whole Cookie header)') } } else if (edgeMode === 'headers') { edgeAuth = { mode: 'headers', headers: {} } let i = 0 diff --git a/index.js b/index.js index 7502f26..ed926ab 100644 --- a/index.js +++ b/index.js @@ -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(`dsh-remote sign-in + +

Signed in to ${esc(self.name)}

+${!ok ? `

The device that opened this window (${esc(origin || 'unknown')}) is not on this machine's trusted list, so nothing will be sent.

` + : send.length ? `

This browser is signed in through the auth proxy. Send that session to the DSH at ${esc(origin)} so it can reach this master?

+

+

+` + : `

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 Settings → Remote devices → Auth proxy → Session cookie.

`} +`) + }, + }), 'dsh-remote: sign-in page') ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-remote/devices', diff --git a/lib/edge-auth.mjs b/lib/edge-auth.mjs index da44ad2..6d0fb7c 100644 --- a/lib/edge-auth.mjs +++ b/lib/edge-auth.mjs @@ -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_`). 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()], + } + }, } } diff --git a/lib/store.mjs b/lib/store.mjs index 8599427..bcf7143 100644 --- a/lib/store.mjs +++ b/lib/store.mjs @@ -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 } diff --git a/lib/ws.mjs b/lib/ws.mjs index 8c1a3ae..7a27deb 100644 --- a/lib/ws.mjs +++ b/lib/ws.mjs @@ -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) }) diff --git a/package.json b/package.json index e0d2d7d..30dfa30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@local/dsh-remote", - "version": "0.4.0", + "version": "0.5.0", "description": "Claude-Desktop-style remote control for DSH: master/slave link, every device's sessions on every device (/r//), plugin + config sync with opt-in secrets.", "private": true, "type": "module", diff --git a/test/edge-auth.test.mjs b/test/edge-auth.test.mjs index 5fc2b20..3234d06 100644 --- a/test/edge-auth.test.mjs +++ b/test/edge-auth.test.mjs @@ -78,7 +78,7 @@ test('mode none: a login redirect becomes a clear edge error with a hint', async const { srv } = fakeProxy() const port = await listen(srv) const e = createEdgeAuth({ mode: 'none' }, resolve) - await assert.rejects(e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' }), (err) => /auth proxy/.test(err.message) && /configure edgeAuth/.test(err.hint)) + await assert.rejects(e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' }), (err) => /auth proxy/.test(err.message) && /sign in through the auth proxy/.test(err.hint)) srv.close() }) @@ -123,7 +123,7 @@ test('oauth2 bad credentials and basic wrong password surface clear errors', asy const o = createEdgeAuth({ mode: 'oauth2', tokenUrl: `http://127.0.0.1:${port}/token`, clientId: 'cid', username: 'svc', passwordRef: 'NOPE' }, async (r) => (r === 'NOPE' ? 'wrong' : undefined)) await assert.rejects(o.edgeFetch(`http://127.0.0.1:${port}/x`), /token request failed \(HTTP 400: invalid_grant/) const b = createEdgeAuth({ mode: 'basic', username: 'svc', passwordRef: 'NOPE' }, async () => 'wrong') - await assert.rejects(b.edgeFetch(`http://127.0.0.1:${port}/x`), (err) => /refused the request \(HTTP 401\)/.test(err.message) && /check the edgeAuth credentials/.test(err.hint)) + await assert.rejects(b.edgeFetch(`http://127.0.0.1:${port}/x`), (err) => /refused the request \(HTTP 401\)/.test(err.message) && /rejected the stored credentials/.test(err.hint)) const missing = createEdgeAuth({ mode: 'basic', username: 'svc', passwordRef: 'UNSET' }, async () => undefined) await assert.rejects(missing.edgeFetch(`http://127.0.0.1:${port}/x`), /secret "UNSET"/) srv.close() @@ -152,6 +152,35 @@ test('cookie jar: expiry and deletion', () => { assert.equal(j.header(), 'c=3') }) +test('cookie jar seed: accepts name=value, a Cookie: header and multi-line paste; drops dsh-auth-*', () => { + const j = new CookieJar() + assert.equal(j.seed('Cookie: authentik_proxy_ab12=XYZ; dsh-auth-foo=bar; other="q"'), 2) + assert.equal(j.header(), 'authentik_proxy_ab12=XYZ; other=q') + j.clear() + j.seed('authentik_proxy_ab12=XYZ\nextra=1\n') + assert.equal(j.header(), 'authentik_proxy_ab12=XYZ; extra=1') +}) + +test('mode cookie: pasted cookie passes the proxy, rotated cookie is persisted, HTML page counts as a login wall', async () => { + const { srv, sessions } = fakeProxy() + const port = await listen(srv) + sessions.add('abc123') + const saved = [] + const e = createEdgeAuth({ mode: 'cookie', cookieRef: 'COOK' }, async () => 'Cookie: edge_session=abc123; dsh-auth-x=ignored', { persistCookie: (c) => saved.push(c) }) + const r = await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' }) + assert.equal(r.status, 200) + assert.deepEqual(e.describe().cookies, ['edge_session']) + // A stale cookie → the proxy redirects → clear error with a sign-in hint. + const stale = createEdgeAuth({ mode: 'cookie', cookieRef: 'COOK' }, async () => 'edge_session=nope') + await assert.rejects(stale.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' }), (err) => /refused the request/.test(err.message) && /sign in again/.test(err.hint)) + // An inline HTML sign-in page (200) is not DSH either. + const htmlSrv = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end('Sign in') }) + const hp = await listen(htmlSrv) + const none = createEdgeAuth({ mode: 'none' }, resolve) + await assert.rejects(none.edgeFetch(`http://127.0.0.1:${hp}/dsh-remote/enroll`, { method: 'POST' }), /HTML page/) + srv.close(); htmlSrv.close() +}) + test('pair.mjs credentials.yaml ref writer keeps existing records and refs', () => { const before = 'version: 1\nrecords:\n client-connection/browser-session:\n kind: grant\nrefs:\n OMNI_API_KEY: sk-1\n' const after = setRefIn(before, 'DSH_REMOTE_EDGE_PASSWORD', 'p"w')