返回目录
其他 插件

dsh-remote-tunnel

Linjiangxian0203/dsh-remote-tunnel

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

Stars
6
Forks
0
Issues
0
更新
5 天前

PROJECT TOPICS

项目标签

INSTALL REFERENCE

安装参考

未验证
dsh plugin --profile web add github:Linjiangxian0203/dsh-remote-tunnel

该命令指向仓库当前默认分支;尚无绑定当前 commit 的完整验证结果。

PROJECT README

README

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
#    want it in the web UI too (Settings → Plugins) with /remote slash commands in chat?
#    also install into the web profile, then restart dsh web:
dsh plugin --profile web add dsh-remote-tunnel

# ⚠️ Sharing one server with others? Do the one-time registration first (needs root —
#    see "Sharing one server" below). Without it the plugin falls back to a private
#    per-account registry and `audit` only shows your own ports.

# 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. Every account (each labmate's own user) prepares its own environment once — idempotent, runs as whoever the ssh alias logs in as: dsh --profile remote bootstrap lab (same script, manual: ssh <host> 'sh -s' < scripts/bootstrap-remote.sh). Everything else lives in $DSH_HOME/remote-tunnel/config.yaml — sensible defaults, no changes needed.

The remote CLI profile is the main interface. Installing into the web profile is what makes the plugin appear under Settings → Plugins and enables the /remote slash commands in chat — restart dsh web once after adding it there.

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 per account via dsh --profile remote bootstrap <host> or scripts/bootstrap-remote.sh — each user runs it once for their own account), 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. Install into the web profile so the plugin shows up in the web UI
#    (Settings → Plugins) and /remote slash commands work in chat;
#    restart dsh web afterwards
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

# check says Node/dsh are missing? Install them for the account you log in as
# (idempotent; each labmate runs it once for their own user)
dsh --profile remote bootstrap lab
#     --upgrade forces dsh to the latest version

# 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).

Desktop app panel

Installed into the desktop profile, the remote dsh web opens inside the right sidebar's Browser panel — no trip to the system browser. The plugin adds four entry points:

Entry point Where What it gives you
Remote connections panel Right sidebar → guide → 远程连接 Host list, tunnel state and every action — the entry that works in a brand-new session
Command card Type /remote … in a conversation The command's output plus tunnel state, with "Open in browser", "Open in sidebar", "Start tunnel / up" and "Disconnect / down"
Status strip Above the composer Always shows the tunnel state with one-click actions (hide it with dock: false)
Plugins page Left sidebar → 插件 Enable/disable the plugin; its title and description follow the UI language

The sidebar「远程连接」panel

The sidebar draws its tab strip and its guide in every session state, so this pane is the one entry that survives a conversation with no history: the command card needs transcript history, and the status strip is not rendered in the hero layout a new session shows.

  1. Open the right sidebar (the button in the conversation header, if it is collapsed).
  2. The guide lists 远程连接 (a monitor glyph) — click the capsule (or the + control in the tab strip).
  3. The pane manages the selected host and its tunnel:
In the pane What it does
启动隧道 / up · 断开连接 / down Starts the tunnel, or stops it (two-step confirmation: tunnel, remote unit and registry release)
在侧栏打开 · 在浏览器打开 Opens the remote dsh web in the sidebar Browser tab, or in the system browser
刷新 / refresh Re-reads /remote-tunnel/status (the pane also polls every 15 s)
vX.Y.Z (beside the title) The version the host half reported — the desktop app's Plugins page renders no version for any plugin
已配置主机 / managed hosts ~/.ssh/config entries (read-only, labelled, hideable) and the plugin's own config.yaml entries (removable, two-step). + 手动添加主机 opens an inline form (alias / host / port / user / workspace) — a host can be defined without the CLI
第一条主机从哪来 With no host at all the pane walks you through it: ① add one (the form, or a discovered candidate) → ② 启动隧道 / up → ③ 在侧栏打开. It also names the one remote prerequisite: dsh --profile remote bootstrap <别名>
发现的主机 / discovered in ~/.ssh Plaintext ~/.ssh/known_hosts entries — hosts this machine has connected to. 添加 writes one into the plugin's config, 忽略 puts it out of the way, and the section's own 刷新 re-scans on demand (it also polls every 15 s); ~/.ssh itself is never written to
已隐藏 / hidden in this pane Everything you hid, each with a 恢复 button — hiding is a display preference, never a delete

