dsh-remote 0.4.0: edge auth – slaves log in to an auth proxy in front of the master
- lib/edge-auth.mjs: basic / bearer / oauth2 client_credentials (Authentik M2M) / custom headers (Cloudflare Access), proxy session cookie jar, retry with fresh token, clear edge-refusal vs DSH-refusal errors - link + enroll go through the edge; master.edgeAuth stored at pair time, secrets as DSH credential refs only; config edgeAuth override - /dsh-remote/edge-auth: show/change/probe; Settings UI section + pair form - pair.mjs / install-mac.sh: --edge … flags, secrets prompted via /dev/tty - tests: fake Authentik-like proxy for all modes + WebSocket through the edge
This commit is contained in:
1 parent
5dff41a3d0
commit
78fce8e1ce
12 files changed
+825
-32
No files matched your search
@@ -50,7 +50,13 @@ On first link-up the Mac pulls the master's plugins and config. With `sync.resta
|
|||||||
|
|
||||||
Re-running the script updates everything and keeps the pairing. To remove it: `launchctl bootout gui/$(id -u)/dev.rizzy.dsh-web`, then delete `~/Library/LaunchAgents/dev.rizzy.dsh-web.plist`, `~/.dsh` and `~/.local/opt/node`.
|
Re-running the script updates everything and keeps the pairing. To remove it: `launchctl bootout gui/$(id -u)/dev.rizzy.dsh-web`, then delete `~/Library/LaunchAgents/dev.rizzy.dsh-web.plist`, `~/.dsh` and `~/.local/opt/node`.
|
||||||
|
|
||||||
Away from home, use `--master https://dsh.rizzy.dev` once the edge exempts `/dsh-remote/link` and `/dsh-remote/enroll` from Authentik (see below).
|
Away from home, point `--master` at the public URL. If an auth proxy guards it, add the `--edge …` flags (see *Masters behind an auth proxy*). Secrets are prompted for on the terminal and never passed on the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.rizzy.dev/DSH-Plugins/dsh-remote/raw/branch/main/deploy/install-mac.sh \
|
||||||
|
| bash -s -- --master https://dsh.rizzy.dev --code XXXX-XXXX-XXXX-XXXX --name my-mac \
|
||||||
|
--edge basic --edge-user dsh-remote-mac
|
||||||
|
```
|
||||||
|
|
||||||
## Pair a device
|
## Pair a device
|
||||||
|
|
||||||
@@ -143,16 +149,32 @@ Local plugins are compared by content hash and re-shipped only when they change.
|
|||||||
- Use TLS (`https://` master URL) whenever the link crosses an untrusted network.
|
- Use TLS (`https://` master URL) whenever the link crosses an untrusted network.
|
||||||
- **Agent control is real remote code execution.** `remote_exec`, `remote_file` and `remote_task` run as the device's DSH user. Anyone who can prompt a master session can run code on every paired device. Revoke a device, or set `agentTools.enabled: false` on it, to opt it out.
|
- **Agent control is real remote code execution.** `remote_exec`, `remote_file` and `remote_task` run as the device's DSH user. Anyone who can prompt a master session can run code on every paired device. Revoke a device, or set `agentTools.enabled: false` on it, to opt it out.
|
||||||
|
|
||||||
## Public access via rizzy.dev
|
## Masters behind an auth proxy (edge auth)
|
||||||
|
|
||||||
`dsh.rizzy.dev` is served by the edge Traefik behind **Authentik forward-auth**. Browsers log in through Authentik as usual. A slave's DSH can't do an interactive login, so the edge must let these two paths through without forward-auth (they carry their own device-key / pairing-code authentication):
|
A master published behind an identity-aware proxy (Authentik, oauth2-proxy, Cloudflare Access, Traefik/nginx Basic auth) redirects every request that isn't logged in. Browsers log in normally. A slave's DSH logs in to the proxy itself with a machine credential, keeps the proxy's session cookie, and sends both on `/dsh-remote/enroll` and on the `/dsh-remote/link` WebSocket. It never sends them anywhere else. Behind the proxy, the usual device key still authenticates the slave to DSH.
|
||||||
|
|
||||||
```
|
| Mode | Sends | Typical use |
|
||||||
/dsh-remote/link (WebSocket upgrade)
|
| --- | --- | --- |
|
||||||
/dsh-remote/enroll (POST, one-time pairing code)
|
| `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 |
|
||||||
|
| `headers` | any headers, e.g. `CF-Access-Client-Id` / `CF-Access-Client-Secret` | Cloudflare Access service tokens, custom gateways |
|
||||||
|
|
||||||
For example, add a second Traefik router with a higher priority, `Host(\`dsh.rizzy.dev\`) && (Path(\`/dsh-remote/link\`) || Path(\`/dsh-remote/enroll\`))`, that uses the same service but no Authentik middleware. Until that exists, pair and link slaves over the LAN (`http://192.168.0.111:3080`).
|
**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"`.
|
||||||
|
- **Paired slave:** Settings → *Remote devices* → **Auth proxy (edge) login** → **Change**. **Test** checks whether the proxy now lets this device through to the master's DSH.
|
||||||
|
- **Config override:** an `edgeAuth: { mode, … }` block in the slave's dsh-remote config takes precedence. Secrets are referenced as `passwordRef` / `tokenRef` / `clientSecretRef` / `"ref:NAME"`, never inline.
|
||||||
|
|
||||||
|
**Secrets:** passwords, tokens and header values are stored as DSH credentials (`DSH_REMOTE_EDGE_PASSWORD`, `DSH_REMOTE_EDGE_TOKEN`, `DSH_REMOTE_EDGE_CLIENT_SECRET`, `DSH_REMOTE_EDGE_HEADER_n` in `~/.dsh/.credentials.yaml`, 0600). The link state holds only the ref names.
|
||||||
|
|
||||||
|
**How it behaves:**
|
||||||
|
|
||||||
|
- A login redirect, or a 401/403 that isn't from DSH, counts as an *edge refusal*. The link reports "auth proxy … blocked the link / rejected the edgeAuth credentials". It retries with a fresh token and a cleared cookie jar, then backs off to 15–30 s.
|
||||||
|
- DSH's own "unknown or revoked device" 401 still means *re-pair*.
|
||||||
|
- The proxy's session cookie is reused, so the proxy isn't asked to verify the credential on every reconnect, as Authentik recommends.
|
||||||
|
|
||||||
|
**rizzy.dev with Authentik:** create a service-account user (e.g. `dsh-remote-mac`), make sure the DSH application's policies allow it, and create an *App password* for it under Directory → Tokens and App passwords. Then pair with `--edge basic --edge-user dsh-remote-mac`. The alternative is to exempt `/dsh-remote/link` and `/dsh-remote/enroll` from forward-auth with a higher-priority Traefik router; those paths carry their own device-key / pairing-code auth.
|
||||||
|
|
||||||
## HTTP API (used by the UI)
|
## HTTP API (used by the UI)
|
||||||
|
|
||||||
@@ -169,6 +191,7 @@ For example, add a second Traefik router with a higher priority, `Host(\`dsh.riz
|
|||||||
| `GET /dsh-remote/sync/status` | manifest (master) / last result (slave) |
|
| `GET /dsh-remote/sync/status` | manifest (master) / last result (slave) |
|
||||||
| `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 |
|
||||||
| `* /r/<device>/…` | that device's DSH |
|
| `* /r/<device>/…` | that device's DSH |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -251,11 +251,89 @@ window.__ModuleLoader__.load({
|
|||||||
h('button', { style: S.btn, onClick: save }, 'Save sync policy'), ' ', msg && h('span', { style: { fontSize: 12, opacity: .75 } }, msg));
|
h('button', { style: S.btn, onClick: save }, 'Save sync policy'), ' ', msg && h('span', { style: { fontSize: 12, opacity: .75 } }, msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth proxy (edge) credentials for reaching the master through Authentik /
|
||||||
|
* oauth2-proxy / Cloudflare Access / Basic auth. Secrets are sent once and stored by
|
||||||
|
* the host as DSH credential refs; the form never shows them again.
|
||||||
|
*/
|
||||||
|
function EdgeAuthFields({ value, onChange }) {
|
||||||
|
const v = value || { mode: 'none' };
|
||||||
|
const set = (patch) => onChange({ ...v, ...patch });
|
||||||
|
const row = (label, el, hint) => h(Field, { label, hint }, el);
|
||||||
|
const input = (key, props = {}) => h('input', { style: S.input, value: v[key] || '', onChange: (e) => set({ [key]: e.target.value }), ...props });
|
||||||
|
return h('div', { style: { margin: '6px 0 10px', padding: '8px 10px', border: '1px dashed rgba(127,127,127,.35)', borderRadius: 8 } },
|
||||||
|
row('Auth proxy in front of the master',
|
||||||
|
h('select', { style: S.input, value: v.mode, onChange: (e) => onChange({ mode: e.target.value }) },
|
||||||
|
h('option', { value: 'none' }, 'None (LAN / proxy exempts /dsh-remote/*)'),
|
||||||
|
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: '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,
|
||||||
|
row('Username', input('username', { placeholder: 'e.g. dsh-remote-mac (Authentik service account)' })),
|
||||||
|
row('Password / app password', input('password', { type: 'password', placeholder: '(stored as credential DSH_REMOTE_EDGE_PASSWORD)' }),
|
||||||
|
'Authentik: Directory → Tokens and App passwords → create an App password for the user, and enable “Intercept header authentication” on the proxy provider.')),
|
||||||
|
v.mode === 'oauth2' && h(React.Fragment, null,
|
||||||
|
row('Token URL', input('tokenUrl', { placeholder: 'https://auth.rizzy.dev/application/o/token/' })),
|
||||||
|
row('Client ID', input('clientId', { placeholder: 'proxy provider client_id' })),
|
||||||
|
row('Username (Authentik service account)', input('username')),
|
||||||
|
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 === '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 }) })));
|
||||||
|
}
|
||||||
|
function edgeSpec(v) {
|
||||||
|
if (!v || v.mode === 'none') return { mode: 'none' };
|
||||||
|
if (v.mode === 'headers') {
|
||||||
|
const headers = {};
|
||||||
|
for (const line of String(v.headersText || '').split('\n')) {
|
||||||
|
const at = line.indexOf(':');
|
||||||
|
if (at > 0) headers[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
||||||
|
}
|
||||||
|
return { mode: 'headers', headers };
|
||||||
|
}
|
||||||
|
const { headersText, ...rest } = v;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EdgeAuthCard({ s }) {
|
||||||
|
const [edit, setEdit] = useState(null);
|
||||||
|
const [info, setInfo] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const run = async (body) => {
|
||||||
|
setBusy(true);
|
||||||
|
try { setInfo(body ? await post('edge-auth', body) : await api('edge-auth')); if (body) setEdit(null); }
|
||||||
|
catch (e) { setInfo({ error: e.message }); }
|
||||||
|
setBusy(false); store.refresh();
|
||||||
|
};
|
||||||
|
if (s.role !== 'slave' || !s.master?.url) return null;
|
||||||
|
const cur = info?.edgeAuth || s.master.edgeAuth || { mode: 'none' };
|
||||||
|
return h('div', { style: S.card },
|
||||||
|
h('strong', null, 'Auth proxy (edge) login'),
|
||||||
|
h('div', { style: { fontSize: 13, margin: '4px 0 8px' } },
|
||||||
|
'Mode: ', h('code', null, cur.mode || 'none'),
|
||||||
|
cur.username ? h('span', null, ' · user ', h('code', null, cur.username)) : null,
|
||||||
|
cur.tokenValidUntil ? ` · token valid until ${new Date(cur.tokenValidUntil).toLocaleTimeString()}` : '',
|
||||||
|
cur.cookies?.length ? ` · proxy session cookie: ${cur.cookies.join(', ')}` : ''),
|
||||||
|
info?.probe && h('div', { style: { fontSize: 13, margin: '0 0 8px', color: info.probe.reachedDsh ? '#3fb950' : '#f85149' } },
|
||||||
|
info.probe.reachedDsh ? '✓ The proxy lets this device through to the master DSH.' : `✗ ${info.probe.error}${info.probe.hint ? ` (${info.probe.hint})` : ''}`),
|
||||||
|
info?.error && h('div', { style: S.err }, info.error),
|
||||||
|
edit ? h('div', null,
|
||||||
|
h(EdgeAuthFields, { value: edit, onChange: setEdit }),
|
||||||
|
h('button', { style: S.btnPrimary, disabled: busy, onClick: () => run({ edgeAuth: edgeSpec(edit) }) }, busy ? 'Saving…' : 'Save & reconnect'), ' ',
|
||||||
|
h('button', { style: S.btn, onClick: () => setEdit(null) }, 'Cancel'))
|
||||||
|
: h('span', null,
|
||||||
|
h('button', { style: S.btn, disabled: busy, onClick: () => run(null) }, busy ? 'Testing…' : 'Test'), ' ',
|
||||||
|
h('button', { style: S.btn, onClick: () => setEdit({ mode: cur.mode || 'none', username: cur.username, tokenUrl: cur.tokenUrl, clientId: cur.clientId }) }, 'Change')));
|
||||||
|
}
|
||||||
function SettingsPanel() {
|
function SettingsPanel() {
|
||||||
const st = useStore();
|
const st = useStore();
|
||||||
const [code, setCode] = useState(null);
|
const [code, setCode] = useState(null);
|
||||||
const [masterUrl, setMasterUrl] = useState('');
|
const [masterUrl, setMasterUrl] = useState('');
|
||||||
const [pairCode, setPairCode] = useState('');
|
const [pairCode, setPairCode] = useState('');
|
||||||
|
const [edge, setEdge] = useState({ mode: 'none' });
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [open, setOpen] = useState({});
|
const [open, setOpen] = useState({});
|
||||||
@@ -284,8 +362,9 @@ window.__ModuleLoader__.load({
|
|||||||
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, 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('button', { style: S.btnPrimary, disabled: busy || !masterUrl || !pairCode, onClick: () => act(async () => {
|
h('button', { style: S.btnPrimary, disabled: busy || !masterUrl || !pairCode, onClick: () => act(async () => {
|
||||||
const r = await post('pair', { masterUrl, code: pairCode });
|
const r = await post('pair', { masterUrl, 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')));
|
||||||
@@ -321,7 +400,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, devices, syncCard, msg && h('div', { style: { ...S.card, fontSize: 13 } }, msg));
|
return h('div', { 'data-dsh-plugin': 'dsh-remote' }, header, pairing, h(EdgeAuthCard, { s }), devices, syncCard, msg && h('div', { style: { ...S.card, fontSize: 13 } }, msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ toasts
|
// ------------------------------------------------------------------ toasts
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Screenshot Settings → Remote devices (headless Chromium via DevTools protocol).
|
||||||
|
// node deploy/e2e-settings.mjs <chrome> <out.png> [port]
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { cookieName, mintCookie } from '../lib/auth-cookie.mjs'
|
||||||
|
|
||||||
|
const [chrome, out, port = '3081'] = process.argv.slice(2)
|
||||||
|
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
||||||
|
const secret = fs.readFileSync(path.join(home, '.credentials.yaml'), 'utf8').match(/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{43})/)[1]
|
||||||
|
const authority = `127.0.0.1:${port}`
|
||||||
|
const prof = fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-set-'))
|
||||||
|
const proc = spawn(chrome, ['--headless=new', '--no-sandbox', '--disable-gpu', `--user-data-dir=${prof}`, '--remote-debugging-port=0', '--window-size=1400,1300', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||||
|
const wsUrl = await new Promise((resolve, reject) => {
|
||||||
|
let buf = ''
|
||||||
|
proc.stderr.on('data', (d) => { buf += d; const m = buf.match(/DevTools listening on (ws:\/\/\S+)/); if (m) resolve(m[1]) })
|
||||||
|
setTimeout(() => reject(new Error('chrome did not start')), 15000)
|
||||||
|
})
|
||||||
|
const page = (await (await fetch(wsUrl.replace(/^ws/, 'http').replace(/\/devtools\/browser\/.*/, '/json/list'))).json()).find((t) => t.type === 'page')
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
||||||
|
await new Promise((r) => ws.addEventListener('open', r))
|
||||||
|
let id = 0
|
||||||
|
const pending = new Map()
|
||||||
|
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } })
|
||||||
|
const send = (method, params = {}) => new Promise((resolve) => { const i = ++id; pending.set(i, resolve); ws.send(JSON.stringify({ id: i, method, params })) })
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
const evalJs = async (expr) => (await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value
|
||||||
|
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable')
|
||||||
|
await send('Network.setCookie', { name: cookieName(authority), value: mintCookie(authority, secret, 1).split('=').slice(1).join('='), domain: '127.0.0.1', path: '/', httpOnly: true })
|
||||||
|
await send('Page.navigate', { url: `http://${authority}/#/settings/dsh-remote` })
|
||||||
|
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)
|
||||||
|
})()`))
|
||||||
|
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 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'))
|
||||||
|
console.log('screenshot', out)
|
||||||
|
ws.close(); proc.kill('SIGKILL')
|
||||||
|
try { fs.rmSync(prof, { recursive: true, force: true }) } catch { /* chrome may still hold files */ }
|
||||||
|
process.exit(0)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Test helper: an Authentik-like auth proxy in front of a DSH master.
|
||||||
|
// node deploy/fake-edge.mjs <listenPort> <upstream http://host:port> <user> <password>
|
||||||
|
// Unauthenticated → 302 to a fake login page; Basic user:password → session cookie +
|
||||||
|
// pass-through (HTTP and WebSocket upgrades); session cookie → pass-through.
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import http from 'node:http'
|
||||||
|
import net from 'node:net'
|
||||||
|
|
||||||
|
const [port, upstream, user, pass] = process.argv.slice(2)
|
||||||
|
const up = new URL(upstream)
|
||||||
|
const sessions = new Set()
|
||||||
|
const want = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`
|
||||||
|
const stats = { redirects: 0, credentialLogins: 0, cookieHits: 0, upgrades: 0 }
|
||||||
|
|
||||||
|
function auth(req) {
|
||||||
|
const sid = /edge_session=([a-f0-9]+)/.exec(req.headers.cookie || '')?.[1]
|
||||||
|
if (sid && sessions.has(sid)) { stats.cookieHits++; return { ok: true } }
|
||||||
|
if (req.headers.authorization === want) {
|
||||||
|
stats.credentialLogins++
|
||||||
|
const id = crypto.randomBytes(12).toString('hex')
|
||||||
|
sessions.add(id)
|
||||||
|
return { ok: true, setCookie: `edge_session=${id}; Path=/; HttpOnly; Max-Age=86400` }
|
||||||
|
}
|
||||||
|
if (req.headers.authorization) return { ok: false, status: 401 }
|
||||||
|
return { ok: false, status: 302 }
|
||||||
|
}
|
||||||
|
const strip = (h) => { const o = { ...h }; delete o.authorization; return o }
|
||||||
|
|
||||||
|
const srv = http.createServer((req, res) => {
|
||||||
|
if (req.url === '/__stats') { res.end(JSON.stringify(stats)); return }
|
||||||
|
const a = auth(req)
|
||||||
|
if (!a.ok) {
|
||||||
|
if (a.status === 302) stats.redirects++
|
||||||
|
res.writeHead(a.status, a.status === 302 ? { location: 'https://auth.example/application/o/authorize/?client_id=x' } : {})
|
||||||
|
return res.end()
|
||||||
|
}
|
||||||
|
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]
|
||||||
|
res.writeHead(r.statusCode, h)
|
||||||
|
r.pipe(res)
|
||||||
|
})
|
||||||
|
p.on('error', () => { res.writeHead(502); res.end() })
|
||||||
|
req.pipe(p)
|
||||||
|
})
|
||||||
|
srv.on('upgrade', (req, socket, head) => {
|
||||||
|
const a = auth(req)
|
||||||
|
if (!a.ok) {
|
||||||
|
if (a.status === 302) stats.redirects++
|
||||||
|
socket.end(a.status === 302 ? 'HTTP/1.1 302 Found\r\nLocation: https://auth.example/login\r\nContent-Length: 0\r\n\r\n' : 'HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats.upgrades++
|
||||||
|
const s = net.connect(Number(up.port), up.hostname, () => {
|
||||||
|
const lines = [`${req.method} ${req.url} HTTP/1.1`]
|
||||||
|
for (const [k, v] of Object.entries({ ...strip(req.headers), host: up.host })) for (const one of [].concat(v)) lines.push(`${k}: ${one}`)
|
||||||
|
s.write(lines.join('\r\n') + '\r\n\r\n')
|
||||||
|
if (head?.length) s.write(head)
|
||||||
|
s.pipe(socket); socket.pipe(s)
|
||||||
|
})
|
||||||
|
s.on('error', () => socket.destroy())
|
||||||
|
socket.on('error', () => s.destroy())
|
||||||
|
})
|
||||||
|
srv.listen(Number(port), '127.0.0.1', () => console.log(`fake edge on 127.0.0.1:${port} → ${upstream}`))
|
||||||
@@ -13,10 +13,14 @@
|
|||||||
# --port PORT local DSH port (default 3081)
|
# --port PORT local DSH port (default 3081)
|
||||||
# --accept LIST comma list of secrets to accept from the master (default OMNI_API_KEY,FORGE_CREDENTIALS)
|
# --accept LIST comma list of secrets to accept from the master (default OMNI_API_KEY,FORGE_CREDENTIALS)
|
||||||
# --no-service don't install the LaunchAgent (run `dsh web` yourself)
|
# --no-service don't install the LaunchAgent (run `dsh web` yourself)
|
||||||
|
# --edge MODE … the master sits behind an auth proxy: basic|bearer|oauth2|headers, with
|
||||||
|
# --edge-user, --edge-token-url, --edge-client-id, --edge-client-secret,
|
||||||
|
# --edge-header "Name: value" (secrets are prompted for, never in argv)
|
||||||
# Re-running is safe: it updates Node/DSH/dsh-remote and keeps the existing pairing.
|
# Re-running is safe: it updates Node/DSH/dsh-remote and keeps the existing pairing.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
MASTER="" CODE="" NAME="" PORT=3081 ACCEPT="OMNI_API_KEY,FORGE_CREDENTIALS" SERVICE=1
|
MASTER="" CODE="" NAME="" PORT=3081 ACCEPT="OMNI_API_KEY,FORGE_CREDENTIALS" SERVICE=1
|
||||||
|
EDGE_ARGS=()
|
||||||
NODE_MAJOR=22
|
NODE_MAJOR=22
|
||||||
REPO_TGZ="https://git.rizzy.dev/DSH-Plugins/dsh-remote/archive/main.tar.gz"
|
REPO_TGZ="https://git.rizzy.dev/DSH-Plugins/dsh-remote/archive/main.tar.gz"
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
@@ -27,7 +31,9 @@ while [ $# -gt 0 ]; do
|
|||||||
--port) PORT="$2"; shift 2 ;;
|
--port) PORT="$2"; shift 2 ;;
|
||||||
--accept) ACCEPT="$2"; shift 2 ;;
|
--accept) ACCEPT="$2"; shift 2 ;;
|
||||||
--no-service) SERVICE=0; shift ;;
|
--no-service) SERVICE=0; shift ;;
|
||||||
-h|--help) sed -n '2,17p' "$0" 2>/dev/null || true; exit 0 ;;
|
--edge|--edge-user|--edge-token-url|--edge-client-id|--edge-scope|--edge-header) EDGE_ARGS+=("$1" "$2"); shift 2 ;;
|
||||||
|
--edge-client-secret) EDGE_ARGS+=("$1"); shift ;;
|
||||||
|
-h|--help) sed -n '2,19p' "$0" 2>/dev/null || true; exit 0 ;;
|
||||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -100,7 +106,7 @@ if [ -f "$STATE" ] && grep -q '"deviceId"' "$STATE"; then
|
|||||||
else
|
else
|
||||||
[ -n "$CODE" ] || die "--code is required for the first pairing (master → Settings → Remote devices → Create pairing code)"
|
[ -n "$CODE" ] || die "--code is required for the first pairing (master → Settings → Remote devices → Create pairing code)"
|
||||||
say "Pairing with $MASTER as \"$NAME\""
|
say "Pairing with $MASTER as \"$NAME\""
|
||||||
node "$PLUGIN_DIR/deploy/pair.mjs" --master "$MASTER" --code "$CODE" --name "$NAME" --state "$STATE"
|
node "$PLUGIN_DIR/deploy/pair.mjs" --master "$MASTER" --code "$CODE" --name "$NAME" --state "$STATE" ${EDGE_ARGS[@]+"${EDGE_ARGS[@]}"}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 7. LaunchAgent: dsh web on 127.0.0.1:$PORT, at login, restarted on exit.
|
# 7. LaunchAgent: dsh web on 127.0.0.1:$PORT, at login, restarted on exit.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Helpers for deploy/pair.mjs (kept separate so they are unit-testable).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set `refs.<ref>` in the text of a DSH `.credentials.yaml`, preserving everything else
|
||||||
|
* (records, other refs, comments). Values are written as JSON strings (valid YAML scalars).
|
||||||
|
*/
|
||||||
|
export function setRefIn(text, ref, value) {
|
||||||
|
if (!/^[A-Z][A-Z0-9_]*$/.test(ref)) throw new Error(`bad ref name ${ref}`)
|
||||||
|
const q = JSON.stringify(String(value))
|
||||||
|
const lines = (text || 'version: 1\n').split('\n')
|
||||||
|
if (lines[lines.length - 1] === '') lines.pop()
|
||||||
|
let refsAt = lines.findIndex((l) => /^refs:\s*(\{\})?\s*$/.test(l))
|
||||||
|
if (refsAt === -1) { lines.push('refs:'); refsAt = lines.length - 1 } else lines[refsAt] = 'refs:'
|
||||||
|
let end = refsAt + 1
|
||||||
|
while (end < lines.length && /^\s+\S/.test(lines[end])) end++
|
||||||
|
const existing = lines.slice(refsAt + 1, end).findIndex((l) => new RegExp(`^\\s+${ref}:`).test(l))
|
||||||
|
if (existing >= 0) lines[refsAt + 1 + existing] = ` ${ref}: ${q}`
|
||||||
|
else lines.splice(end, 0, ` ${ref}: ${q}`)
|
||||||
|
return lines.join('\n') + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read `refs.<ref>` from `.credentials.yaml` text (plain, quoted or JSON-quoted scalars). */
|
||||||
|
export function getRefIn(text, ref) {
|
||||||
|
const m = String(text || '').match(new RegExp(`^\\s+${ref}:\\s*(.+)$`, 'm'))
|
||||||
|
if (!m) return undefined
|
||||||
|
const v = m[1].trim()
|
||||||
|
if (v.startsWith('"')) { try { return JSON.parse(v) } catch { return v.slice(1, -1) } }
|
||||||
|
return v.replace(/^'|'$/g, '')
|
||||||
|
}
|
||||||
+98
-11
@@ -1,48 +1,135 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Pair this machine with a dsh-remote master WITHOUT a running local DSH: exchange a
|
// Pair this machine with a dsh-remote master WITHOUT a running local DSH: exchange a
|
||||||
// one-time pairing code for a device key and write the slave link state.
|
// one-time pairing code for a device key and write the slave link state.
|
||||||
// node deploy/pair.mjs --master http://192.168.0.111:3080 --code ABCD-... --name my-mac [--state <file>]
|
//
|
||||||
// The state file defaults to $DSH_HOME/profiles/web/dsh-remote/state.json. Restart DSH after.
|
// node deploy/pair.mjs --master https://dsh.example.com --code ABCD-... --name my-mac
|
||||||
|
// [--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 headers --edge-header "CF-Access-Client-Id: …" --edge-header "CF-Access-Client-Secret: …"]
|
||||||
|
// [--state <file>]
|
||||||
|
//
|
||||||
|
// Edge = an auth proxy (Authentik, oauth2-proxy, Cloudflare Access, Basic) in front of the
|
||||||
|
// master. Its secrets are stored in $DSH_HOME/.credentials.yaml (refs, 0600) — the same store
|
||||||
|
// DSH's credentials service reads — and only their ref names go into the link state.
|
||||||
|
// Restart DSH afterwards.
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import readline from 'node:readline'
|
||||||
|
import tty from 'node:tty'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const { createEdgeAuth } = await import(path.join(here, '..', 'lib', 'edge-auth.mjs'))
|
||||||
|
const { setRefIn, getRefIn } = await import(path.join(here, 'pair-lib.mjs'))
|
||||||
|
|
||||||
const args = process.argv.slice(2)
|
const args = process.argv.slice(2)
|
||||||
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 ? args[i + 1] : d }
|
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 ? args[i + 1] : d }
|
||||||
|
const opts = (k) => args.flatMap((a, i) => (a === `--${k}` ? [args[i + 1]] : []))
|
||||||
const master = String(opt('master', '')).replace(/\/+$/, '')
|
const master = String(opt('master', '')).replace(/\/+$/, '')
|
||||||
const code = opt('code', '')
|
const code = opt('code', '')
|
||||||
const name = opt('name', os.hostname())
|
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>]')
|
console.error('usage: pair.mjs --master <http(s)://…> --code <pairing code> [--name <device name>] [--edge basic|bearer|oauth2|headers …]')
|
||||||
process.exit(2)
|
process.exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- credentials.yaml refs (the store DSH's credentials service reads) ----------------
|
||||||
|
const credFile = path.join(home, '.credentials.yaml')
|
||||||
|
function setRef(ref, value) {
|
||||||
|
let text = ''
|
||||||
|
try { text = fs.readFileSync(credFile, 'utf8') } catch { text = '' }
|
||||||
|
fs.mkdirSync(home, { recursive: true, mode: 0o700 })
|
||||||
|
const tmp = `${credFile}.${process.pid}.tmp`
|
||||||
|
fs.writeFileSync(tmp, setRefIn(text, ref, value), { mode: 0o600 })
|
||||||
|
fs.renameSync(tmp, credFile)
|
||||||
|
}
|
||||||
|
function getRef(ref) {
|
||||||
|
if (process.env[ref]) return process.env[ref]
|
||||||
|
try { return getRefIn(fs.readFileSync(credFile, 'utf8'), ref) } catch { return undefined }
|
||||||
|
}
|
||||||
|
function prompt(question) {
|
||||||
|
// `curl … | bash` makes stdin the script; read from the terminal instead.
|
||||||
|
let input = process.stdin
|
||||||
|
let output = process.stdout
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync('/dev/tty', 'r+')
|
||||||
|
input = new tty.ReadStream(fd)
|
||||||
|
output = new tty.WriteStream(fd)
|
||||||
|
} catch { return Promise.resolve('') }
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = readline.createInterface({ input, output, terminal: true })
|
||||||
|
rl._writeToOutput = (s) => { if (s.includes(question)) output.write(s) } // hide typed secret
|
||||||
|
rl.question(question, (a) => { rl.close(); output.write('\n'); if (input !== process.stdin) input.destroy(); resolve(a.trim()) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- edge auth spec ----------------------------------------------------------------------
|
||||||
|
const edgeMode = opt('edge', 'none')
|
||||||
|
let edgeAuth = { mode: 'none' }
|
||||||
|
async function secretRef(ref, what) {
|
||||||
|
let v = getRef(ref)
|
||||||
|
if (!v) v = await prompt(`${what}: `)
|
||||||
|
if (!v) { console.error(`missing ${what} (set ${ref} or answer the prompt)`); process.exit(2) }
|
||||||
|
setRef(ref, v)
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
if (edgeMode === 'basic') {
|
||||||
|
edgeAuth = { mode: 'basic', username: opt('edge-user', ''), passwordRef: await secretRef('DSH_REMOTE_EDGE_PASSWORD', `Password / app password for "${opt('edge-user', '')}" at the auth proxy`) }
|
||||||
|
} else if (edgeMode === 'bearer') {
|
||||||
|
edgeAuth = { mode: 'bearer', tokenRef: await secretRef('DSH_REMOTE_EDGE_TOKEN', 'Bearer token for the auth proxy') }
|
||||||
|
} else if (edgeMode === 'oauth2') {
|
||||||
|
edgeAuth = { mode: 'oauth2', tokenUrl: opt('edge-token-url'), clientId: opt('edge-client-id'), username: opt('edge-user'), scope: opt('edge-scope') }
|
||||||
|
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 === 'headers') {
|
||||||
|
edgeAuth = { mode: 'headers', headers: {} }
|
||||||
|
let i = 0
|
||||||
|
for (const h of opts('edge-header')) {
|
||||||
|
const at = h.indexOf(':')
|
||||||
|
if (at <= 0) { console.error(`bad --edge-header "${h}" (want "Name: value")`); process.exit(2) }
|
||||||
|
const ref = `DSH_REMOTE_EDGE_HEADER_${++i}`
|
||||||
|
setRef(ref, h.slice(at + 1).trim())
|
||||||
|
edgeAuth.headers[h.slice(0, at).trim()] = `ref:${ref}`
|
||||||
|
}
|
||||||
|
} else if (edgeMode !== 'none') {
|
||||||
|
console.error(`unknown --edge mode ${edgeMode}`)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- enroll -----------------------------------------------------------------------------------
|
||||||
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'device'
|
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'device'
|
||||||
let state = {}
|
let state = {}
|
||||||
try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')) } catch { state = {} }
|
try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')) } catch { state = {} }
|
||||||
state.self ||= { id: slug(name), name, createdAt: Date.now() }
|
state.self ||= { id: slug(name), name, createdAt: Date.now() }
|
||||||
state.self.name = name
|
state.self.name = name
|
||||||
|
|
||||||
const r = await fetch(`${master}/dsh-remote/enroll`, {
|
const edge = createEdgeAuth(edgeAuth, async (ref) => getRef(ref))
|
||||||
|
let r
|
||||||
|
try {
|
||||||
|
r = await edge.edgeFetch(`${master}/dsh-remote/enroll`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ code: code.trim().toUpperCase(), id: state.self.id, name, platform: `${process.platform}-${process.arch}` }),
|
body: JSON.stringify({ code: code.trim().toUpperCase(), id: state.self.id, name, platform: `${process.platform}-${process.arch}` }),
|
||||||
redirect: 'manual',
|
|
||||||
})
|
})
|
||||||
let j = {}
|
} catch (e) {
|
||||||
try { j = await r.json() } catch { j = {} }
|
console.error(`pairing failed: ${e.message}${e.hint ? `\n hint: ${e.hint}` : ''}`)
|
||||||
if (r.status >= 300 && r.status < 400) {
|
|
||||||
console.error(`pairing failed: ${master} redirected (${r.headers.get('location')?.slice(0, 80)}…). A login proxy (e.g. Authentik) is in front of /dsh-remote/enroll; use the LAN URL or exempt that path.`)
|
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
let j = {}
|
||||||
|
try { j = await r.json() } catch { j = {} }
|
||||||
if (!r.ok) { console.error(`pairing failed: ${j.error || `HTTP ${r.status}`}`); process.exit(1) }
|
if (!r.ok) { console.error(`pairing failed: ${j.error || `HTTP ${r.status}`}`); process.exit(1) }
|
||||||
|
|
||||||
state.master = { url: master, deviceId: j.deviceId, key: j.key, master: j.master, pairedAt: Date.now() }
|
state.master = { url: master, deviceId: j.deviceId, key: j.key, master: j.master, edgeAuth, pairedAt: Date.now() }
|
||||||
state.self.id = j.deviceId
|
state.self.id = j.deviceId
|
||||||
fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 })
|
fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 })
|
||||||
const tmp = `${stateFile}.${process.pid}.tmp`
|
const tmp = `${stateFile}.${process.pid}.tmp`
|
||||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 })
|
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 })
|
||||||
fs.renameSync(tmp, stateFile)
|
fs.renameSync(tmp, stateFile)
|
||||||
console.log(`paired as "${j.deviceId}" with master ${j.master?.name || master}`)
|
console.log(`paired as "${j.deviceId}" with master ${j.master?.name || master}${edgeAuth.mode !== 'none' ? ` (through ${edgeAuth.mode} edge auth)` : ''}`)
|
||||||
@@ -33,6 +33,7 @@ import { CookieJar } from './lib/auth-cookie.mjs'
|
|||||||
import { ownHttp, ownUpgrade, viewHttp, viewUpgrade } from './lib/proxy.mjs'
|
import { ownHttp, ownUpgrade, viewHttp, viewUpgrade } from './lib/proxy.mjs'
|
||||||
import * as sync from './lib/sync.mjs'
|
import * as sync from './lib/sync.mjs'
|
||||||
import * as ops from './lib/agent-ops.mjs'
|
import * as ops from './lib/agent-ops.mjs'
|
||||||
|
import { createEdgeAuth } from './lib/edge-auth.mjs'
|
||||||
|
|
||||||
export const name = 'dsh-remote'
|
export const name = 'dsh-remote'
|
||||||
export const inject = {
|
export const inject = {
|
||||||
@@ -421,12 +422,15 @@ export function apply(ctx, config = {}) {
|
|||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : url.protocol === 'http:' ? 'ws:' : url.protocol
|
url.protocol = url.protocol === 'https:' ? 'wss:' : url.protocol === 'http:' ? 'ws:' : url.protocol
|
||||||
url.pathname = url.pathname.replace(/\/+$/, '') + LINK_PATH
|
url.pathname = url.pathname.replace(/\/+$/, '') + LINK_PATH
|
||||||
try {
|
try {
|
||||||
|
const edge = edgeFor(m)
|
||||||
const ws = await connect(url.href, {
|
const ws = await connect(url.href, {
|
||||||
headers: {
|
headers: {
|
||||||
|
...(await edge.headers()),
|
||||||
'x-dsh-remote-device': m.deviceId,
|
'x-dsh-remote-device': m.deviceId,
|
||||||
'x-dsh-remote-key': m.key,
|
'x-dsh-remote-key': m.key,
|
||||||
'x-dsh-remote-platform': `${process.platform}-${process.arch}`,
|
'x-dsh-remote-platform': `${process.platform}-${process.arch}`,
|
||||||
},
|
},
|
||||||
|
onHeaders: (h) => edge.absorb(h),
|
||||||
})
|
})
|
||||||
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'
|
||||||
@@ -454,11 +458,43 @@ export function apply(ctx, config = {}) {
|
|||||||
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
|
masterLink = null
|
||||||
if (e.status === 401) { log.warn(`dsh-remote: master refused key (${e.message}); re-pair this device`); lastLinkError = e.message; return }
|
// DSH itself refuses an unknown/revoked key with 401 + this exact text; anything else
|
||||||
lastLinkError = e.message
|
// 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 || '')
|
||||||
|
if (deviceRefused) { log.warn(`dsh-remote: master refused key (${e.message}); re-pair this device`); lastLinkError = e.message; return }
|
||||||
|
const edgeRefused = (e.status >= 300 && e.status < 400) || e.status === 401 || e.status === 403
|
||||||
|
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} rejected the edgeAuth credentials (${e.status})`
|
||||||
|
edgeFor(m).reset()
|
||||||
|
backoff = Math.max(backoff, 15000)
|
||||||
|
} else lastLinkError = e.message
|
||||||
|
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
|
||||||
|
// time, overridden by `edgeAuth` in this plugin's config. Secrets resolve via credentials.
|
||||||
|
let edgeCache = null
|
||||||
|
function edgeConfigFor(m) {
|
||||||
|
return config.edgeAuth?.mode ? config.edgeAuth : (m?.edgeAuth || { mode: 'none' })
|
||||||
|
}
|
||||||
|
function edgeFor(m) {
|
||||||
|
const cfg = edgeConfigFor(m)
|
||||||
|
const key = JSON.stringify(cfg)
|
||||||
|
if (edgeCache?.key !== key) edgeCache = { key, client: createEdgeAuth(cfg, resolveSecretRef) }
|
||||||
|
return edgeCache.client
|
||||||
|
}
|
||||||
|
async function resolveSecretRef(ref) {
|
||||||
|
const creds = svc(ctx, 'credentials')
|
||||||
|
if (creds) {
|
||||||
|
try { const r = await creds.resolve(ref); if (r?.value) return r.value } catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return process.env[ref]
|
||||||
|
}
|
||||||
let lastLinkError = null
|
let lastLinkError = null
|
||||||
let timer = null
|
let timer = null
|
||||||
function schedule() {
|
function schedule() {
|
||||||
@@ -598,7 +634,7 @@ 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 } : 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 } : null,
|
||||||
devices: devicesView(),
|
devices: devicesView(),
|
||||||
publicUrl: config.publicUrl || null,
|
publicUrl: config.publicUrl || null,
|
||||||
lanUrl: config.lanUrl || null,
|
lanUrl: config.lanUrl || null,
|
||||||
@@ -634,19 +670,90 @@ export function apply(ctx, config = {}) {
|
|||||||
},
|
},
|
||||||
}), 'dsh-remote: enroll')
|
}), 'dsh-remote: enroll')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise an edgeAuth spec from the UI/API. Secret values arrive inline once and are
|
||||||
|
* written to DSH credentials under a ref; only the ref names are kept in state.
|
||||||
|
*/
|
||||||
|
async function storeEdgeAuth(spec) {
|
||||||
|
if (!spec || !spec.mode || spec.mode === 'none') return { mode: 'none' }
|
||||||
|
const creds = svc(ctx, 'credentials')
|
||||||
|
const put = async (ref, value) => {
|
||||||
|
if (value === undefined || value === null || value === '') return ref
|
||||||
|
if (!creds) throw httpErr(503, 'credentials service unavailable to store the edge secret')
|
||||||
|
await creds.set(ref, String(value))
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
const S = (s) => (s ? String(s).trim() : undefined)
|
||||||
|
const out = { mode: spec.mode }
|
||||||
|
if (spec.mode === 'basic') {
|
||||||
|
out.username = S(spec.username) || ''
|
||||||
|
out.passwordRef = await put(S(spec.passwordRef) || 'DSH_REMOTE_EDGE_PASSWORD', spec.password)
|
||||||
|
} else if (spec.mode === 'bearer') {
|
||||||
|
out.tokenRef = await put(S(spec.tokenRef) || 'DSH_REMOTE_EDGE_TOKEN', spec.token)
|
||||||
|
} else if (spec.mode === 'oauth2') {
|
||||||
|
Object.assign(out, { tokenUrl: S(spec.tokenUrl), clientId: S(spec.clientId), username: S(spec.username), scope: S(spec.scope) })
|
||||||
|
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 === 'headers') {
|
||||||
|
out.headers = {}
|
||||||
|
let i = 0
|
||||||
|
for (const [k, v] of Object.entries(spec.headers || {})) {
|
||||||
|
if (!/^[A-Za-z0-9-]+$/.test(k)) throw httpErr(400, `bad header name ${k}`)
|
||||||
|
const val = String(v ?? '')
|
||||||
|
// Values are treated as secrets unless already a ref.
|
||||||
|
out.headers[k] = val.startsWith('ref:') ? val : `ref:${await put(`DSH_REMOTE_EDGE_HEADER_${++i}`, val)}`
|
||||||
|
}
|
||||||
|
} else throw httpErr(400, `unknown edgeAuth mode ${spec.mode}`)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
route('/dsh-remote/pair', ['POST'], async (req) => {
|
route('/dsh-remote/pair', ['POST'], async (req) => {
|
||||||
const body = await readJson(req)
|
const body = await readJson(req)
|
||||||
const masterUrl = String(body.masterUrl || '').trim().replace(/\/+$/, '')
|
const masterUrl = String(body.masterUrl || '').trim().replace(/\/+$/, '')
|
||||||
if (!/^https?:\/\//.test(masterUrl)) throw httpErr(400, 'masterUrl must start with http:// or https://')
|
if (!/^https?:\/\//.test(masterUrl)) throw httpErr(400, 'masterUrl must start with http:// or https://')
|
||||||
const r = await fetch(`${masterUrl}/dsh-remote/enroll`, {
|
const edgeAuth = await storeEdgeAuth(body.edgeAuth)
|
||||||
|
const edge = createEdgeAuth(config.edgeAuth?.mode ? config.edgeAuth : edgeAuth, resolveSecretRef)
|
||||||
|
let r
|
||||||
|
try {
|
||||||
|
r = await edge.edgeFetch(`${masterUrl}/dsh-remote/enroll`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ code: body.code, id: self.id, name: self.name, platform: `${process.platform}-${process.arch}` }),
|
body: JSON.stringify({ code: body.code, id: self.id, name: self.name, platform: `${process.platform}-${process.arch}` }),
|
||||||
})
|
})
|
||||||
|
} catch (e) {
|
||||||
|
throw httpErr(e.status && e.status < 600 ? (e.status >= 300 && e.status < 400 ? 502 : e.status) : 502, `${e.message}${e.hint ? ` – ${e.hint}` : ''}`)
|
||||||
|
}
|
||||||
const j = await r.json().catch(() => ({}))
|
const j = await r.json().catch(() => ({}))
|
||||||
if (!r.ok) throw httpErr(r.status, j.error || `enroll failed (HTTP ${r.status})`)
|
if (!r.ok) throw httpErr(r.status, j.error || `enroll failed (HTTP ${r.status})`)
|
||||||
store.setMaster({ url: masterUrl, deviceId: j.deviceId, key: j.key, master: j.master, pairedAt: Date.now() })
|
store.setMaster({ url: masterUrl, deviceId: j.deviceId, key: j.key, master: j.master, edgeAuth, pairedAt: Date.now() })
|
||||||
return { ok: true, deviceId: j.deviceId, master: j.master, restartRequired: role !== 'slave' }
|
edgeCache = null
|
||||||
|
return { ok: true, deviceId: j.deviceId, master: j.master, edgeAuth: edge.describe(), restartRequired: role !== 'slave' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Change / test edge auth of an already paired slave without re-pairing.
|
||||||
|
route('/dsh-remote/edge-auth', ['GET', 'POST'], async (req) => {
|
||||||
|
if (!store.master) throw httpErr(400, 'not paired with a master')
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const body = await readJson(req)
|
||||||
|
const edgeAuth = await storeEdgeAuth(body.edgeAuth)
|
||||||
|
store.setMaster({ ...store.master, edgeAuth })
|
||||||
|
edgeCache = null
|
||||||
|
masterLink?.mux?.close(1000, 'edge auth changed')
|
||||||
|
backoff = 1000
|
||||||
|
if (!masterLink) { clearTimeout(timer); connectMaster() }
|
||||||
|
}
|
||||||
|
// Probe: an unauthenticated GET of the link path through the edge must reach DSH, which
|
||||||
|
// answers a plain GET on an upgrade-only path with 404/426 (not a login redirect).
|
||||||
|
const edge = edgeFor(store.master)
|
||||||
|
let probe
|
||||||
|
try {
|
||||||
|
const r = await edge.edgeFetch(`${store.master.url}/dsh-remote/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
||||||
|
probe = { reachedDsh: true, status: r.status }
|
||||||
|
} catch (e) {
|
||||||
|
probe = { reachedDsh: false, error: e.message, hint: e.hint }
|
||||||
|
}
|
||||||
|
return { master: store.master.url, override: !!config.edgeAuth?.mode, edgeAuth: edge.describe(), probe, linked: !!masterLink?.mux, linkError: lastLinkError }
|
||||||
})
|
})
|
||||||
|
|
||||||
route('/dsh-remote/unpair', ['POST'], async () => {
|
route('/dsh-remote/unpair', ['POST'], async () => {
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// Edge (auth proxy) login for the master link.
|
||||||
|
//
|
||||||
|
// A master published behind an identity-aware proxy (Authentik, oauth2-proxy, Cloudflare
|
||||||
|
// Access, plain HTTP Basic, …) redirects every request it can't attribute to a user. A slave's
|
||||||
|
// DSH has no browser, so it authenticates to the proxy itself and replays what it gets
|
||||||
|
// (cookies, headers) on /dsh-remote/enroll and the /dsh-remote/link WebSocket.
|
||||||
|
//
|
||||||
|
// Modes (config `edgeAuth` on the slave, or passed to pair):
|
||||||
|
// none no proxy
|
||||||
|
// basic { username, passwordRef } Authorization: Basic … (Authentik: user + app
|
||||||
|
// password; "goauthentik.io/token" + token works too)
|
||||||
|
// bearer { tokenRef } Authorization: Bearer <static token>
|
||||||
|
// oauth2 { tokenUrl, clientId, username?, passwordRef?, clientSecretRef?, scope? }
|
||||||
|
// client_credentials grant → Bearer JWT, refreshed
|
||||||
|
// before expiry (Authentik machine-to-machine:
|
||||||
|
// username = service account, password = app password)
|
||||||
|
// headers { headers: { "CF-Access-Client-Id": "…", "CF-Access-Client-Secret": "ref:NAME" } }
|
||||||
|
// static extra headers (Cloudflare Access service
|
||||||
|
// tokens, custom gateways); "ref:NAME" values resolve
|
||||||
|
// through the secret resolver
|
||||||
|
//
|
||||||
|
// Every mode keeps a small cookie jar: proxies such as Authentik answer an authenticated
|
||||||
|
// request with a session cookie, and replaying it avoids re-authenticating each call.
|
||||||
|
// Secrets are never stored in config: *Ref fields name a DSH credential ref (or env var).
|
||||||
|
|
||||||
|
const SKEW_MS = 60_000
|
||||||
|
|
||||||
|
export class EdgeAuthError extends Error {
|
||||||
|
constructor(message, { status, hint } = {}) {
|
||||||
|
super(message)
|
||||||
|
this.status = status
|
||||||
|
this.hint = hint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal cookie jar scoped to one origin (enough for proxy session cookies). */
|
||||||
|
export class CookieJar {
|
||||||
|
constructor() { this.cookies = new Map() }
|
||||||
|
|
||||||
|
/** Absorb Set-Cookie headers from a fetch Response or node IncomingMessage. */
|
||||||
|
absorb(headers) {
|
||||||
|
let list = []
|
||||||
|
if (typeof headers?.getSetCookie === 'function') list = headers.getSetCookie()
|
||||||
|
else if (Array.isArray(headers?.['set-cookie'])) list = headers['set-cookie']
|
||||||
|
else if (typeof headers?.['set-cookie'] === 'string') list = [headers['set-cookie']]
|
||||||
|
for (const raw of list) {
|
||||||
|
const [pair, ...attrs] = String(raw).split(';')
|
||||||
|
const eq = pair.indexOf('=')
|
||||||
|
if (eq <= 0) continue
|
||||||
|
const name = pair.slice(0, eq).trim()
|
||||||
|
// Never keep the master DSH's own browser-session cookie: the slave authenticates to
|
||||||
|
// DSH with its device key, the jar is only for the auth proxy's session.
|
||||||
|
if (/^dsh-auth-/.test(name)) continue
|
||||||
|
const value = pair.slice(eq + 1).trim()
|
||||||
|
let expires = null
|
||||||
|
for (const a of attrs) {
|
||||||
|
const [k, v] = a.split('=').map((s) => s?.trim())
|
||||||
|
if (/^max-age$/i.test(k)) expires = Date.now() + Number(v) * 1000
|
||||||
|
else if (/^expires$/i.test(k) && !expires) { const t = Date.parse(v); if (!Number.isNaN(t)) expires = t }
|
||||||
|
}
|
||||||
|
if (!value || (expires && expires <= Date.now())) this.cookies.delete(name)
|
||||||
|
else this.cookies.set(name, { value, expires })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header() {
|
||||||
|
const now = Date.now()
|
||||||
|
const parts = []
|
||||||
|
for (const [k, c] of this.cookies) {
|
||||||
|
if (c.expires && c.expires <= now) { this.cookies.delete(k); continue }
|
||||||
|
parts.push(`${k}=${c.value}`)
|
||||||
|
}
|
||||||
|
return parts.join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() { this.cookies.clear() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an edge-auth client.
|
||||||
|
* @param cfg edgeAuth config ({mode, ...})
|
||||||
|
* @param resolveSecret async (refName) => string|undefined (DSH credentials, then env)
|
||||||
|
*/
|
||||||
|
export function createEdgeAuth(cfg, resolveSecret) {
|
||||||
|
const mode = cfg?.mode || 'none'
|
||||||
|
const jar = new CookieJar()
|
||||||
|
let token = null // { value, expiresAt }
|
||||||
|
|
||||||
|
const secret = async (ref, what) => {
|
||||||
|
if (!ref) throw new EdgeAuthError(`edgeAuth: ${what} is not configured`)
|
||||||
|
const v = await resolveSecret(ref)
|
||||||
|
if (!v) throw new EdgeAuthError(`edgeAuth: secret "${ref}" (${what}) is not set on this device`, { hint: `store it with: dsh credentials or the ${ref} env var` })
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
async function oauthToken(force = false) {
|
||||||
|
if (!force && token && token.expiresAt - SKEW_MS > Date.now()) return token.value
|
||||||
|
if (!cfg.tokenUrl || !cfg.clientId) throw new EdgeAuthError('edgeAuth oauth2: tokenUrl and clientId are required')
|
||||||
|
const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: cfg.clientId })
|
||||||
|
if (cfg.scope) body.set('scope', cfg.scope)
|
||||||
|
if (cfg.username) {
|
||||||
|
// Authentik M2M: username + app password (or a JWT from another provider as password).
|
||||||
|
body.set('username', cfg.username)
|
||||||
|
body.set('password', await secret(cfg.passwordRef, 'password / app password'))
|
||||||
|
}
|
||||||
|
if (cfg.clientSecretRef) body.set('client_secret', await secret(cfg.clientSecretRef, 'client secret'))
|
||||||
|
const r = await fetch(cfg.tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body })
|
||||||
|
let j = {}
|
||||||
|
try { j = await r.json() } catch { j = {} }
|
||||||
|
if (!r.ok || !j.access_token) {
|
||||||
|
throw new EdgeAuthError(`edgeAuth oauth2: token request failed (HTTP ${r.status}${j.error ? `: ${j.error}${j.error_description ? ` – ${j.error_description}` : ''}` : ''})`, { status: r.status })
|
||||||
|
}
|
||||||
|
token = { value: j.access_token, expiresAt: Date.now() + (Number(j.expires_in) || 300) * 1000 }
|
||||||
|
return token.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Headers to add to a request through the edge (auth + jar cookies). */
|
||||||
|
async function headers({ forceRefresh = false } = {}) {
|
||||||
|
const h = {}
|
||||||
|
if (mode === 'basic') {
|
||||||
|
const pass = await secret(cfg.passwordRef, 'password / app password')
|
||||||
|
h.authorization = `Basic ${Buffer.from(`${cfg.username || ''}:${pass}`).toString('base64')}`
|
||||||
|
} else if (mode === 'bearer') {
|
||||||
|
h.authorization = `Bearer ${await secret(cfg.tokenRef, 'token')}`
|
||||||
|
} else if (mode === 'oauth2') {
|
||||||
|
h.authorization = `Bearer ${await oauthToken(forceRefresh)}`
|
||||||
|
} else if (mode === '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)
|
||||||
|
}
|
||||||
|
} else if (mode !== 'none') {
|
||||||
|
throw new EdgeAuthError(`edgeAuth: unknown mode "${mode}"`)
|
||||||
|
}
|
||||||
|
const c = jar.header()
|
||||||
|
if (c) h.cookie = c
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classify a response that came back from the edge instead of DSH. */
|
||||||
|
function edgeBlocked(status, location) {
|
||||||
|
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}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch() through the edge: adds auth, keeps cookies, never follows redirects (a redirect
|
||||||
|
* to a login page means the edge rejected us). Retries once with a fresh token / cleared
|
||||||
|
* jar when the edge refuses.
|
||||||
|
*/
|
||||||
|
async function edgeFetch(url, init = {}) {
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
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'))
|
||||||
|
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 (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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw new EdgeAuthError('unreachable')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
jar,
|
||||||
|
headers,
|
||||||
|
edgeFetch,
|
||||||
|
edgeBlocked,
|
||||||
|
/** Feed upgrade-response cookies back (WebSocket handshake). */
|
||||||
|
absorb: (h) => jar.absorb(h),
|
||||||
|
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()] } },
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -167,7 +167,7 @@ export function rejectUpgrade(socket, status, message = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Client connect: resolves a WsConn, rejects with {status, body} on refusal. */
|
/** Client connect: resolves a WsConn, rejects with {status, body} on refusal. */
|
||||||
export function connect(url, { headers = {}, timeoutMs = 15000, rejectUnauthorized = true } = {}) {
|
export function connect(url, { headers = {}, timeoutMs = 15000, rejectUnauthorized = true, onHeaders } = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const u = new URL(url)
|
const u = new URL(url)
|
||||||
const secure = u.protocol === 'wss:' || u.protocol === 'https:'
|
const secure = u.protocol === 'wss:' || u.protocol === 'https:'
|
||||||
@@ -192,12 +192,15 @@ export function connect(url, { headers = {}, timeoutMs = 15000, rejectUnauthoriz
|
|||||||
req.on('timeout', () => { req.destroy(new Error(`connect timeout after ${timeoutMs} ms`)) })
|
req.on('timeout', () => { req.destroy(new Error(`connect timeout after ${timeoutMs} ms`)) })
|
||||||
req.on('error', reject)
|
req.on('error', reject)
|
||||||
req.on('response', (res) => {
|
req.on('response', (res) => {
|
||||||
|
onHeaders?.(res.headers)
|
||||||
let body = ''
|
let body = ''
|
||||||
res.setEncoding('utf8')
|
res.setEncoding('utf8')
|
||||||
res.on('data', (d) => { if (body.length < 4096) body += d })
|
res.on('data', (d) => { if (body.length < 4096) body += d })
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
const err = new Error(`upgrade refused: HTTP ${res.statusCode} ${body.trim()}`)
|
const loc = res.headers.location ? ` → ${String(res.headers.location).slice(0, 100)}` : ''
|
||||||
|
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.body = body
|
err.body = body
|
||||||
reject(err)
|
reject(err)
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@local/dsh-remote",
|
"name": "@local/dsh-remote",
|
||||||
"version": "0.3.0",
|
"version": "0.4.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",
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Edge auth against a fake identity-aware proxy that behaves like Authentik's outpost:
|
||||||
|
// unauthenticated → 302 to a login page; valid credential → session cookie + pass-through;
|
||||||
|
// valid session cookie → pass-through; invalid credential → 401.
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import http from 'node:http'
|
||||||
|
import { createEdgeAuth, CookieJar } from '../lib/edge-auth.mjs'
|
||||||
|
import { acceptUpgrade, connect } from '../lib/ws.mjs'
|
||||||
|
import { setRefIn } from '../deploy/pair-lib.mjs'
|
||||||
|
|
||||||
|
const listen = (srv) => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port)))
|
||||||
|
|
||||||
|
function fakeProxy() {
|
||||||
|
const sessions = new Set()
|
||||||
|
const issuedTokens = new Set()
|
||||||
|
const stats = { credentialLogins: 0, cookieHits: 0, tokenRequests: 0 }
|
||||||
|
const authOk = (req) => {
|
||||||
|
const cookie = /edge_session=([a-z0-9]+)/.exec(req.headers.cookie || '')?.[1]
|
||||||
|
if (cookie && sessions.has(cookie)) { stats.cookieHits++; return 'cookie' }
|
||||||
|
const a = req.headers.authorization || ''
|
||||||
|
if (a === `Basic ${Buffer.from('svc:app-pass').toString('base64')}`) return 'cred'
|
||||||
|
if (a === 'Bearer static-token') return 'cred'
|
||||||
|
if (a.startsWith('Bearer jwt-') && issuedTokens.has(a.slice(7))) return 'cred'
|
||||||
|
if (req.headers['cf-access-client-id'] === 'id1' && req.headers['cf-access-client-secret'] === 'sec1') return 'cred'
|
||||||
|
if (a) return 'bad'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const srv = http.createServer((req, res) => {
|
||||||
|
if (req.url === '/token' && req.method === 'POST') {
|
||||||
|
let body = ''
|
||||||
|
req.on('data', (d) => { body += d })
|
||||||
|
req.on('end', () => {
|
||||||
|
stats.tokenRequests++
|
||||||
|
const p = new URLSearchParams(body)
|
||||||
|
if (p.get('grant_type') !== 'client_credentials' || p.get('client_id') !== 'cid' || p.get('username') !== 'svc' || p.get('password') !== 'app-pass') {
|
||||||
|
res.writeHead(400, { 'content-type': 'application/json' }); return res.end('{"error":"invalid_grant"}')
|
||||||
|
}
|
||||||
|
const t = `jwt-${Math.random().toString(36).slice(2)}`
|
||||||
|
issuedTokens.add(t)
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ access_token: t, token_type: 'bearer', expires_in: 3600 }))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const a = authOk(req)
|
||||||
|
if (a === 'bad') { res.writeHead(401); return res.end('bad credentials') }
|
||||||
|
if (!a) { res.writeHead(302, { location: 'https://auth.example/application/o/authorize/?x=1' }); return res.end() }
|
||||||
|
const headers = { 'content-type': 'application/json' }
|
||||||
|
if (a === 'cred') {
|
||||||
|
stats.credentialLogins++
|
||||||
|
const sid = Math.random().toString(36).slice(2)
|
||||||
|
sessions.add(sid)
|
||||||
|
headers['set-cookie'] = `edge_session=${sid}; Path=/; HttpOnly; Max-Age=3600`
|
||||||
|
}
|
||||||
|
if (req.url === '/dsh-remote/enroll') {
|
||||||
|
res.writeHead(req.headers['x-bad-code'] ? 403 : 200, headers)
|
||||||
|
return res.end(JSON.stringify(req.headers['x-bad-code'] ? { error: 'invalid or expired pairing code' } : { deviceId: 'mac', key: 'k', master: { id: 'm', name: 'M' } }))
|
||||||
|
}
|
||||||
|
res.writeHead(404); res.end()
|
||||||
|
})
|
||||||
|
srv.on('upgrade', (req, socket, head) => {
|
||||||
|
const a = authOk(req)
|
||||||
|
if (a !== 'cred' && a !== 'cookie') {
|
||||||
|
socket.end(`HTTP/1.1 302 Found\r\nLocation: https://auth.example/login\r\nContent-Length: 0\r\n\r\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ws = acceptUpgrade(req, socket, head)
|
||||||
|
ws.on('error', () => {})
|
||||||
|
ws.on('message', (m) => ws.send(`echo:${m}`))
|
||||||
|
})
|
||||||
|
return { srv, stats, sessions }
|
||||||
|
}
|
||||||
|
|
||||||
|
const secrets = { PW: 'app-pass', TOK: 'static-token', H1: 'id1', H2: 'sec1' }
|
||||||
|
const resolve = async (ref) => secrets[ref]
|
||||||
|
|
||||||
|
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))
|
||||||
|
srv.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [mode, cfg] of [
|
||||||
|
['basic', { mode: 'basic', username: 'svc', passwordRef: 'PW' }],
|
||||||
|
['bearer', { mode: 'bearer', tokenRef: 'TOK' }],
|
||||||
|
['headers', { mode: 'headers', headers: { 'CF-Access-Client-Id': 'ref:H1', 'CF-Access-Client-Secret': 'ref:H2' } }],
|
||||||
|
]) {
|
||||||
|
test(`mode ${mode}: enroll passes, session cookie is kept and reused`, async () => {
|
||||||
|
const { srv, stats } = fakeProxy()
|
||||||
|
const port = await listen(srv)
|
||||||
|
const e = createEdgeAuth(cfg, resolve)
|
||||||
|
const r = await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' })
|
||||||
|
assert.equal(r.status, 200)
|
||||||
|
assert.equal((await r.json()).deviceId, 'mac')
|
||||||
|
assert.deepEqual(e.describe().cookies, ['edge_session'])
|
||||||
|
// DSH-level JSON 403 (bad pairing code) is passed through, not treated as an edge refusal.
|
||||||
|
const bad = await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST', headers: { 'x-bad-code': '1' } })
|
||||||
|
assert.equal(bad.status, 403)
|
||||||
|
assert.ok(stats.cookieHits >= 1, 'second request rode on the proxy session cookie')
|
||||||
|
srv.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('mode oauth2: client_credentials token, used as Bearer, cached', async () => {
|
||||||
|
const { srv, stats } = fakeProxy()
|
||||||
|
const port = await listen(srv)
|
||||||
|
const e = createEdgeAuth({ mode: 'oauth2', tokenUrl: `http://127.0.0.1:${port}/token`, clientId: 'cid', username: 'svc', passwordRef: 'PW' }, resolve)
|
||||||
|
const r1 = await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' })
|
||||||
|
assert.equal(r1.status, 200)
|
||||||
|
e.jar.clear() // force credential use again, token must be reused (not re-requested)
|
||||||
|
const r2 = await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' })
|
||||||
|
assert.equal(r2.status, 200)
|
||||||
|
assert.equal(stats.tokenRequests, 1)
|
||||||
|
assert.ok(e.describe().tokenValidUntil)
|
||||||
|
srv.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('oauth2 bad credentials and basic wrong password surface clear errors', async () => {
|
||||||
|
const { srv } = fakeProxy()
|
||||||
|
const port = await listen(srv)
|
||||||
|
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))
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('WebSocket link through the edge: refused without auth, passes with basic + cookie', async () => {
|
||||||
|
const { srv, stats } = fakeProxy()
|
||||||
|
const port = await listen(srv)
|
||||||
|
await assert.rejects(connect(`ws://127.0.0.1:${port}/dsh-remote/link`), (err) => err.status === 302 && /auth\.example/.test(err.location))
|
||||||
|
const e = createEdgeAuth({ mode: 'basic', username: 'svc', passwordRef: 'PW' }, resolve)
|
||||||
|
// First request gets the session cookie (like the slave's enroll / probe)…
|
||||||
|
await e.edgeFetch(`http://127.0.0.1:${port}/dsh-remote/enroll`, { method: 'POST' })
|
||||||
|
const ws = await connect(`ws://127.0.0.1:${port}/dsh-remote/link`, { headers: await e.headers(), onHeaders: (h) => e.absorb(h) })
|
||||||
|
const reply = await new Promise((r) => { ws.on('message', (m) => r(String(m))); ws.send('hi') })
|
||||||
|
assert.equal(reply, 'echo:hi')
|
||||||
|
ws.close()
|
||||||
|
assert.ok(stats.cookieHits >= 1)
|
||||||
|
srv.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cookie jar: expiry and deletion', () => {
|
||||||
|
const j = new CookieJar()
|
||||||
|
j.absorb({ 'set-cookie': ['a=1; Max-Age=3600', 'b=2; Max-Age=0', 'c=3'] })
|
||||||
|
assert.equal(j.header(), 'a=1; c=3')
|
||||||
|
j.absorb({ 'set-cookie': 'a=; Max-Age=0' })
|
||||||
|
assert.equal(j.header(), 'c=3')
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
assert.match(after, /records:\n client-connection\/browser-session:\n kind: grant\n/)
|
||||||
|
assert.match(after, /refs:\n OMNI_API_KEY: sk-1\n DSH_REMOTE_EDGE_PASSWORD: "p\\"w"\n/)
|
||||||
|
const again = setRefIn(after, 'DSH_REMOTE_EDGE_PASSWORD', 'new')
|
||||||
|
assert.equal((again.match(/DSH_REMOTE_EDGE_PASSWORD/g) || []).length, 1)
|
||||||
|
assert.match(setRefIn('', 'X_Y', 'v'), /^version: 1\nrefs:\n X_Y: "v"\n$/)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user