dsh-remote 0.1.0: master/slave remote control for DSH
- authenticated WebSocket link (dependency-free RFC6455 + stream mux), pairing codes, per-device keys (hashed), revoke/remove - /r/<device>/ proxy of the full DSH UI (HTTP, Remote WebSocket, SSE) with per-device cookie minting; slave->slave relay through the master - Devices sidebar panel (all devices' sessions, new session on device), Remote devices settings page, finished-session toasts - plugin + config sync master->slave: registry bundles via pluginManager, link: plugins as tarballs, cordis.patch.yml into a managed bundle layer (!!js preserved), allow/deny, opt-in per-secret credential sync - deploy helpers + e2e probes, unit tests
This commit is contained in:
commit
8872e73e76
21 files changed
+2914
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
*.tgz
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# dsh-remote
|
||||||
|
|
||||||
|
Remote control for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), in the style of Claude Desktop: one **master** and any number of **slaves**.
|
||||||
|
|
||||||
|
- **Every session on every device.** Sessions run on the machine that created them. From any device you can browse all of them, open them, and chat in them; the work still executes on the owning machine.
|
||||||
|
- **The full remote UI at `/r/<device>/`.** The whole DSH UI of another device is proxied over the link: HTTP, the Remote WebSocket, SSE, plugin bundles and terminals. Slave→slave goes through the master's relay.
|
||||||
|
- **Plugin and config sync, master → slaves.** Slaves get the master's npm/registry bundles, its local `link:` plugins (packed and shipped over the link) and its `cordis.patch.yml`. The config lands in a *managed* bundle layer, so each slave's own `cordis.patch.yml` still wins and slave-only plugins stay untouched.
|
||||||
|
- **Opt-in secrets.** A credential (`OMNI_API_KEY`, …) reaches a slave only if three places agree: the master offers it, the master enables it for that device, and the slave accepts it.
|
||||||
|
- **Devices UI.** A sidebar panel lists every device and its sessions (live running state, filter, "New session on <device>"). A "Remote devices" settings page handles pairing, revocation and per-device sync policy. You get a toast when a session on any device finishes.
|
||||||
|
|
||||||
|
No dependencies: the WebSocket, the multiplexer and the proxy are plain Node.
|
||||||
|
|
||||||
|
```
|
||||||
|
slave (laptop) ──outbound WebSocket /dsh-remote/link──► master ◄── slave (ai-pc)
|
||||||
|
own sessions + own local plugins plugins, config, relay hub
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
On every machine, register the plugin in the DSH web profile and restart DSH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# master (this repo as a link: plugin, e.g. in Beast-Workspace)
|
||||||
|
# profile package.json → "@local/dsh-remote": "link:/path/to/dsh-remote" + bundle entry
|
||||||
|
# or use the helper (also creates the node_modules link and a config block):
|
||||||
|
node deploy/install-local.mjs --role master --name "Pelican-Node"
|
||||||
|
node deploy/install-local.mjs --role auto --name "ai-pc" # on a slave
|
||||||
|
systemctl --user restart dsh # or restart `dsh web` however you run it
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy/dsh-web.user.service` is a ready-made systemd user unit for a rootless `dsh web` (127.0.0.1:3081).
|
||||||
|
|
||||||
|
## Pair a device
|
||||||
|
|
||||||
|
1. **Master:** Settings → *Remote devices* → **Create pairing code** (single use, valid 15 min).
|
||||||
|
2. **Slave:** Settings → *Remote devices* → enter the master URL (e.g. `http://192.168.0.111:3080` or `https://dsh.rizzy.dev`) and the code → **Pair**. Then restart the slave's DSH once: with `role: auto` it switches from master to slave.
|
||||||
|
|
||||||
|
The slave gets its own device key; the master stores only its hash. **Revoke** cuts the device off immediately: open links close with code 4401 and later connections are refused with 401.
|
||||||
|
|
||||||
|
## Use it
|
||||||
|
|
||||||
|
- **Sidebar → Devices:** every device, its sessions grouped by workspace, a running indicator, search. Click a session to open it. A local session opens in place. A remote one opens `/r/<device>/#/session/<id>`, the full UI of that device, with a "Remote: <device> · back to local" pill in the corner.
|
||||||
|
- **+ New session** on a device card creates a session in one of that device's workspaces and opens it.
|
||||||
|
- **Open DSH ↗** opens a device's whole UI.
|
||||||
|
- **Toasts:** "<device>: '<title>' finished", with an *Open* action.
|
||||||
|
|
||||||
|
## Configuration (`cordis.patch.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: dsh-remote
|
||||||
|
config:
|
||||||
|
role: master # master | slave | auto (auto = slave once paired)
|
||||||
|
deviceName: Pelican-Node
|
||||||
|
lanUrl: http://192.168.0.111:3080 # shown on the pairing page
|
||||||
|
publicUrl: https://dsh.rizzy.dev
|
||||||
|
sync:
|
||||||
|
auto: true # slave: sync on link-up and when the master pushes
|
||||||
|
allow: [] # slave: only these bundles (exact or prefix*)
|
||||||
|
deny: [] # slave: never these bundles (wins over allow)
|
||||||
|
secrets: [] # slave: credential refs it accepts from the master
|
||||||
|
offerSecrets: [OMNI_API_KEY] # master: refs it may hand out (per-device opt-in on the Devices page)
|
||||||
|
excludeBundles: [] # master: never sync these bundles
|
||||||
|
excludePatchIds: [] # master: never sync these cordis.patch.yml entry ids
|
||||||
|
```
|
||||||
|
|
||||||
|
Never synced: the master's `dsh-remote` and `dsh-lanmode` entries, and `connection`/`webserver` settings, since these are machine-local.
|
||||||
|
|
||||||
|
## How sync works
|
||||||
|
|
||||||
|
| Master has | Slave gets |
|
||||||
|
| --- | --- |
|
||||||
|
| registry bundle `name@ver` | `pluginManager.installBundle(name@ver)` (pnpm in the slave profile) |
|
||||||
|
| `link:` local plugin | a tarball over the link, unpacked to `<profile>/dsh-remote/sync/plugins/<name>/` and linked into the profile. Its `@deepseek-ai/*` imports are re-linked to the slave's DSH install |
|
||||||
|
| `cordis.patch.yml` entries | `<profile>/dsh-remote/managed-bundle/cordis.patch.yml`, added as the **last bundle**. The slave's own `cordis.patch.yml` is applied after it and wins. `!!js` expressions are kept |
|
||||||
|
| offered secret (opted in on both sides) | `ctx.credentials.set(name, value)` on the slave |
|
||||||
|
|
||||||
|
Local plugins are compared by content hash and re-shipped only when they change. When the bundle list changes, DSH needs a restart to load host code; the sync result reports `restartRequired`. *Push sync to all devices* on the master triggers an immediate pull everywhere.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
- Browsers authenticate with DSH's own cookie. Every `/dsh-remote/*` and `/r/*` route checks `ctx.connection.requestRejection`.
|
||||||
|
- The link is authenticated with a per-device key, sent in the upgrade headers and stored as a SHA-256 hash on the master.
|
||||||
|
- The proxy strips the browser's DSH cookies. The **owning** device mints a short-lived cookie for its own loopback authority, signed with its own browser-session secret, and rewrites `Host`/`Origin`. A remote DSH therefore only trusts its own minted cookie, and credentials never leave the machine they belong to, except secrets you explicitly opted into.
|
||||||
|
- `/dsh-remote/enroll` is the only unauthenticated route. It accepts a single-use pairing code.
|
||||||
|
- Use TLS (`https://` master URL) whenever the link crosses an untrusted network.
|
||||||
|
|
||||||
|
## Public access via rizzy.dev
|
||||||
|
|
||||||
|
`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):
|
||||||
|
|
||||||
|
```
|
||||||
|
/dsh-remote/link (WebSocket upgrade)
|
||||||
|
/dsh-remote/enroll (POST, one-time pairing code)
|
||||||
|
```
|
||||||
|
|
||||||
|
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`).
|
||||||
|
|
||||||
|
## HTTP API (used by the UI)
|
||||||
|
|
||||||
|
| Route | |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /dsh-remote/state` | role, self, link status, devices |
|
||||||
|
| `GET /dsh-remote/sessions` | sessions of every reachable device |
|
||||||
|
| `GET /dsh-remote/stream` | SSE: `session-finished`, `devices`, `sync` events |
|
||||||
|
| `POST /dsh-remote/pairing` | master: create a pairing code |
|
||||||
|
| `POST /dsh-remote/pair` | slave: `{masterUrl, code}` |
|
||||||
|
| `POST /dsh-remote/unpair` | slave: forget the master |
|
||||||
|
| `POST /dsh-remote/devices/<id>/{revoke,remove,sync}` | master: device management / sync policy |
|
||||||
|
| `POST /dsh-remote/sync` | slave: pull now · master: push to all |
|
||||||
|
| `GET /dsh-remote/sync/status` | manifest (master) / last result (slave) |
|
||||||
|
| `POST /dsh-remote/new-session` | `{device, workspaceId? , cwd?}` |
|
||||||
|
| `* /r/<device>/…` | that device's DSH |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # link, mux, proxy (HTTP + WS), cookie, allow/deny
|
||||||
|
node deploy/dshr-cli.mjs GET state # call the local API as the DSH owner (loopback)
|
||||||
|
node deploy/e2e-ws.mjs ai-pc # Remote WebSocket through /r/ai-pc/
|
||||||
|
node deploy/e2e-events.mjs 30 # watch the SSE event stream
|
||||||
|
node deploy/e2e-ui.mjs <chrome> /tmp/out # headless UI check + screenshots
|
||||||
|
deploy/push-slave.sh agent@192.168.0.110 # copy to a slave + restart its dsh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **`/r/<device>/` needs relative URLs.** Plugins that call absolute URLs (`fetch('/x')`) miss the mount there. Currently that is `@liustack/modlens` (paste/config) and parts of `@goodandready/dsh-lanmode`'s own UI. Core DSH is fully relative.
|
||||||
|
- **Restart after plugin changes.** A sync that adds or removes plugins needs a DSH restart on the slave to load their host code, as the result reports. Config-only changes apply after a restart too: DSH's HMR doesn't watch bundle patch files.
|
||||||
|
- **Multiple devices on one origin.** Viewing several devices in one browser happens under one origin, so `localStorage`-based UI state (e.g. layout choices) is shared between them.
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
/**
|
||||||
|
* @local/dsh-remote — browser side.
|
||||||
|
*
|
||||||
|
* - Sidebar panel "Devices" (sidebar.panellist + keyed main slot): every linked device
|
||||||
|
* with its sessions grouped by workspace, live running state, "open" (local session →
|
||||||
|
* in place, remote → /r/<device>/#/session/<id>), "new session on device".
|
||||||
|
* - Settings section "Remote devices": role, link status, pairing (master: create code;
|
||||||
|
* slave: enter master URL + code), device list with revoke/remove and per-device sync
|
||||||
|
* policy (enabled, allow/deny, opt-in secrets), "sync now".
|
||||||
|
* - Toast when a session on any device finishes (server-sent events).
|
||||||
|
* - When the page itself is served under /r/<device>/, a small banner shows which
|
||||||
|
* device you are looking at, with a link back to the local DSH.
|
||||||
|
*/
|
||||||
|
window.__ModuleLoader__.load({
|
||||||
|
id: '@local/dsh-remote',
|
||||||
|
factory(require) {
|
||||||
|
const React = require('react');
|
||||||
|
const ReactDOM = require('react-dom');
|
||||||
|
const h = React.createElement;
|
||||||
|
const { useState, useEffect, useCallback, useMemo, useRef } = React;
|
||||||
|
let P = {};
|
||||||
|
try { P = require('@deepseek-ai/dsh-client-ui-primitives') || {}; } catch { P = {}; }
|
||||||
|
|
||||||
|
const PANEL_ID = 'dsh-remote-devices';
|
||||||
|
|
||||||
|
// When this UI is itself proxied (/r/<dev>/), API calls must go to *this* page's
|
||||||
|
// host DSH, i.e. the remote device. Relative URLs already do that. For the device
|
||||||
|
// overview we always want the browser's own DSH, which is the URL root before /r/.
|
||||||
|
const mount = (() => {
|
||||||
|
const m = location.pathname.match(/^(.*?)\/r\/([a-z0-9-]+)\//);
|
||||||
|
return m ? { root: m[1] + '/', device: m[2] } : { root: location.pathname.replace(/[^/]*$/, ''), device: null };
|
||||||
|
})();
|
||||||
|
const rootUrl = (p) => mount.root + p;
|
||||||
|
|
||||||
|
async function api(path, init) {
|
||||||
|
const res = await fetch(rootUrl('dsh-remote/' + path), {
|
||||||
|
...init,
|
||||||
|
headers: { accept: 'application/json', ...(init && init.body ? { 'content-type': 'application/json' } : {}) },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
let json;
|
||||||
|
try { json = await res.json(); } catch { json = {}; }
|
||||||
|
if (!res.ok) throw new Error(json.error || res.statusText || String(res.status));
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
const post = (path, body) => api(path, { method: 'POST', body: JSON.stringify(body || {}) });
|
||||||
|
|
||||||
|
function sessionUrl(device, selfId, sessionId) {
|
||||||
|
const base = device === selfId ? mount.root : `${mount.root}r/${device}/`;
|
||||||
|
return `${base}#/session/${encodeURIComponent(sessionId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ago = (t) => {
|
||||||
|
if (!t) return '';
|
||||||
|
const s = Math.max(0, (Date.now() - t) / 1000);
|
||||||
|
if (s < 60) return 'just now';
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||||
|
return `${Math.floor(s / 86400)}d ago`;
|
||||||
|
};
|
||||||
|
const base = (p) => String(p || '').replace(/\/+$/, '').split('/').pop() || p || '';
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ styles
|
||||||
|
const S = {
|
||||||
|
page: { padding: '20px 28px', overflow: 'auto', height: '100%', boxSizing: 'border-box', color: 'var(--color-text, inherit)' },
|
||||||
|
h1: { fontSize: 20, fontWeight: 600, margin: '0 0 4px' },
|
||||||
|
sub: { opacity: 0.65, fontSize: 13, margin: '0 0 18px' },
|
||||||
|
card: { border: '1px solid var(--color-border, rgba(127,127,127,.25))', borderRadius: 10, padding: '12px 14px', margin: '0 0 14px', background: 'var(--color-surface, transparent)' },
|
||||||
|
devHead: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 },
|
||||||
|
dot: (on) => ({ width: 9, height: 9, borderRadius: 9, background: on ? '#3fb950' : '#8b949e', flex: 'none' }),
|
||||||
|
badge: (tone) => ({ fontSize: 11, padding: '1px 7px', borderRadius: 9, border: '1px solid currentColor', opacity: 0.8, color: tone === 'accent' ? '#58a6ff' : tone === 'warn' ? '#d29922' : tone === 'ok' ? '#3fb950' : 'inherit' }),
|
||||||
|
ws: { fontSize: 12, textTransform: 'uppercase', letterSpacing: '.04em', opacity: 0.6, margin: '10px 0 4px' },
|
||||||
|
row: { display: 'flex', alignItems: 'center', gap: 8, padding: '5px 8px', borderRadius: 6, cursor: 'pointer', fontSize: 14 },
|
||||||
|
title: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||||
|
meta: { fontSize: 12, opacity: 0.55, flex: 'none' },
|
||||||
|
btn: { font: 'inherit', fontSize: 13, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--color-border, rgba(127,127,127,.35))', background: 'transparent', color: 'inherit', cursor: 'pointer' },
|
||||||
|
btnPrimary: { font: 'inherit', fontSize: 13, padding: '5px 12px', borderRadius: 6, border: '1px solid #2f81f7', background: '#2f81f7', color: '#fff', cursor: 'pointer' },
|
||||||
|
input: { font: 'inherit', fontSize: 13, padding: '5px 8px', borderRadius: 6, border: '1px solid var(--color-border, rgba(127,127,127,.35))', background: 'transparent', color: 'inherit', minWidth: 0 },
|
||||||
|
err: { color: '#f85149', fontSize: 13 },
|
||||||
|
code: { fontFamily: 'ui-monospace, monospace', fontSize: 18, letterSpacing: '.08em', padding: '6px 10px', border: '1px dashed currentColor', borderRadius: 6, display: 'inline-block' },
|
||||||
|
spin: { display: 'inline-block', width: 8, height: 8, borderRadius: 8, background: '#d29922', animation: 'dshr-pulse 1s infinite' },
|
||||||
|
};
|
||||||
|
if (!document.getElementById('dshr-style')) {
|
||||||
|
const st = document.createElement('style');
|
||||||
|
st.id = 'dshr-style';
|
||||||
|
st.textContent = '@keyframes dshr-pulse{0%,100%{opacity:.3}50%{opacity:1}} .dshr-row:hover{background:var(--color-hover,rgba(127,127,127,.12))}';
|
||||||
|
document.head.appendChild(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ live store
|
||||||
|
const store = {
|
||||||
|
state: null, sessions: null, error: null, listeners: new Set(), events: new Set(),
|
||||||
|
emit() { for (const l of this.listeners) l(); },
|
||||||
|
async refresh() {
|
||||||
|
try {
|
||||||
|
const [state, sessions] = await Promise.all([api('state'), api('sessions')]);
|
||||||
|
this.state = state; this.sessions = sessions.devices; this.error = null;
|
||||||
|
} catch (e) { this.error = e.message; }
|
||||||
|
this.emit();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let es = null;
|
||||||
|
let refreshTimer = null;
|
||||||
|
function startStream() {
|
||||||
|
if (es) return;
|
||||||
|
try {
|
||||||
|
es = new EventSource(rootUrl('dsh-remote/stream'));
|
||||||
|
es.onmessage = (m) => {
|
||||||
|
let ev; try { ev = JSON.parse(m.data); } catch { return; }
|
||||||
|
for (const l of store.events) l(ev);
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
refreshTimer = setTimeout(() => store.refresh(), 300);
|
||||||
|
};
|
||||||
|
es.onerror = () => { /* EventSource reconnects on its own */ };
|
||||||
|
} catch { es = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useStore() {
|
||||||
|
const [, force] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const l = () => force((x) => x + 1);
|
||||||
|
store.listeners.add(l);
|
||||||
|
if (!store.state) store.refresh();
|
||||||
|
const t = setInterval(() => store.refresh(), 10000);
|
||||||
|
return () => { store.listeners.delete(l); clearInterval(t); };
|
||||||
|
}, []);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Devices panel
|
||||||
|
function DeviceIcon({ size }) {
|
||||||
|
return h('svg', { 'data-dsh-panel-entry': PANEL_ID, viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
||||||
|
h('rect', { x: 1.5, y: 2.5, width: 9, height: 6.5, rx: 1 }),
|
||||||
|
h('path', { d: 'M4 12h4M6 9v3' }),
|
||||||
|
h('rect', { x: 11.5, y: 5.5, width: 3, height: 8, rx: .8 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionRow({ dev, s, selfId }) {
|
||||||
|
return h('a', {
|
||||||
|
className: 'dshr-row', style: { ...S.row, color: 'inherit', textDecoration: 'none' },
|
||||||
|
href: sessionUrl(dev.device, selfId, s.sessionId),
|
||||||
|
title: `${s.title || s.sessionId}\n${s.cwd || ''}`,
|
||||||
|
},
|
||||||
|
s.running ? h('span', { style: S.spin, title: 'running' }) : h('span', { style: { width: 8, flex: 'none' } }),
|
||||||
|
h('span', { style: S.title }, s.title || h('i', { style: { opacity: .6 } }, 'untitled')),
|
||||||
|
h('span', { style: S.meta }, ago(s.updatedAt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewSession({ dev, selfId }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const options = dev.workspaces || [];
|
||||||
|
const [ws, setWs] = useState(options[0]?.id || '');
|
||||||
|
const go = async () => {
|
||||||
|
setBusy(true); setErr('');
|
||||||
|
try {
|
||||||
|
const r = await post('new-session', { device: dev.device, workspaceId: ws || undefined });
|
||||||
|
location.href = sessionUrl(dev.device, selfId, r.sessionId);
|
||||||
|
if (dev.device === selfId) setTimeout(() => location.reload(), 50);
|
||||||
|
} catch (e) { setErr(e.message); setBusy(false); }
|
||||||
|
};
|
||||||
|
if (!open) return h('button', { style: S.btn, onClick: () => setOpen(true) }, '+ New session');
|
||||||
|
return h('span', { style: { display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
|
||||||
|
h('select', { style: S.input, value: ws, onChange: (e) => setWs(e.target.value) },
|
||||||
|
options.length ? options.map((w) => h('option', { key: w.id, value: w.id }, w.title || base(w.path))) : h('option', { value: '' }, '(no workspace)')),
|
||||||
|
h('button', { style: S.btnPrimary, disabled: busy || !options.length, onClick: go }, busy ? 'Creating…' : 'Create'),
|
||||||
|
h('button', { style: S.btn, onClick: () => setOpen(false) }, 'Cancel'),
|
||||||
|
err && h('span', { style: S.err }, err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevicesPage() {
|
||||||
|
const st = useStore();
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const self = st.state?.self;
|
||||||
|
if (!st.state && !st.error) return h('div', { style: S.page }, 'Loading devices…');
|
||||||
|
const devices = st.sessions || [];
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
const meta = Object.fromEntries((st.state?.devices || []).map((d) => [d.id, d]));
|
||||||
|
return h('div', { style: S.page, 'data-dsh-plugin': 'dsh-remote' },
|
||||||
|
h('h1', { style: S.h1 }, 'Devices'),
|
||||||
|
h('p', { style: S.sub },
|
||||||
|
st.state ? `This device: ${self?.name} (${st.state.role})` : '',
|
||||||
|
st.state?.role === 'slave' && st.state.master
|
||||||
|
? (st.state.master.linked ? ` · linked to ${st.state.master.info?.name || st.state.master.url}` : ` · master unreachable${st.state.master.error ? ': ' + st.state.master.error : ''}`)
|
||||||
|
: ''),
|
||||||
|
st.error && h('p', { style: S.err }, st.error),
|
||||||
|
h('div', { style: { display: 'flex', gap: 8, margin: '0 0 14px' } },
|
||||||
|
h('input', { style: { ...S.input, flex: 1 }, placeholder: 'Filter sessions…', value: filter, onChange: (e) => setFilter(e.target.value) }),
|
||||||
|
h('button', { style: S.btn, onClick: () => store.refresh() }, 'Refresh')),
|
||||||
|
devices.map((dev) => {
|
||||||
|
const m = meta[dev.device] || {};
|
||||||
|
const items = (dev.items || []).filter((s) => !q || `${s.title || ''} ${s.cwd || ''}`.toLowerCase().includes(q));
|
||||||
|
const byWs = new Map();
|
||||||
|
for (const s of items) {
|
||||||
|
const key = s.cwd || '';
|
||||||
|
if (!byWs.has(key)) byWs.set(key, []);
|
||||||
|
byWs.get(key).push(s);
|
||||||
|
}
|
||||||
|
const wsTitle = (cwd) => (dev.workspaces || []).find((w) => w.path === cwd)?.title || base(cwd) || 'No workspace';
|
||||||
|
return h('div', { key: dev.device, style: S.card },
|
||||||
|
h('div', { style: S.devHead },
|
||||||
|
h('span', { style: S.dot(!dev.error) }),
|
||||||
|
h('strong', null, dev.name || dev.device),
|
||||||
|
dev.self && h('span', { style: S.badge('accent') }, 'this device'),
|
||||||
|
m.role === 'master' && h('span', { style: S.badge() }, 'master'),
|
||||||
|
m.via && h('span', { style: S.badge() }, 'via master'),
|
||||||
|
h('span', { style: { flex: 1 } }),
|
||||||
|
!dev.self && h('a', { style: { ...S.btn, textDecoration: 'none' }, href: `${mount.root}r/${dev.device}/` }, 'Open DSH ↗'),
|
||||||
|
h(NewSession, { dev, selfId: self?.id })),
|
||||||
|
dev.error && h('div', { style: S.err }, dev.error),
|
||||||
|
!dev.error && !items.length && h('div', { style: { opacity: .6, fontSize: 13 } }, q ? 'No matching sessions.' : 'No sessions yet.'),
|
||||||
|
[...byWs.entries()].map(([cwd, list]) => h('div', { key: cwd },
|
||||||
|
h('div', { style: S.ws, title: cwd }, wsTitle(cwd)),
|
||||||
|
list.map((s) => h(SessionRow, { key: s.sessionId, dev, s, selfId: self?.id })))));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Settings section
|
||||||
|
function Field({ label, children, hint }) {
|
||||||
|
return h('label', { style: { display: 'grid', gap: 4, margin: '0 0 10px', fontSize: 13 } },
|
||||||
|
h('span', { style: { opacity: .8 } }, label), children, hint && h('span', { style: { opacity: .55, fontSize: 12 } }, hint));
|
||||||
|
}
|
||||||
|
const listToText = (l) => (l || []).join(', ');
|
||||||
|
const textToList = (t) => String(t || '').split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
function DeviceSync({ d, offer, onSaved }) {
|
||||||
|
const [enabled, setEnabled] = useState(d.sync?.enabled !== false);
|
||||||
|
const [allow, setAllow] = useState(listToText(d.sync?.allow));
|
||||||
|
const [deny, setDeny] = useState(listToText(d.sync?.deny));
|
||||||
|
const [secrets, setSecrets] = useState(new Set(d.sync?.secrets || []));
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
await post(`devices/${d.id}/sync`, { enabled, allow: textToList(allow), deny: textToList(deny), secrets: [...secrets] });
|
||||||
|
setMsg('Saved; device notified.'); onSaved();
|
||||||
|
} catch (e) { setMsg(e.message); }
|
||||||
|
};
|
||||||
|
return h('div', { style: { margin: '8px 0 0 18px', paddingLeft: 10, borderLeft: '2px solid rgba(127,127,127,.25)' } },
|
||||||
|
h('label', { style: { display: 'flex', gap: 6, alignItems: 'center', fontSize: 13, marginBottom: 8 } },
|
||||||
|
h('input', { type: 'checkbox', checked: enabled, onChange: (e) => setEnabled(e.target.checked) }), 'Sync plugins + config to this device'),
|
||||||
|
h(Field, { label: 'Only these bundles (empty = all)', hint: 'Exact names or prefix*, comma separated' },
|
||||||
|
h('input', { style: S.input, value: allow, onChange: (e) => setAllow(e.target.value) })),
|
||||||
|
h(Field, { label: 'Never these bundles' }, h('input', { style: S.input, value: deny, onChange: (e) => setDeny(e.target.value) })),
|
||||||
|
h('div', { style: { fontSize: 13, margin: '0 0 8px' } },
|
||||||
|
h('div', { style: { opacity: .8, marginBottom: 4 } }, 'Secrets this device may pull (it must also opt in locally)'),
|
||||||
|
offer.length ? offer.map((n) => h('label', { key: n, style: { display: 'inline-flex', gap: 4, marginRight: 12 } },
|
||||||
|
h('input', { type: 'checkbox', checked: secrets.has(n), onChange: (e) => { const s = new Set(secrets); e.target.checked ? s.add(n) : s.delete(n); setSecrets(s); } }),
|
||||||
|
h('code', null, n)))
|
||||||
|
: h('span', { style: { opacity: .55 } }, 'None offered. Add names to sync.offerSecrets in the dsh-remote config.')),
|
||||||
|
h('button', { style: S.btn, onClick: save }, 'Save sync policy'), ' ', msg && h('span', { style: { fontSize: 12, opacity: .75 } }, msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsPanel() {
|
||||||
|
const st = useStore();
|
||||||
|
const [code, setCode] = useState(null);
|
||||||
|
const [masterUrl, setMasterUrl] = useState('');
|
||||||
|
const [pairCode, setPairCode] = useState('');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [open, setOpen] = useState({});
|
||||||
|
const [syncRes, setSyncRes] = useState(null);
|
||||||
|
const s = st.state;
|
||||||
|
if (!s) return h('div', { style: { padding: 4 } }, st.error ? h('span', { style: S.err }, st.error) : 'Loading…');
|
||||||
|
const act = async (fn) => { setBusy(true); setMsg(''); try { await fn(); } catch (e) { setMsg(e.message); } setBusy(false); store.refresh(); };
|
||||||
|
|
||||||
|
const header = h('div', { style: S.card },
|
||||||
|
h('div', { style: S.devHead }, h('strong', null, s.self.name), h('span', { style: S.badge('accent') }, s.role), h('code', { style: { opacity: .6, fontSize: 12 } }, s.self.id)),
|
||||||
|
s.role === 'master' && h('div', { style: { fontSize: 13, opacity: .8 } },
|
||||||
|
'Slaves pair with this URL: ', h('code', null, s.publicUrl || s.lanUrl || location.origin + mount.root.replace(/\/$/, '')),
|
||||||
|
s.publicUrl && s.lanUrl ? h('span', null, ' (LAN: ', h('code', null, s.lanUrl), ')') : null),
|
||||||
|
s.role === 'slave' && h('div', { style: { fontSize: 13 } },
|
||||||
|
h('span', { style: S.dot(s.master?.linked) }), ' ',
|
||||||
|
s.master?.linked ? `Linked to ${s.master.info?.name} · ${s.master.url}` : `Not linked (${s.master?.url || 'no master'})${s.master?.error ? ': ' + s.master.error : ''}`));
|
||||||
|
|
||||||
|
const pairing = s.role === 'master'
|
||||||
|
? h('div', { style: S.card },
|
||||||
|
h('strong', null, 'Pair a new device'),
|
||||||
|
h('p', { style: { fontSize: 13, opacity: .75, margin: '4px 0 10px' } }, 'Create a one-time code (valid 15 minutes), then enter it in Settings → Remote devices on the new machine.'),
|
||||||
|
code ? h('div', null, h('span', { style: S.code }, code.code), h('div', { style: { fontSize: 12, opacity: .6, marginTop: 4 } }, `expires ${new Date(code.expiresAt).toLocaleTimeString()}`))
|
||||||
|
: h('button', { style: S.btnPrimary, disabled: busy, onClick: () => act(async () => setCode(await post('pairing', {}))) }, 'Create pairing code'))
|
||||||
|
: h('div', { style: S.card },
|
||||||
|
h('strong', null, s.master?.url ? 'Re-pair with a master' : 'Pair with a master'),
|
||||||
|
h(Field, { label: 'Master URL', hint: 'e.g. https://dsh.rizzy.dev or http://192.168.0.111:3080' },
|
||||||
|
h('input', { style: S.input, value: masterUrl, placeholder: 'https://…', onChange: (e) => setMasterUrl(e.target.value) })),
|
||||||
|
h(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('button', { style: S.btnPrimary, disabled: busy || !masterUrl || !pairCode, onClick: () => act(async () => {
|
||||||
|
const r = await post('pair', { masterUrl, code: pairCode });
|
||||||
|
setMsg(r.restartRequired ? 'Paired. Restart DSH on this machine to switch it to slave mode.' : `Paired with ${r.master?.name}.`);
|
||||||
|
}) }, 'Pair'),
|
||||||
|
s.master?.url && h('span', null, ' ', h('button', { style: S.btn, disabled: busy, onClick: () => act(() => post('unpair', {})) }, 'Unpair')));
|
||||||
|
|
||||||
|
const devices = s.role === 'master' && h('div', { style: S.card },
|
||||||
|
h('strong', null, 'Paired devices'),
|
||||||
|
(s.devices || []).filter((d) => !d.self).length === 0 && h('div', { style: { fontSize: 13, opacity: .6, marginTop: 6 } }, 'No devices yet.'),
|
||||||
|
(s.devices || []).filter((d) => !d.self).map((d) => h('div', { key: d.id, style: { padding: '8px 0', borderTop: '1px solid rgba(127,127,127,.15)' } },
|
||||||
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 14 } },
|
||||||
|
h('span', { style: S.dot(d.online) }), h('strong', null, d.name), h('code', { style: { fontSize: 12, opacity: .6 } }, d.id),
|
||||||
|
d.revoked && h('span', { style: S.badge('warn') }, 'revoked'),
|
||||||
|
h('span', { style: { fontSize: 12, opacity: .6 } }, d.online ? `online since ${ago(d.connectedAt)}` : d.lastSeenAt ? `seen ${ago(d.lastSeenAt)}` : 'never connected'),
|
||||||
|
h('span', { style: { flex: 1 } }),
|
||||||
|
h('button', { style: S.btn, onClick: () => setOpen({ ...open, [d.id]: !open[d.id] }) }, open[d.id] ? 'Hide sync' : 'Sync policy'),
|
||||||
|
!d.revoked && h('button', { style: S.btn, disabled: busy, onClick: () => confirm(`Revoke ${d.name}? It will be disconnected and must re-pair.`) && act(() => post(`devices/${d.id}/revoke`, {})) }, 'Revoke'),
|
||||||
|
h('button', { style: S.btn, disabled: busy, onClick: () => confirm(`Remove ${d.name} completely?`) && act(() => post(`devices/${d.id}/remove`, {})) }, 'Remove')),
|
||||||
|
open[d.id] && h(DeviceSync, { d, offer: s.sync?.offerSecrets || [], onSaved: () => store.refresh() }))));
|
||||||
|
|
||||||
|
const syncCard = h('div', { style: S.card },
|
||||||
|
h('strong', null, 'Plugin + config sync'),
|
||||||
|
s.role === 'master'
|
||||||
|
? h('p', { style: { fontSize: 13, opacity: .75, margin: '4px 0 8px' } }, 'Slaves pull this machine\'s bundles (registry + local link: plugins) and its cordis.patch.yml (minus machine-local entries) into a managed layer that their own config overrides.')
|
||||||
|
: h('div', { style: { fontSize: 13, margin: '4px 0 8px' } },
|
||||||
|
h('div', null, `Auto sync: ${s.sync?.auto ? 'on' : 'off'} · allow: ${listToText(s.sync?.allow) || 'all'} · deny: ${listToText(s.sync?.deny) || 'none'}`),
|
||||||
|
h('div', null, 'Accepted secrets: ', listToText(s.sync?.secrets) || h('span', { style: { opacity: .6 } }, 'none (set sync.secrets in this device\'s dsh-remote config)')),
|
||||||
|
s.lastSync && h('div', { style: { marginTop: 6, opacity: .8 } },
|
||||||
|
`Last sync ${ago(s.lastSync.at)}: ${s.lastSync.installed?.length || 0} installed, ${s.lastSync.updated?.length || 0} updated, ${s.lastSync.failed?.length || 0} failed, ${s.lastSync.patchEntries || 0} config entries`,
|
||||||
|
(s.lastSync.failed || []).map((f, i) => h('div', { key: i, style: S.err }, f)))),
|
||||||
|
h('button', { style: S.btn, disabled: busy, onClick: () => act(async () => {
|
||||||
|
const r = await post('sync', {});
|
||||||
|
setSyncRes(r);
|
||||||
|
if (r.restartRequired) setMsg('Synced. Some plugins changed: restart DSH on this device to load them.');
|
||||||
|
}) }, s.role === 'master' ? 'Push sync to all devices' : 'Sync now'),
|
||||||
|
syncRes && s.role === 'master' && h('span', { style: { fontSize: 12, opacity: .7 } }, ` notified ${syncRes.pushed} device(s)`));
|
||||||
|
|
||||||
|
return h('div', { 'data-dsh-plugin': 'dsh-remote' }, header, pairing, devices, syncCard, msg && h('div', { style: { ...S.card, fontSize: 13 } }, msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ toasts
|
||||||
|
function ToastHost() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
const l = (ev) => {
|
||||||
|
if (ev.type !== 'session-finished') return;
|
||||||
|
const id = `${ev.device}:${ev.sessionId}:${ev.at}`;
|
||||||
|
setItems((xs) => [...xs.slice(-2), { id, ev }]);
|
||||||
|
};
|
||||||
|
store.events.add(l);
|
||||||
|
return () => store.events.delete(l);
|
||||||
|
}, []);
|
||||||
|
return h(React.Fragment, null, items.map(({ id, ev }) => {
|
||||||
|
const text = `${ev.deviceName}: “${ev.title || 'session'}” finished`;
|
||||||
|
const done = () => setItems((xs) => xs.filter((x) => x.id !== id));
|
||||||
|
const actions = [{ label: 'Open', prefix: ' · ', onClick: () => { location.href = sessionUrl(ev.device, store.state?.self?.id, ev.sessionId); done(); } }];
|
||||||
|
if (P.Toast) return h(P.Toast, { key: id, text, tone: 'success', holdMs: 6000, actions, onDone: done });
|
||||||
|
return h('div', { key: id, onClick: actions[0].onClick, style: { position: 'fixed', top: 16, left: '50%', transform: 'translateX(-50%)', zIndex: 9999, background: '#1f6feb', color: '#fff', padding: '8px 14px', borderRadius: 8, cursor: 'pointer', fontSize: 14 } }, text);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountToasts() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.id = 'dshr-toasts';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
const root = ReactDOM.createRoot ? ReactDOM.createRoot(el) : null;
|
||||||
|
if (root) root.render(h(ToastHost));
|
||||||
|
else ReactDOM.render(h(ToastHost), el);
|
||||||
|
return () => { try { root ? root.unmount() : ReactDOM.unmountComponentAtNode(el); } catch { /* ignore */ } el.remove(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banner when this whole UI is a remote device seen through /r/<device>/.
|
||||||
|
function mountRemoteBanner() {
|
||||||
|
if (!mount.device) return () => {};
|
||||||
|
const el = document.createElement('a');
|
||||||
|
el.href = mount.root;
|
||||||
|
el.textContent = `● Remote: ${mount.device} · back to local`;
|
||||||
|
el.title = 'You are using the DSH of another device through dsh-remote. Sessions run there.';
|
||||||
|
Object.assign(el.style, { position: 'fixed', bottom: '10px', right: '12px', zIndex: 9998, background: 'rgba(31,111,235,.92)', color: '#fff', padding: '4px 10px', borderRadius: '12px', fontSize: '12px', textDecoration: 'none', fontFamily: 'system-ui, sans-serif' });
|
||||||
|
document.body.appendChild(el);
|
||||||
|
return () => el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ plugin
|
||||||
|
return {
|
||||||
|
inject: ['slots'],
|
||||||
|
apply(ctx) {
|
||||||
|
// Toasts / banner only belong to the outermost (local) UI, not proxied copies.
|
||||||
|
startStream();
|
||||||
|
const disposers = [];
|
||||||
|
if (!mount.device) disposers.push(mountToasts());
|
||||||
|
disposers.push(mountRemoteBanner());
|
||||||
|
|
||||||
|
disposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
||||||
|
name: 'sidebar.panellist', id: PANEL_ID, order: 50, label: 'Devices',
|
||||||
|
}, DeviceIcon)));
|
||||||
|
disposers.push(ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID }, DevicesPage)));
|
||||||
|
disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||||
|
name: 'settings.section', id: 'dsh-remote', order: 160, label: 'Remote devices',
|
||||||
|
}, SettingsPanel)));
|
||||||
|
|
||||||
|
ctx.effect(() => () => {
|
||||||
|
for (const d of disposers.splice(0)) { try { d && d(); } catch { /* ignore */ } }
|
||||||
|
try { es && es.close(); } catch { /* ignore */ }
|
||||||
|
es = null;
|
||||||
|
}, 'dsh-remote: ui');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
- insert:
|
||||||
|
- id: dsh-remote
|
||||||
|
name: '@local/dsh-remote'
|
||||||
|
config:
|
||||||
|
# master | slave | auto (auto = slave once paired with a master, else master)
|
||||||
|
role: auto
|
||||||
|
# Display name of this device (defaults to the hostname); its id is the slug of it.
|
||||||
|
deviceName: ''
|
||||||
|
# Shown in the Devices page so other machines know where to pair.
|
||||||
|
lanUrl: ''
|
||||||
|
publicUrl: ''
|
||||||
|
sync:
|
||||||
|
# slave: pull plugins/config automatically on link-up and when the master pushes.
|
||||||
|
auto: true
|
||||||
|
# slave: bundle allow/deny lists (exact names or prefix*). deny wins.
|
||||||
|
allow: []
|
||||||
|
deny: []
|
||||||
|
# slave: names of credential refs to accept from the master (opt-in, per secret).
|
||||||
|
secrets: []
|
||||||
|
# master: credential refs offered to devices (each device still opts in on the master
|
||||||
|
# Devices page AND locally via `secrets` above).
|
||||||
|
offerSecrets: []
|
||||||
|
# master: never sync these bundles / cordis.patch.yml entry ids.
|
||||||
|
excludeBundles: []
|
||||||
|
excludePatchIds: []
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=DeepSeek Harness (dsh web) for this user on 127.0.0.1:3081
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=%h/.local/bin/dsh web --host 127.0.0.1 --port 3081 --no-open
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Tiny admin CLI for dsh-remote that talks to the LOCAL DSH over loopback, authenticating
|
||||||
|
// with DSH's own browser-session secret from $DSH_HOME/.credentials.yaml (owner-only file).
|
||||||
|
//
|
||||||
|
// node dshr-cli.mjs GET state
|
||||||
|
// node dshr-cli.mjs POST pairing '{}'
|
||||||
|
// node dshr-cli.mjs POST pair '{"masterUrl":"http://192.168.0.111:3080","code":"ABCD-..."}'
|
||||||
|
// node dshr-cli.mjs GET /r/<device>/ (raw path, prints status + first bytes)
|
||||||
|
//
|
||||||
|
// Env: DSH_PORT (default 3081), DSH_HOME (default ~/.dsh)
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { mintCookie } from '../lib/auth-cookie.mjs'
|
||||||
|
|
||||||
|
const [method = 'GET', target = 'state', body] = process.argv.slice(2)
|
||||||
|
const port = process.env.DSH_PORT || '3081'
|
||||||
|
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
||||||
|
const creds = fs.readFileSync(path.join(home, '.credentials.yaml'), 'utf8')
|
||||||
|
const m = creds.match(/client-connection\/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{43})/)
|
||||||
|
if (!m) { console.error('browser-session secret not found'); process.exit(2) }
|
||||||
|
const authority = `127.0.0.1:${port}`
|
||||||
|
const cookie = mintCookie(authority, m[1], 1)
|
||||||
|
const url = target.startsWith('/') ? `http://${authority}${target}` : `http://${authority}/dsh-remote/${target}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { cookie, ...(body ? { 'content-type': 'application/json' } : {}) },
|
||||||
|
body: body || undefined,
|
||||||
|
redirect: 'manual',
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
if (target.startsWith('/')) {
|
||||||
|
console.log(res.status, res.headers.get('content-type') || '', res.headers.get('location') || '')
|
||||||
|
console.log(text.slice(0, Number(process.env.BYTES || 400)))
|
||||||
|
} else {
|
||||||
|
console.log(res.status)
|
||||||
|
try { console.log(JSON.stringify(JSON.parse(text), null, 2)) } catch { console.log(text) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// E2E: listen to the local /dsh-remote/stream (SSE) for N seconds and print events.
|
||||||
|
// node deploy/e2e-events.mjs [seconds] [port]
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import http from 'node:http'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { mintCookie } from '../lib/auth-cookie.mjs'
|
||||||
|
|
||||||
|
const secs = Number(process.argv[2] || 30)
|
||||||
|
const port = process.argv[3] || process.env.DSH_PORT || '3081'
|
||||||
|
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 req = http.get({ host: '127.0.0.1', port, path: '/dsh-remote/stream', headers: { cookie: mintCookie(authority, secret, 1), host: authority } }, (res) => {
|
||||||
|
console.log('stream', res.statusCode)
|
||||||
|
res.setEncoding('utf8')
|
||||||
|
res.on('data', (d) => { for (const line of d.split('\n')) if (line.startsWith('data:')) console.log(new Date().toISOString().slice(11, 19), line.slice(5).trim()) })
|
||||||
|
})
|
||||||
|
setTimeout(() => { req.destroy(); process.exit(0) }, secs * 1000)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Load one URL N times in headless Chromium and count uncaught exceptions per load.
|
||||||
|
// node deploy/e2e-exc.mjs <chrome> <url> <cookieAuthority> [loads] [settleMs]
|
||||||
|
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, url, authority, loads = '3', settle = '8000'] = 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 prof = fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-exc-'))
|
||||||
|
const proc = spawn(chrome, ['--headless=new', '--no-sandbox', '--disable-gpu', `--user-data-dir=${prof}`, '--remote-debugging-port=0', '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()
|
||||||
|
let exc = []
|
||||||
|
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) }
|
||||||
|
if (m.method === 'Runtime.exceptionThrown') exc.push((m.params.exceptionDetails?.exception?.description || '').split('\n')[0].slice(0, 120))
|
||||||
|
})
|
||||||
|
const send = (method, params = {}) => new Promise((resolve) => { const i = ++id; pending.set(i, resolve); ws.send(JSON.stringify({ id: i, method, params })) })
|
||||||
|
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable')
|
||||||
|
const host = authority.split(':')[0]
|
||||||
|
await send('Network.setCookie', { name: cookieName(authority), value: mintCookie(authority, secret, 1).split('=').slice(1).join('='), domain: host, path: '/', httpOnly: true })
|
||||||
|
for (let i = 0; i < Number(loads); i++) {
|
||||||
|
exc = []
|
||||||
|
await send('Page.navigate', { url: `${url}${url.includes('?') ? '&' : '?'}n=${i}` })
|
||||||
|
await new Promise((r) => setTimeout(r, Number(settle)))
|
||||||
|
console.log(`load ${i}: ${exc.length} exceptions ${exc.join(' || ')}`)
|
||||||
|
}
|
||||||
|
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,73 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// E2E UI check with headless Chromium over the DevTools protocol (no playwright dep).
|
||||||
|
// node deploy/e2e-ui.mjs <chrome-binary> <out-dir> [port]
|
||||||
|
// Loads the local DSH (cookie minted from the browser-session secret), opens the Devices
|
||||||
|
// panel, screenshots it, then opens /r/<first remote device>/ and screenshots that.
|
||||||
|
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, outDir, 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 cookieVal = mintCookie(authority, secret, 1).split('=').slice(1).join('=')
|
||||||
|
fs.mkdirSync(outDir, { recursive: true })
|
||||||
|
const prof = fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-chrome-'))
|
||||||
|
const proc = spawn(chrome, ['--headless=new', '--no-sandbox', '--disable-gpu', `--user-data-dir=${prof}`, '--remote-debugging-port=0', '--window-size=1500,950', '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 list = await (await fetch(wsUrl.replace(/^ws/, 'http').replace(/\/devtools\/browser\/.*/, '/json/list'))).json()
|
||||||
|
const page = list.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()
|
||||||
|
const logs = []
|
||||||
|
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) }
|
||||||
|
if (m.method === 'Runtime.consoleAPICalled') logs.push(`${m.params.type}: ${m.params.args.map((a) => a.value ?? a.description ?? '').join(' ')}`.slice(0, 300))
|
||||||
|
if (m.method === 'Runtime.exceptionThrown') logs.push(`EXC: ${m.params.exceptionDetails?.exception?.description || m.params.exceptionDetails?.text}`.slice(0, Number(process.env.EXC_LEN || 400)))
|
||||||
|
})
|
||||||
|
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
|
||||||
|
const shot = async (name) => {
|
||||||
|
const r = await send('Page.captureScreenshot', { format: 'png' })
|
||||||
|
fs.writeFileSync(path.join(outDir, name), Buffer.from(r.result.data, 'base64'))
|
||||||
|
console.log('screenshot', path.join(outDir, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
await send('Page.enable'); await send('Runtime.enable')
|
||||||
|
await send('Network.enable')
|
||||||
|
await send('Network.setCookie', { name: cookieName(authority), value: cookieVal, domain: '127.0.0.1', path: '/', httpOnly: true })
|
||||||
|
await send('Page.navigate', { url: `http://${authority}/` })
|
||||||
|
await sleep(9000)
|
||||||
|
// Click the Devices panel entry (its icon carries data-dsh-panel-entry).
|
||||||
|
const clicked = await evalJs(`(() => { const svg = document.querySelector('[data-dsh-panel-entry="dsh-remote-devices"]'); if (!svg) return 'no-entry'; (svg.closest('button,a,[role=button]') || svg).click(); return 'clicked' })()`)
|
||||||
|
console.log('devices entry:', clicked)
|
||||||
|
await sleep(3500)
|
||||||
|
console.log('panel text:', String(await evalJs(`(document.querySelector('[data-dsh-plugin="dsh-remote"]')?.innerText || 'NO PANEL').slice(0, 600)`)).replace(/\n+/g, ' | '))
|
||||||
|
await shot('devices-panel.png')
|
||||||
|
const localErrors = logs.filter((l) => /EXC/.test(l)).length
|
||||||
|
console.log('local page exceptions:', localErrors, logs.filter((l) => /EXC/.test(l)).map((l) => l.slice(0, 90)).join(' || '))
|
||||||
|
logs.length = 0
|
||||||
|
|
||||||
|
const remote = process.env.REMOTE_DEVICE || 'ai-pc'
|
||||||
|
const remoteSession = process.env.REMOTE_SESSION || ''
|
||||||
|
await send('Page.navigate', { url: `http://${authority}/r/${remote}/${remoteSession ? `#/session/${remoteSession}` : ''}` })
|
||||||
|
await sleep(remoteSession ? 14000 : 10000)
|
||||||
|
console.log('remote title/banner:', await evalJs(`document.title + ' :: ' + (document.querySelector('a[title*="another device"]')?.innerText || 'no banner') + ' :: hash=' + location.hash`))
|
||||||
|
if (remoteSession) {
|
||||||
|
console.log('remote session view:', String(await evalJs(`document.body.innerText.slice(0, 500)`)).replace(/\n+/g, ' | '))
|
||||||
|
}
|
||||||
|
await shot('remote-ui.png')
|
||||||
|
console.log('console:', logs.filter((l) => /error|EXC|warn/i.test(l)).slice(0, 12).join('\n '))
|
||||||
|
ws.close(); proc.kill('SIGKILL'); fs.rmSync(prof, { recursive: true, force: true })
|
||||||
|
process.exit(0)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// E2E probe: open the DSH Remote stream WebSocket of a device THROUGH the local
|
||||||
|
// /r/<device>/api/remote.mux proxy and check that the remote DSH answers.
|
||||||
|
// node deploy/e2e-ws.mjs <device> [port]
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { connect } from '../lib/ws.mjs'
|
||||||
|
import { mintCookie } from '../lib/auth-cookie.mjs'
|
||||||
|
|
||||||
|
const device = process.argv[2] || 'ai-pc'
|
||||||
|
const port = process.argv[3] || process.env.DSH_PORT || '3081'
|
||||||
|
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 cookie = mintCookie(authority, secret, 1)
|
||||||
|
const t0 = Date.now()
|
||||||
|
const ws = await connect(`ws://${authority}/r/${device}/api/remote.mux`, { headers: { cookie, origin: `http://${authority}` } })
|
||||||
|
console.log(`ws open through /r/${device}/ in ${Date.now() - t0} ms`)
|
||||||
|
const got = await new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => resolve('(no frame within 4s: connection stays open, server waits for client frames)'), 4000)
|
||||||
|
ws.on('message', (m) => { clearTimeout(timer); resolve(`first frame: ${String(m).slice(0, 160)}`) })
|
||||||
|
ws.on('close', (c, r) => { clearTimeout(timer); resolve(`closed ${c} ${r}`) })
|
||||||
|
})
|
||||||
|
console.log(got)
|
||||||
|
console.log(`still open: ${ws.open}`)
|
||||||
|
ws.close()
|
||||||
|
process.exit(0)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Register a dsh-remote checkout as a `link:` bundle in the current user's DSH web profile
|
||||||
|
// (no pnpm/network needed) and optionally set its config.
|
||||||
|
//
|
||||||
|
// node deploy/install-local.mjs [--role slave|master|auto] [--name "My PC"] [--profile web]
|
||||||
|
//
|
||||||
|
// Effects (idempotent):
|
||||||
|
// <profile>/package.json dependencies["@local/dsh-remote"] = "link:<this dir>"
|
||||||
|
// dsh.profile.bundles += "@local/dsh-remote"
|
||||||
|
// <profile>/node_modules/@local/dsh-remote -> <this dir>
|
||||||
|
// <profile>/cordis.patch.yml appends a `- id: dsh-remote` config block if missing
|
||||||
|
// Restart `dsh web` afterwards (host code is loaded at boot).
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 ? args[i + 1] : d }
|
||||||
|
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
||||||
|
const prof = path.join(home, 'profiles', opt('profile', 'web'))
|
||||||
|
const NAME = '@local/dsh-remote'
|
||||||
|
|
||||||
|
const pkgFile = path.join(prof, 'package.json')
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'))
|
||||||
|
pkg.dependencies ||= {}
|
||||||
|
pkg.dependencies[NAME] = `link:${pluginDir}`
|
||||||
|
const bundles = (((pkg.dsh ||= {}).profile ||= {}).bundles ||= [])
|
||||||
|
if (!bundles.includes(NAME)) bundles.push(NAME)
|
||||||
|
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
|
||||||
|
const linkDir = path.join(prof, 'node_modules', '@local')
|
||||||
|
fs.mkdirSync(linkDir, { recursive: true })
|
||||||
|
const link = path.join(linkDir, 'dsh-remote')
|
||||||
|
try { fs.rmSync(link, { recursive: true, force: true }) } catch { /* ignore */ }
|
||||||
|
fs.symlinkSync(pluginDir, link)
|
||||||
|
|
||||||
|
const patchFile = path.join(prof, 'cordis.patch.yml')
|
||||||
|
let patch = ''
|
||||||
|
try { patch = fs.readFileSync(patchFile, 'utf8') } catch { patch = '' }
|
||||||
|
if (!/^- id: dsh-remote\b/m.test(patch)) {
|
||||||
|
const role = opt('role', 'auto')
|
||||||
|
const name = opt('name', '')
|
||||||
|
const block = [
|
||||||
|
'# dsh-remote (installed by deploy/install-local.mjs)',
|
||||||
|
'- id: dsh-remote',
|
||||||
|
' config:',
|
||||||
|
` role: ${role}`,
|
||||||
|
` deviceName: ${JSON.stringify(name)}`,
|
||||||
|
' sync:',
|
||||||
|
' auto: true',
|
||||||
|
' allow: []',
|
||||||
|
' deny: []',
|
||||||
|
' secrets: []',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
// An empty/comment-only patch file fails DSH boot, so only append real entries.
|
||||||
|
fs.writeFileSync(patchFile, (patch.trim() && !/^\s*\[\]\s*$/m.test(patch.trim()) ? patch.replace(/\n*$/, '\n') : '') + block)
|
||||||
|
}
|
||||||
|
console.log(`dsh-remote linked into ${prof}; restart dsh web to load it.`)
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Dev helper: copy this checkout to a slave's ~/.dsh/plugins/dsh-remote and restart its dsh.
|
||||||
|
# deploy/push-slave.sh agent@192.168.0.110
|
||||||
|
set -euo pipefail
|
||||||
|
HOST="${1:?usage: push-slave.sh user@host}"
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
O=(-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR)
|
||||||
|
tar --exclude=.git --exclude=node_modules -C "$DIR" -czf - . | timeout 20 ssh "${O[@]}" "$HOST" \
|
||||||
|
'mkdir -p ~/.dsh/plugins/dsh-remote && tar -xzf - -C ~/.dsh/plugins/dsh-remote'
|
||||||
|
timeout 45 ssh "${O[@]}" "$HOST" 'export PATH=$HOME/.local/bin:$PATH XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||||
|
systemctl --user restart dsh
|
||||||
|
for i in $(seq 1 40); do curl -s -m 2 -o /dev/null http://127.0.0.1:3081/ && break; sleep 0.5; done
|
||||||
|
echo "slave dsh: $(systemctl --user is-active dsh)"'
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
// @local/dsh-remote — Host side.
|
||||||
|
//
|
||||||
|
// Master/slave remote control for DSH (Claude-Desktop-remote style):
|
||||||
|
// * slaves keep one authenticated WebSocket link to the master (outbound, NAT-friendly);
|
||||||
|
// * every device's DSH is reachable from every other device at /r/<device>/ (HTTP +
|
||||||
|
// WebSocket tunnelled over the link; sessions execute on the owning machine);
|
||||||
|
// slave→slave goes through the master's relay;
|
||||||
|
// * the master aggregates device + session lists for the Devices UI;
|
||||||
|
// * plugins and config are synced master → slave (managed bundle layer, allow/deny,
|
||||||
|
// opt-in per-secret credential sync).
|
||||||
|
//
|
||||||
|
// HTTP routes (all behind DSH's own browser auth via ctx.connection.requestRejection):
|
||||||
|
// GET /dsh-remote/state → role, self, link status, devices (+ online)
|
||||||
|
// GET /dsh-remote/sessions → sessions of every reachable device
|
||||||
|
// POST /dsh-remote/pairing → master: create a one-time pairing code
|
||||||
|
// POST /dsh-remote/pair → slave: pair with {masterUrl, code}
|
||||||
|
// POST /dsh-remote/unpair → slave: forget the master
|
||||||
|
// POST /dsh-remote/devices/<id>/revoke → master: revoke a device key
|
||||||
|
// POST /dsh-remote/devices/<id>/remove → master: delete a device
|
||||||
|
// POST /dsh-remote/devices/<id>/sync → master: set {enabled, allow, deny, secrets}
|
||||||
|
// POST /dsh-remote/sync → slave: pull + apply now
|
||||||
|
// GET /dsh-remote/sync/status → slave: last sync result / master: manifest summary
|
||||||
|
// POST /dsh-remote/new-session → {device, cwd?} create a session on a device
|
||||||
|
// * /r/<device>/... → proxy to that device's DSH
|
||||||
|
// Upgrade routes:
|
||||||
|
// /dsh-remote/link → slave → master link (device id + key headers)
|
||||||
|
// /r/<device>/api/remote.mux → DSH Remote stream WebSocket of another device
|
||||||
|
import os from 'node:os'
|
||||||
|
import { acceptUpgrade, connect, rejectUpgrade } from './lib/ws.mjs'
|
||||||
|
import { Mux } from './lib/mux.mjs'
|
||||||
|
import { Store, randomId, setProfileDir, slug } from './lib/store.mjs'
|
||||||
|
import { CookieJar } from './lib/auth-cookie.mjs'
|
||||||
|
import { ownHttp, ownUpgrade, viewHttp, viewUpgrade } from './lib/proxy.mjs'
|
||||||
|
import * as sync from './lib/sync.mjs'
|
||||||
|
|
||||||
|
export const name = 'dsh-remote'
|
||||||
|
export const inject = {
|
||||||
|
webServer: { required: true },
|
||||||
|
connection: { required: true },
|
||||||
|
credentials: { required: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional services, read at call time (they may load after this plugin). */
|
||||||
|
function svc(ctx, key) {
|
||||||
|
try { return ctx.get(key) } catch { return undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BODY = 256 * 1024
|
||||||
|
const PREFIX = '/r/'
|
||||||
|
const LINK_PATH = '/dsh-remote/link'
|
||||||
|
const MUX_PATH = '/api/remote.mux'
|
||||||
|
|
||||||
|
function sendJson(res, status, payload) {
|
||||||
|
res.statusCode = status
|
||||||
|
res.setHeader('content-type', 'application/json; charset=utf-8')
|
||||||
|
res.setHeader('cache-control', 'no-store')
|
||||||
|
res.end(JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(req) {
|
||||||
|
const chunks = []
|
||||||
|
let size = 0
|
||||||
|
for await (const c of req) {
|
||||||
|
size += c.length
|
||||||
|
if (size > MAX_BODY) throw Object.assign(new Error('body too large'), { status: 413 })
|
||||||
|
chunks.push(c)
|
||||||
|
}
|
||||||
|
if (!size) return {}
|
||||||
|
const v = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||||
|
if (!v || typeof v !== 'object' || Array.isArray(v)) throw Object.assign(new Error('body must be an object'), { status: 400 })
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpErr = (status, message) => Object.assign(new Error(message), { status })
|
||||||
|
|
||||||
|
export function apply(ctx, config = {}) {
|
||||||
|
const prof = svc(ctx, 'profileContext')
|
||||||
|
if (prof?.dir) setProfileDir(prof.dir)
|
||||||
|
const store = new Store()
|
||||||
|
const self = store.identity(config.deviceName || os.hostname())
|
||||||
|
if (config.deviceName && config.deviceName !== self.name) {
|
||||||
|
self.name = config.deviceName
|
||||||
|
store.save()
|
||||||
|
}
|
||||||
|
const role = config.role === 'master' ? 'master' : config.role === 'slave' ? 'slave' : (store.master ? 'slave' : 'master')
|
||||||
|
const log = ctx.logger ?? console
|
||||||
|
const cookies = new CookieJar(ctx)
|
||||||
|
const localTarget = () => ({ host: '127.0.0.1', port: ctx.webServer.port })
|
||||||
|
|
||||||
|
// device id -> { mux, info, connectedAt } (master: slaves; slave: { master })
|
||||||
|
const links = new Map()
|
||||||
|
const listeners = new Set()
|
||||||
|
const notify = (event) => { for (const l of listeners) { try { l(event) } catch { /* ignore */ } } }
|
||||||
|
let lastSync = store.data.lastSync || null
|
||||||
|
let masterLink = null // slave only
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ stream routing
|
||||||
|
// A stream's meta.to names the device that must serve it. "self" streams hit local DSH.
|
||||||
|
// The master relays streams whose target is another slave.
|
||||||
|
function serveStream(stream) {
|
||||||
|
const to = stream.meta.to
|
||||||
|
if (to && to !== self.id) {
|
||||||
|
if (role !== 'master') return stream.fail(`cannot relay to ${to}`)
|
||||||
|
const peer = links.get(to)
|
||||||
|
if (!peer) return stream.fail(`device ${to} offline`)
|
||||||
|
return relay(stream, peer.mux)
|
||||||
|
}
|
||||||
|
if (stream.kind === 'http') return ownHttp(stream, { target: localTarget(), cookie: (a) => cookies.get(a) })
|
||||||
|
if (stream.kind === 'ws') return ownUpgrade(stream, { target: localTarget(), cookie: (a) => cookies.get(a) })
|
||||||
|
if (stream.kind === 'blob') return serveBlob(stream)
|
||||||
|
return stream.fail(`unknown stream kind ${stream.kind}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splice an incoming stream onto a new stream to another link (master relay). */
|
||||||
|
function relay(inbound, mux) {
|
||||||
|
const out = mux.open(inbound.kind, inbound.meta)
|
||||||
|
const pipe = (a, b) => {
|
||||||
|
a.on('head', (m) => b.head(m))
|
||||||
|
a.on('data', (d) => b.write(d))
|
||||||
|
a.on('end', () => b.end())
|
||||||
|
a.on('error', (e) => b.fail(e.message))
|
||||||
|
a.on('remote-pause', () => b.mux.sendControl({ t: 'pause', id: b.id }))
|
||||||
|
a.on('remote-resume', () => b.mux.sendControl({ t: 'resume', id: b.id }))
|
||||||
|
}
|
||||||
|
pipe(inbound, out)
|
||||||
|
pipe(out, inbound)
|
||||||
|
inbound.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ control handlers
|
||||||
|
const handlers = {
|
||||||
|
hello: async (p) => ({ id: self.id, name: self.name, role, version: config.version || '1' }),
|
||||||
|
sessions: async () => listLocalSessions(),
|
||||||
|
newSession: async (p) => createLocalSession(p),
|
||||||
|
devices: async (p, mux) => devicesView(),
|
||||||
|
route: async () => ({ ok: true }),
|
||||||
|
manifest: async (p, mux) => {
|
||||||
|
if (role !== 'master') throw httpErr(400, 'not a master')
|
||||||
|
const dev = store.devices()[mux.deviceId]
|
||||||
|
if (dev?.sync?.enabled === false) return { version: 1, bundles: [], patch: [], secrets: [], disabled: true }
|
||||||
|
const m = await buildManifestCached()
|
||||||
|
return { ...m, _dirs: undefined, secrets: offerSecretsFor(dev) }
|
||||||
|
},
|
||||||
|
secret: async (p, mux) => {
|
||||||
|
if (role !== 'master') throw httpErr(400, 'not a master')
|
||||||
|
const dev = store.devices()[mux.deviceId]
|
||||||
|
const name = String(p.name || '')
|
||||||
|
if (!offerSecretsFor(dev).includes(name)) throw httpErr(403, `secret ${name} not offered to this device`)
|
||||||
|
const c = svc(ctx, 'credentials')
|
||||||
|
if (!c) throw httpErr(503, 'credentials service unavailable')
|
||||||
|
const r = await c.resolve(name)
|
||||||
|
if (!r?.value) throw httpErr(404, `secret ${name} not set on master`)
|
||||||
|
return { name, value: r.value }
|
||||||
|
},
|
||||||
|
// Forwarded calls: master answers for another device by asking it.
|
||||||
|
forward: async (p) => {
|
||||||
|
if (role !== 'master') throw httpErr(400, 'not a master')
|
||||||
|
const peer = links.get(p.device)
|
||||||
|
if (!peer) throw httpErr(404, `device ${p.device} offline`)
|
||||||
|
return peer.mux.call(p.method, p.params || {}, 20000)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function offerSecretsFor(dev) {
|
||||||
|
const offered = new Set(config.sync?.offerSecrets || [])
|
||||||
|
return (dev?.sync?.secrets || []).filter((n) => offered.has(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifestCache = null
|
||||||
|
async function buildManifestCached() {
|
||||||
|
if (manifestCache && Date.now() - manifestCache.at < 15000) return manifestCache.value
|
||||||
|
const value = await sync.buildManifest({
|
||||||
|
self,
|
||||||
|
include: config.sync || {},
|
||||||
|
offerSecrets: config.sync?.offerSecrets || [],
|
||||||
|
})
|
||||||
|
manifestCache = { at: Date.now(), value }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveBlob(stream) {
|
||||||
|
// Only the master serves blobs: packed local plugins named in its manifest.
|
||||||
|
try {
|
||||||
|
if (role !== 'master') throw new Error('not a master')
|
||||||
|
const m = await buildManifestCached()
|
||||||
|
const dir = m._dirs?.[stream.meta.name]
|
||||||
|
if (!dir) throw new Error(`no local plugin ${stream.meta.name}`)
|
||||||
|
const dev = store.devices()[stream.mux.deviceId]
|
||||||
|
if (dev && !sync.allowed(stream.meta.name, dev.sync || {})) throw new Error('denied for this device')
|
||||||
|
const buf = await sync.packLocal(dir)
|
||||||
|
stream.head({ status: 200, size: buf.length })
|
||||||
|
stream.write(buf)
|
||||||
|
stream.end()
|
||||||
|
} catch (e) {
|
||||||
|
stream.fail(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ sessions
|
||||||
|
async function listLocalSessions() {
|
||||||
|
const sc = svc(ctx, 'sessionController')
|
||||||
|
if (!sc) return { items: [], workspaces: [] }
|
||||||
|
const ac = new AbortController()
|
||||||
|
const t = setTimeout(() => ac.abort(), 10000)
|
||||||
|
try {
|
||||||
|
const { items } = await sc.list({}, ac.signal)
|
||||||
|
const wr = svc(ctx, 'workspaceRegistry')
|
||||||
|
const workspaces = wr ? wr.list().map((w) => ({ id: w.id, title: w.title, path: w.path })) : []
|
||||||
|
return {
|
||||||
|
items: items
|
||||||
|
.filter((s) => !s.parentSessionId && !s.blank)
|
||||||
|
.slice(0, config.maxSessions || 200)
|
||||||
|
.map((s) => ({
|
||||||
|
sessionId: s.sessionId,
|
||||||
|
updatedAt: s.updatedAt,
|
||||||
|
running: s.running,
|
||||||
|
cwd: s.cwd,
|
||||||
|
title: s.projections?.values?.title ?? null,
|
||||||
|
})),
|
||||||
|
workspaces,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLocalSession({ cwd, workspaceId } = {}) {
|
||||||
|
const sc = svc(ctx, 'sessionController')
|
||||||
|
if (!sc) throw httpErr(503, 'session controller unavailable')
|
||||||
|
const req = {}
|
||||||
|
if (workspaceId) req.workspaceId = workspaceId
|
||||||
|
else if (cwd) req.cwd = cwd
|
||||||
|
const r = await sc.create(req)
|
||||||
|
return { sessionId: r.sessionId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ device views
|
||||||
|
function devicesView() {
|
||||||
|
const out = [{ id: self.id, name: self.name, role, self: true, online: true }]
|
||||||
|
if (role === 'master') {
|
||||||
|
for (const d of Object.values(store.devices())) {
|
||||||
|
const l = links.get(d.id)
|
||||||
|
out.push({
|
||||||
|
id: d.id, name: d.name, platform: d.platform, role: 'slave', revoked: !!d.revoked,
|
||||||
|
online: !!l, connectedAt: l?.connectedAt || null, lastSeenAt: d.lastSeenAt || null,
|
||||||
|
sync: d.sync,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (masterLink?.info) {
|
||||||
|
out.push({ ...masterLink.info, role: 'master', online: true })
|
||||||
|
// Peers in the master's broadcast are by definition online right now.
|
||||||
|
for (const d of masterLink.peers || []) if (d.id !== self.id && d.role !== 'master') out.push({ ...d, online: true, via: 'master' })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mux that reaches `device` from here (null = local). */
|
||||||
|
function routeTo(device) {
|
||||||
|
if (!device || device === self.id) return { local: true }
|
||||||
|
if (role === 'master') {
|
||||||
|
const l = links.get(device)
|
||||||
|
return l ? { mux: l.mux, meta: {} } : null
|
||||||
|
}
|
||||||
|
if (!masterLink?.mux || masterLink.mux.closed) return null
|
||||||
|
if (masterLink.info?.id === device) return { mux: masterLink.mux, meta: {} }
|
||||||
|
return { mux: masterLink.mux, meta: { to: device } } // relay through master
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callDevice(device, method, params) {
|
||||||
|
const r = routeTo(device)
|
||||||
|
if (!r) throw httpErr(404, `device ${device} unreachable`)
|
||||||
|
if (r.local) return handlers[method](params || {})
|
||||||
|
if (r.meta.to) return r.mux.call('forward', { device, method, params }, 25000)
|
||||||
|
return r.mux.call(method, params || {}, 20000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function allSessions() {
|
||||||
|
const devices = devicesView().filter((d) => d.online && !d.revoked)
|
||||||
|
const results = await Promise.allSettled(devices.map(async (d) => ({ device: d, ...(await callDevice(d.id, 'sessions', {})) })))
|
||||||
|
return results.map((r, i) => r.status === 'fulfilled'
|
||||||
|
? { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, items: r.value.items, workspaces: r.value.workspaces }
|
||||||
|
: { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, error: String(r.reason?.message ?? r.reason), items: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ session activity watch
|
||||||
|
// Poll local running states; emit "finished" events (for notifications) to all viewers.
|
||||||
|
const lastRunning = new Map()
|
||||||
|
async function pollActivity() {
|
||||||
|
try {
|
||||||
|
const { items } = await listLocalSessions()
|
||||||
|
for (const s of items) {
|
||||||
|
const was = lastRunning.get(s.sessionId)
|
||||||
|
if (was === true && !s.running) {
|
||||||
|
const ev = { type: 'session-finished', device: self.id, deviceName: self.name, sessionId: s.sessionId, title: s.title, at: Date.now() }
|
||||||
|
notify(ev)
|
||||||
|
broadcast('activity', ev)
|
||||||
|
}
|
||||||
|
lastRunning.set(s.sessionId, s.running)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
const activityTimer = setInterval(pollActivity, config.activityPollMs || 4000)
|
||||||
|
activityTimer.unref?.()
|
||||||
|
ctx.effect(() => () => clearInterval(activityTimer), 'dsh-remote: activity poll')
|
||||||
|
|
||||||
|
function broadcast(name, data) {
|
||||||
|
if (role === 'master') for (const l of links.values()) l.mux.event(name, data)
|
||||||
|
else masterLink?.mux?.event(name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLinkEvent(name, data, fromDevice) {
|
||||||
|
if (name === 'activity') {
|
||||||
|
notify(data)
|
||||||
|
// Master fans slave activity out to the other slaves.
|
||||||
|
if (role === 'master') for (const [id, l] of links) if (id !== fromDevice) l.mux.event(name, data)
|
||||||
|
} else if (name === 'peers' && role === 'slave' && masterLink) {
|
||||||
|
masterLink.peers = data
|
||||||
|
notify({ type: 'devices' })
|
||||||
|
} else if (name === 'sync-available' && role === 'slave') {
|
||||||
|
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushPeers() {
|
||||||
|
if (role !== 'master') return
|
||||||
|
const peers = devicesView().filter((d) => d.online).map(({ id, name, role: r, platform }) => ({ id, name, role: r, platform }))
|
||||||
|
for (const l of links.values()) l.mux.event('peers', peers)
|
||||||
|
notify({ type: 'devices' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ master: accept links
|
||||||
|
if (role === 'master') {
|
||||||
|
ctx.effect(() => ctx.webServer.registerUpgrade({
|
||||||
|
path: LINK_PATH,
|
||||||
|
handler: (req, socket, head) => {
|
||||||
|
const id = String(req.headers['x-dsh-remote-device'] || '')
|
||||||
|
const key = String(req.headers['x-dsh-remote-key'] || '')
|
||||||
|
const dev = store.verifyDevice(id, key)
|
||||||
|
if (!dev) return rejectUpgrade(socket, 401, 'unknown or revoked device')
|
||||||
|
const ws = acceptUpgrade(req, socket, head)
|
||||||
|
if (!ws) return
|
||||||
|
const prev = links.get(id)
|
||||||
|
if (prev) prev.mux.close(4000, 'replaced by new connection')
|
||||||
|
const mux = new Mux(ws, { odd: false, handlers, onStream: serveStream })
|
||||||
|
mux.deviceId = id
|
||||||
|
const entry = { mux, connectedAt: Date.now(), info: { id, name: dev.name } }
|
||||||
|
links.set(id, entry)
|
||||||
|
dev.lastSeenAt = Date.now()
|
||||||
|
dev.platform = String(req.headers['x-dsh-remote-platform'] || dev.platform || '')
|
||||||
|
store.save()
|
||||||
|
mux.on('event', (n, d) => onLinkEvent(n, d, id))
|
||||||
|
mux.on('close', () => {
|
||||||
|
if (links.get(id) === entry) links.delete(id)
|
||||||
|
const d = store.devices()[id]
|
||||||
|
if (d) { d.lastSeenAt = Date.now(); store.save() }
|
||||||
|
log.info?.(`dsh-remote: ${id} disconnected`)
|
||||||
|
pushPeers()
|
||||||
|
})
|
||||||
|
log.info?.(`dsh-remote: ${id} connected`)
|
||||||
|
pushPeers()
|
||||||
|
},
|
||||||
|
}), 'dsh-remote: link endpoint')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ slave: keep link up
|
||||||
|
let stopped = false
|
||||||
|
let backoff = 1000
|
||||||
|
async function connectMaster() {
|
||||||
|
const m = store.master
|
||||||
|
if (!m || stopped) return
|
||||||
|
const url = new URL(m.url)
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : url.protocol === 'http:' ? 'ws:' : url.protocol
|
||||||
|
url.pathname = url.pathname.replace(/\/+$/, '') + LINK_PATH
|
||||||
|
try {
|
||||||
|
const ws = await connect(url.href, {
|
||||||
|
headers: {
|
||||||
|
'x-dsh-remote-device': m.deviceId,
|
||||||
|
'x-dsh-remote-key': m.key,
|
||||||
|
'x-dsh-remote-platform': `${process.platform}-${process.arch}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const mux = new Mux(ws, { odd: true, handlers, onStream: serveStream })
|
||||||
|
mux.deviceId = 'master'
|
||||||
|
masterLink = { mux, info: null, peers: [] }
|
||||||
|
backoff = 1000
|
||||||
|
mux.on('event', (n, d) => onLinkEvent(n, d, 'master'))
|
||||||
|
mux.on('close', (code, reason) => {
|
||||||
|
masterLink = null
|
||||||
|
notify({ type: 'devices' })
|
||||||
|
if (code === 4401) {
|
||||||
|
lastLinkError = `master rejected this device (${reason || 'revoked'}); re-pair it`
|
||||||
|
log.warn(`dsh-remote: ${lastLinkError}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastLinkError = `link closed (${code}${reason ? ' ' + reason : ''}); reconnecting`
|
||||||
|
if (!stopped) schedule()
|
||||||
|
})
|
||||||
|
const info = await mux.call('hello', { id: self.id, name: self.name })
|
||||||
|
masterLink.info = { id: info.id, name: info.name }
|
||||||
|
// Only devices that are online on the master are reachable through its relay.
|
||||||
|
masterLink.peers = (await mux.call('devices', {})).filter((d) => d.online && !d.revoked).map(({ id, name, role: r }) => ({ id, name, role: r }))
|
||||||
|
notify({ type: 'devices' })
|
||||||
|
lastLinkError = null
|
||||||
|
log.info?.(`dsh-remote: linked to master ${info.name} (${m.url})`)
|
||||||
|
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
|
||||||
|
} catch (e) {
|
||||||
|
masterLink = null
|
||||||
|
if (e.status === 401) { log.warn(`dsh-remote: master refused key (${e.message}); re-pair this device`); lastLinkError = e.message; return }
|
||||||
|
lastLinkError = e.message
|
||||||
|
schedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lastLinkError = null
|
||||||
|
let timer = null
|
||||||
|
function schedule() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(connectMaster, backoff)
|
||||||
|
timer.unref?.()
|
||||||
|
backoff = Math.min(backoff * 2, 30000)
|
||||||
|
}
|
||||||
|
if (role === 'slave') connectMaster()
|
||||||
|
ctx.effect(() => () => {
|
||||||
|
stopped = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
masterLink?.mux?.close(1001, 'shutdown')
|
||||||
|
for (const l of links.values()) l.mux.close(1001, 'shutdown')
|
||||||
|
}, 'dsh-remote: links')
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ slave: sync
|
||||||
|
let syncing = null
|
||||||
|
async function runSlaveSync() {
|
||||||
|
if (role !== 'slave') throw httpErr(400, 'only slaves sync')
|
||||||
|
if (syncing) return syncing
|
||||||
|
syncing = (async () => {
|
||||||
|
const mux = masterLink?.mux
|
||||||
|
if (!mux) throw httpErr(503, 'not linked to a master')
|
||||||
|
const local = config.sync || {}
|
||||||
|
const manifest = await mux.call('manifest', {}, 30000)
|
||||||
|
const result = { at: Date.now(), installed: [], updated: [], skipped: [], failed: [], secrets: [], patchEntries: manifest.patch?.length || 0 }
|
||||||
|
if (manifest.disabled) { result.skipped.push('sync disabled for this device on master'); return result }
|
||||||
|
const pm = svc(ctx, 'pluginManager')
|
||||||
|
const prev = store.data.synced || {}
|
||||||
|
const next = {}
|
||||||
|
let profileChanged = false
|
||||||
|
for (const b of manifest.bundles || []) {
|
||||||
|
if (!sync.allowed(b.name, local)) { result.skipped.push(`${b.name} (local deny/allow)`); continue }
|
||||||
|
try {
|
||||||
|
if (b.kind === 'local') {
|
||||||
|
if (prev[b.name]?.hash === b.hash) { next[b.name] = prev[b.name]; continue }
|
||||||
|
const stream = mux.open('blob', { name: b.name })
|
||||||
|
const buf = await new Promise((resolve, reject) => {
|
||||||
|
const parts = []
|
||||||
|
stream.on('data', (d) => parts.push(d))
|
||||||
|
stream.on('end', () => { resolve(Buffer.concat(parts)); stream.end() })
|
||||||
|
stream.on('error', reject)
|
||||||
|
})
|
||||||
|
const dir = await sync.unpackLocal(b.name, b.version, buf)
|
||||||
|
profileChanged = sync.linkLocalBundle(b.name, dir) || profileChanged
|
||||||
|
next[b.name] = { kind: 'local', hash: b.hash, version: b.version, dir }
|
||||||
|
;(prev[b.name] ? result.updated : result.installed).push(`${b.name}@${b.version}`)
|
||||||
|
} else {
|
||||||
|
if (!pm) throw new Error('plugin manager unavailable')
|
||||||
|
const have = (await pm.listBundles()).find((x) => x.name === b.name)
|
||||||
|
if (have?.installed && have.version === b.version) {
|
||||||
|
if (!have.enabled) await pm.setBundleEnabled(b.name, true)
|
||||||
|
next[b.name] = { kind: 'registry', version: b.version }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const r = await pm.installBundle(`${b.name}@${b.version}`, { enabled: true })
|
||||||
|
if (r.application === 'failed') throw new Error(r.error?.message || 'install failed')
|
||||||
|
next[b.name] = { kind: 'registry', version: b.version }
|
||||||
|
;(have ? result.updated : result.installed).push(`${b.name}@${b.version}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
result.failed.push(`${b.name}: ${e.message}`)
|
||||||
|
if (prev[b.name]) next[b.name] = prev[b.name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Config layer.
|
||||||
|
await sync.writeManagedBundle(manifest.patch || [], Object.keys(next))
|
||||||
|
profileChanged = sync.ensureManagedBundleOrder() || profileChanged
|
||||||
|
// Opt-in secrets.
|
||||||
|
for (const name of manifest.secrets || []) {
|
||||||
|
if (!(local.secrets || []).includes(name)) continue
|
||||||
|
try {
|
||||||
|
const { value } = await mux.call('secret', { name }, 15000)
|
||||||
|
const creds = svc(ctx, 'credentials')
|
||||||
|
if (!creds) throw new Error('credentials service unavailable')
|
||||||
|
await creds.set(name, value)
|
||||||
|
result.secrets.push(name)
|
||||||
|
} catch (e) {
|
||||||
|
result.failed.push(`secret ${name}: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.data.synced = next
|
||||||
|
store.data.lastSync = result
|
||||||
|
store.save()
|
||||||
|
lastSync = result
|
||||||
|
result.restartRequired = profileChanged
|
||||||
|
notify({ type: 'sync', result })
|
||||||
|
return result
|
||||||
|
})()
|
||||||
|
try { return await syncing } finally { syncing = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ HTTP API
|
||||||
|
const route = (path, methods, handler) => ctx.effect(() => ctx.webServer.register({
|
||||||
|
kind: 'exact',
|
||||||
|
path,
|
||||||
|
handler: async (req, res) => {
|
||||||
|
const rejection = ctx.connection.requestRejection(req)
|
||||||
|
if (rejection !== undefined) return sendJson(res, rejection, { error: 'rejected' })
|
||||||
|
if (!methods.includes(req.method)) { res.statusCode = 405; res.setHeader('allow', methods.join(', ')); return res.end() }
|
||||||
|
try {
|
||||||
|
sendJson(res, 200, await handler(req, new URL(req.url || '/', 'http://x')))
|
||||||
|
} catch (e) {
|
||||||
|
sendJson(res, e.status && e.status < 600 ? e.status : 500, { error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}), `dsh-remote: ${path}`)
|
||||||
|
|
||||||
|
route('/dsh-remote/state', ['GET'], async () => ({
|
||||||
|
role,
|
||||||
|
self,
|
||||||
|
master: role === 'slave' ? { url: store.master?.url || null, linked: !!masterLink?.mux, info: masterLink?.info || null, error: masterLink ? null : lastLinkError } : null,
|
||||||
|
devices: devicesView(),
|
||||||
|
publicUrl: config.publicUrl || null,
|
||||||
|
lanUrl: config.lanUrl || null,
|
||||||
|
lastSync,
|
||||||
|
sync: { auto: config.sync?.auto !== false, allow: config.sync?.allow || [], deny: config.sync?.deny || [], secrets: config.sync?.secrets || [], offerSecrets: config.sync?.offerSecrets || [] },
|
||||||
|
}))
|
||||||
|
|
||||||
|
route('/dsh-remote/sessions', ['GET'], async () => ({ devices: await allSessions() }))
|
||||||
|
|
||||||
|
route('/dsh-remote/pairing', ['POST'], async (req) => {
|
||||||
|
if (role !== 'master') throw httpErr(400, 'pairing codes are created on the master')
|
||||||
|
const body = await readJson(req)
|
||||||
|
return store.createPairingCode({ label: String(body.label || '') })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pair endpoint ON THE MASTER, called by a slave's DSH (not by a browser): exchanges a
|
||||||
|
// pairing code for a device key. Not behind browser auth (the code is the credential).
|
||||||
|
ctx.effect(() => ctx.webServer.register({
|
||||||
|
kind: 'exact',
|
||||||
|
path: '/dsh-remote/enroll',
|
||||||
|
handler: async (req, res) => {
|
||||||
|
if (req.method !== 'POST') { res.statusCode = 405; return res.end() }
|
||||||
|
try {
|
||||||
|
if (role !== 'master') throw httpErr(400, 'not a master')
|
||||||
|
const body = await readJson(req)
|
||||||
|
const rec = store.consumePairingCode(body.code)
|
||||||
|
if (!rec) throw httpErr(403, 'invalid or expired pairing code')
|
||||||
|
const dev = store.addDevice({ id: body.id, name: body.name, platform: body.platform })
|
||||||
|
sendJson(res, 200, { deviceId: dev.id, key: dev.secret, master: { id: self.id, name: self.name } })
|
||||||
|
} catch (e) {
|
||||||
|
sendJson(res, e.status || 500, { error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}), 'dsh-remote: enroll')
|
||||||
|
|
||||||
|
route('/dsh-remote/pair', ['POST'], async (req) => {
|
||||||
|
const body = await readJson(req)
|
||||||
|
const masterUrl = String(body.masterUrl || '').trim().replace(/\/+$/, '')
|
||||||
|
if (!/^https?:\/\//.test(masterUrl)) throw httpErr(400, 'masterUrl must start with http:// or https://')
|
||||||
|
const r = await fetch(`${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}` }),
|
||||||
|
})
|
||||||
|
const j = await r.json().catch(() => ({}))
|
||||||
|
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() })
|
||||||
|
return { ok: true, deviceId: j.deviceId, master: j.master, restartRequired: role !== 'slave' }
|
||||||
|
})
|
||||||
|
|
||||||
|
route('/dsh-remote/unpair', ['POST'], async () => {
|
||||||
|
store.setMaster(null)
|
||||||
|
masterLink?.mux?.close(1000, 'unpaired')
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.effect(() => ctx.webServer.register({
|
||||||
|
kind: 'prefix',
|
||||||
|
path: '/dsh-remote/devices',
|
||||||
|
handler: async (req, res) => {
|
||||||
|
const rejection = ctx.connection.requestRejection(req)
|
||||||
|
if (rejection !== undefined) return sendJson(res, rejection, { error: 'rejected' })
|
||||||
|
try {
|
||||||
|
if (role !== 'master') throw httpErr(400, 'devices are managed on the master')
|
||||||
|
if (req.method !== 'POST') throw httpErr(405, 'POST only')
|
||||||
|
const m = new URL(req.url || '/', 'http://x').pathname.match(/^\/dsh-remote\/devices\/([a-z0-9-]+)\/(revoke|remove|sync)$/)
|
||||||
|
if (!m) throw httpErr(404, 'not found')
|
||||||
|
const [, id, action] = m
|
||||||
|
if (action === 'revoke') { store.revokeDevice(id); links.get(id)?.mux.close(4401, 'revoked') }
|
||||||
|
else if (action === 'remove') { store.removeDevice(id); links.get(id)?.mux.close(4401, 'removed') }
|
||||||
|
else {
|
||||||
|
const dev = store.devices()[id]
|
||||||
|
if (!dev) throw httpErr(404, 'unknown device')
|
||||||
|
const body = await readJson(req)
|
||||||
|
const asList = (v) => (Array.isArray(v) ? v.map(String).filter(Boolean) : [])
|
||||||
|
dev.sync = { enabled: body.enabled !== false, allow: asList(body.allow), deny: asList(body.deny), secrets: asList(body.secrets) }
|
||||||
|
store.save()
|
||||||
|
links.get(id)?.mux.event('sync-available', {})
|
||||||
|
}
|
||||||
|
pushPeers()
|
||||||
|
sendJson(res, 200, { ok: true, devices: devicesView() })
|
||||||
|
} catch (e) {
|
||||||
|
sendJson(res, e.status || 500, { error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}), 'dsh-remote: devices')
|
||||||
|
|
||||||
|
route('/dsh-remote/sync', ['POST'], async () => {
|
||||||
|
if (role === 'master') {
|
||||||
|
manifestCache = null
|
||||||
|
for (const l of links.values()) l.mux.event('sync-available', {})
|
||||||
|
return { ok: true, pushed: links.size }
|
||||||
|
}
|
||||||
|
return runSlaveSync()
|
||||||
|
})
|
||||||
|
|
||||||
|
route('/dsh-remote/sync/status', ['GET'], async () => {
|
||||||
|
if (role === 'master') {
|
||||||
|
const m = await buildManifestCached()
|
||||||
|
return { role, bundles: m.bundles, patchIds: m.patch.map((e) => e.id || (e.insert ? 'insert' : '?')), patchError: m.patchError || null, offerSecrets: m.secrets }
|
||||||
|
}
|
||||||
|
return { role, lastSync, synced: store.data.synced || {} }
|
||||||
|
})
|
||||||
|
|
||||||
|
route('/dsh-remote/new-session', ['POST'], async (req) => {
|
||||||
|
const body = await readJson(req)
|
||||||
|
const device = String(body.device || self.id)
|
||||||
|
const r = await callDevice(device, 'newSession', { cwd: body.cwd, workspaceId: body.workspaceId })
|
||||||
|
return { device, sessionId: r.sessionId, url: device === self.id ? `./#/session/${r.sessionId}` : `./r/${device}/#/session/${r.sessionId}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Server-sent events for the client: devices changes, activity, sync results.
|
||||||
|
route('/dsh-remote/events', ['GET'], async () => ({}))
|
||||||
|
ctx.effect(() => {
|
||||||
|
const dispose = ctx.webServer.register({
|
||||||
|
kind: 'exact',
|
||||||
|
path: '/dsh-remote/stream',
|
||||||
|
handler: (req, res) => {
|
||||||
|
const rejection = ctx.connection.requestRejection(req)
|
||||||
|
if (rejection !== undefined) { res.statusCode = rejection; return res.end() }
|
||||||
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' })
|
||||||
|
res.write(': ok\n\n')
|
||||||
|
const l = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`)
|
||||||
|
listeners.add(l)
|
||||||
|
const ka = setInterval(() => res.write(': ka\n\n'), 20000)
|
||||||
|
req.on('close', () => { listeners.delete(l); clearInterval(ka) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return dispose
|
||||||
|
}, 'dsh-remote: event stream')
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ /r/<device>/ proxy
|
||||||
|
function parseRemote(urlPath) {
|
||||||
|
const m = urlPath.match(/^\/r\/([a-z0-9-]+)(\/.*)?$/)
|
||||||
|
if (!m) return null
|
||||||
|
return { device: m[1], rest: m[2] || '/' }
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.effect(() => ctx.webServer.register({
|
||||||
|
kind: 'prefix',
|
||||||
|
path: '/r',
|
||||||
|
handler: (req, res) => {
|
||||||
|
const rejection = ctx.connection.requestRejection(req)
|
||||||
|
if (rejection !== undefined) {
|
||||||
|
res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
|
||||||
|
return res.end(rejection === 401 ? 'dsh-remote: open this DSH first (authentication required)\n' : 'forbidden\n')
|
||||||
|
}
|
||||||
|
const url = new URL(req.url || '/', 'http://x')
|
||||||
|
const p = parseRemote(url.pathname)
|
||||||
|
if (!p) { res.statusCode = 404; return res.end() }
|
||||||
|
// "/r/ai-pc" → "/r/ai-pc/" so relative URLs (<base href="./">) resolve under the mount.
|
||||||
|
if (url.pathname === `/r/${p.device}`) {
|
||||||
|
res.writeHead(301, { location: `/r/${p.device}/${url.search}${url.hash}` })
|
||||||
|
return res.end()
|
||||||
|
}
|
||||||
|
if (p.device === self.id) {
|
||||||
|
res.writeHead(302, { location: `/${url.search}` })
|
||||||
|
return res.end()
|
||||||
|
}
|
||||||
|
const r = routeTo(p.device)
|
||||||
|
if (!r) {
|
||||||
|
res.writeHead(502, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
||||||
|
return res.end(`<!doctype html><meta charset=utf-8><title>offline</title><body style="font:15px system-ui;padding:3em;background:#111;color:#ddd">
|
||||||
|
<h2>Device <code>${p.device}</code> is offline</h2><p>Its DSH is not linked right now. <a style="color:#8af" href="/">Back</a></p>`)
|
||||||
|
}
|
||||||
|
viewHttp(req, res, (kind, meta) => r.mux.open(kind, { ...meta, ...r.meta }), { rest: p.rest + url.search, basePath: `/r/${p.device}` })
|
||||||
|
},
|
||||||
|
}), 'dsh-remote: /r proxy')
|
||||||
|
|
||||||
|
// Upgrades: DSH's webserver matches upgrade routes exactly, so register the Remote mux
|
||||||
|
// path for each known device id (and re-register as devices appear).
|
||||||
|
const upgradeRoutes = new Map()
|
||||||
|
function ensureUpgradeRoute(device) {
|
||||||
|
if (device === self.id || upgradeRoutes.has(device)) return
|
||||||
|
const path = `/r/${device}${MUX_PATH}`
|
||||||
|
try {
|
||||||
|
const dispose = ctx.webServer.registerUpgrade({
|
||||||
|
path,
|
||||||
|
handler: (req, socket, head) => {
|
||||||
|
if (ctx.connection.requestRejection(req) !== undefined) return rejectUpgrade(socket, 401, 'auth required')
|
||||||
|
const r = routeTo(device)
|
||||||
|
if (!r) return rejectUpgrade(socket, 502, 'device offline')
|
||||||
|
const url = new URL(req.url || '/', 'http://x')
|
||||||
|
viewUpgrade(req, socket, head, (kind, meta) => r.mux.open(kind, { ...meta, ...r.meta }), { rest: MUX_PATH + url.search })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
upgradeRoutes.set(device, dispose)
|
||||||
|
} catch (e) {
|
||||||
|
log.warn(`dsh-remote: upgrade route ${path}: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const upgradeSync = setInterval(() => { for (const d of devicesView()) ensureUpgradeRoute(d.id) }, 2000)
|
||||||
|
upgradeSync.unref?.()
|
||||||
|
for (const d of Object.keys(store.devices())) ensureUpgradeRoute(d)
|
||||||
|
ctx.effect(() => () => {
|
||||||
|
clearInterval(upgradeSync)
|
||||||
|
for (const dispose of upgradeRoutes.values()) { try { dispose() } catch { /* ignore */ } }
|
||||||
|
upgradeRoutes.clear()
|
||||||
|
}, 'dsh-remote: upgrade routes')
|
||||||
|
|
||||||
|
log.info?.(`dsh-remote: ${role} "${self.name}" (${self.id}) ready`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Mint DSH's own browser-session cookie for a given authority.
|
||||||
|
//
|
||||||
|
// Format (dsh-client-connection BrowserAuth): cookie name `dsh-auth-` +
|
||||||
|
// base64url(sha256(authority)), value `v1.<b64u(json)>.<b64u(hmac-sha256(secret, body))>`
|
||||||
|
// with json {version:1, authority, issuedAt, expiresAt}. The 32-byte secret is the
|
||||||
|
// credential record `client-connection/browser-session` (kind grant, payload.secret).
|
||||||
|
//
|
||||||
|
// The target DSH accepts proxied requests only when Host matches the cookie's authority,
|
||||||
|
// so the proxy rewrites Host to the loopback authority it forwards to and attaches a
|
||||||
|
// cookie minted for exactly that authority. The browser never sees this cookie.
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
const DAY = 86400000
|
||||||
|
const b64u = (b) => Buffer.from(b).toString('base64url')
|
||||||
|
|
||||||
|
export function cookieName(authority) {
|
||||||
|
return 'dsh-auth-' + b64u(crypto.createHash('sha256').update(authority).digest())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mintCookie(authority, secretB64u, maxAgeDays = 1) {
|
||||||
|
const secret = Buffer.from(String(secretB64u || ''), 'base64url')
|
||||||
|
if (secret.length !== 32) return null
|
||||||
|
const issuedAt = Date.now()
|
||||||
|
const body = b64u(JSON.stringify({ version: 1, authority, issuedAt, expiresAt: issuedAt + maxAgeDays * DAY }))
|
||||||
|
const sig = b64u(crypto.createHmac('sha256', secret).update(body).digest())
|
||||||
|
return `${cookieName(authority)}=v1.${body}.${sig}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the browser-session signing secret through ctx.credentials (no file access). */
|
||||||
|
export async function readBrowserSecret(ctx) {
|
||||||
|
let creds
|
||||||
|
try { creds = typeof ctx.get === 'function' ? ctx.get('credentials') : ctx.credentials } catch { creds = undefined }
|
||||||
|
if (!creds || typeof creds.readRecord !== 'function') return ''
|
||||||
|
try {
|
||||||
|
const rec = await creds.readRecord('client-connection/browser-session')
|
||||||
|
return rec?.payload?.secret || ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small cache: re-mint a cookie per authority once an hour. */
|
||||||
|
export class CookieJar {
|
||||||
|
constructor(ctx) {
|
||||||
|
this.ctx = ctx
|
||||||
|
this.cache = new Map()
|
||||||
|
this.secret = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(authority) {
|
||||||
|
const hit = this.cache.get(authority)
|
||||||
|
if (hit && hit.at > Date.now() - 3600000) return hit.cookie
|
||||||
|
if (!this.secret) this.secret = await readBrowserSecret(this.ctx)
|
||||||
|
const cookie = mintCookie(authority, this.secret, 1)
|
||||||
|
if (cookie) this.cache.set(authority, { cookie, at: Date.now() })
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
}
|
||||||
+245
@@ -0,0 +1,245 @@
|
|||||||
|
// Stream multiplexer over one WsConn.
|
||||||
|
//
|
||||||
|
// Frames:
|
||||||
|
// text = JSON control message { t: <type>, ...fields }
|
||||||
|
// binary = data frame [u32 stream id][payload]
|
||||||
|
//
|
||||||
|
// Streams are opened with a control message `{t:'open', id, kind, meta}` and carry raw
|
||||||
|
// bytes in both directions. They implement backpressure-lite: a stream pauses its local
|
||||||
|
// source while the underlying socket buffer is above HIGH_WATER.
|
||||||
|
//
|
||||||
|
// Request/response control calls: `{t:'call', cid, method, params}` →
|
||||||
|
// `{t:'reply', cid, ok, result | error}`. Unsolicited `{t:'event', name, data}`.
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
|
||||||
|
const HIGH_WATER = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
export class Mux extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* @param ws WsConn
|
||||||
|
* @param {{ odd: boolean, handlers?: Record<string, Function>, onStream?: Function }} opts
|
||||||
|
* odd: this side allocates odd stream ids (master = even, slave = odd) so ids never collide.
|
||||||
|
*/
|
||||||
|
constructor(ws, { odd, handlers = {}, onStream } = {}) {
|
||||||
|
super()
|
||||||
|
this.ws = ws
|
||||||
|
this.nextId = odd ? 1 : 2
|
||||||
|
this.nextCid = 1
|
||||||
|
this.streams = new Map()
|
||||||
|
this.calls = new Map()
|
||||||
|
this.handlers = handlers
|
||||||
|
this.onStream = onStream
|
||||||
|
this.closed = false
|
||||||
|
this.lastSeen = Date.now()
|
||||||
|
ws.on('message', (data, binary) => {
|
||||||
|
this.lastSeen = Date.now()
|
||||||
|
if (binary) this._data(data)
|
||||||
|
else this._control(data)
|
||||||
|
})
|
||||||
|
ws.on('pong', () => { this.lastSeen = Date.now() })
|
||||||
|
ws.on('close', (code, reason) => this._teardown(code, reason))
|
||||||
|
this.pinger = setInterval(() => {
|
||||||
|
if (Date.now() - this.lastSeen > 45000) return ws.destroy(1001, 'ping timeout')
|
||||||
|
ws.ping()
|
||||||
|
}, 15000)
|
||||||
|
this.pinger.unref?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
_teardown(code, reason) {
|
||||||
|
if (this.closed) return
|
||||||
|
this.closed = true
|
||||||
|
clearInterval(this.pinger)
|
||||||
|
for (const s of this.streams.values()) s._remoteEnd(new Error('link closed'))
|
||||||
|
this.streams.clear()
|
||||||
|
for (const { reject, timer } of this.calls.values()) { clearTimeout(timer); reject(new Error('link closed')) }
|
||||||
|
this.calls.clear()
|
||||||
|
this.emit('close', code, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(code, reason) { this.ws.close(code, reason) }
|
||||||
|
|
||||||
|
sendControl(msg) {
|
||||||
|
if (this.closed) return false
|
||||||
|
return this.ws.send(JSON.stringify(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
_control(text) {
|
||||||
|
let msg
|
||||||
|
try { msg = JSON.parse(text) } catch { return }
|
||||||
|
switch (msg.t) {
|
||||||
|
case 'open': {
|
||||||
|
const s = new Stream(this, msg.id, msg.kind, msg.meta || {})
|
||||||
|
this.streams.set(msg.id, s)
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => (this.onStream ? this.onStream(s) : s.fail('no stream handler')))
|
||||||
|
.catch((e) => s.fail(String(e?.message ?? e)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'head': return this.streams.get(msg.id)?._head(msg.meta || {})
|
||||||
|
case 'end': return this.streams.get(msg.id)?._remoteEnd()
|
||||||
|
case 'fail': return this.streams.get(msg.id)?._remoteEnd(new Error(msg.error || 'stream failed'))
|
||||||
|
case 'pause': return this.streams.get(msg.id)?.emit('remote-pause')
|
||||||
|
case 'resume': return this.streams.get(msg.id)?.emit('remote-resume')
|
||||||
|
case 'call': return this._call(msg)
|
||||||
|
case 'reply': {
|
||||||
|
const c = this.calls.get(msg.cid)
|
||||||
|
if (!c) return
|
||||||
|
this.calls.delete(msg.cid)
|
||||||
|
clearTimeout(c.timer)
|
||||||
|
if (msg.ok) c.resolve(msg.result)
|
||||||
|
else c.reject(Object.assign(new Error(msg.error || 'remote error'), { status: msg.status }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'event': return this.emit('event', msg.name, msg.data)
|
||||||
|
default: return this.emit('control', msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _call(msg) {
|
||||||
|
const h = this.handlers[msg.method]
|
||||||
|
try {
|
||||||
|
if (!h) throw Object.assign(new Error(`unknown method ${msg.method}`), { status: 404 })
|
||||||
|
const result = await h(msg.params ?? {}, this)
|
||||||
|
this.sendControl({ t: 'reply', cid: msg.cid, ok: true, result: result ?? null })
|
||||||
|
} catch (e) {
|
||||||
|
this.sendControl({ t: 'reply', cid: msg.cid, ok: false, error: String(e?.message ?? e), status: e?.status })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
call(method, params = {}, timeoutMs = 30000) {
|
||||||
|
if (this.closed) return Promise.reject(new Error('link closed'))
|
||||||
|
const cid = this.nextCid++
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.calls.delete(cid)
|
||||||
|
reject(new Error(`call ${method} timed out`))
|
||||||
|
}, timeoutMs)
|
||||||
|
timer.unref?.()
|
||||||
|
this.calls.set(cid, { resolve, reject, timer })
|
||||||
|
this.sendControl({ t: 'call', cid, method, params })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
event(name, data) { this.sendControl({ t: 'event', name, data }) }
|
||||||
|
|
||||||
|
_data(buf) {
|
||||||
|
if (buf.length < 4) return
|
||||||
|
const id = buf.readUInt32BE(0)
|
||||||
|
this.streams.get(id)?._remoteData(buf.subarray(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open an outgoing stream. */
|
||||||
|
open(kind, meta = {}) {
|
||||||
|
const id = this.nextId
|
||||||
|
this.nextId += 2
|
||||||
|
const s = new Stream(this, id, kind, meta)
|
||||||
|
this.streams.set(id, s)
|
||||||
|
this.sendControl({ t: 'open', id, kind, meta })
|
||||||
|
// The opener attaches listeners synchronously; deliver from the next tick on.
|
||||||
|
queueMicrotask(() => s.start())
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
_writeData(id, chunk) {
|
||||||
|
const header = Buffer.alloc(4)
|
||||||
|
header.writeUInt32BE(id, 0)
|
||||||
|
// Split large chunks so one stream cannot monopolise the link.
|
||||||
|
const MAX = 256 * 1024
|
||||||
|
let ok = true
|
||||||
|
for (let off = 0; off < chunk.length; off += MAX) {
|
||||||
|
ok = this.ws.send(Buffer.concat([header, chunk.subarray(off, off + MAX)])) && ok
|
||||||
|
}
|
||||||
|
return ok && this.ws.bufferedAmount < HIGH_WATER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bidirectional byte stream. Events: 'head'(meta), 'data'(buf), 'end', 'error'(err),
|
||||||
|
* 'drain'. Writers should stop on write()===false and wait for 'drain'.
|
||||||
|
*/
|
||||||
|
export class Stream extends EventEmitter {
|
||||||
|
constructor(mux, id, kind, meta) {
|
||||||
|
super()
|
||||||
|
this.mux = mux
|
||||||
|
this.id = id
|
||||||
|
this.kind = kind
|
||||||
|
this.meta = meta
|
||||||
|
this.localEnded = false
|
||||||
|
this.remoteEnded = false
|
||||||
|
this.done = false
|
||||||
|
// Incoming events are queued until the consumer attaches its listeners and calls
|
||||||
|
// start(): handlers often await something (cookie, upstream connect) first, and
|
||||||
|
// data/end arriving in the meantime must not be lost.
|
||||||
|
this.paused = true
|
||||||
|
this.queue = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin delivering queued + future incoming events. Idempotent. */
|
||||||
|
start() {
|
||||||
|
if (!this.paused) return this
|
||||||
|
this.paused = false
|
||||||
|
const q = this.queue
|
||||||
|
this.queue = []
|
||||||
|
for (const [ev, arg] of q) this._deliver(ev, arg)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
_deliver(ev, arg) {
|
||||||
|
if (this.paused) { this.queue.push([ev, arg]); return }
|
||||||
|
if (ev === 'head') this.emit('head', arg)
|
||||||
|
else if (ev === 'data') this.emit('data', arg)
|
||||||
|
else if (ev === 'end') { this.emit('end'); this._maybeDone() }
|
||||||
|
else if (ev === 'error') { if (this.listenerCount('error')) this.emit('error', arg); this._finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
head(meta) { this.mux.sendControl({ t: 'head', id: this.id, meta }) }
|
||||||
|
|
||||||
|
write(chunk) {
|
||||||
|
if (this.localEnded || this.mux.closed) return false
|
||||||
|
const ok = this.mux._writeData(this.id, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||||
|
if (!ok && !this._drainWait) {
|
||||||
|
this._drainWait = true
|
||||||
|
const sock = this.mux.ws.socket
|
||||||
|
sock.once('drain', () => { this._drainWait = false; this.emit('drain') })
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
end() {
|
||||||
|
if (this.localEnded) return
|
||||||
|
this.localEnded = true
|
||||||
|
this.mux.sendControl({ t: 'end', id: this.id })
|
||||||
|
this._maybeDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(error) {
|
||||||
|
if (this.done) return
|
||||||
|
this.mux.sendControl({ t: 'fail', id: this.id, error: String(error) })
|
||||||
|
this.localEnded = true
|
||||||
|
this.remoteEnded = true
|
||||||
|
this._finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
_head(meta) { this._deliver('head', meta) }
|
||||||
|
_remoteData(buf) { if (!this.remoteEnded) this._deliver('data', buf) }
|
||||||
|
|
||||||
|
_remoteEnd(err) {
|
||||||
|
if (this.remoteEnded) return
|
||||||
|
this.remoteEnded = true
|
||||||
|
if (err) {
|
||||||
|
this.localEnded = true
|
||||||
|
this._deliver('error', err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._deliver('end')
|
||||||
|
}
|
||||||
|
|
||||||
|
_maybeDone() { if (this.localEnded && this.remoteEnded) this._finish() }
|
||||||
|
|
||||||
|
_finish() {
|
||||||
|
if (this.done) return
|
||||||
|
this.done = true
|
||||||
|
this.mux.streams.delete(this.id)
|
||||||
|
this.emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
// HTTP + WebSocket tunnelling between a browser-facing webserver and a remote DSH over
|
||||||
|
// the link mux.
|
||||||
|
//
|
||||||
|
// viewer side (where the browser is): /r/<device>/<rest> ──stream "http"|"ws"──►
|
||||||
|
// owner side (where the session runs): forward to 127.0.0.1:<own port>/<rest>
|
||||||
|
//
|
||||||
|
// Owner side rewrites Host/Origin to its own loopback authority and attaches a freshly
|
||||||
|
// minted DSH auth cookie, so DSH's /api trust fence and cookie check pass. The viewer's
|
||||||
|
// own DSH cookie is stripped before forwarding (it would be meaningless remotely).
|
||||||
|
import http from 'node:http'
|
||||||
|
|
||||||
|
const HOP = new Set([
|
||||||
|
'connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade', 'te', 'trailer',
|
||||||
|
'proxy-authorization', 'proxy-authenticate', 'host', 'content-length',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function stripDshCookies(cookieHeader) {
|
||||||
|
return String(cookieHeader || '')
|
||||||
|
.split(';')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s && !/^dsh-auth-/.test(s) && !/^dsh_auth_session=/.test(s))
|
||||||
|
.join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardHeaders(headers) {
|
||||||
|
const out = {}
|
||||||
|
for (const [k, v] of Object.entries(headers || {})) {
|
||||||
|
if (HOP.has(k.toLowerCase())) continue
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// viewer side
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipe one browser HTTP request through `openStream()` (returns a mux Stream).
|
||||||
|
* `rest` is the path+query to request on the remote side ("/api/remote/…").
|
||||||
|
* `basePath` is the local mount ("/r/ai-pc") used to rewrite redirects/cookies.
|
||||||
|
*/
|
||||||
|
export function viewHttp(req, res, openStream, { rest, basePath, extra = {} }) {
|
||||||
|
const headers = forwardHeaders(req.headers)
|
||||||
|
headers.cookie = stripDshCookies(req.headers.cookie)
|
||||||
|
if (!headers.cookie) delete headers.cookie
|
||||||
|
const stream = openStream('http', { method: req.method, path: rest, headers, ...extra })
|
||||||
|
let responded = false
|
||||||
|
|
||||||
|
stream.on('head', (meta) => {
|
||||||
|
responded = true
|
||||||
|
const outHeaders = { ...meta.headers }
|
||||||
|
// Keep relative redirects inside the mount (DSH answers "./" which is already relative).
|
||||||
|
if (typeof outHeaders.location === 'string' && outHeaders.location.startsWith('/')) {
|
||||||
|
outHeaders.location = basePath + outHeaders.location
|
||||||
|
}
|
||||||
|
// The remote never sets cookies the browser should keep under our authority.
|
||||||
|
delete outHeaders['set-cookie']
|
||||||
|
try { res.writeHead(meta.status || 502, outHeaders) } catch { /* headers already sent */ }
|
||||||
|
})
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
if (!res.write(chunk)) {
|
||||||
|
stream.mux.sendControl({ t: 'pause', id: stream.id })
|
||||||
|
res.once('drain', () => stream.mux.sendControl({ t: 'resume', id: stream.id }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
stream.on('end', () => res.end())
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
if (!responded) {
|
||||||
|
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
||||||
|
res.end(`dsh-remote: ${err.message}\n`)
|
||||||
|
} else {
|
||||||
|
res.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
res.on('close', () => { if (!stream.done) stream.fail('client closed') })
|
||||||
|
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
if (!stream.write(chunk)) {
|
||||||
|
req.pause()
|
||||||
|
stream.once('drain', () => req.resume())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.on('end', () => stream.end())
|
||||||
|
req.on('error', () => stream.fail('request error'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tunnel a browser WebSocket upgrade (raw socket) through a mux stream. */
|
||||||
|
export function viewUpgrade(req, socket, head, openStream, { rest, extra = {} }) {
|
||||||
|
const headers = forwardHeaders(req.headers)
|
||||||
|
// Keep the WebSocket handshake headers (forwardHeaders dropped connection/upgrade).
|
||||||
|
headers.cookie = stripDshCookies(req.headers.cookie)
|
||||||
|
if (!headers.cookie) delete headers.cookie
|
||||||
|
const stream = openStream('ws', { path: rest, headers, ...extra })
|
||||||
|
let open = false
|
||||||
|
stream.on('head', (meta) => {
|
||||||
|
// The owner side already completed the upstream handshake; replay its 101 to the
|
||||||
|
// browser with the browser's own Sec-WebSocket-Key accepted value.
|
||||||
|
if (meta.status !== 101) {
|
||||||
|
socket.end(`HTTP/1.1 ${meta.status || 502} Upgrade Failed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
open = true
|
||||||
|
const lines = ['HTTP/1.1 101 Switching Protocols']
|
||||||
|
for (const [k, v] of Object.entries(meta.headers || {})) {
|
||||||
|
for (const one of Array.isArray(v) ? v : [v]) lines.push(`${k}: ${one}`)
|
||||||
|
}
|
||||||
|
socket.write(lines.join('\r\n') + '\r\n\r\n')
|
||||||
|
if (head && head.length) stream.write(head)
|
||||||
|
})
|
||||||
|
stream.on('data', (chunk) => { if (!socket.write(chunk)) { /* tcp backpressure handled by kernel */ } })
|
||||||
|
stream.on('end', () => socket.end())
|
||||||
|
stream.on('error', () => {
|
||||||
|
if (!open) {
|
||||||
|
try { socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') } catch { socket.destroy() }
|
||||||
|
} else socket.destroy()
|
||||||
|
})
|
||||||
|
socket.on('data', (chunk) => stream.write(chunk))
|
||||||
|
socket.on('end', () => stream.end())
|
||||||
|
socket.on('close', () => { if (!stream.done) stream.fail('client closed') })
|
||||||
|
socket.on('error', () => { if (!stream.done) stream.fail('client error') })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// owner side
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve one incoming "http" stream against the local DSH.
|
||||||
|
* @param target {host, port}; authority = `${host}:${port}`
|
||||||
|
* @param cookie async () => "dsh-auth-…=v1…" for that authority
|
||||||
|
*/
|
||||||
|
export async function ownHttp(stream, { target, cookie }) {
|
||||||
|
const { method = 'GET', path = '/', headers = {} } = stream.meta
|
||||||
|
const authority = `${target.host}:${target.port}`
|
||||||
|
const h = { ...headers, host: authority }
|
||||||
|
if (h.origin) h.origin = `http://${authority}`
|
||||||
|
if (h.referer) h.referer = `http://${authority}/`
|
||||||
|
delete h['sec-fetch-site']
|
||||||
|
const c = await cookie(authority)
|
||||||
|
h.cookie = [h.cookie, c].filter(Boolean).join('; ')
|
||||||
|
|
||||||
|
const req = http.request({ host: target.host, port: target.port, method, path, headers: h })
|
||||||
|
req.on('socket', (s) => s.on('error', () => {}))
|
||||||
|
req.on('response', (res) => {
|
||||||
|
stream.head({ status: res.statusCode, headers: forwardHeaders(res.headers) })
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
if (!stream.write(chunk)) {
|
||||||
|
res.pause()
|
||||||
|
stream.once('drain', () => res.resume())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
res.on('end', () => stream.end())
|
||||||
|
res.on('error', () => stream.fail('upstream response error'))
|
||||||
|
stream.on('remote-pause', () => res.pause())
|
||||||
|
stream.on('remote-resume', () => res.resume())
|
||||||
|
})
|
||||||
|
req.on('error', (err) => stream.fail(`upstream: ${err.message}`))
|
||||||
|
stream.on('data', (chunk) => req.write(chunk))
|
||||||
|
stream.on('end', () => req.end())
|
||||||
|
stream.on('error', () => req.destroy())
|
||||||
|
stream.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serve one incoming "ws" stream: perform the upgrade against local DSH, then pipe. */
|
||||||
|
export async function ownUpgrade(stream, { target, cookie }) {
|
||||||
|
const { path = '/', headers = {} } = stream.meta
|
||||||
|
const authority = `${target.host}:${target.port}`
|
||||||
|
const h = { ...headers, host: authority, connection: 'Upgrade', upgrade: headers.upgrade || 'websocket' }
|
||||||
|
if (h.origin) h.origin = `http://${authority}`
|
||||||
|
delete h['sec-fetch-site']
|
||||||
|
const c = await cookie(authority)
|
||||||
|
h.cookie = [h.cookie, c].filter(Boolean).join('; ')
|
||||||
|
|
||||||
|
const req = http.request({ host: target.host, port: target.port, method: 'GET', path, headers: h })
|
||||||
|
req.on('socket', (s) => s.on('error', () => {}))
|
||||||
|
req.on('upgrade', (res, sock, head) => {
|
||||||
|
const out = {}
|
||||||
|
for (const [k, v] of Object.entries(res.headers)) out[k] = v
|
||||||
|
stream.head({ status: 101, headers: out })
|
||||||
|
if (head && head.length) stream.write(head)
|
||||||
|
sock.on('data', (chunk) => stream.write(chunk))
|
||||||
|
sock.on('end', () => stream.end())
|
||||||
|
sock.on('error', () => stream.fail('upstream socket error'))
|
||||||
|
stream.on('data', (chunk) => sock.write(chunk))
|
||||||
|
stream.on('end', () => sock.end())
|
||||||
|
stream.on('error', () => sock.destroy())
|
||||||
|
stream.on('close', () => sock.destroy())
|
||||||
|
// Browser frames sent before the upstream handshake finished were queued; flush now.
|
||||||
|
stream.start()
|
||||||
|
})
|
||||||
|
req.on('response', (res) => {
|
||||||
|
stream.head({ status: res.statusCode || 502, headers: {} })
|
||||||
|
res.resume()
|
||||||
|
stream.end()
|
||||||
|
})
|
||||||
|
req.on('error', (err) => stream.fail(`upstream: ${err.message}`))
|
||||||
|
req.end()
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
// Persistent state for dsh-remote: device identity, paired devices (master) or master
|
||||||
|
// link credentials (slave), sync bookkeeping. One JSON file, written atomically, 0600.
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
let PROFILE_DIR = ''
|
||||||
|
|
||||||
|
/** Set once from ctx.profileContext.dir at plugin start (authoritative). */
|
||||||
|
export function setProfileDir(dir) { if (dir) PROFILE_DIR = dir }
|
||||||
|
|
||||||
|
export function profileDir() {
|
||||||
|
if (PROFILE_DIR) return PROFILE_DIR
|
||||||
|
if (process.env.DSH_PROFILE_DIR) return process.env.DSH_PROFILE_DIR
|
||||||
|
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
||||||
|
return path.join(home, 'profiles', process.env.DSH_PROFILE || 'web')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stateDir() {
|
||||||
|
return path.join(profileDir(), 'dsh-remote')
|
||||||
|
}
|
||||||
|
|
||||||
|
const b64u = (buf) => Buffer.from(buf).toString('base64url')
|
||||||
|
export const randomId = (bytes = 16) => b64u(crypto.randomBytes(bytes))
|
||||||
|
export const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex')
|
||||||
|
|
||||||
|
export function safeEqual(a, b) {
|
||||||
|
const x = Buffer.from(String(a))
|
||||||
|
const y = Buffer.from(String(b))
|
||||||
|
return x.length === y.length && crypto.timingSafeEqual(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Device id: lowercase slug, safe in URL paths (/r/<id>/). */
|
||||||
|
export function slug(s) {
|
||||||
|
const v = String(s || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
||||||
|
return v || 'device'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Store {
|
||||||
|
constructor(file = path.join(stateDir(), 'state.json')) {
|
||||||
|
this.file = file
|
||||||
|
this.data = this._load()
|
||||||
|
}
|
||||||
|
|
||||||
|
_load() {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(fs.readFileSync(this.file, 'utf8'))
|
||||||
|
if (v && typeof v === 'object') return v
|
||||||
|
} catch { /* first run */ }
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 })
|
||||||
|
const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2), { mode: 0o600 })
|
||||||
|
fs.renameSync(tmp, this.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This installation's stable identity. */
|
||||||
|
identity(defaultName) {
|
||||||
|
if (!this.data.self) {
|
||||||
|
const name = defaultName || os.hostname()
|
||||||
|
this.data.self = { id: slug(name), name, createdAt: Date.now() }
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
return this.data.self
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- master side: paired devices + pairing codes -------------------------
|
||||||
|
devices() { return (this.data.devices ||= {}) }
|
||||||
|
|
||||||
|
pairingCodes() { return (this.data.pairing ||= {}) }
|
||||||
|
|
||||||
|
createPairingCode({ ttlMs = 15 * 60 * 1000, label = '' } = {}) {
|
||||||
|
// 4 groups of 4 from an unambiguous alphabet: easy to type, ~80 bits.
|
||||||
|
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||||
|
const raw = crypto.randomBytes(16)
|
||||||
|
const code = [...raw].map((b) => alphabet[b % alphabet.length]).join('').replace(/(.{4})(?=.)/g, '$1-')
|
||||||
|
this.pairingCodes()[sha256(code)] = { expiresAt: Date.now() + ttlMs, label, createdAt: Date.now() }
|
||||||
|
this._prunePairing()
|
||||||
|
this.save()
|
||||||
|
return { code, expiresAt: Date.now() + ttlMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
consumePairingCode(code) {
|
||||||
|
this._prunePairing()
|
||||||
|
const key = sha256(String(code || '').trim().toUpperCase())
|
||||||
|
const rec = this.pairingCodes()[key]
|
||||||
|
if (!rec) return null
|
||||||
|
delete this.pairingCodes()[key]
|
||||||
|
this.save()
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
_prunePairing() {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [k, v] of Object.entries(this.pairingCodes())) if (!v || v.expiresAt < now) delete this.pairingCodes()[k]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register (or re-register) a device; returns its secret key (shown once). */
|
||||||
|
addDevice({ id, name, platform }) {
|
||||||
|
const devices = this.devices()
|
||||||
|
let devId = slug(id || name)
|
||||||
|
// Keep ids unique; a re-pair of the same machine replaces its key.
|
||||||
|
const secret = randomId(32)
|
||||||
|
devices[devId] = {
|
||||||
|
id: devId,
|
||||||
|
name: name || devId,
|
||||||
|
platform: platform || '',
|
||||||
|
keyHash: sha256(secret),
|
||||||
|
createdAt: devices[devId]?.createdAt || Date.now(),
|
||||||
|
revoked: false,
|
||||||
|
sync: devices[devId]?.sync || { enabled: true, allow: [], deny: [], secrets: [] },
|
||||||
|
}
|
||||||
|
this.save()
|
||||||
|
return { id: devId, secret }
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyDevice(id, secret) {
|
||||||
|
const d = this.devices()[id]
|
||||||
|
if (!d || d.revoked) return null
|
||||||
|
return safeEqual(d.keyHash, sha256(String(secret || ''))) ? d : null
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeDevice(id) {
|
||||||
|
const d = this.devices()[id]
|
||||||
|
if (!d) return false
|
||||||
|
d.revoked = true
|
||||||
|
d.revokedAt = Date.now()
|
||||||
|
this.save()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
removeDevice(id) {
|
||||||
|
if (!this.devices()[id]) return false
|
||||||
|
delete this.devices()[id]
|
||||||
|
this.save()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- slave side: link to master ------------------------------------------
|
||||||
|
get master() { return this.data.master || null }
|
||||||
|
|
||||||
|
setMaster(m) {
|
||||||
|
this.data.master = m
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
+308
@@ -0,0 +1,308 @@
|
|||||||
|
// Plugin + config sync (master → slaves).
|
||||||
|
//
|
||||||
|
// Manifest (built on the master, served over the link):
|
||||||
|
// {
|
||||||
|
// version, builtAt, master,
|
||||||
|
// bundles: [{ name, spec, kind: 'registry'|'local', version, hash }],
|
||||||
|
// patch: [ ...cordis patch entries from the master's cordis.patch.yml selected for sync ],
|
||||||
|
// secrets: [ names of credential refs the master offers for opt-in sync ]
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Slave application:
|
||||||
|
// * registry bundles -> ctx.pluginManager.installBundle(`${name}@${version}`) unless already
|
||||||
|
// installed at that version, or excluded by the slave's deny list.
|
||||||
|
// * local (link:) bundles -> fetched as a tarball over the link, unpacked to
|
||||||
|
// <profile>/dsh-remote/plugins/<name>/, installed as `file:` spec.
|
||||||
|
// * patch entries -> written to <profile>/dsh-remote/managed-bundle/cordis.patch.yml,
|
||||||
|
// a bundle the slave enables BEFORE its own cordis.patch.yml, so
|
||||||
|
// anything the slave sets locally keeps precedence.
|
||||||
|
// * secrets -> only the names the slave explicitly opted into are requested;
|
||||||
|
// values travel over the authenticated link and are written with
|
||||||
|
// ctx.credentials.set(ref, value).
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { profileDir, stateDir } from './store.mjs'
|
||||||
|
|
||||||
|
export const MANAGED_BUNDLE = '@dsh-remote/managed'
|
||||||
|
|
||||||
|
/** Patch entry ids never synced: they are machine-local by nature. */
|
||||||
|
const LOCAL_ONLY_IDS = new Set(['dsh-remote', 'dsh-lanmode', 'host-webserver', 'webserver', 'client-connection'])
|
||||||
|
|
||||||
|
function run(cmd, args, opts = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile(cmd, args, { maxBuffer: 64 * 1024 * 1024, ...opts }, (err, stdout, stderr) => {
|
||||||
|
resolve({ code: err ? (err.code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DSH's own entry-list YAML dialect (JSON schema + `!!js` expressions), loaded from the
|
||||||
|
* running installation so synced patches keep `!!js` nodes. Over the wire a `!!js` node is
|
||||||
|
* `{__jsExpr: "<code>"}`; dumping with this schema writes it back as `!!js`.
|
||||||
|
*/
|
||||||
|
let yamlCache = null
|
||||||
|
async function loadYaml() {
|
||||||
|
if (yamlCache) return yamlCache
|
||||||
|
// Resolve DSH's own copies (not whatever the plugin dir could see): the dsh install's
|
||||||
|
// node_modules, found by following the real path of the running `dsh` bin.
|
||||||
|
const nm = dshNodeModules()
|
||||||
|
if (!nm) throw new Error('cannot locate the DSH installation (node_modules)')
|
||||||
|
const incPkg = JSON.parse(fs.readFileSync(path.join(nm, '@deepseek-ai', 'cordis-plugin-include', 'package.json'), 'utf8'))
|
||||||
|
const incMain = typeof incPkg.exports === 'string' ? incPkg.exports
|
||||||
|
: incPkg.exports?.['.']?.default || incPkg.exports?.['.']?.import || incPkg.exports?.['.'] || incPkg.main || 'lib/index.js'
|
||||||
|
const inc = await import(path.join(nm, '@deepseek-ai', 'cordis-plugin-include', String(incMain)))
|
||||||
|
const yaml = await import(path.join(nm, 'js-yaml', 'dist', 'js-yaml.mjs'))
|
||||||
|
const y = yaml.default || yaml
|
||||||
|
yamlCache = {
|
||||||
|
load: (s) => y.load(s, { schema: inc.entryListSchema }),
|
||||||
|
dump: (v, o = {}) => y.dump(v, { ...o, schema: inc.entryListSchema }),
|
||||||
|
}
|
||||||
|
return yamlCache
|
||||||
|
}
|
||||||
|
|
||||||
|
export { loadYaml }
|
||||||
|
|
||||||
|
function readJson(file) {
|
||||||
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirHash(dir) {
|
||||||
|
const h = crypto.createHash('sha256')
|
||||||
|
const walk = (d) => {
|
||||||
|
for (const ent of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
if (ent.name === 'node_modules' || ent.name === '.git' || ent.name.startsWith('.')) continue
|
||||||
|
const p = path.join(d, ent.name)
|
||||||
|
if (ent.isDirectory()) walk(p)
|
||||||
|
else if (ent.isFile()) { h.update(path.relative(dir, p)); h.update(fs.readFileSync(p)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(dir)
|
||||||
|
return h.digest('hex').slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// master side
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function buildManifest({ self, include = {}, offerSecrets = [] }) {
|
||||||
|
const prof = profileDir()
|
||||||
|
const pkg = readJson(path.join(prof, 'package.json')) || {}
|
||||||
|
const deps = pkg.dependencies || {}
|
||||||
|
const enabled = new Set(pkg.dsh?.profile?.bundles || [])
|
||||||
|
const excludeBundles = new Set(include.excludeBundles || [])
|
||||||
|
const bundles = []
|
||||||
|
for (const [name, spec] of Object.entries(deps)) {
|
||||||
|
if (!enabled.has(name) || excludeBundles.has(name) || name === '@local/dsh-remote' || name === MANAGED_BUNDLE) continue
|
||||||
|
const s = String(spec)
|
||||||
|
if (s.startsWith('link:') || s.startsWith('file:')) {
|
||||||
|
const dir = path.resolve(prof, s.replace(/^(link|file):/, ''))
|
||||||
|
const m = readJson(path.join(dir, 'package.json'))
|
||||||
|
if (!m) continue
|
||||||
|
bundles.push({ name, kind: 'local', version: m.version || '0.0.0', hash: dirHash(dir), dir })
|
||||||
|
} else {
|
||||||
|
const installed = readJson(path.join(prof, 'node_modules', name, 'package.json'))
|
||||||
|
bundles.push({ name, kind: 'registry', spec: s, version: installed?.version || s.replace(/^[\^~]/, '') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch entries: everything in the master's cordis.patch.yml except machine-local ids.
|
||||||
|
let patch = []
|
||||||
|
let patchError = null
|
||||||
|
try {
|
||||||
|
const yaml = await loadYaml()
|
||||||
|
const doc = yaml.load(fs.readFileSync(path.join(prof, 'cordis.patch.yml'), 'utf8')) || []
|
||||||
|
const excludeIds = new Set([...(include.excludePatchIds || []), ...LOCAL_ONLY_IDS])
|
||||||
|
patch = (Array.isArray(doc) ? doc : []).filter((e) => e && typeof e === 'object' && !excludeIds.has(e.id))
|
||||||
|
} catch (e) {
|
||||||
|
patch = []
|
||||||
|
patchError = String(e?.message ?? e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
builtAt: Date.now(),
|
||||||
|
master: { id: self.id, name: self.name },
|
||||||
|
bundles: bundles.map(({ dir, ...b }) => b),
|
||||||
|
patch,
|
||||||
|
patchError,
|
||||||
|
secrets: offerSecrets,
|
||||||
|
_dirs: Object.fromEntries(bundles.filter((b) => b.dir).map((b) => [b.name, b.dir])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pack a local plugin dir into a tgz Buffer (excludes .git, node_modules). */
|
||||||
|
export async function packLocal(dir) {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-remote-pack-'))
|
||||||
|
const out = path.join(tmp, 'pkg.tgz')
|
||||||
|
const r = await run('tar', ['--exclude=.git', '--exclude=node_modules', '--exclude=*.bak', '-czf', out, '-C', dir, '.'])
|
||||||
|
if (r.code !== 0) throw new Error(`tar failed: ${r.stderr.trim()}`)
|
||||||
|
const buf = fs.readFileSync(out)
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true })
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// slave side
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function syncDir() { return path.join(stateDir(), 'sync') }
|
||||||
|
|
||||||
|
export function managedBundleDir() { return path.join(stateDir(), 'managed-bundle') }
|
||||||
|
|
||||||
|
/** Write the managed bundle (package.json + patch) that carries synced config. */
|
||||||
|
export async function writeManagedBundle(patch, insertedLocalPlugins = []) {
|
||||||
|
const dir = managedBundleDir()
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
const yaml = await loadYaml()
|
||||||
|
const header = '# Managed by dsh-remote: config synced from the master. Do not edit;\n'
|
||||||
|
+ '# your own profile cordis.patch.yml is applied after this layer and wins.\n'
|
||||||
|
const body = patch.length ? yaml.dump(patch, { lineWidth: 200, noRefs: true }) : '[]\n'
|
||||||
|
fs.writeFileSync(path.join(dir, 'cordis.patch.yml'), header + body)
|
||||||
|
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||||
|
name: MANAGED_BUNDLE,
|
||||||
|
version: `0.0.${Date.now()}`,
|
||||||
|
private: true,
|
||||||
|
description: 'dsh-remote managed layer: plugins/config synced from the master',
|
||||||
|
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||||
|
dsh_remote: { localPlugins: insertedLocalPlugins },
|
||||||
|
}, null, 2))
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create/refresh <profile>/node_modules/<name> -> dir. DSH resolves bundle packages
|
||||||
|
* from the profile's node_modules; a `link:` dependency alone is only materialised by a
|
||||||
|
* pnpm install, so make the link ourselves (idempotent, no network).
|
||||||
|
*/
|
||||||
|
export function linkIntoNodeModules(name, dir) {
|
||||||
|
const link = path.join(profileDir(), 'node_modules', ...name.split('/'))
|
||||||
|
fs.mkdirSync(path.dirname(link), { recursive: true })
|
||||||
|
try {
|
||||||
|
if (fs.readlinkSync(link) === dir) return false
|
||||||
|
} catch { /* missing or not a link */ }
|
||||||
|
fs.rmSync(link, { recursive: true, force: true })
|
||||||
|
fs.symlinkSync(dir, link)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure the managed bundle is linked and listed last, i.e. right before the user's own patch. */
|
||||||
|
export function ensureManagedBundleOrder() {
|
||||||
|
const prof = profileDir()
|
||||||
|
const file = path.join(prof, 'package.json')
|
||||||
|
const pkg = readJson(file)
|
||||||
|
if (!pkg) return false
|
||||||
|
pkg.dependencies ||= {}
|
||||||
|
const spec = `link:${managedBundleDir()}`
|
||||||
|
let changed = linkIntoNodeModules(MANAGED_BUNDLE, managedBundleDir())
|
||||||
|
let pkgChanged = false
|
||||||
|
if (pkg.dependencies[MANAGED_BUNDLE] !== spec) { pkg.dependencies[MANAGED_BUNDLE] = spec; pkgChanged = true }
|
||||||
|
const bundles = (((pkg.dsh ||= {}).profile ||= {}).bundles ||= [])
|
||||||
|
const idx = bundles.indexOf(MANAGED_BUNDLE)
|
||||||
|
if (idx !== bundles.length - 1) {
|
||||||
|
if (idx !== -1) bundles.splice(idx, 1)
|
||||||
|
bundles.push(MANAGED_BUNDLE)
|
||||||
|
pkgChanged = true
|
||||||
|
}
|
||||||
|
if (pkgChanged) fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
return changed || pkgChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unpackLocal(name, version, tgz) {
|
||||||
|
const safe = name.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
||||||
|
const dir = path.join(syncDir(), 'plugins', safe)
|
||||||
|
const tmp = `${dir}.new`
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(tmp, { recursive: true })
|
||||||
|
const tgzFile = path.join(syncDir(), `${safe}.tgz`)
|
||||||
|
fs.writeFileSync(tgzFile, tgz)
|
||||||
|
return run('tar', ['-xzf', tgzFile, '-C', tmp]).then((r) => {
|
||||||
|
fs.rmSync(tgzFile, { force: true })
|
||||||
|
if (r.code !== 0) throw new Error(`untar ${name}: ${r.stderr.trim()}`)
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true })
|
||||||
|
fs.renameSync(tmp, dir)
|
||||||
|
linkDshDeps(dir)
|
||||||
|
return dir
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DSH installation's own node_modules (where @deepseek-ai/* packages live). */
|
||||||
|
export function dshNodeModules() {
|
||||||
|
const bin = process.argv[1] || ''
|
||||||
|
let real = bin
|
||||||
|
try { real = fs.realpathSync(bin) } catch { /* keep argv path */ }
|
||||||
|
const cand = [
|
||||||
|
path.resolve(path.dirname(real), '..', 'node_modules'),
|
||||||
|
path.resolve(path.dirname(bin), '..', 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules'),
|
||||||
|
]
|
||||||
|
for (const c of cand) if (fs.existsSync(path.join(c, '@deepseek-ai'))) return c
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synced local plugins import `@deepseek-ai/*` modules that the master resolved through
|
||||||
|
* a node_modules symlink into its DSH install. Recreate those links against this
|
||||||
|
* machine's DSH so the plugin loads unchanged.
|
||||||
|
*/
|
||||||
|
export function linkDshDeps(pluginDir) {
|
||||||
|
const nm = dshNodeModules()
|
||||||
|
if (!nm) return []
|
||||||
|
const wanted = new Set()
|
||||||
|
const scan = (d, depth = 0) => {
|
||||||
|
if (depth > 3) return
|
||||||
|
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
|
||||||
|
if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue
|
||||||
|
const p = path.join(d, ent.name)
|
||||||
|
if (ent.isDirectory()) scan(p, depth + 1)
|
||||||
|
else if (/\.(m?js|cjs)$/.test(ent.name)) {
|
||||||
|
const src = fs.readFileSync(p, 'utf8')
|
||||||
|
for (const m of src.matchAll(/(?:from\s+|import\s*\(\s*|require\(\s*)['"](@deepseek-ai\/[a-z0-9-]+)/g)) wanted.add(m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scan(pluginDir)
|
||||||
|
const linked = []
|
||||||
|
for (const mod of wanted) {
|
||||||
|
const target = path.join(nm, mod)
|
||||||
|
if (!fs.existsSync(target)) continue
|
||||||
|
const link = path.join(pluginDir, 'node_modules', mod)
|
||||||
|
fs.mkdirSync(path.dirname(link), { recursive: true })
|
||||||
|
try { fs.rmSync(link, { recursive: true, force: true }) } catch { /* ignore */ }
|
||||||
|
fs.symlinkSync(target, link)
|
||||||
|
linked.push(mod)
|
||||||
|
}
|
||||||
|
return linked
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decide whether a bundle passes the slave's allow/deny lists. */
|
||||||
|
export function allowed(name, { allow = [], deny = [] } = {}) {
|
||||||
|
const match = (pat) => pat === name || (pat.endsWith('*') && name.startsWith(pat.slice(0, -1)))
|
||||||
|
if (deny.some(match)) return false
|
||||||
|
if (allow.length && !allow.some(match)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link-spec install of a local plugin dir into the profile (package.json + bundles list). */
|
||||||
|
export function linkLocalBundle(name, dir) {
|
||||||
|
const prof = profileDir()
|
||||||
|
const file = path.join(prof, 'package.json')
|
||||||
|
const pkg = readJson(file)
|
||||||
|
if (!pkg) throw new Error('profile package.json unreadable')
|
||||||
|
pkg.dependencies ||= {}
|
||||||
|
const spec = `link:${dir}`
|
||||||
|
let changed = false
|
||||||
|
if (pkg.dependencies[name] !== spec) { pkg.dependencies[name] = spec; changed = true }
|
||||||
|
const bundles = (((pkg.dsh ||= {}).profile ||= {}).bundles ||= [])
|
||||||
|
if (!bundles.includes(name)) {
|
||||||
|
// Synced plugins go before the managed layer so the master's config for them applies.
|
||||||
|
const at = bundles.indexOf(MANAGED_BUNDLE)
|
||||||
|
if (at === -1) bundles.push(name)
|
||||||
|
else bundles.splice(at, 0, name)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
return linkIntoNodeModules(name, dir) || changed
|
||||||
|
}
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
// Minimal dependency-free WebSocket (RFC 6455) for the dsh-remote link.
|
||||||
|
//
|
||||||
|
// Why not `ws`: DSH plugins loaded through `link:` resolve imports from their own
|
||||||
|
// folder, and slaves install the plugin from a tarball without node_modules. The link
|
||||||
|
// only needs binary + text frames, ping/pong and close, so a small implementation keeps
|
||||||
|
// the plugin installable anywhere with zero dependencies.
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import http from 'node:http'
|
||||||
|
import https from 'node:https'
|
||||||
|
|
||||||
|
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||||
|
const MAX_FRAME = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
export const acceptKey = (key) => crypto.createHash('sha1').update(key + GUID).digest('base64')
|
||||||
|
|
||||||
|
/** One WebSocket endpoint over a raw socket (server or client role). */
|
||||||
|
export class WsConn extends EventEmitter {
|
||||||
|
constructor(socket, { client = false, head } = {}) {
|
||||||
|
super()
|
||||||
|
this.socket = socket
|
||||||
|
this.client = client
|
||||||
|
this.open = true
|
||||||
|
this.buf = head && head.length ? Buffer.from(head) : Buffer.alloc(0)
|
||||||
|
this.frag = null
|
||||||
|
socket.setNoDelay(true)
|
||||||
|
socket.on('data', (d) => this._onData(d))
|
||||||
|
socket.on('close', () => this._closed(1006, 'socket closed'))
|
||||||
|
// Never throw from an unhandled 'error' event: a reset peer must not crash the host.
|
||||||
|
socket.on('error', (e) => { if (this.listenerCount('error')) this.emit('error', e); this._closed(1006, String(e?.message ?? e)) })
|
||||||
|
if (this.buf.length) queueMicrotask(() => this._onData(Buffer.alloc(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
get bufferedAmount() { return this.socket.writableLength }
|
||||||
|
|
||||||
|
_closed(code, reason) {
|
||||||
|
if (!this.open) return
|
||||||
|
this.open = false
|
||||||
|
this.emit('close', code, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
_onData(chunk) {
|
||||||
|
if (chunk.length) this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk
|
||||||
|
for (;;) {
|
||||||
|
const b = this.buf
|
||||||
|
if (b.length < 2) return
|
||||||
|
const fin = (b[0] & 0x80) !== 0
|
||||||
|
const op = b[0] & 0x0f
|
||||||
|
const masked = (b[1] & 0x80) !== 0
|
||||||
|
let len = b[1] & 0x7f
|
||||||
|
let off = 2
|
||||||
|
if (len === 126) {
|
||||||
|
if (b.length < 4) return
|
||||||
|
len = b.readUInt16BE(2); off = 4
|
||||||
|
} else if (len === 127) {
|
||||||
|
if (b.length < 10) return
|
||||||
|
const big = b.readBigUInt64BE(2)
|
||||||
|
if (big > BigInt(MAX_FRAME)) return this.destroy(1009, 'frame too large')
|
||||||
|
len = Number(big); off = 10
|
||||||
|
}
|
||||||
|
if (len > MAX_FRAME) return this.destroy(1009, 'frame too large')
|
||||||
|
const maskOff = off
|
||||||
|
if (masked) off += 4
|
||||||
|
if (b.length < off + len) return
|
||||||
|
let payload = b.subarray(off, off + len)
|
||||||
|
if (masked) {
|
||||||
|
const mask = b.subarray(maskOff, maskOff + 4)
|
||||||
|
payload = Buffer.from(payload)
|
||||||
|
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]
|
||||||
|
}
|
||||||
|
this.buf = b.subarray(off + len)
|
||||||
|
this._frame(fin, op, payload)
|
||||||
|
if (!this.open) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_frame(fin, op, payload) {
|
||||||
|
if (op === 0x8) {
|
||||||
|
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1005
|
||||||
|
try { this._send(0x8, payload.subarray(0, 2)) } catch {}
|
||||||
|
this.socket.end()
|
||||||
|
return this._closed(code, payload.subarray(2).toString('utf8'))
|
||||||
|
}
|
||||||
|
if (op === 0x9) return this._send(0xa, payload)
|
||||||
|
if (op === 0xa) return this.emit('pong', payload)
|
||||||
|
if (op === 0x0) {
|
||||||
|
if (!this.frag) return this.destroy(1002, 'unexpected continuation')
|
||||||
|
this.frag.parts.push(payload)
|
||||||
|
if (!fin) return
|
||||||
|
const data = Buffer.concat(this.frag.parts)
|
||||||
|
const binary = this.frag.op === 0x2
|
||||||
|
this.frag = null
|
||||||
|
return this.emit('message', binary ? data : data.toString('utf8'), binary)
|
||||||
|
}
|
||||||
|
if (op === 0x1 || op === 0x2) {
|
||||||
|
if (!fin) { this.frag = { op, parts: [payload] }; return }
|
||||||
|
return this.emit('message', op === 0x2 ? Buffer.from(payload) : payload.toString('utf8'), op === 0x2)
|
||||||
|
}
|
||||||
|
this.destroy(1002, 'bad opcode')
|
||||||
|
}
|
||||||
|
|
||||||
|
_send(op, data) {
|
||||||
|
if (!this.socket.writable) return false
|
||||||
|
const payload = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||||
|
const len = payload.length
|
||||||
|
let header
|
||||||
|
if (len < 126) { header = Buffer.alloc(2); header[1] = len }
|
||||||
|
else if (len < 65536) { header = Buffer.alloc(4); header[1] = 126; header.writeUInt16BE(len, 2) }
|
||||||
|
else { header = Buffer.alloc(10); header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2) }
|
||||||
|
header[0] = 0x80 | op
|
||||||
|
if (this.client) {
|
||||||
|
header[1] |= 0x80
|
||||||
|
const mask = crypto.randomBytes(4)
|
||||||
|
const body = Buffer.from(payload)
|
||||||
|
for (let i = 0; i < body.length; i++) body[i] ^= mask[i & 3]
|
||||||
|
return this.socket.write(Buffer.concat([header, mask, body]))
|
||||||
|
}
|
||||||
|
return this.socket.write(Buffer.concat([header, payload]))
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data) {
|
||||||
|
if (!this.open) return false
|
||||||
|
return typeof data === 'string' ? this._send(0x1, Buffer.from(data, 'utf8')) : this._send(0x2, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
ping(data = Buffer.alloc(0)) { if (this.open) this._send(0x9, data) }
|
||||||
|
|
||||||
|
close(code = 1000, reason = '') {
|
||||||
|
if (!this.open) return
|
||||||
|
const p = Buffer.alloc(2 + Buffer.byteLength(reason))
|
||||||
|
p.writeUInt16BE(code, 0); p.write(reason, 2)
|
||||||
|
try { this._send(0x8, p) } catch {}
|
||||||
|
this.socket.end()
|
||||||
|
setTimeout(() => this.socket.destroy(), 2000).unref()
|
||||||
|
this._closed(code, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(code = 1006, reason = '') {
|
||||||
|
this._closed(code, reason)
|
||||||
|
this.socket.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete a server-side upgrade on (req, socket, head). Returns a WsConn or null. */
|
||||||
|
export function acceptUpgrade(req, socket, head, extraHeaders = {}) {
|
||||||
|
const key = req.headers['sec-websocket-key']
|
||||||
|
if (!key || String(req.headers.upgrade).toLowerCase() !== 'websocket') {
|
||||||
|
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const lines = [
|
||||||
|
'HTTP/1.1 101 Switching Protocols',
|
||||||
|
'Upgrade: websocket',
|
||||||
|
'Connection: Upgrade',
|
||||||
|
`Sec-WebSocket-Accept: ${acceptKey(key)}`,
|
||||||
|
...Object.entries(extraHeaders).map(([k, v]) => `${k}: ${v}`),
|
||||||
|
]
|
||||||
|
socket.write(lines.join('\r\n') + '\r\n\r\n')
|
||||||
|
return new WsConn(socket, { head })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectUpgrade(socket, status, message = '') {
|
||||||
|
const text = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', 502: 'Bad Gateway' }[status] || 'Error'
|
||||||
|
try {
|
||||||
|
socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`)
|
||||||
|
} catch { socket.destroy() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client connect: resolves a WsConn, rejects with {status, body} on refusal. */
|
||||||
|
export function connect(url, { headers = {}, timeoutMs = 15000, rejectUnauthorized = true } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const u = new URL(url)
|
||||||
|
const secure = u.protocol === 'wss:' || u.protocol === 'https:'
|
||||||
|
const key = crypto.randomBytes(16).toString('base64')
|
||||||
|
const req = (secure ? https : http).request({
|
||||||
|
host: u.hostname,
|
||||||
|
port: u.port || (secure ? 443 : 80),
|
||||||
|
path: u.pathname + u.search,
|
||||||
|
method: 'GET',
|
||||||
|
servername: u.hostname,
|
||||||
|
rejectUnauthorized,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
Host: u.host,
|
||||||
|
Connection: 'Upgrade',
|
||||||
|
Upgrade: 'websocket',
|
||||||
|
'Sec-WebSocket-Version': '13',
|
||||||
|
'Sec-WebSocket-Key': key,
|
||||||
|
},
|
||||||
|
timeout: timeoutMs,
|
||||||
|
})
|
||||||
|
req.on('timeout', () => { req.destroy(new Error(`connect timeout after ${timeoutMs} ms`)) })
|
||||||
|
req.on('error', reject)
|
||||||
|
req.on('response', (res) => {
|
||||||
|
let body = ''
|
||||||
|
res.setEncoding('utf8')
|
||||||
|
res.on('data', (d) => { if (body.length < 4096) body += d })
|
||||||
|
res.on('end', () => {
|
||||||
|
const err = new Error(`upgrade refused: HTTP ${res.statusCode} ${body.trim()}`)
|
||||||
|
err.status = res.statusCode
|
||||||
|
err.body = body
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
req.on('upgrade', (res, socket, head) => {
|
||||||
|
if (res.headers['sec-websocket-accept'] !== acceptKey(key)) {
|
||||||
|
socket.destroy()
|
||||||
|
return reject(new Error('bad Sec-WebSocket-Accept'))
|
||||||
|
}
|
||||||
|
socket.setTimeout(0)
|
||||||
|
resolve(new WsConn(socket, { client: true, head }))
|
||||||
|
})
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@local/dsh-remote",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Claude-Desktop-style remote control for DSH: master/slave link, every device's sessions on every device (/r/<device>/), plugin + config sync with opt-in secrets.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.js",
|
||||||
|
"./client": "./client.js",
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": ["index.js", "client.js", "lib/*.mjs", "cordis.patch.yml", "README.md", "deploy/*"],
|
||||||
|
"scripts": { "test": "node --test test/*.test.mjs" },
|
||||||
|
"dsh": {
|
||||||
|
"bundle": { "patch": "./cordis.patch.yml" },
|
||||||
|
"client": {
|
||||||
|
"platform": "web",
|
||||||
|
"immediately": true,
|
||||||
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-ui-layout",
|
||||||
|
"@deepseek-ai/dsh-client-ui-sidebar",
|
||||||
|
"@deepseek-ai/dsh-client-ui-workspace"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Offline tests for the link stack: WebSocket framing, mux calls/streams, HTTP + WS
|
||||||
|
// tunnelling through the proxy helpers, cookie minting, sync allow/deny.
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import http from 'node:http'
|
||||||
|
import { acceptUpgrade, connect, rejectUpgrade } from '../lib/ws.mjs'
|
||||||
|
import { Mux } from '../lib/mux.mjs'
|
||||||
|
import { ownHttp, ownUpgrade, viewHttp, viewUpgrade, stripDshCookies } from '../lib/proxy.mjs'
|
||||||
|
import { cookieName, mintCookie } from '../lib/auth-cookie.mjs'
|
||||||
|
import { allowed } from '../lib/sync.mjs'
|
||||||
|
|
||||||
|
const listen = (srv) => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port)))
|
||||||
|
|
||||||
|
async function linkPair({ handlersA = {}, handlersB = {}, onStreamA, onStreamB } = {}) {
|
||||||
|
const srv = http.createServer()
|
||||||
|
let serverMux
|
||||||
|
const ready = new Promise((resolve) => {
|
||||||
|
srv.on('upgrade', (req, socket, head) => {
|
||||||
|
if (req.headers['x-key'] !== 'k') return rejectUpgrade(socket, 401, 'nope')
|
||||||
|
const ws = acceptUpgrade(req, socket, head)
|
||||||
|
serverMux = new Mux(ws, { odd: false, handlers: handlersA, onStream: onStreamA })
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const port = await listen(srv)
|
||||||
|
const ws = await connect(`ws://127.0.0.1:${port}/link`, { headers: { 'x-key': 'k' } })
|
||||||
|
const clientMux = new Mux(ws, { odd: true, handlers: handlersB, onStream: onStreamB })
|
||||||
|
await ready
|
||||||
|
return { srv, port, serverMux, clientMux, close: () => { clientMux.close(); srv.close() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ws: refused upgrade surfaces HTTP status', async () => {
|
||||||
|
const srv = http.createServer()
|
||||||
|
srv.on('upgrade', (req, socket) => rejectUpgrade(socket, 401, 'bad key'))
|
||||||
|
const port = await listen(srv)
|
||||||
|
await assert.rejects(connect(`ws://127.0.0.1:${port}/`), (e) => e.status === 401 && /bad key/.test(e.message))
|
||||||
|
srv.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mux: calls both ways, large binary stream, errors', async () => {
|
||||||
|
const big = crypto.randomBytes(3 * 1024 * 1024 + 17)
|
||||||
|
const p = await linkPair({
|
||||||
|
handlersA: { echo: async (x) => ({ back: x.v }), boom: async () => { throw Object.assign(new Error('kaboom'), { status: 418 }) } },
|
||||||
|
handlersB: { whoami: async () => 'slave' },
|
||||||
|
onStreamA: (s) => {
|
||||||
|
const parts = []
|
||||||
|
s.on('data', (d) => parts.push(d))
|
||||||
|
s.on('end', () => { const buf = Buffer.concat(parts); s.head({ size: buf.length }); s.write(buf); s.end() })
|
||||||
|
s.start()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.deepEqual(await p.clientMux.call('echo', { v: 42 }), { back: 42 })
|
||||||
|
assert.equal(await p.serverMux.call('whoami'), 'slave')
|
||||||
|
await assert.rejects(p.clientMux.call('boom'), (e) => e.status === 418 && e.message === 'kaboom')
|
||||||
|
await assert.rejects(p.clientMux.call('missing'), /unknown method/)
|
||||||
|
|
||||||
|
const s = p.clientMux.open('echo', {})
|
||||||
|
const got = await new Promise((resolve, reject) => {
|
||||||
|
const parts = []
|
||||||
|
s.on('data', (d) => parts.push(d))
|
||||||
|
s.on('end', () => resolve(Buffer.concat(parts)))
|
||||||
|
s.on('error', reject)
|
||||||
|
s.write(big)
|
||||||
|
s.end()
|
||||||
|
})
|
||||||
|
assert.equal(got.length, big.length)
|
||||||
|
assert.ok(got.equals(big))
|
||||||
|
p.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('proxy: HTTP + WebSocket through the link with cookie + host rewrite', async () => {
|
||||||
|
// "Remote DSH": checks host + cookie like dsh-client-connection would.
|
||||||
|
const secret = crypto.randomBytes(32).toString('base64url')
|
||||||
|
const upstream = http.createServer((req, res) => {
|
||||||
|
const authority = req.headers.host
|
||||||
|
const ok = (req.headers.cookie || '').includes(cookieName(authority) + '=v1.')
|
||||||
|
if (!ok) { res.writeHead(401); return res.end('auth') }
|
||||||
|
const body = []
|
||||||
|
req.on('data', (d) => body.push(d))
|
||||||
|
req.on('end', () => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/plain', 'x-path': req.url, 'set-cookie': 'leak=1' })
|
||||||
|
res.end(`${req.method} ${req.url} ${Buffer.concat(body).toString()} origin=${req.headers.origin || '-'} viewerCookie=${/dsh-auth-viewer/.test(req.headers.cookie) ? 'leaked' : 'stripped'}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
upstream.on('upgrade', (req, socket, head) => {
|
||||||
|
const ok = (req.headers.cookie || '').includes(cookieName(req.headers.host) + '=v1.')
|
||||||
|
if (!ok) return rejectUpgrade(socket, 401, 'auth')
|
||||||
|
const ws = acceptUpgrade(req, socket, head)
|
||||||
|
ws.on('error', () => {})
|
||||||
|
ws.on('message', (m) => ws.send(`echo:${m}`))
|
||||||
|
})
|
||||||
|
const upPort = await listen(upstream)
|
||||||
|
const target = { host: '127.0.0.1', port: upPort }
|
||||||
|
const cookie = async (authority) => mintCookie(authority, secret)
|
||||||
|
|
||||||
|
const p = await linkPair({
|
||||||
|
onStreamA: (s) => (s.kind === 'http' ? ownHttp(s, { target, cookie }) : ownUpgrade(s, { target, cookie })),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Viewer webserver that mounts the remote at /r/x/.
|
||||||
|
const viewer = http.createServer((req, res) => {
|
||||||
|
viewHttp(req, res, (kind, meta) => p.clientMux.open(kind, meta), { rest: req.url.replace(/^\/r\/x/, ''), basePath: '/r/x' })
|
||||||
|
})
|
||||||
|
viewer.on('upgrade', (req, socket, head) => {
|
||||||
|
viewUpgrade(req, socket, head, (kind, meta) => p.clientMux.open(kind, meta), { rest: req.url.replace(/^\/r\/x/, '') })
|
||||||
|
})
|
||||||
|
const vPort = await listen(viewer)
|
||||||
|
|
||||||
|
const r = await fetch(`http://127.0.0.1:${vPort}/r/x/api/thing?q=1`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'hello',
|
||||||
|
headers: { cookie: 'dsh-auth-viewer=abc; other=1', origin: `http://127.0.0.1:${vPort}` },
|
||||||
|
})
|
||||||
|
const text = await r.text()
|
||||||
|
assert.equal(r.status, 200)
|
||||||
|
assert.equal(r.headers.get('set-cookie'), null, 'remote set-cookie must not reach the browser')
|
||||||
|
assert.match(text, /^POST \/api\/thing\?q=1 hello/)
|
||||||
|
assert.match(text, new RegExp(`origin=http://127.0.0.1:${upPort}`))
|
||||||
|
assert.match(text, /viewerCookie=stripped/)
|
||||||
|
|
||||||
|
const ws = await connect(`ws://127.0.0.1:${vPort}/r/x/api/remote.mux`)
|
||||||
|
const reply = await new Promise((resolve) => { ws.on('message', (m) => resolve(m)); ws.send('ping-1') })
|
||||||
|
assert.equal(reply, 'echo:ping-1')
|
||||||
|
ws.close()
|
||||||
|
|
||||||
|
p.close(); viewer.close(); upstream.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cookie + allow/deny helpers', () => {
|
||||||
|
const secret = crypto.randomBytes(32).toString('base64url')
|
||||||
|
const c = mintCookie('127.0.0.1:3081', secret)
|
||||||
|
assert.match(c, /^dsh-auth-[A-Za-z0-9_-]+=v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/)
|
||||||
|
assert.equal(mintCookie('a', 'short'), null)
|
||||||
|
assert.equal(stripDshCookies('dsh-auth-x=1; a=2; dsh_auth_session=3'), 'a=2')
|
||||||
|
assert.equal(allowed('@local/foo', {}), true)
|
||||||
|
assert.equal(allowed('@local/foo', { deny: ['@local/*'] }), false)
|
||||||
|
assert.equal(allowed('@local/foo', { allow: ['@other/*'] }), false)
|
||||||
|
assert.equal(allowed('@local/foo', { allow: ['@local/foo'], deny: [] }), true)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user