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:
lyuksovannyy committed 2026-10-11 12:19:36 +00:00
1 parent 5dff41a3d0
commit 78fce8e1ce
12 files changed
+833 -40

No files matched your search

+31 -8
View File
@@ -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
+81 -2
View File
@@ -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
+49
View File
@@ -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)
+65
View File
@@ -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}`))
+8 -2
View File
@@ -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.
+29
View File
@@ -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, '')
}
+102 -15
View File
@@ -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))
method: 'POST', let r
headers: { 'content-type': 'application/json' }, try {
body: JSON.stringify({ code: code.trim().toUpperCase(), id: state.self.id, name, platform: `${process.platform}-${process.arch}` }), r = await edge.edgeFetch(`${master}/dsh-remote/enroll`, {
redirect: 'manual', method: 'POST',
}) headers: { 'content-type': 'application/json' },
let j = {} body: JSON.stringify({ code: code.trim().toUpperCase(), id: state.self.id, name, platform: `${process.platform}-${process.arch}` }),
try { j = await r.json() } catch { j = {} } })
if (r.status >= 300 && r.status < 400) { } catch (e) {
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.`) console.error(`pairing failed: ${e.message}${e.hint ? `\n hint: ${e.hint}` : ''}`)
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)` : ''}`)
+117 -10
View File
@@ -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)
method: 'POST', const edge = createEdgeAuth(config.edgeAuth?.mode ? config.edgeAuth : edgeAuth, resolveSecretRef)
headers: { 'content-type': 'application/json' }, let r
body: JSON.stringify({ code: body.code, id: self.id, name: self.name, platform: `${process.platform}-${process.arch}` }), try {
}) r = await edge.edgeFetch(`${masterUrl}/dsh-remote/enroll`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
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 () => {
+182
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+163
View File
@@ -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$/)
})