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 ## 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). 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. 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. | | `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 | | `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 | | `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 | | `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:** **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"`. - **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?}` | | `POST /dsh-remote/new-session` | `{device, workspaceId? , cwd?}` |
| `GET /dsh-remote/debug/inspect` | `?sessionId=…&device=…`: last events of a session on any device | | `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/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 | | `* /r/<device>/…` | that device's DSH |
## Development ## 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-events.mjs 30 # watch the SSE event stream
node deploy/e2e-ui.mjs <chrome> /tmp/out # headless UI check + screenshots 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/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 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: 'basic' }, 'Basic: user + (app) password (Authentik, nginx, Traefik)'),
h('option', { value: 'oauth2' }, 'OAuth2 client credentials (Authentik machine-to-machine)'), h('option', { value: 'oauth2' }, 'OAuth2 client credentials (Authentik machine-to-machine)'),
h('option', { value: 'bearer' }, 'Bearer token'), 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, …)')), 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.'), '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, v.mode === 'basic' && h(React.Fragment, null,
@@ -281,6 +282,9 @@ window.__ModuleLoader__.load({
row('App password', input('password', { type: 'password' })), row('App password', input('password', { type: 'password' })),
row('Client secret (optional)', input('clientSecret', { 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 === '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")', 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 }) }))); 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('div', { style: S.card },
h('strong', null, s.master?.url ? 'Re-pair with a master' : 'Pair with a master'), 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(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(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(EdgeAuthFields, { value: edge, onChange: setEdge }),
h('button', { style: S.btnPrimary, disabled: busy || !masterUrl || !pairCode, onClick: () => act(async () => { h('button', { style: S.btnPrimary, disabled: busy || !(masterUrl || window.__dshrMasterOrigin) || !pairCode, onClick: () => act(async () => {
const r = await post('pair', { masterUrl, code: pairCode, edgeAuth: edgeSpec(edge) }); 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}.`); setMsg(r.restartRequired ? 'Paired. Restart DSH on this machine to switch it to slave mode.' : `Paired with ${r.master?.name}.`);
}) }, 'Pair'), }) }, 'Pair'),
s.master?.url && h('span', null, ' ', h('button', { style: S.btn, disabled: busy, onClick: () => act(() => post('unpair', {})) }, 'Unpair'))); 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'), }) }, 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)`)); 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 // ------------------------------------------------------------------ toasts
@@ -434,7 +438,113 @@ window.__ModuleLoader__.load({
return () => { try { root ? root.unmount() : ReactDOM.unmountComponentAtNode(el); } catch { /* ignore */ } el.remove(); }; 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() { function mountRemoteBanner() {
if (!mount.device) return () => {}; if (!mount.device) return () => {};
const el = document.createElement('a'); const el = document.createElement('a');
+3
View File
@@ -9,6 +9,9 @@
# Shown in the Devices page so other machines know where to pair. # Shown in the Devices page so other machines know where to pair.
lanUrl: '' lanUrl: ''
publicUrl: '' publicUrl: ''
# Extra browser origins allowed to receive the proxy session from /dsh-remote/signin
# (loopback and private-network origins are always allowed).
signInOrigins: []
sync: sync:
# slave: pull plugins/config automatically on link-up and when the master pushes. # slave: pull plugins/config automatically on link-up and when the master pushes.
auto: true 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. // Open "Change" on the edge card to show the form, and switch it to Basic.
console.log(await evalJs(`(() => { console.log(await evalJs(`(() => {
const root = document.querySelector('[data-dsh-plugin="dsh-remote"]'); if (!root) return 'no settings panel' 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, 700)
return 'panel: ' + root.innerText.replace(/\\n+/g, ' | ').slice(0, 500)
})()`)) })()`))
await sleep(800) await sleep(500)
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 evalJs(`document.querySelector('[data-dsh-plugin="dsh-remote"]')?.scrollIntoView()`) await evalJs(`document.querySelector('[data-dsh-plugin="dsh-remote"]')?.scrollIntoView()`)
const r = await send('Page.captureScreenshot', { format: 'png' }) const r = await send('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(out, Buffer.from(r.result.data, 'base64')) 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 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 } 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) res.writeHead(r.statusCode, h)
r.pipe(res) 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 basic --edge-user USER] # password from $DSH_REMOTE_EDGE_PASSWORD or prompt
// [--edge bearer] # token from $DSH_REMOTE_EDGE_TOKEN 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 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: …"] // [--edge headers --edge-header "CF-Access-Client-Id: …" --edge-header "CF-Access-Client-Secret: …"]
// [--state <file>] // [--state <file>]
// //
@@ -33,7 +34,7 @@ const name = opt('name', os.hostname())
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh') 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')) const stateFile = opt('state', path.join(home, 'profiles', opt('profile', 'web'), 'dsh-remote', 'state.json'))
if (!/^https?:\/\//.test(master) || !code) { 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) 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.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 (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') 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') { } else if (edgeMode === 'headers') {
edgeAuth = { mode: 'headers', headers: {} } edgeAuth = { mode: 'headers', headers: {} }
let i = 0 let i = 0
+164 -11
View File
@@ -89,7 +89,11 @@ export function apply(ctx, config = {}) {
self.name = config.deviceName self.name = config.deviceName
store.save() 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 log = ctx.logger ?? console
const cookies = new CookieJar(ctx) const cookies = new CookieJar(ctx)
const localTarget = () => ({ host: '127.0.0.1', port: ctx.webServer.port }) const localTarget = () => ({ host: '127.0.0.1', port: ctx.webServer.port })
@@ -415,7 +419,14 @@ export function apply(ctx, config = {}) {
// ------------------------------------------------------------------ slave: keep link up // ------------------------------------------------------------------ slave: keep link up
let stopped = false let stopped = false
let backoff = 1000 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 const m = store.master
if (!m || stopped) return if (!m || stopped) return
const url = new URL(m.url) 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 }) const mux = new Mux(ws, { odd: true, handlers, onStream: serveStream })
mux.deviceId = 'master' mux.deviceId = 'master'
masterLink = { mux, info: null, peers: [] } const link = { mux, info: null, peers: [] }
masterLink = link
backoff = 1000 backoff = 1000
mux.on('event', (n, d) => onLinkEvent(n, d, 'master')) mux.on('event', (n, d) => onLinkEvent(n, d, 'master'))
mux.on('close', (code, reason) => { mux.on('close', (code, reason) => {
masterLink = null if (masterLink === link) masterLink = null
notify({ type: 'devices' }) notify({ type: 'devices' })
if (code === 4401) { if (code === 4401) {
lastLinkError = `master rejected this device (${reason || 'revoked'}); re-pair it` lastLinkError = `master rejected this device (${reason || 'revoked'}); re-pair it`
@@ -449,15 +461,16 @@ export function apply(ctx, config = {}) {
if (!stopped) schedule() if (!stopped) schedule()
}) })
const info = await mux.call('hello', { id: self.id, name: self.name }) 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. // 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' }) notify({ type: 'devices' })
lastLinkError = null lastLinkError = null
authRequired = null
log.info?.(`dsh-remote: linked to master ${info.name} (${m.url})`) 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}`)) if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
} catch (e) { } 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 // 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. // 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 || '') const deviceRefused = e.status === 401 && /unknown or revoked device/.test(e.body || '')
@@ -466,17 +479,33 @@ export function apply(ctx, config = {}) {
if (edgeRefused) { if (edgeRefused) {
const mode = edgeFor(m).mode const mode = edgeFor(m).mode
lastLinkError = mode === 'none' 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})` : `auth proxy in front of ${url.host} rejected the edgeAuth credentials (${e.status})`
edgeFor(m).reset() edgeFor(m).reset()
backoff = Math.max(backoff, 15000) 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 } else lastLinkError = e.message
log.warn(`dsh-remote: link to master failed: ${lastLinkError}`) log.warn(`dsh-remote: link to master failed: ${lastLinkError}`)
schedule() 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. // time, overridden by `edgeAuth` in this plugin's config. Secrets resolve via credentials.
let edgeCache = null let edgeCache = null
function edgeConfigFor(m) { function edgeConfigFor(m) {
@@ -485,7 +514,7 @@ export function apply(ctx, config = {}) {
function edgeFor(m) { function edgeFor(m) {
const cfg = edgeConfigFor(m) const cfg = edgeConfigFor(m)
const key = JSON.stringify(cfg) 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 return edgeCache.client
} }
async function resolveSecretRef(ref) { async function resolveSecretRef(ref) {
@@ -495,6 +524,11 @@ export function apply(ctx, config = {}) {
} }
return process.env[ref] 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 lastLinkError = null
let timer = null let timer = null
function schedule() { function schedule() {
@@ -615,6 +649,10 @@ export function apply(ctx, config = {}) {
setTimeout(() => attempt(0), 3000).unref?.() 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 // ------------------------------------------------------------------ HTTP API
const route = (path, methods, handler) => ctx.effect(() => ctx.webServer.register({ const route = (path, methods, handler) => ctx.effect(() => ctx.webServer.register({
kind: 'exact', kind: 'exact',
@@ -634,7 +672,8 @@ export function apply(ctx, config = {}) {
route('/dsh-remote/state', ['GET'], async () => ({ route('/dsh-remote/state', ['GET'], async () => ({
role, role,
self, 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(), devices: devicesView(),
publicUrl: config.publicUrl || null, publicUrl: config.publicUrl || null,
lanUrl: config.lanUrl || 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 (!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.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) 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') { } else if (spec.mode === 'headers') {
out.headers = {} out.headers = {}
let i = 0 let i = 0
@@ -762,6 +804,117 @@ export function apply(ctx, config = {}) {
return { ok: true } 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({ ctx.effect(() => ctx.webServer.register({
kind: 'prefix', kind: 'prefix',
path: '/dsh-remote/devices', path: '/dsh-remote/devices',
+50 -8
View File
@@ -74,6 +74,24 @@ export class CookieJar {
} }
clear() { this.cookies.clear() } 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 cfg edgeAuth config ({mode, ...})
* @param resolveSecret async (refName) => string|undefined (DSH credentials, then env) * @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 mode = cfg?.mode || 'none'
const jar = new CookieJar() const jar = new CookieJar()
let token = null // { value, expiresAt } 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) => { const secret = async (ref, what) => {
if (!ref) throw new EdgeAuthError(`edgeAuth: ${what} is not configured`) 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 || {})) { 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) 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') { } else if (mode !== 'none') {
throw new EdgeAuthError(`edgeAuth: unknown mode "${mode}"`) 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. */ /** 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 >= 300 && status < 400 && location && !/\/dsh-remote\//.test(location)) return `redirected to ${String(location).slice(0, 90)}`
if (status === 401 || status === 403) return `HTTP ${status}` 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 return null
} }
@@ -153,16 +186,18 @@ export function createEdgeAuth(cfg, resolveSecret) {
const h = { ...(init.headers || {}), ...(await headers({ forceRefresh: attempt > 0 })) } const h = { ...(init.headers || {}), ...(await headers({ forceRefresh: attempt > 0 })) }
const r = await fetch(url, { ...init, headers: h, redirect: 'manual' }) const r = await fetch(url, { ...init, headers: h, redirect: 'manual' })
jar.absorb(r.headers) 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 if (!blocked) return r
// A DSH JSON error (e.g. invalid pairing code → 403) is not an edge refusal. // 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 } 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})`, { throw new EdgeAuthError(`the auth proxy in front of ${new URL(url).host} refused the request (${blocked})`, {
status: r.status, status: r.status,
hint: mode === 'none' hint: mode === 'none'
? 'configure edgeAuth (basic / bearer / oauth2 / headers) for this master, or exempt /dsh-remote/link and /dsh-remote/enroll on the proxy' ? 'sign in through the auth proxy (Settings → Remote devices), configure edgeAuth, or exempt /dsh-remote/link and /dsh-remote/enroll on the proxy'
: 'check the edgeAuth credentials and that this identity may access the application', : 'the proxy rejected the stored credentials or cookie; sign in again or check the edgeAuth settings',
}) })
} }
throw new EdgeAuthError('unreachable') throw new EdgeAuthError('unreachable')
@@ -175,8 +210,15 @@ export function createEdgeAuth(cfg, resolveSecret) {
edgeFetch, edgeFetch,
edgeBlocked, edgeBlocked,
/** Feed upgrade-response cookies back (WebSocket handshake). */ /** Feed upgrade-response cookies back (WebSocket handshake). */
absorb: (h) => jar.absorb(h), absorb: (h) => { jar.absorb(h); persist() },
reset() { jar.clear(); token = null }, 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 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 ------------------------------------------ // ---- slave side: link to master ------------------------------------------
get master() { return this.data.master || null } 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()) const err = new Error(`upgrade refused: HTTP ${res.statusCode}${loc} ${res.headers.location ? '' : body.trim().slice(0, 200)}`.trim())
err.status = res.statusCode err.status = res.statusCode
err.location = res.headers.location err.location = res.headers.location
err.contentType = String(res.headers['content-type'] || '')
err.body = body err.body = body
reject(err) reject(err)
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@local/dsh-remote", "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.", "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, "private": true,
"type": "module", "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 { srv } = fakeProxy()
const port = await listen(srv) const port = await listen(srv)
const e = createEdgeAuth({ mode: 'none' }, resolve) 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() 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)) 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/) 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') 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) 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"/) await assert.rejects(missing.edgeFetch(`http://127.0.0.1:${port}/x`), /secret "UNSET"/)
srv.close() srv.close()
@@ -152,6 +152,35 @@ test('cookie jar: expiry and deletion', () => {
assert.equal(j.header(), 'c=3') 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', () => { 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 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') const after = setRefIn(before, 'DSH_REMOTE_EDGE_PASSWORD', 'p"w')