dsh-remote 0.5.0: node settings, session-cookie edge auth, sign-in popup
- Settings → Remote devices → Node: type master|slave (stored in state, beats config role; Restart DSH button under systemd/launchd), origin (read-only: own origin / paired master), 'Master has an auth middleware' toggle - middleware on: a non-DSH answer on the link (login redirect, 401/403, inline HTML) raises auth-required; the UI opens <master>/dsh-remote/signin in a popup, the user signs in to the proxy, one click hands the proxy session cookie back (postMessage, origin-checked, trusted origins only), link retries - edge mode 'cookie': pasted authentik_proxy_… (or a whole Cookie header), rotated proxy cookies persisted back; pair.mjs/install-mac --edge cookie - connectMaster serialised; a failed attempt never clears a finished link - tests: cookie seed/mode, HTML login wall; e2e-signin against a fake proxy
This commit is contained in:
1 parent
78fce8e1ce
commit
bfbc3c66e4
13 files changed
+491
-34
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -89,7 +89,11 @@ export function apply(ctx, config = {}) {
|
||||
self.name = config.deviceName
|
||||
store.save()
|
||||
}
|
||||
const role = config.role === 'master' ? 'master' : config.role === 'slave' ? 'slave' : (store.master ? 'slave' : 'master')
|
||||
// Node type: Settings → Remote devices (stored in state) beats the config file; "auto"
|
||||
// (nothing chosen) = slave once paired with a master, else master.
|
||||
const uiSettings = store.settings()
|
||||
const configuredRole = ['master', 'slave'].includes(uiSettings.nodeType) ? uiSettings.nodeType : config.role
|
||||
const role = configuredRole === 'master' ? 'master' : configuredRole === 'slave' ? 'slave' : (store.master ? 'slave' : 'master')
|
||||
const log = ctx.logger ?? console
|
||||
const cookies = new CookieJar(ctx)
|
||||
const localTarget = () => ({ host: '127.0.0.1', port: ctx.webServer.port })
|
||||
@@ -415,7 +419,14 @@ export function apply(ctx, config = {}) {
|
||||
// ------------------------------------------------------------------ slave: keep link up
|
||||
let stopped = false
|
||||
let backoff = 1000
|
||||
async function connectMaster() {
|
||||
let connecting = null
|
||||
/** One connect attempt at a time (retry-link, the backoff timer and edge changes can race). */
|
||||
function connectMaster() {
|
||||
if (connecting) return connecting
|
||||
connecting = connectMasterOnce().finally(() => { connecting = null })
|
||||
return connecting
|
||||
}
|
||||
async function connectMasterOnce() {
|
||||
const m = store.master
|
||||
if (!m || stopped) return
|
||||
const url = new URL(m.url)
|
||||
@@ -434,11 +445,12 @@ export function apply(ctx, config = {}) {
|
||||
})
|
||||
const mux = new Mux(ws, { odd: true, handlers, onStream: serveStream })
|
||||
mux.deviceId = 'master'
|
||||
masterLink = { mux, info: null, peers: [] }
|
||||
const link = { mux, info: null, peers: [] }
|
||||
masterLink = link
|
||||
backoff = 1000
|
||||
mux.on('event', (n, d) => onLinkEvent(n, d, 'master'))
|
||||
mux.on('close', (code, reason) => {
|
||||
masterLink = null
|
||||
if (masterLink === link) masterLink = null
|
||||
notify({ type: 'devices' })
|
||||
if (code === 4401) {
|
||||
lastLinkError = `master rejected this device (${reason || 'revoked'}); re-pair it`
|
||||
@@ -449,15 +461,16 @@ export function apply(ctx, config = {}) {
|
||||
if (!stopped) schedule()
|
||||
})
|
||||
const info = await mux.call('hello', { id: self.id, name: self.name })
|
||||
masterLink.info = { id: info.id, name: info.name }
|
||||
link.info = { id: info.id, name: info.name }
|
||||
// Only devices that are online on the master are reachable through its relay.
|
||||
masterLink.peers = (await mux.call('devices', {})).filter((d) => d.online && !d.revoked).map(({ id, name, role: r }) => ({ id, name, role: r }))
|
||||
link.peers = (await mux.call('devices', {})).filter((d) => d.online && !d.revoked).map(({ id, name, role: r }) => ({ id, name, role: r }))
|
||||
notify({ type: 'devices' })
|
||||
lastLinkError = null
|
||||
authRequired = null
|
||||
log.info?.(`dsh-remote: linked to master ${info.name} (${m.url})`)
|
||||
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
|
||||
} catch (e) {
|
||||
masterLink = null
|
||||
if (masterLink && !masterLink.info) masterLink = null // never clear a link that finished its handshake
|
||||
// DSH itself refuses an unknown/revoked key with 401 + this exact text; anything else
|
||||
// that is a redirect/401/403 came from an auth proxy in front of the master.
|
||||
const deviceRefused = e.status === 401 && /unknown or revoked device/.test(e.body || '')
|
||||
@@ -466,17 +479,33 @@ export function apply(ctx, config = {}) {
|
||||
if (edgeRefused) {
|
||||
const mode = edgeFor(m).mode
|
||||
lastLinkError = mode === 'none'
|
||||
? `auth proxy in front of ${url.host} blocked the link (${e.status}); configure edgeAuth for this master (Settings → Remote devices)`
|
||||
? `auth proxy in front of ${url.host} blocked the link (${e.status}); sign in through the auth proxy (Settings → Remote devices)`
|
||||
: `auth proxy in front of ${url.host} rejected the edgeAuth credentials (${e.status})`
|
||||
edgeFor(m).reset()
|
||||
backoff = Math.max(backoff, 15000)
|
||||
raiseAuthRequired(m, { status: e.status, location: e.location, reason: lastLinkError })
|
||||
} else if (e.status === 200 || /text\/html/i.test(e.contentType || '')) {
|
||||
// Upgrade answered with a normal page instead of 101: a login page served inline.
|
||||
lastLinkError = `auth proxy in front of ${url.host} answered the link with a web page; sign in through the auth proxy (Settings → Remote devices)`
|
||||
raiseAuthRequired(m, { status: e.status, reason: lastLinkError })
|
||||
} else lastLinkError = e.message
|
||||
log.warn(`dsh-remote: link to master failed: ${lastLinkError}`)
|
||||
schedule()
|
||||
}
|
||||
}
|
||||
|
||||
// Edge auth (auth proxy in front of the master). Config: master.edgeAuth stored at pair
|
||||
// The master sits behind an auth middleware that wants an interactive sign-in. Surfaced in
|
||||
// /dsh-remote/state so the UI can open the sign-in popup when "auth middleware" is on.
|
||||
let authRequired = null
|
||||
function raiseAuthRequired(m, { status, location, reason }) {
|
||||
if (store.settings().authMiddleware === false) return
|
||||
const loginUrl = (() => {
|
||||
try { return new URL(location || '', m.url).href } catch { return null }
|
||||
})()
|
||||
authRequired = { at: Date.now(), status, reason, loginUrl, masterUrl: m.url, signInUrl: `${m.url}/dsh-remote/enroll` }
|
||||
notify({ type: 'auth-required', ...authRequired })
|
||||
}
|
||||
|
||||
// time, overridden by `edgeAuth` in this plugin's config. Secrets resolve via credentials.
|
||||
let edgeCache = null
|
||||
function edgeConfigFor(m) {
|
||||
@@ -485,7 +514,7 @@ export function apply(ctx, config = {}) {
|
||||
function edgeFor(m) {
|
||||
const cfg = edgeConfigFor(m)
|
||||
const key = JSON.stringify(cfg)
|
||||
if (edgeCache?.key !== key) edgeCache = { key, client: createEdgeAuth(cfg, resolveSecretRef) }
|
||||
if (edgeCache?.key !== key) edgeCache = { key, client: createEdgeAuth(cfg, resolveSecretRef, { persistCookie: (c) => persistProxyCookie(cfg, c) }) }
|
||||
return edgeCache.client
|
||||
}
|
||||
async function resolveSecretRef(ref) {
|
||||
@@ -495,6 +524,11 @@ export function apply(ctx, config = {}) {
|
||||
}
|
||||
return process.env[ref]
|
||||
}
|
||||
/** Cookie mode: write the (possibly rotated) proxy session cookie back to its credential ref. */
|
||||
async function persistProxyCookie(cfg, cookieHeader) {
|
||||
const creds = svc(ctx, 'credentials')
|
||||
if (creds && cfg?.cookieRef) await creds.set(cfg.cookieRef, cookieHeader)
|
||||
}
|
||||
let lastLinkError = null
|
||||
let timer = null
|
||||
function schedule() {
|
||||
@@ -615,6 +649,10 @@ export function apply(ctx, config = {}) {
|
||||
setTimeout(() => attempt(0), 3000).unref?.()
|
||||
}
|
||||
|
||||
// Restart this DSH from the UI (node type changes only take effect at boot). Relies on the
|
||||
// service manager (systemd Restart=always / LaunchAgent KeepAlive), like restartOnSync.
|
||||
const isManaged = () => !!(process.env.INVOCATION_ID || process.env.XPC_SERVICE_NAME || process.env.JOURNAL_STREAM)
|
||||
|
||||
// ------------------------------------------------------------------ HTTP API
|
||||
const route = (path, methods, handler) => ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
@@ -634,7 +672,8 @@ export function apply(ctx, config = {}) {
|
||||
route('/dsh-remote/state', ['GET'], async () => ({
|
||||
role,
|
||||
self,
|
||||
master: role === 'slave' ? { url: store.master?.url || null, linked: !!masterLink?.mux, info: masterLink?.info || null, error: masterLink ? null : lastLinkError, edgeAuth: store.master ? edgeFor(store.master).describe() : null } : null,
|
||||
master: role === 'slave' ? { url: store.master?.url || null, linked: !!masterLink?.mux, info: masterLink?.info || null, error: masterLink ? null : lastLinkError, edgeAuth: store.master ? edgeFor(store.master).describe() : null, authRequired } : null,
|
||||
node: nodeView(false),
|
||||
devices: devicesView(),
|
||||
publicUrl: config.publicUrl || null,
|
||||
lanUrl: config.lanUrl || null,
|
||||
@@ -695,6 +734,9 @@ export function apply(ctx, config = {}) {
|
||||
if (!out.tokenUrl || !out.clientId) throw httpErr(400, 'oauth2 edgeAuth needs tokenUrl and clientId')
|
||||
if (spec.password || spec.passwordRef) out.passwordRef = await put(S(spec.passwordRef) || 'DSH_REMOTE_EDGE_PASSWORD', spec.password)
|
||||
if (spec.clientSecret || spec.clientSecretRef) out.clientSecretRef = await put(S(spec.clientSecretRef) || 'DSH_REMOTE_EDGE_CLIENT_SECRET', spec.clientSecret)
|
||||
} else if (spec.mode === 'cookie') {
|
||||
// Session cookie pasted from a browser that is already signed in through the proxy.
|
||||
out.cookieRef = await put(S(spec.cookieRef) || 'DSH_REMOTE_EDGE_COOKIE', spec.cookie)
|
||||
} else if (spec.mode === 'headers') {
|
||||
out.headers = {}
|
||||
let i = 0
|
||||
@@ -762,6 +804,117 @@ export function apply(ctx, config = {}) {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* Node settings from Settings → Remote devices:
|
||||
* nodeType master | slave (restart needed when it changes: it decides which
|
||||
* endpoints/links this instance starts)
|
||||
* masterOrigin slave: origin of the master (https://dsh.example.com), pairs with it
|
||||
* authMiddleware true: an auth proxy sits in front of the master; when the link meets a
|
||||
* non-DSH answer the UI opens a sign-in popup, then calls retry-link
|
||||
* false: behave as before (no popup)
|
||||
*/
|
||||
route('/dsh-remote/node', ['GET', 'POST'], async (req) => {
|
||||
if (req.method === 'POST') {
|
||||
const body = await readJson(req)
|
||||
const patch = {}
|
||||
if (body.nodeType !== undefined) {
|
||||
if (!['master', 'slave'].includes(body.nodeType)) throw httpErr(400, 'nodeType must be master or slave')
|
||||
patch.nodeType = body.nodeType
|
||||
}
|
||||
if (body.authMiddleware !== undefined) patch.authMiddleware = !!body.authMiddleware
|
||||
const before = store.settings()
|
||||
store.setSettings(patch)
|
||||
if (patch.authMiddleware === false) authRequired = null
|
||||
if (body.masterOrigin !== undefined && store.master) {
|
||||
const origin = originOf(body.masterOrigin)
|
||||
if (origin !== store.master.url) throw httpErr(400, 'to change the master, re-pair (Pair with a master)')
|
||||
}
|
||||
return nodeView(before.nodeType !== patch.nodeType && patch.nodeType !== undefined)
|
||||
}
|
||||
return nodeView(false)
|
||||
})
|
||||
|
||||
const originOf = (u) => {
|
||||
const url = new URL(String(u || '').trim())
|
||||
if (!/^https?:$/.test(url.protocol)) throw httpErr(400, 'origin must start with http:// or https://')
|
||||
return url.origin + url.pathname.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function nodeView(restartRequired) {
|
||||
const s = store.settings()
|
||||
return {
|
||||
nodeType: s.nodeType || null, // what the user chose (null = auto)
|
||||
effectiveNodeType: role, // what this process is running as
|
||||
restartRequired: !!restartRequired || (!!s.nodeType && s.nodeType !== role),
|
||||
// master: the origin slaves should use (own origin as the browser sees it is shown by the UI);
|
||||
// slave: the paired master's origin (read-only once paired).
|
||||
masterOrigin: store.master?.url || null,
|
||||
authMiddleware: s.authMiddleware === true,
|
||||
authRequired,
|
||||
}
|
||||
}
|
||||
|
||||
// Retry the master link now (after the user signed in through the popup).
|
||||
route('/dsh-remote/retry-link', ['POST'], async () => {
|
||||
if (role !== 'slave' || !store.master) throw httpErr(400, 'not a paired slave')
|
||||
authRequired = null
|
||||
backoff = 1000
|
||||
edgeFor(store.master).reset()
|
||||
clearTimeout(timer)
|
||||
await connectMaster()
|
||||
return { linked: !!masterLink?.mux, error: masterLink ? null : lastLinkError, authRequired }
|
||||
})
|
||||
|
||||
route('/dsh-remote/restart', ['POST'], async () => {
|
||||
if (!isManaged()) throw httpErr(409, 'this DSH is not running under a service manager (systemd / launchd); restart it yourself')
|
||||
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 800).unref?.()
|
||||
return { ok: true, restarting: true }
|
||||
})
|
||||
|
||||
// ---- sign-in hand-over page (served by the MASTER, reachable only through its auth proxy)
|
||||
// A slave UI opens this in a popup. The browser signs in to the auth proxy (Authentik, …),
|
||||
// lands back here, and — after an explicit click — this page postMessages the proxy
|
||||
// session cookie it received to the slave UI that opened it, so the slave's DSH can replay
|
||||
// it on the link. No DSH auth is required (the proxy is the gate), and it only ever returns
|
||||
// the caller's own cookies to an origin the user approves; origins are limited to
|
||||
// loopback / private-network hosts or `signInOrigins` in the config.
|
||||
const isPrivateHost = (h) => /^(localhost|\[::1\]|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|[^.]+\.local)$/i.test(h)
|
||||
const originAllowed = (o) => {
|
||||
try {
|
||||
const u = new URL(o)
|
||||
return isPrivateHost(u.hostname) || (config.signInOrigins || []).includes(u.origin)
|
||||
} catch { return false }
|
||||
}
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
path: '/dsh-remote/signin',
|
||||
handler: (req, res) => {
|
||||
const url = new URL(req.url || '/', 'http://x')
|
||||
const origin = url.searchParams.get('origin') || ''
|
||||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
||||
const cookies = String(req.headers.cookie || '').split(';').map((s) => s.trim()).filter((s) => s && !/^dsh-auth-/.test(s))
|
||||
const proxyCookies = cookies.filter((c) => /^(authentik_proxy|_oauth2_proxy|cf_authorization|authelia_session|keycloak|kc-|_forward_auth)/i.test(c))
|
||||
const send = proxyCookies.length ? proxyCookies : cookies
|
||||
const ok = originAllowed(origin)
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'x-frame-options': 'DENY', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-ancestors 'none'" })
|
||||
res.end(`<!doctype html><meta charset=utf-8><title>dsh-remote sign-in</title>
|
||||
<body style="font:15px system-ui;max-width:34em;margin:3em auto;padding:0 1em;background:#111;color:#ddd">
|
||||
<h2>Signed in to ${esc(self.name)}</h2>
|
||||
${!ok ? `<p>The device that opened this window (<code>${esc(origin || 'unknown')}</code>) is not on this machine's trusted list, so nothing will be sent.</p>`
|
||||
: send.length ? `<p>This browser is signed in through the auth proxy. Send that session to the DSH at <code>${esc(origin)}</code> so it can reach this master?</p>
|
||||
<p><button id=go style="font:inherit;padding:.5em 1.2em;border-radius:8px;border:0;background:#2f81f7;color:#fff;cursor:pointer">Send session</button> <button onclick="window.close()" style="font:inherit;padding:.5em 1.2em;border-radius:8px;border:1px solid #555;background:none;color:#ddd;cursor:pointer">Cancel</button></p>
|
||||
<p id=msg style="opacity:.7"></p>
|
||||
<script>
|
||||
const cookie=${JSON.stringify(send.join('; ')).replace(/</g, '\\u003c')};
|
||||
document.getElementById('go').onclick=()=>{
|
||||
if(!window.opener){document.getElementById('msg').textContent='No opener window: close this and use the Sign in button again.';return}
|
||||
window.opener.postMessage({type:'dsh-remote-signin',cookie,master:location.origin},${JSON.stringify(origin)});
|
||||
document.getElementById('msg').textContent='Sent. You can close this window.';setTimeout(()=>window.close(),700)}
|
||||
</script>`
|
||||
: `<p>You are signed in, but the auth proxy did not forward its session cookie to DSH, so it cannot be handed over automatically. Copy it from your browser (DevTools → Application → Cookies → this site) and paste it into <b>Settings → Remote devices → Auth proxy → Session cookie</b>.</p>`}
|
||||
</body>`)
|
||||
},
|
||||
}), 'dsh-remote: sign-in page')
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/dsh-remote/devices',
|
||||
|
||||
+50
-8
@@ -74,6 +74,24 @@ export class CookieJar {
|
||||
}
|
||||
|
||||
clear() { this.cookies.clear() }
|
||||
|
||||
/**
|
||||
* Seed from a pasted cookie string: "a=1; b=2", a "Cookie: a=1; b=2" header line, or one
|
||||
* "name=value" per line (as copied from browser dev tools). The master DSH's own
|
||||
* `dsh-auth-*` cookie is dropped (the slave authenticates to DSH with its device key).
|
||||
*/
|
||||
seed(text) {
|
||||
const body = String(text || '').trim().replace(/^cookie\s*:\s*/i, '')
|
||||
for (const part of body.split(/;|\r?\n/)) {
|
||||
const p = part.trim()
|
||||
const eq = p.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
const name = p.slice(0, eq).trim()
|
||||
if (/^dsh-auth-/.test(name) || /\s/.test(name)) continue
|
||||
this.cookies.set(name, { value: p.slice(eq + 1).trim().replace(/^"|"$/g, ''), expires: null })
|
||||
}
|
||||
return this.cookies.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,10 +99,17 @@ export class CookieJar {
|
||||
* @param cfg edgeAuth config ({mode, ...})
|
||||
* @param resolveSecret async (refName) => string|undefined (DSH credentials, then env)
|
||||
*/
|
||||
export function createEdgeAuth(cfg, resolveSecret) {
|
||||
export function createEdgeAuth(cfg, resolveSecret, { persistCookie } = {}) {
|
||||
const mode = cfg?.mode || 'none'
|
||||
const jar = new CookieJar()
|
||||
let token = null // { value, expiresAt }
|
||||
let persisted = ''
|
||||
/** Cookie mode: write rotated proxy cookies back so the session keeps extending itself. */
|
||||
function persist() {
|
||||
if (mode !== 'cookie' || !persistCookie) return
|
||||
const h = jar.header()
|
||||
if (h && h !== persisted) { persisted = h; Promise.resolve(persistCookie(h)).catch(() => {}) }
|
||||
}
|
||||
|
||||
const secret = async (ref, what) => {
|
||||
if (!ref) throw new EdgeAuthError(`edgeAuth: ${what} is not configured`)
|
||||
@@ -128,6 +153,11 @@ export function createEdgeAuth(cfg, resolveSecret) {
|
||||
for (const [k, v] of Object.entries(cfg.headers || {})) {
|
||||
h[k.toLowerCase()] = typeof v === 'string' && v.startsWith('ref:') ? await secret(v.slice(4), k) : String(v)
|
||||
}
|
||||
} else if (mode === 'cookie') {
|
||||
// A session cookie copied from a browser that already logged in through the proxy
|
||||
// (e.g. Authentik's `authentik_proxy_<hash>`). Seeded once; cookies the proxy rotates
|
||||
// afterwards replace it in the jar for the life of this process.
|
||||
if (!jar.cookies.size) jar.seed(await secret(cfg.cookieRef, 'proxy session cookie'))
|
||||
} else if (mode !== 'none') {
|
||||
throw new EdgeAuthError(`edgeAuth: unknown mode "${mode}"`)
|
||||
}
|
||||
@@ -137,9 +167,12 @@ export function createEdgeAuth(cfg, resolveSecret) {
|
||||
}
|
||||
|
||||
/** Classify a response that came back from the edge instead of DSH. */
|
||||
function edgeBlocked(status, location) {
|
||||
function edgeBlocked(status, location, contentType = '') {
|
||||
if (status >= 300 && status < 400 && location && !/\/dsh-remote\//.test(location)) return `redirected to ${String(location).slice(0, 90)}`
|
||||
if (status === 401 || status === 403) return `HTTP ${status}`
|
||||
// Some proxies answer with the login page inline (HTTP 200 text/html); DSH's own
|
||||
// endpoints answer JSON / plain text, never HTML.
|
||||
if (status === 200 && /text\/html/i.test(contentType)) return 'answered with an HTML page (sign-in page?)'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -153,16 +186,18 @@ export function createEdgeAuth(cfg, resolveSecret) {
|
||||
const h = { ...(init.headers || {}), ...(await headers({ forceRefresh: attempt > 0 })) }
|
||||
const r = await fetch(url, { ...init, headers: h, redirect: 'manual' })
|
||||
jar.absorb(r.headers)
|
||||
const blocked = edgeBlocked(r.status, r.headers.get('location'))
|
||||
persist()
|
||||
const ct = r.headers.get('content-type') || ''
|
||||
const blocked = edgeBlocked(r.status, r.headers.get('location'), ct)
|
||||
if (!blocked) return r
|
||||
// A DSH JSON error (e.g. invalid pairing code → 403) is not an edge refusal.
|
||||
if ((r.status === 401 || r.status === 403) && /json/.test(r.headers.get('content-type') || '')) return r
|
||||
if ((r.status === 401 || r.status === 403) && /json/.test(ct)) return r
|
||||
if (attempt === 0 && mode !== 'none') { jar.clear(); token = null; continue }
|
||||
throw new EdgeAuthError(`the auth proxy in front of ${new URL(url).host} refused the request (${blocked})`, {
|
||||
status: r.status,
|
||||
hint: mode === 'none'
|
||||
? 'configure edgeAuth (basic / bearer / oauth2 / headers) for this master, or exempt /dsh-remote/link and /dsh-remote/enroll on the proxy'
|
||||
: 'check the edgeAuth credentials and that this identity may access the application',
|
||||
? 'sign in through the auth proxy (Settings → Remote devices), configure edgeAuth, or exempt /dsh-remote/link and /dsh-remote/enroll on the proxy'
|
||||
: 'the proxy rejected the stored credentials or cookie; sign in again or check the edgeAuth settings',
|
||||
})
|
||||
}
|
||||
throw new EdgeAuthError('unreachable')
|
||||
@@ -175,8 +210,15 @@ export function createEdgeAuth(cfg, resolveSecret) {
|
||||
edgeFetch,
|
||||
edgeBlocked,
|
||||
/** Feed upgrade-response cookies back (WebSocket handshake). */
|
||||
absorb: (h) => jar.absorb(h),
|
||||
absorb: (h) => { jar.absorb(h); persist() },
|
||||
reset() { jar.clear(); token = null },
|
||||
describe() { return { mode, ...(mode === 'basic' ? { username: cfg.username } : {}), ...(mode === 'oauth2' ? { tokenUrl: cfg.tokenUrl, clientId: cfg.clientId, username: cfg.username, tokenValidUntil: token ? new Date(token.expiresAt).toISOString() : null } : {}), cookies: [...jar.cookies.keys()] } },
|
||||
describe() {
|
||||
return {
|
||||
mode,
|
||||
...(mode === 'basic' ? { username: cfg.username } : {}),
|
||||
...(mode === 'oauth2' ? { tokenUrl: cfg.tokenUrl, clientId: cfg.clientId, username: cfg.username, tokenValidUntil: token ? new Date(token.expiresAt).toISOString() : null } : {}),
|
||||
cookies: [...jar.cookies.keys()],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,16 @@ export class Store {
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- UI-managed node settings ---------------------------------------------
|
||||
/** { nodeType?: 'master'|'slave', authMiddleware?: boolean } — set from Settings → Remote devices. */
|
||||
settings() { return (this.data.settings ||= {}) }
|
||||
|
||||
setSettings(patch) {
|
||||
this.data.settings = { ...this.settings(), ...patch }
|
||||
this.save()
|
||||
return this.data.settings
|
||||
}
|
||||
|
||||
// ---- slave side: link to master ------------------------------------------
|
||||
get master() { return this.data.master || null }
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ export function connect(url, { headers = {}, timeoutMs = 15000, rejectUnauthoriz
|
||||
const err = new Error(`upgrade refused: HTTP ${res.statusCode}${loc} ${res.headers.location ? '' : body.trim().slice(0, 200)}`.trim())
|
||||
err.status = res.statusCode
|
||||
err.location = res.headers.location
|
||||
err.contentType = String(res.headers['content-type'] || '')
|
||||
err.body = body
|
||||
reject(err)
|
||||
})
|
||||
|
||||
+1
-1
@@ -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
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user