Hashed known_hosts entries (HashKnownHosts yes, the OpenSSH default on many systems) cannot be reversed into a hostname; the pane reports how many were skipped instead of guessing. A candidate on a non-default port is spelled host:port in known_hosts, which is display text rather than a legal alias — adding it stores a safe alias such as host-port together with the real port.

Hiding writes the plugin's own config.yaml (hiddenHosts) and nothing else: the CLI keeps listing every host, so an entry hidden in the pane is never lost.

The pane belongs to the client half. Changing src/client.js changes the bundle's revision, and a running host refuses to serve a stale revision — restart the desktop app after updating the plugin.

Install it into the desktop app

Use the GUI, exactly like any other plugin:

Plugins page → "Add plugin" → dsh-remote-tunnel → install → restart the desktop app

Equivalent on the command line:

dsh plugin --profile desktop add dsh-remote-tunnel

The desktop app reads its profile at startup, so the app must be fully restarted (close the window and quit the tray process).

Two ways to open

  • Open in sidebar (recommended): the remote dsh web renders in the right sidebar's Browser panel; the plugin performs the token → cookie hand-off for you, so dsh web authentication required never appears;
  • Open in browser: the system browser opens the same local URL — handy when you want the page in its own window or need browser extensions.

openIn picks the default (ask / browser / panel).

Desktop settings (Settings → Plugins)

Field Default Meaning
home $DSH_HOME/remote-tunnel Where tunnel state, logs and config.yaml live
openIn ask Preferred way to open
autoOpen false Open the remote dsh web in the sidebar at app start
dock true Show the always-on status strip above the composer

The same values can be set directly on the remote-tunnel row's config in the profile patch layer ($DSH_HOME/profiles/desktop/cordis.patch.yml).

Shell behaviours worth knowing (not bugs)

  • A brand-new conversation shows no command card: the chat view renders the welcome surface until the session has model history, and command lifecycles are deliberately not model history. After the first turn, every earlier /remote card appears at once — the commands had already run;
  • The status strip only renders once a session has content: it lives in the composer's dock seat, which the hero (empty-session) layout does not render. 0.2.1 ships the "Remote connections" sidebar panel instead, which is reachable in every session state;
  • A third-party slash command's description is not localized by the client (only first-party definitions carry localizations), which is why the plugin ships bilingual command copy.

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)
bootstrap <host> [--upgrade]     prepare the ssh account: Node/dsh (~/.npm-global)/~/.dsh/linger (idempotent)
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 and disable the remote unit (an enabled unit would otherwise come back after a server reboot and re-occupy the released port) → verify the port is really free. up/provision enable it again; --keep-service leaves the unit untouched. 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 — do this first on a lab server)

When several people use one server, have root do the one-time registration first. Without it the plugin silently falls back to a per-account registry: allocation still avoids real collisions (the bind probe and PID attribution keep everyone on their own service), but audit can only see your own rows — not who holds which port.

One-time registration (needs root; never repeated) — the shared registry plus a shared group:

sudo groupadd -f dshports
sudo install -m 0664 -o root -g dshports /dev/null /etc/dsh-ports.tsv
sudo install -m 0664 -o root -g dshports /dev/null /etc/dsh-ports.tsv.lock
sudo usermod -aG dshports <user1> <user2> ...    # every account that will use the plugin

Both files are required up front: they sit in a root-only directory, so a member cannot create the lock themselves and every operation takes it. Only those two files carry the group-write bit (0664) — the directory stays root-only, which is fine: each registry update stages through a per-user mktemp file and rewrites the registry in place, never touching the directory nor changing the file's owner/group.

Each member: ① re-login over SSH once so the group applies; ② dsh --profile remote bootstrap <host>; ③ dsh --profile remote check <host> should report registry: /etc/dsh-ports.tsv (shared-direct) — no configuration change needed, the plugin switches from its private registry automatically.

