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

+17
View File
@@ -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 <JWT>`, 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 <static token>` | gateways that accept a static API token |
| `cookie` | the proxy session cookie you paste (`name=value`, or a whole `Cookie:` header) | **Authentik** `authentik_proxy_<hash>` 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 `<master>/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/<device>/…` | 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 <chrome> /tmp/out # headless UI check + screenshots
node deploy/session-log.mjs <sessionId> # compact transcript (DEVICE=ai-pc for a remote one)
node deploy/fake-edge.mjs 3098 <master> u p # Authentik-like auth proxy for tests
node deploy/e2e-signin.mjs <master> <code> 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
```
+115 -5
View File
@@ -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_<hash>). 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/<device>/.
// ------------------------------------------------------------------ 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');
+3
View File
@@ -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
+2 -5
View File
@@ -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'))
+92
View File
@@ -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 <masterHttpUrl> <pairingCode> <chromeBinary>
// 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)
+1 -1
View File
@@ -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)
})
+4 -1
View File
@@ -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 <file>]
//
@@ -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 <http(s)://…> --code <pairing code> [--name <device name>] [--edge basic|bearer|oauth2|headers …]')
console.error('usage: pair.mjs --master <http(s)://…> --code <pairing code> [--name <device 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
+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',
+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()],
}
},
}
}
+10
View File
@@ -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 }
+1
View File
@@ -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)
})
+1 -1
View File
@@ -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/<device>/), plugin + config sync with opt-in secrets.",
"private": true,
"type": "module",
+31 -2
View File
@@ -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('<html>Sign in</html>') })
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')