Linjiangxian0203 / dsh-remote-tunnel

Listed

Remote Host Tunnel Manager for dsh: remote port allocation + registry + resilient SSH tunnel

mainOther View source

Installation

npx -y @deepseek-ai/dsh plugin --profile web add github:Linjiangxian0203/dsh-remote-tunnel

This installation command is an unverified starting point generated from the GitHub repository address.

README

Maintainer-authored documentation snapshot.

View on GitHub ↗
Commit 3503ea1Synced Aug 18, 2026

dsh-remote-tunnel

中文 | English

Awesome DSH Plugin

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) — audit compares 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

  1. Remote port allocation (atomic): one remote script runs under a flock lock — 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.
  2. 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) plus loginctl enable-linger — no root required at all. Survives reboots and crashes.
  3. TOCTOU fallback: if dsh loses a bind race at startup (EADDRINUSE shows 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).
  4. 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, with netstat+tasklist naming the occupier). When the ssh process exits, it respawns with a backoff sequence (1s→2s→4s→8s→15s→30s cap), forever by default (maxAttempts configurable). The tunnel deliberately does not pass ClearAllForwardings (Windows OpenSSH would clear the command-line -L along with it); exec sessions still clear config forwards.
  5. Heartbeat: while the tunnel lives, the registry's last_heartbeat is refreshed in-place under the lock every heartbeatSeconds (default 120).
  6. Release: down (or Ctrl+C on up) runs in order: stop tunnel → remove local state → registry released → stop the remote unit → verify the port is really free. An up supervisor 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 row in-use — which is accurate, not a leak: the next up cleans 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 setupRegistrySupervision
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

SymptomCause and fix
Error: listen EADDRINUSE ... 127.0.0.1:3080Someone (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 failedThe 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 portThe 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 requiredKeys 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 promptFirst connection needs the host key accepted; the plugin passes accept-new (TOFU) by default.
Tunnel does not come back after a network dropReconnection 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 reachableOn 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.1 only (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 /remote slash commands

Project files and signals

Shown items are public repository signals detected in the directory snapshot.

TestsDetected
DocumentationDetected

Repository information

Language
JavaScript
License
MIT
Last updated
Aug 16, 2026, 2:49 AM

Install deliberately

Review source code, permissions, lifecycle hooks, dependencies and network access. Test untrusted plugins in an isolated environment.