dsh-remote-hub
Manifest validdsh-remote-hub
English | 中文
Deployment guide (Chinese): docs/DEPLOY.zh.md Robustness notes (Chinese): docs/ROBUSTNESS.zh.md
Hub-side DSH host plugin. One dsh web (the hub) serves the browser at its own origin and
reverse-proxies every registered remote backend under /remotes/<id>/. The browser therefore
only ever talks to the hub origin — which is what DSH's same-origin fences require
(Host/Origin equality, SameSite=Strict cookies, no CORS).
Transport is deliberately out of scope: a remote is just an HTTP base URL the hub can reach.
That URL can come from a manual ssh -L, a later auto-managed tunnel, or a future agent
channel — the proxy code does not change.
Status
P0, validated end-to-end against a real remote over ssh -L:
| Check | Result |
|---|---|
/remotes/ without the hub cookie | 401 |
/remotes/ fallback entry page | 200 |
/remotes/<id>/ remote index | 200, own <base href="https://github.com/yhuan416/dsh-remote-hub/blob/HEAD/"> |
/remotes/<id>/assets/* | 200 |
/remotes/<id>/plugins/** | 200 |
POST /remotes/<id>/api/<ns>/<method> | 200 (real gateway envelope) |
GET /remotes/<id>/plugins/events (SSE) | 200 |
Upgrade /remotes/<id>/api/remote.mux | 101 |
cross-origin Origin | 403 |
| unknown remote | 404 |
| two remotes at once | both 200/101, independent boot graphs |
Management UI
Remote management lives in the unified Plugins page: Settings → Plugins → dsh-remote-hub (the plugin's detail page). The plugin contributes two pieces there:
- a generated configuration form (from its exported
Config):upstreamTimeoutMs,probeIntervalMs,switcher.mode,switcher.dropdown; - a 远端链路 section (
plugins.detail.section, registered by the hand-written client bundlelib/client.js): table of remotes with state dot / label / transport / token source / latency / last error, per-row 打开 / 测试 / 删除, an add form (label, ssh host, user, remote port), and a 远端工作区 list: every backend's workspaces and sessions aggregated by the Hub (workspace store over ssh +session/listRPC; cwd grouping when the store is unreadable). Clicking a session opens that backend in the top-bar pane and sends{type:'dsh-hub-open-session', sessionId}to the framed page, whose client half callsuiWorkspace.openSession()(retried until acked; falls back to the backend's home page).
/remotes/ is now only a fallback entry page (plain links, no admin console). /api/remote-hosts/*
remains the API behind the Plugins section.
Store
Runtime configuration lives in $DSH_HOME/remote-hosts.json (0600, atomic write):
{
"version": 1,
"removed": ["old-id"], // tombstones: a seed in cordis.patch.yml stays deleted
"hosts": [ { "id": "...", "label": "...", "ssh": {...}, "tokenFile": {...} } ]
}
- First start: the
remotesarray fromcordis.patch.ymlis imported into the store. - Later starts: the store is authoritative; new seed ids are imported unless tombstoned.
- Deleting in the UI adds a tombstone, so the seed does not come back.
Admin API (owner-only, same-origin cookie)
| Route | Method | Body | Result |
|---|---|---|---|
| /api/remote-hosts/list | GET | — | sanitized remotes + state/health/latency/tunnel/token/stats |
| /api/remote-hosts/save | POST | label, transport, ssh{host,user,port}, remotePort, localPort, upstream, tokenMode, token, tokenPath | { ok, id } |
| /api/remote-hosts/remove | POST | { id } | { ok } |
| /api/remote-hosts/probe | POST | { id } | { ok, result: { status, latencyMs, tokenResolved, hasCookie } } |
| /api/remote-hosts/workspaces | GET | — | per remote: workspaces[{id,title,path,sessions[]}] + orphans; ssh reads the backend workspace store, direct groups by cwd |
These routes are registered through ctx.connection.fetch.register, so the framework enforces
the owner Peer and the browser trust fence for them.
Entry link on the default UI
The plugin injects one floating pill into the hub's own index page via the official
webserver/index-inject hook (kind: "html", body placement):
-
default mode
bar: a persistent top bar lists 本机 + every remote. Picking a remote shows that backend in an iframe below the bar (mounted once, kept alive); 本机 hides the pane again and reveals the same local page — never a reload. The bar reserves 34px throughbody{padding-top}so the app layout does not shift. -
colors come from the app's own theme tokens (
--dsw-alias-bg-base,--dsw-alias-label-primary,--dsw-alias-border-l2,--dsw-alias-interactive-bg-*), so the bar follows light/dark mode. -
every remote tab carries a status dot polled from
/api/remote-hosts/listevery 5s: green online, amber pending/starting, red offline, grey unknown; hovering shows state / tunnel state / last error. -
the sidebar dropdown below the "new session" control is off by default now that the bar is always visible; set
switcher.dropdown: trueto re-enable it (it uses the same theme tokens). -
config:
config: switcher: mode: sidebar # sidebar (default) | floating corner: bottom-right # floating mode only offsetX: 14 offsetY: 14 -
content: a dropdown listing 本机 + every remote (current one highlighted) and a "管理远端…" link;
-
picking a remote opens a fullscreen overlay with that backend in an iframe, so switching never reloads the page you are on; iframes stay mounted once opened and switching between backends is instant. 本机 / 关闭 / Escape hide the overlay; a remote page rendered inside the overlay posts a message to the parent instead of navigating the top window;
-
it only ever appears on the hub's authenticated index response — the 401 page and proxied remote pages never contain it, and no token is embedded in the link (the hub cookie already authorizes it);
-
the launcher page carries a "← 本机" link back to the local UI.
The top bar is plain HTML injected by the host plugin. Remote management uses a hand-written
client bundle (lib/client.js, declared through dsh.client) that only registers a Plugins-page
section — no build step for either half.
Entry points
/remotes/?token=<hub launch token>— one-shot entry: the plugin relays the token to the hub's own/?token=exchange, sets the hub cookie, then 303s to the clean/remotes/. Use the same hostname you used in the token URL (127.0.0.1 vs localhost are different cookie hosts)./remotes/— fallback entry page (plain links); management moved to the Plugins page./remotes/<id>/— a remote's own SPA, proxied./remotes/_status— JSON diagnostics: per remotehasToken / hasCookie / lastError.
How it works
- The hub authenticates the browser exactly like any dsh page (its own token/cookie).
- For each remote the plugin keeps a server-side cookie: on first use it performs the
remote's
GET /?token=<token>exchange itself and caches the pair. The browser never sees the remote token or the remote cookie. - Requests are forwarded with:
- path prefix stripped (
/remotes/n100/api/x->/api/x), HostandOriginrewritten to the upstream authority (127.0.0.1:3182), which the remote's Host/Origin fence accepts as loopback,- the cached remote cookie.
- path prefix stripped (
- Because every remote has its own local port, the rewritten authority is unique per remote, so cookies minted upstream never collide — no cookie renaming is needed.
- WebSocket upgrades are registered per remote (exact route) and piped socket-to-socket.
Config
- insert:
- id: remote-hub
name: dsh-remote-hub
config:
remotes:
- id: n100 # [A-Za-z0-9_-]+, unique, stable
label: yhuan@192.168.4.50
upstream: http://127.0.0.1:3182 # whatever the hub can reach
token: <remote dsh launch token> # option A: inline (rotates on every remote restart)
- id: n100
upstream: http://127.0.0.1:3182
tokenFile: # option B: read the token automatically
kind: ssh # ssh | file
host: 192.168.4.50
user: yhuan
path: ~/.dsh/remote-agent.json # written by dsh-remote-agent on the backend
- id: local2
upstream: http://127.0.0.1:3183
tokenFile: /home/yhuan/.dsh/remote-agent.json # option B, same host
The token is a credential: keep the profile file at 0600 and never log it.
Tuning keys live in the same config block:
config:
upstreamTimeoutMs: 30000 # response-header timeout; expired requests answer 504
probeIntervalMs: 30000 # periodic health probe (GET /); 0 disables it
Transport
Two forms, per remote:
# A. the hub can already reach it (manual ssh -L, LAN, wg, ...)
- id: local2
upstream: http://127.0.0.1:3183
# B. the plugin manages the tunnel itself
- id: n100
ssh: { host: 192.168.4.50, user: yhuan } # optional: port, identityFile, proxyJump
remotePort: 3080 # remote dsh port, default 3080
# localPort: 32000 # optional; default = 32000 + index (stable),
# falls back to a free port when taken
Managed tunnel behaviour:
- spawns
ssh -N -L 127.0.0.1:<local>:127.0.0.1:<remotePort>withBatchMode=yes ConnectTimeout=8 ExitOnForwardFailure=yes ServerAliveInterval=15 StrictHostKeyChecking=accept-new; - waits up to 8s for the local port to accept, then reports
tunnel.state=up; - on exit reconnects with exponential backoff (1s -> 30s); after 3 consecutive failures it
reallocates the local port (unless
localPortwas configured explicitly); - kills the child on plugin unload, so no orphan
sshand no leaked listening port; - when the tunnel is down,
/remotes/<id>/...answers 502 with the last ssh error.
Manual transport (first step)
# on the remote
dsh web --no-open --port 3080 # prints http://127.0.0.1:3080/?token=...
# on the hub
ssh -N -L 3182:127.0.0.1:3080 yhuan@192.168.4.50
# then put the remote token into config.remotes[].token
Install into a profile
git clone https://github.com/yhuan416/dsh-remote-hub ~/.dsh/plugins/dsh-remote-hub
cd ~/.dsh/plugins/dsh-remote-hub
./scripts/install.sh web
The script symlinks the package, records it as a profile dependency and selects it as a
bundle (the Plugins page only lists packages that are dependencies; a bare patch row stays
invisible there), drops the legacy manual patch row when present, and backs up both files first.
Restart dsh web afterwards: the bundle selection is composed at startup.
Management lives in Settings → Plugins → Installed plugins → dsh-remote-hub.
Roadmap
- ✅ Reverse proxy (HTTP / WebSocket / SSE), auth gate, cookie isolation
- ✅ Admin console + persistent store + owner-only API (list / save / remove / probe)
- ✅ Per-remote
ssh -Lsupervisor (port allocation, backoff, cleanup) - ✅ Agent plugin publishing
~/.dsh/remote-agent.json; the hub reads the token over ssh - ✅ Sidebar switcher on the default UI
- ⬜ Transport abstraction:
ssh-reverse/ WebSocket dial when the hub is reachable - ✅ Client half: Plugins-page section (远端链路) + generated config form
- ⬜ Unified workspace/session list across backends
Comments
Loading…