From then on every up allocates under a server-side flock and writes the same table: remote ports are always distinct, and audit shows who holds which port and whether anything is stale. Clean up ownerless rows with dsh --profile remote audit <host> --clean-stale.

Server setup Registry Supervision
Admin created dshports as above (recommended) /etc/dsh-ports.tsv (group 0664, no sudo) user unit + linger
Every member has passwordless sudo /etc/dsh-ports.tsv (sudo writes, 0644) system unit, one port per user
Nothing configured falls back to ~/.dsh-ports.tsv (own rows only; check points at the admin setup) user unit + linger

The shared registry is world-readable (0644/0664) and holds only port, account, workspace, source, timestamps and status — no passwords, keys or tokens. Writes are serialized by flock, so accounts outside the group cannot tamper with it.

Each account prepares its own environment (N users on one server = N idempotent runs):

dsh --profile remote bootstrap <host>     # from each user's own machine

This installs Node / dsh (into that account's own ~/.npm-global) / ~/.dsh / linger without touching any other account — session history is per-account too.

Each user runs up independently and gets a different remote port; audit shows who holds which port and flags stale/conflicting rows.

Remote bootstrap (per account)

dsh --profile remote bootstrap <host> prepares the account the ssh alias logs in as: Node ≥ 22.19 (when installable), dsh into that account's ~/.npm-global, ~/.dsh, systemd lingering and the npm-global PATH entries — idempotent, so each labmate runs it once for their own user. --upgrade forces dsh to the latest version.

dsh --profile remote bootstrap lab              # prepare the current account
dsh --profile remote bootstrap lab --upgrade    # update remote dsh to the latest

The same script ships as scripts/bootstrap-remote.sh for manual use (identical behavior):

ssh <host> 'sh -s' < scripts/bootstrap-remote.sh

Troubleshooting

Symptom Cause and fix
dsh not found / node not found (check or up) The ssh account has no Node/dsh yet — the plugin works per account. dsh --profile remote bootstrap <host> installs them for whoever you log in as (idempotent; --upgrade refreshes). Even when the ssh channel's PATH misses ~/.npm-global, the plugin now probes that path directly.
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 cleared the command-line -L, so the tunnel connected without forwarding. Fixed since 0.1.1: the tunnel no longer passes that option (exec sessions still do).
Opening the tunnel URL shows dsh web authentication required; reopen the URL printed by dsh web. dsh web ≥ 0.1.2-rc gates its UI behind a one-time token in the launch URL it prints at startup. up now prints that URL (rewritten to your local port) as the auth: line. If it expired (the service restarted), copy the dsh web: http://…?token=… line out of logs <host> into your address bar.
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.
Bad owner or permissions on .../.ssh/config (every remote operation fails) Another tool (AtomGit DevEnv, a conda environment, ...) injected an Include into your ~/.ssh/config, and the included file's permissions are too open (Everyone:(F)), so OpenSSH refuses to load the whole config. Fix: icacls "<included file>" /inheritance:r /grant:r "$env:USERDOMAIN\$env:USERNAME:F" /grant:r "NT AUTHORITY\SYSTEM:F" (do the same for its directory). A redirected HOME causes the same class of failure — check echo $env:HOME.
audit shows in-use rows marked STALE and ports stay "taken" Those are historical rows left by sessions that were killed instead of downed (allocation treats them as occupied to avoid a collision). Clean them with dsh --profile remote audit <host> --clean-stale.

Development and testing

npm install             # plugin dependencies (package-lock.json is committed)
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.en.md · 中文)
  • Remote scripts only append/rewrite the registry and the systemd unit under flock; no other writes
  • The desktop/web /remote-tunnel/* routes go through the runtime's own admission check (ctx.connection.admit() — the same Host/Origin fence plus browser-session cookie the /api channel uses) and answer 401/403 otherwise, because they hand out a URL carrying a one-time launch token. A carrier that genuinely cannot present a cookie can opt out with auth: false (not recommended)

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

CLASSIFICATION EVIDENCE

分类依据

项目类型插件
功能分类其他
规则置信度低

系统优先读取 GitHub Topics,再与站内分类词典和词根规则比对。当前命中: 无有效分类标签。