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
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user