Remote host tunnel manager: runs dsh web on a remote Linux server (systemd supervision, no root needed) behind an auto-reconnecting SSH tunnel; allocates and registers remote ports on the server, multi-user safe, with an audit view.
Install
# from npm (prebuilt)
dsh plugin --profile web add dsh-remote-tunnel
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:Linjiangxian0203/dsh-remote-tunnel
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time. Only install sources you trust, and pin a commit (github:owner/repo#sha).
README
中文 | English
Remote Host Tunnel Manager: automates the "local browser → dsh web on a remote Linux server" link — remote port allocation with a registry, systemd supervision, resilient SSH tunneling, local URL output, and full lifecycle management. Built for single users and for teams sharing one server.
- Sessions and files live on the server (the remote dsh web's workspace = server directories); the local machine only keeps a tunnel
- Each user automatically gets a dedicated remote port, double-checked on the server (real occupancy + registry) before allocation — safe under concurrency
- Every allocation is recorded in a registry on the server (
/etc/dsh-ports.tsv, or a per-user fallback depending on permissions) —auditcompares the registry against real occupancy at any time - The tunnel auto-reconnects after network drops (backoff respawn), and heartbeats keep the registry fresh
- Occupied local ports shift automatically, with the occupying process reported
If you're just a user (not developing)
# 1. Install (published npm package)
dsh plugin --profile remote add dsh-remote-tunnel
# 2. Confirm your server is visible (Host aliases from ~/.ssh/config are auto-discovered)
dsh --profile remote hosts
# not there? define one:
dsh --profile remote hosts add lab --host 192.0.2.10 --user alice --workspace /home/alice/project
# 3. First run: a health check tells you step by step what's missing
# (keys / Node / dsh / registry / systemd)
dsh --profile remote check lab
# 4. Bring the tunnel up; the browser opens the remote dsh web
dsh --profile remote up lab --open
# Everyday: status / down / logs / audit
dsh --profile remote down lab
The remote server needs: Node ≥ 22.19, dsh, systemd, and key-based ssh login. One command initializes it:
ssh <host> 'sh -s' < scripts/bootstrap-remote.sh. Everything else lives in $DSH_HOME/remote-tunnel/config.yaml — sensible defaults, no changes needed.
Requirements
- Local: Windows/macOS/Linux with the built-in OpenSSH client (Windows 10+ ships it), Node ≥ 22.19
- Remote: Linux, Node ≥ 22.19 + dsh (installable via
scripts/bootstrap-remote.sh), systemd (user-level is enough — no root needed) - Recommended: passwordless ssh key login (
ssh <alias>connects without prompts)
Install (development)
# 1. Install into a dedicated CLI profile (initializes the profile on first use)
cd <plugin checkout> # or the npm package name: dsh-remote-tunnel
dsh plugin --profile remote add .
# 2. Optional: install into the web profile to get the /remote slash commands
dsh plugin --profile web add .
Quick start
# Hosts from ~/.ssh/config are auto-discovered
dsh --profile remote hosts
# Or define one manually (when there is no ~/.ssh/config entry)
dsh --profile remote hosts add lab --host 192.0.2.10 --user alice --workspace /home/alice/project
# Readiness diagnostics: keys / Node / dsh / registry / systemd, item by item
dsh --profile remote check lab
# One command: allocate remote port → register → write systemd unit → start
# remote dsh web → open the local tunnel
dsh --profile remote up lab --open
# Example output:
# allocated remote port 3081 (range 3080-3119, registered for alice)
# ✓ tunnel up — http://127.0.0.1:3083 (remote lab:3081)
# stop: dsh --profile remote down lab (or Ctrl+C)
# Inspect / stop / review
dsh --profile remote status lab
dsh --profile remote logs lab # remote dsh web logs (journalctl)
dsh --profile remote audit lab # registry vs. real occupancy
dsh --profile remote down lab # stop tunnel + registry released + stop unit + verify port freed
The local URL opens the dsh web on the server: chat and read/write server files. Configure the API key in the remote web's Settings → Models (written to the server's ~/.dsh/.credentials.yaml — this plugin and the tunnel never touch credentials).
Commands
hosts / hosts add <alias> --host H [--port 22] [--user U] [--workspace DIR] / hosts rm <alias>
check <host> readiness diagnostics (usable as a CI probe: nonzero exit = broken)
provision <host> [--port N] remote side only: allocate + systemd unit + start + register (no tunnel)
up <host> [--port N] [--local-port N] [--open] [--heartbeat seconds]
down [host] [--keep-service] stop tunnel + released + stop unit + verify port freed
status [host] [--json]
list
logs <host> [--lines N] [--follow] [--local]
audit <host> [--json] [--release <port>] [--clean-stale]
open [host]
config show / config path
How it works
- Remote port allocation (atomic): one remote script runs under a
flocklock — read the registry's in-use set + probe every port in the range with a real bind → pick the first port free on both sides → append a TSV row → echo the port. Concurrent allocators can never hand out the same port. - Remote supervision: writes a systemd unit and
enable --nows it. With passwordless sudo it uses a system unit (/etc/systemd/system/dsh-web-<user>.service); without sudo it automatically falls back to a user unit (~/.config/systemd/user/dsh-web.service) plusloginctl enable-linger— no root required at all. Survives reboots and crashes. - TOCTOU fallback: if dsh loses a bind race at startup (
EADDRINUSEshows up in the unit journal), the port is added to the exclusion set and the next free port is retried (up to 5 rounds by default). - Local tunnel:
ssh -N -L 127.0.0.1:<local>:127.0.0.1:<remote> <alias>; the local port is checked first (shifts automatically when occupied, withnetstat+tasklistnaming the occupier). When the ssh process exits, it respawns with a backoff sequence (1s→2s→4s→8s→15s→30s cap), forever by default (maxAttemptsconfigurable). The tunnel deliberately does not passClearAllForwardings(Windows OpenSSH would clear the command-line-Lalong with it); exec sessions still clear config forwards. - Heartbeat: while the tunnel lives, the registry's
last_heartbeatis refreshed in-place under the lock everyheartbeatSeconds(default 120). - Release:
down(or Ctrl+C onup) runs in order: stop tunnel → remove local state → registryreleased→ stop the remote unit → verify the port is really free. Anupsupervisor in another process notices the removed state file and stops reconnecting — no resurrection. Closing the terminal hard (without Ctrl+C) leaves the remote service running and the registry rowin-use— which is accurate, not a leak: the nextupcleans the stale local state and reuses the same registered port (no accumulation).
Configuration
$DSH_HOME/remote-tunnel/config.yaml (dsh --profile remote config path prints the path):
hosts:
lab: # manually defined hosts (merged with ~/.ssh/config aliases; wins on name collision)
host: 192.0.2.10
port: 22
user: alice
workspace: /home/alice/project
remotePortRange: [3080, 3119] # optional per-host override
defaults:
remotePortRange: [3080, 3119] # remote dsh port range (occupancy-checked before allocation)
localPortRange: [3081, 3140] # local tunnel port range
registry:
path: /etc/dsh-ports.tsv
lockPath: /etc/dsh-ports.lock
sudo: auto # auto | always | never
fallbackPath: .dsh-ports.tsv # used when the shared registry is not writable (relative = remote home)
unit:
prefix: dsh-web-
restartSec: 5
type: auto # auto | system | user
heartbeatSeconds: 120 # 0 = disable heartbeats
remoteWaitSeconds: 60 # wait for the remote port to listen
localWaitSeconds: 15 # wait for the local URL to respond
reconnect:
delaysMs: [1000, 2000, 4000, 8000, 15000, 30000]
maxAttempts: 0 # 0 = never give up
allocateRetries: 5
ssh:
connectTimeout: 0 # 0 = do not pass -o ConnectTimeout (see Troubleshooting)
extraArgs: []
Sharing one server (multi-user)
| Server setup | Registry | Supervision |
|---|---|---|
| Members have passwordless sudo | /etc/dsh-ports.tsv (sudo writes) |
system unit, one port per user |
| No sudo, admin created a dshports group | /etc/dsh-ports.tsv (group 0664, no sudo) |
user unit + linger |
| Nothing configured (default) | falls back to ~/.dsh-ports.tsv (own rows only; check points at the admin setup) |
user unit + linger |
One-time admin setup for the shared registry (either):
# A. every member has passwordless sudo
sudo install -m 0644 -o root -g root /dev/null /etc/dsh-ports.tsv
# B. members have no sudo: shared group writes
sudo groupadd dshports && sudo usermod -aG dshports alice bob ...
sudo install -m 0664 -o root -g dshports /dev/null /etc/dsh-ports.tsv
# each member's plugin config: registry.sudo: never
Each user runs up independently and gets a different remote port; audit shows who holds which port and flags stale/conflicting rows.
Remote bootstrap (optional)
scripts/bootstrap-remote.sh installs Node (when missing) / dsh (into ~/.npm-global) / creates ~/.dsh / enables linger:
ssh <host> 'sh -s' < scripts/bootstrap-remote.sh
Troubleshooting
| Symptom | Cause and fix |
|---|---|
Error: listen EADDRINUSE ... 127.0.0.1:3080 |
Someone (or your previous instance) holds the port. This plugin double-checks before allocation and retries the next port automatically on a startup race; you only see this when starting dsh by hand. |
Could not resolve hostname <alias> |
The alias is neither in ~/.ssh/config nor in the plugin config. hosts add or add it to ssh config. |
Connection refused / remote port forwarding failed |
The remote dsh web is down or on the wrong port. check <host> → "web port listening"; logs <host> for the remote journal; ss -tln | grep <port> on the server. |
channel_setup_fwd_listener_tcpip: cannot listen to port |
The local port is taken (common: two dsh web instances). The plugin shifts automatically and names the occupying process; or pass --local-port. |
Permission denied (publickey) / sudo: a password is required |
Keys not set up / no NOPASSWD sudo. ssh-copy-id for the former; the latter is optional — the user-unit + fallback-registry path works without sudo. |
Could not create directory '/home/xxx/.ssh' + host key prompt |
First connection needs the host key accepted; the plugin passes accept-new (TOFU) by default. |
| Tunnel does not come back after a network drop | Reconnection is infinite by default; status shows whether the ssh pid is alive and logs <host> --local shows reconnect activity. If reconnect.maxAttempts is set, it stops at the cap. |
Tunnel stays connected but the local URL stays not reachable |
On Windows OpenSSH 8.1, -o ClearAllForwardings=yes also clears the command-line -L, so the tunnel connected without forwarding. Fixed: the tunnel no longer passes that option (exec sessions still do). Upgrade to a build that includes fix: don't clear the tunnel's own -L forward on Windows. |
Registry unreadable (/etc/dsh-ports.tsv missing) |
Created automatically on first allocation (requires write permission); without it the plugin falls back to ~/.dsh-ports.tsv and check prints the admin setup command. |
| Every ssh command is slow (~N seconds each) | On some servers, passing ConnectTimeout to ssh makes every connection wait out the full timeout even when the connect is instant. The default no longer passes it (ssh.connectTimeout: 0); enable it explicitly if you need it. |
Development and testing
pnpm install # plugin dependencies
npm test # unit tests + mock-ssh integration tests (no real server needed)
The integration suite uses a fake ssh that interprets the plugin's remote commands against a temp "server" (with real TCP forwarding for the tunnel), covering: allocate/register/release, concurrent allocation by multiple users, TOCTOU retry, local port conflict shift, auto-reconnect, cross-process down cancellation, audit stale/orphan/clean.
Security notes
- The tunnel and the remote dsh bind
127.0.0.1only (dsh itself rejects--host 0.0.0.0) - The plugin never stores or transmits passwords, keys, or API keys; ssh always uses existing keys (BatchMode — no password prompts, no hangs)
- The registry records no sensitive information (see
docs/registry-format.md) - Remote scripts only append/rewrite the registry and the systemd unit under
flock; no other writes
Non-goals
- No SSH/SFTP/remote-mount implementation: the design is "run dsh on the server"; the tunnel only brings HTTP back locally
- No new TUI: CLI subcommands + the web profile's
/remoteslash commands
Links
More in this category
strukto-ai/mirage#dsh★ 3448
Swaps the filesystem and bash providers for a mirage virtual workspace: file tools and shell commands run over mounted resources (RAM, S3, Redis, Slack, Gmail, Notion, Postgres) instead of the host disk, with per-mount read/write/exec modes, per-command sandbox routing (monty, pyodide, quickjs in process; docker, e2b, daytona remote), and installed CLIs (git, gh, slack, linear, ntn, gws, or one you register) as head words in the virtual terminal.
hust-open-atom-club/oh-dsh★ 196
Community distribution: TUI, desktop, and Web UI as one bundle with layered installation.
Jayden-X-L/forkprobe★ 66
Compare multiple skills on the same task and pick the winner.
vlln/plugin-registry★ 43
Ecosystem infrastructure: a thin browser console for managing official repository plugins (zero patches) plus a make-dsh-plugin skill for guided plugin development.
forrestchang/dsh-multica-runtime★ 37
Run the dsh runtime on Multica.
omdsh-dev/dsh-plugin-check★ 21
Plugin health checks: manifest protocol / patch format / build traps, zero-dependency and read-only.