@uued/dsh-ssh-tunnel
v0.2.0
Published
Standalone SSH-tunnel remote connections for dsh: manage loopback-only `ssh -N -T -L 127.0.0.1:<LP>:127.0.0.1:<RP>` tunnels to remote dsh instances from the Web UI (sidebar capsule + Settings section), test reachability, and open each remote instance in a
Readme
dsh-ssh-tunnel
Host-plane Cordis plugin for dsh: connect to remote dsh instances over
loopback-only SSH tunnels — extracted from @uued/dsh-desktop's ssh
connection mode, minus the native desktop shell. Works in the normal browser
workflow: the sidebar "SSH 隧道" capsule and the Settings section manage
connections; clicking 连接 opens the tunnel and the remote dsh UI in a
new browser tab, so the local instance (and its disconnect control) stays
alive.
Install (official, one command — no compilation, no manual wiring)
The package is an npm bundle (dsh.bundle.patch ships cordis.patch.yml),
so a single dsh plugin call installs the plugin AND activates it:
# from a registry build (published):
dsh plugin --profile web add @uued/dsh-ssh-tunnel
# or from a local source checkout:
dsh plugin --profile web add file:/data/dsh_plugins/dsh-ssh-tunnelWhat happens automatically:
- pnpm installs
@uued/dsh-ssh-tunnel. dsh pluginreconcile seesdsh.bundle.patch→ appends the package to the profile'sdsh.profile.bundles.- At boot, the bundle's
cordis.patch.ymlinserts the plugin row automatically. Do NOT also insert it into the profile's own cordis.patch.yml — that would mount it twice.
Scope placeholder: published names use the
@uuedscope. Swap it for your real npm scope in:package.json(name),cordis.patch.yml(insertname), andlib/client.js(moduleid) before first publish.
How it works
dsh web process (this plugin) your browser
┌────────────────────────────────────┐ fetch ┌─────────────────────────────┐
│ TunnelManager (Map id → ssh child) │◀─────────▶│ sidebar capsule + Settings │
│ allocLoopbackPort() → 127.0.0.1 │ POST │ "连接" → new tab opens │
│ ssh -N -T -L <LP>:<RP> (loopback) │ connect │ http://127.0.0.1:<LP> │
│ probeTunnelReady() → HTTP GET │──────────▶│ (the REMOTE dsh instance) │
│ teardown() on plugin dispose │ └─────────────────────────────┘
└────────────────────────────────────┘- Loopback-only, always: every tunnel is
ssh -N -T -o ExitOnForwardFailure=yes -o ConnectTimeout=10 -L 127.0.0.1:<LP>:127.0.0.1:<RP>— never bound to0.0.0.0. The tunnel is a privileged path into a remote instance and must never be exposed on the LAN. - Multi-tunnel: every connected id keeps its own ssh child until disconnected; connecting more hosts does not kill earlier tunnels (each still has its own browser tab).
- Readiness probe: the plugin HTTP-GETs
http://127.0.0.1:<LP>/(or…/?token=…when a token is set) every 400 ms until the remote dsh answers, or the ssh process exits, ortimeoutMs(default 20000) elapses. - Lifecycle: tunnels are spawned through the harness
subprocessservice (detached, process-grouped tree);teardown()terminates every ssh child on plugin disposal or dsh exit — no orphans. - Key-based auth only:
ssh -N -Twithstdin: "ignore"makes interactive password prompts impossible by design — usekey(a path,~expands) or a running ssh-agent.extraArgscan carry any additional-ooptions. The per-connectiontokenis a dsh Web UI access token (appended to the opened URL + probe), never an ssh credential. - New-tab opening: the client reserves the new tab synchronously in the click handler (so popup blockers allow it), then navigates it once the tunnel is up. If the popup is blocked, the UI shows the tunnel URL as a link instead — nothing is lost.
Troubleshooting "ssh tunnel to did not become ready"
The readiness probe (HTTP GET on the loopback endpoint, timeoutMs max) failed,
so the tunnel was torn down and the connection/test errored. Since 0.1.1 the
error message carries the real cause (ssh stderr tail + exit code), e.g.:
… (ssh exited: code=255)+Permission denied (publickey)→ the private key is not authorized on the remote host, or the connection'skeyfield points at the wrong file. Two classic causes:keypoints at the public key (~/.ssh/id_ed25519.pub) —ssh -ineeds the private key file; since 0.1.2 the plugin rejects a.pubpath with a clear error at save/connect time.- the private key file is world-readable (
Permissions 0644 … too open) —chmod 600 ~/.ssh/id_ed25519.
Host key verification failed→ the remote host is not inknown_hostsand non-interactive ssh cannot confirm its fingerprint: run a plainssh <user>@<host>once and accept the host key (then retry).connect failed: Connection refusedon the far side (exit withExitOnForwardFailure) → the remote dsh is not listening onremotePort(default 3080) — checkss -ltnon the target.Connection timed out/No route to hostafter ~10 s → host unreachable / port 22 filtered from this machine.
Manual reproduction (run on the machine whose dsh web UI you clicked in):
ssh -N -T -o ExitOnForwardFailure=yes -L 127.0.0.1:39998:127.0.0.1:3080 <user>@<host>
then in another terminal curl -v http://127.0.0.1:39998/ — 200 means the
tunnel works and only the probe was too slow; Connection refused means the
remote port is wrong.
Config (dsh-ssh-tunnel: namespace in settings.yaml)
dsh-ssh-tunnel:
timeoutMs: 20000 # ms to wait for a tunnel to answer the probe
connections:
- id: home-server # arbitrary unique id (also an SSH connection to a
host: my.box # REMOTE dsh, e.g. a headless server)
port: 22
user: me
key: ~/.ssh/id_ed25519
remotePort: 3080 # the remote dsh `--port` (the <RP>)
localPort: 0 # <LP> the LOCAL loopback bind port; 0/absent = auto-allocate a free one
# (set a fixed port e.g. 3081 to keep `http://127.0.0.1:3081` stable)
token: "" # the remote dsh WEB UI access token from `dsh web`'s
# `http://127.0.0.1:<port>/?token=…` line; appended as `?token=…`
# to the opened URL and the readiness probe (optional)
# extraArgs: ["-o", "ServerAliveInterval=30"]Browser-plane UI
- Sidebar footer capsule "SSH 隧道" (order 21): shows how many tunnels are up; the popover lists every connection with 连接/断开, per-row status, and a 全部断开 footer. Refreshed on open + every 5 s while open (an ssh child can die on its own).
- Settings section "SSH 隧道连接" (order 90): CRUD for connections
(id/host/user/key/port/remotePort/localPort/token/extraArgs) + 测试/连接/断开 per
row.
localPort(本地映射端口) default0= auto-allocate a free loopback port (set a fixed port to keephttp://127.0.0.1:<LP>stable; an occupied port errors instead of silently moving).token(访问令牌) is the remote dsh web UI access token, appended to the opened URL as?token=…. Saving persists first; it never auto-connects.
The browser half talks to the host ONLY over same-origin HTTP routes (registered once webServer is bound; no CORS — every call is same-origin, and the new tab is served by the remote instance itself):
GET /plugins/dsh-ssh-tunnel/state → { state, connections:[{id,host,user,key,port,remotePort,configuredLocalPort,token,extraArgs,connected,localPort,url}] }
POST /plugins/dsh-ssh-tunnel/connect → { id } → { ok, connected, id, localPort, url } (localPort = actual bind; url carries ?token=… when set)
POST /plugins/dsh-ssh-tunnel/disconnect → { id? } (no id → disconnect all)
POST /plugins/dsh-ssh-tunnel/test → { id } → { ok, url?, error?, durationMs } (probe-only, never leaves a tunnel)
GET|POST /plugins/dsh-ssh-tunnel/config → { connections:[...] } (persisted via the settings namespace)localPort in the state/connect routes is the actual loopback bind port;
configuredLocalPort is the requested value (0 = auto-allocate).
The token is the dsh Web UI access token — it is appended to the URL the
browser opens and to the HTTP readiness probe. It is not an ssh-auth
credential: ssh -N -T stays key-based only, so the token never reaches the
ssh command line.
Testing
Unit tests (pure tunnel machinery + TunnelManager lifecycle, no real ssh):
node --test dsh-ssh-tunnel/test/unit.mjs # 35 checksManual end-to-end with a LOCAL sshd (see test/e2e-patch.yml for the full
recipe): run dsh web with the patch, POST connect for a 127.0.0.1 sshd
connection (passwordless key), verify http://127.0.0.1:<LP>/ serves the UI
through the tunnel, disconnect, then confirm no ssh leftover:
pgrep -af 'ssh .*127.0.0.1:' # → nothingRelationship to @uued/dsh-desktop
The ssh-tunnel machinery in this package started as a copy of dsh-desktop's ssh
connection mode (same sshTunnelSpec/probeTunnelReady semantics, same
loopback-only guarantee). The two packages are independent: dsh-desktop keeps
its own ssh support (inside its native-shell world); this plugin targets the
plain browser workflow. Neither imports from the other.
Wiring (this workspace)
The workspace web profile uses the official bundle model:
.dsh-home/profiles/web/package.json: dependency"@uued/dsh-ssh-tunnel": "link:/home/hp400g5/plugin_dev/dsh-ssh-tunnel"anddsh.profile.bundlesincludes@uued/dsh-ssh-tunnel(a bundle-declaring dep;dsh pluginadds it automatically).node_modules/@uued/dsh-ssh-tunnel→ symlink to this directory (mirrored from the existing@uued/dsh-desktoplink).- No manual insert in the profile's
cordis.patch.yml— the bundle's owncordis.patch.ymlmounts the row.
