dsh-remote-tunnel
v0.2.0
Published
Remote Host Tunnel Manager for dsh: allocate and register remote ports, run dsh web on a remote Linux server via systemd, and keep a resilient SSH tunnel from this machine to it.
Maintainers
Readme
dsh-remote-tunnel
中文 | English
Remote Host Tunnel Manager:把「本地浏览器 → 远程 Linux 服务器上的 dsh web」这条链路自动化——远程端口分配与登记、systemd 守护、SSH 隧道保活、本地 URL 输出、全生命周期管理,并面向多人共用同一台服务器的场景。
- 会话与文件都在服务器上(远程 dsh web 的工作区 = 服务器目录),本地只开一条隧道
- 每个使用者自动分到独立的远程端口,分配前在服务器上双重检查(真实占用 + 登记表),并发也安全
- 每次分配都在服务器上的登记表留档(
/etc/dsh-ports.tsv或按权限自动降级),可随时audit对照审查 - 隧道断线自动重连(进程退出后按退避重新拉起),心跳定期刷新登记表
- 本地端口被占自动顺延,并报告占用者进程
如果你只是使用者(不开发)
# 1. 安装(npm 发布版)
dsh plugin --profile remote add dsh-remote-tunnel
# 想在 web UI 里也看到它(「设置 → 插件」)并用 /remote 斜杠命令?
# 再装进 web profile,并重启 dsh web:
dsh plugin --profile web add dsh-remote-tunnel
# ⚠️ 多人共用同一台服务器?先做一次性注册(需要 root,见下面「多用户共享服务器」一节):
# 不注册也能用,但会退化成每人一份私有登记表 —— audit 只看得到自己占的端口
# 2. 确认你的服务器能被识别(~/.ssh/config 里的 Host 别名自动发现)
dsh --profile remote hosts
# 没有?手动定义一台:
dsh --profile remote hosts add lab --host 192.0.2.10 --user alice --workspace /home/alice/project
# 3. 第一次先体检,缺什么它逐项告诉你(密钥/Node/dsh/登记表/systemd)
dsh --profile remote check lab
# 3.5 体检说 Node/dsh 缺失?按你登录的账号一键补齐(幂等;师兄师姐各自跑一次)
dsh --profile remote bootstrap lab
# --upgrade 则强制把远程 dsh 更新到最新版
# 4. 起隧道,浏览器自动打开服务器上的 dsh web
dsh --profile remote up lab --open
# 日常:status 看状态 / down 收尾 / logs 看远端日志 / audit 审查端口登记
dsh --profile remote down lab远程服务器需要:Node ≥ 22.19、dsh、systemd、免密钥 ssh 登录。每个账号(包括实验室里其他师兄师姐各自的账号)在自己电脑上跑一次初始化即可,幂等:
dsh --profile remote bootstrap lab(同一脚本亦可手动:ssh <host> 'sh -s' < scripts/bootstrap-remote.sh)。其余参数在 $DSH_HOME/remote-tunnel/config.yaml,不改就能用。
remoteCLI profile 是插件的主界面。装进 web profile 才会让插件出现在「设置 → 插件」里、并启用聊天中的/remote斜杠命令——装完记得重启一次dsh web。
要求
- 本地:Windows/macOS/Linux,自带 OpenSSH 客户端(Windows 10+ 已内置),Node ≥ 22.19
- 远程:Linux,Node ≥ 22.19 + dsh(用
dsh --profile remote bootstrap <host>或scripts/bootstrap-remote.sh安装,每个账号各自装一次),systemd(用户级即可,无需 root) - 推荐:远程已配置 SSH 免密钥登录(
ssh <别名>直接能进,不弹密码)
安装
# 1. 安装到专用 CLI profile(推荐;首次会自动初始化 remote profile)
cd <插件源码目录> # 或 npm 包名 dsh-remote-tunnel
dsh plugin --profile remote add .
# 2. 装到 web profile,让插件在 web UI 的「设置 → 插件」里可见、
# 并启用聊天里的 /remote 斜杠命令;装完重启 dsh web
dsh plugin --profile web add .快速上手
# 看看有哪些主机(~/.ssh/config 里的 Host 别名会被自动发现)
dsh --profile remote hosts
# 也可以手动定义一台主机(不含 ~/.ssh/config 时)
dsh --profile remote hosts add lab --host 192.0.2.10 --user alice --workspace /home/alice/project
# 就绪诊断:密钥/Node/dsh/登记表/systemd 逐项检查
dsh --profile remote check lab
# 一键:分配远程端口 → 登记 → 写 systemd 单元并启动远程 dsh web → 本地起隧道
dsh --profile remote up lab --open
# 输出示例:
# 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)
# 查询/停止/审查
dsh --profile remote status lab
dsh --profile remote logs lab # 远程 dsh web 日志(journalctl)
dsh --profile remote audit lab # 登记表 vs 真实占用
dsh --profile remote down lab # 停隧道 + 登记表 released + 停服务 + 核实端口已释放打开本地 URL 后,登录到的是服务器上的 dsh web:能对话、能读写服务器文件。API key 在远程 web 的「设置 → 模型」里配置(写入服务器 ~/.dsh/.credentials.yaml,本插件与隧道不触碰凭据)。
桌面端面板(DSH 桌面应用)
装进桌面 profile 后,远程的 dsh web 可以直接开在右侧栏「浏览器」面板里,不必跳系统浏览器。插件在桌面端提供三个入口:
| 入口 | 位置 | 用途 |
|---|---|---|
| 命令卡片 | 对话里发 /remote … | 命令结果 + 隧道状态 + 「在浏览器打开」「在侧栏打开」「启动隧道 / up」「断开连接 / down」 |
| 状态条 | 输入框上方 | 常驻显示隧道状态与一键操作(可用 dock: false 整条隐藏) |
| 插件页 | 左侧「插件」 | 开关插件;标题与描述跟随界面语言(中文界面显示「远程隧道 (dsh-remote-tunnel)」) |
安装到桌面端
推荐走 GUI(和其它插件一样):
「插件」页 →「添加插件」→ 输入 dsh-remote-tunnel → 安装 → 重启桌面应用
命令行等价写法:
dsh plugin --profile desktop add dsh-remote-tunnel桌面应用只在自己启动时读取 profile,所以装完必须重启应用(窗口关掉后还要退出托盘进程)。
两种打开方式
- 在侧栏打开(推荐):远程 dsh web 显示在右侧栏「浏览器」面板里,鉴权(
?token=→ cookie)由插件自动完成,不会再出现dsh web authentication required; - 在浏览器打开:用系统默认浏览器打开同一条本地 URL,适合希望页面独占窗口、或需要浏览器扩展的场景。
openIn 可设置默认偏好(ask / browser / panel)。
桌面端配置(设置 → 插件)
| 字段 | 默认 | 说明 |
|---|---|---|
| home | $DSH_HOME/remote-tunnel | 隧道状态、日志与 config.yaml 所在目录 |
| openIn | ask | 打开方式偏好 |
| autoOpen | false | 启动桌面应用时自动把远程 dsh web 开进侧栏 |
| dock | true | 是否显示输入框上方的常驻状态条 |
也可以直接改 profile 补丁层($DSH_HOME/profiles/desktop/cordis.patch.yml)里 remote-tunnel 那一行的 config,效果相同。
已知的外壳行为(不是故障)
- 纯新会话里看不到命令卡片:会话视图在没有对话内容时显示欢迎页,而命令记录不属于模型历史;发出第一轮对话后,之前所有
/remote卡片会一起出现(命令其实早已执行成功); - 状态条只在"会话已有内容"时出现:它挂在输入框的 dock 座位,而全新会话用的是居中(hero)布局,该座位不渲染 —— 0.2.1 会增加侧栏「远程主机」面板,任何会话状态都能点到;
- 斜杠命令的描述不会被客户端本地化(只有第一方命令有本地化副本),因此插件里的命令描述写成中英双语。
命令一览
hosts / hosts add <别名> --host H [--port 22] [--user U] [--workspace DIR] / hosts rm <别名>
check <host> 就绪诊断(可作 CI 探针,非零退出码 = 有问题)
bootstrap <host> [--upgrade] 按登录账号补齐远程环境:Node/dsh(~/.npm-global)/~/.dsh/linger(幂等)
provision <host> [--port N] 只做远程侧:分配端口 + systemd 单元 + 启动 + 登记(不起隧道)
up <host> [--port N] [--local-port N] [--open] [--heartbeat 秒]
down [host] [--keep-service] 停隧道 + released + 停单元 + 核实端口释放
status [host] [--json]
list
logs <host> [--lines N] [--follow] [--local]
audit <host> [--json] [--release <port>] [--clean-stale]
open [host]
config show / config path工作原理
- 远程端口分配(原子):一条远程脚本在
flock锁内完成——读登记表的 in-use 集合 + 对区间内每个端口做真实 bind 探测 → 取第一个「两者都空闲」的端口 → 追加 TSV 行 → 回显端口。多账号并发分配互不冲突。 - 远程守护:写入 systemd 单元并
enable --now。有密码 sudo 时用系统级单元(/etc/systemd/system/dsh-web-<user>.service,与任务书模板一致);没有 sudo 时自动改用用户级单元(~/.config/systemd/user/dsh-web.service)+loginctl enable-linger,完全不需要 root。服务器重启自动拉起,崩溃自动重启。 - TOCTOU 兜底:若 dsh 启动时端口被抢(
EADDRINUSE出现在单元日志),自动把该端口加入排除集,顺延下一个空闲端口重试(默认最多 5 轮)。 - 本地隧道:
ssh -N -L 127.0.0.1:<本地>:127.0.0.1:<远程> <别名>,本地端口先检查占用(被占自动顺延,并用netstat+tasklist报出占用者);ssh 进程退出后按退避序列(1s→2s→4s→8s→15s→30s 封顶)自动重连,永不断线(可配maxAttempts)。隧道明确不传ClearAllForwardings(Windows OpenSSH 会把它连同命令行-L一起清掉);exec 会话仍会清掉 config 里的转发。 - 心跳:隧道存活期间每
heartbeatSeconds(默认 120 秒)在锁内原位刷新登记表last_heartbeat。 - 释放:
down(或up的 Ctrl+C)按序:停隧道 → 删除本地状态 → 登记表released→ 停远端单元并 disable(禁用,避免服务器重启后自己回来占住已释放的端口)→ 核实端口真的释放。up/provision会重新 enable;--keep-service则完全不动远端单元。另一个进程里的up监督器检测到状态文件被删除后自动停止重连,不会「诈尸」。硬关终端(不按 Ctrl+C)则远端服务照跑、登记表仍是 in-use——这是真实占用,不是泄漏:下次up会自动清理残留的本地状态并复用同一个已登记端口,不会越攒越多。
配置
$DSH_HOME/remote-tunnel/config.yaml(dsh --profile remote config path 查看路径):
hosts:
lab: # 手动定义的主机(与 ~/.ssh/config 的别名合并,二者同名时这里优先)
host: 192.0.2.10
port: 22
user: alice
workspace: /home/alice/project
remotePortRange: [3080, 3119] # 可选,按主机覆盖
defaults:
remotePortRange: [3080, 3119] # 远程 dsh 端口区间(先查占用再分配)
localPortRange: [3081, 3140] # 本地隧道端口区间
registry:
path: /etc/dsh-ports.tsv
lockPath: /etc/dsh-ports.lock
sudo: auto # auto | always | never
fallbackPath: .dsh-ports.tsv # 共享登记表不可写时,降级到远程家目录(相对路径)
unit:
prefix: dsh-web-
restartSec: 5
type: auto # auto | system | user
heartbeatSeconds: 120 # 0 = 关闭心跳
remoteWaitSeconds: 60 # 等远程端口就绪
localWaitSeconds: 15 # 等本地 URL 可访问
reconnect:
delaysMs: [1000, 2000, 4000, 8000, 15000, 30000]
maxAttempts: 0 # 0 = 永不放弃
allocateRetries: 5
ssh:
connectTimeout: 0 # 0 = 不传 -o ConnectTimeout(见排错表)
extraArgs: []多用户共享服务器(实验室场景:先做这一步)
多人用同一台服务器时,建议第一次先用 root 做一次性注册——否则插件会自动降级为"每人一份私有登记表",
端口分配只能靠真实占用探测避让(仍然不会连错、不会永久撞车,但 audit 看不到别人占了哪些端口)。
注册一次(需要 root,之后永不再做)——建共享登记表 + 共享组:
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 <用户名1> <用户名2> ... # 需要用到插件的每个账号两个文件都必须提前建好:它们位于仅 root 可写的目录里,成员自己无法创建锁文件,而所有登记操作都要先拿这把锁。
只有这两个文件带组写权限(0664),所在目录保持仅 root 即可——更新登记表时插件用用户级 mktemp 中转、原地改写,
既不触碰目录,也不改变文件的属主/组。
每个成员:① 重新 SSH 登录一次让组生效;② 首次准备环境 dsh --profile remote bootstrap <host>;
③ dsh --profile remote check <host> 应显示 registry: /etc/dsh-ports.tsv (shared-direct)——无需改任何配置,
插件会自动从私有登记表切到共享表。
之后每个人 up 的分配都在服务器上被 flock 串行化并写入同一张表:分到的远程端口必定互不相同,
audit 能直接看出"谁占了哪个端口、有无 stale/冲突"。无主行定期清理:dsh --profile remote audit <host> --clean-stale。
| 服务器环境 | 登记表 | 服务守护 |
|---|---|---|
| 管理员按上面建了 dshports 组(推荐) | /etc/dsh-ports.tsv(组 0664,免 sudo) | 用户级单元 + linger |
| 成员都有 passwordless sudo | /etc/dsh-ports.tsv(sudo 写入,0644) | 系统级单元,一人一个端口 |
| 什么都没配 | 自动降级 ~/.dsh-ports.tsv(只含本人记录;check 会提示找管理员) | 用户级单元 + linger |
共享登记表全员可读(0644/0664),里面只有端口、账号、工作区、来源、时间戳和状态——不含任何密码、密钥或 token; 写入由
flock串行化,非本组成员无法篡改。
每个账号各自准备好自己的环境(一台服务器 N 个用户 = 各自跑一次,幂等):
dsh --profile remote bootstrap <host> # 在自己电脑上,以自己账号 ssh 登录后执行这一步把 Node/dsh(装进该账号自己的 ~/.npm-global)/~/.dsh/linger 全部补齐——它不碰别的账号的任何东西,会话历史也按账号彼此独立。
两个用户各自 up → 自动分到不同远程端口;audit 能看出谁占哪个端口、有无 stale/冲突。
远程账号初始化(bootstrap,按登录账号)
dsh --profile remote bootstrap <host> 为 ssh 别名登录的那个账号补齐环境:Node ≥ 22.19(缺失时尽量装)、dsh(装进该账号 ~/.npm-global)、~/.dsh、systemd linger、npm-global 的 PATH 条目——幂等,新账号跑一次即可。--upgrade 则会强制把 dsh 更新到最新版。
dsh --profile remote bootstrap lab # 准备好当前账号
dsh --profile remote bootstrap lab --upgrade # 更新远程 dsh 到最新版同一脚本也随包提供,可手动执行(和插件行为完全一致):
ssh <host> 'sh -s' < scripts/bootstrap-remote.sh常见排错
| 症状 | 原因与处理 |
|---|---|
| dsh not found / node not found(check 或 up 报错) | 该 ssh 登录账号还没装 Node/dsh——插件为每个账号分别工作。dsh --profile remote bootstrap <host> 按当前账号补齐(幂等,--upgrade 可更新);已装但 ssh 通道 PATH 看不到时,插件也会自动探测 ~/.npm-global/bin/dsh。 |
| Error: listen EADDRINUSE ... 127.0.0.1:3080 | 有人(或你上一个实例)占了该端口。本插件分配前双重检查,up 时若仍发生(TOCTOU)会自动顺延;手工起 dsh 才会看到这个报错。 |
| Could not resolve hostname <别名> | 别名不在 ~/.ssh/config 里,且没在插件配置里定义。hosts add 或写入 ssh config 后重试。 |
| Connection refused / remote port forwarding failed | 远端 dsh web 没起或端口不对。check <host> 看「web port listening」;logs <host> 看远端日志;ss -tln \| grep <port> 在服务器上核实。 |
| channel_setup_fwd_listener_tcpip: cannot listen to port | 本地端口已被占(常见:两个 dsh web 实例)。本插件会自动顺延,并输出占用者进程名;也可 --local-port 手动指定。 |
| Permission denied (publickey) / sudo: a password is required | 密钥没配好 / 没有 NOPASSWD sudo。前者 ssh-copy-id;后者见上表,无 sudo 也能用(用户级单元 + 兜底登记表)。 |
| Could not create directory '/home/xxx/.ssh' + host key 提示 | 首次连接需接受主机指纹,插件默认 accept-new(TOFU),已在自动处理。 |
| 断网后隧道没恢复 | 默认无限重连,status 看 ssh pid 是否 alive;logs <host> --local 看重连日志。若设了 reconnect.maxAttempts,达到上限会停止。 |
| 隧道进程一直活着,但本地 URL 始终 not reachable(端口不通) | Windows OpenSSH 8.1 会把 -o ClearAllForwardings=yes 连同命令行自己的 -L 一起清掉,导致隧道只连接、不转发。已于 0.1.1 修复:隧道不再传该选项(exec 会话仍保留)。 |
| 打开隧道 URL 只显示 dsh web authentication required; reopen the URL printed by dsh web. | dsh web ≥ 0.1.2-rc 用启动时打印的一次性 token URL 鉴权。up 现在会打印改写成本地端口的带 token 地址(auth: 行)。若已过期(服务重启过),把 logs <host> 里的 dsh web: http://…?token=… 整行复制到浏览器地址栏。 |
| 登记表读不到(/etc/dsh-ports.tsv missing) | 首次分配时自动创建(需写入权限);无权限时自动降级到 ~/.dsh-ports.tsv,check 会给出管理员初始化命令。 |
| 每条 ssh 命令都慢 ~N 秒 | 部分服务器上给 ssh 传 ConnectTimeout 会让每条连接都等满超时(即使秒连)。默认已不传该参数(ssh.connectTimeout: 0);需要时再显式打开。 |
| Bad owner or permissions on .../.ssh/config(所有远程操作全挂) | 你的 ~/.ssh/config 里被别的工具(如 AtomGit DevEnv、conda 环境)注入了 Include,而那个被包含的文件权限过宽(带 Everyone:(F)),OpenSSH 直接拒绝加载整份配置。修复:icacls "<被包含的文件>" /inheritance:r /grant:r "$env:USERDOMAIN\$env:USERNAME:F" /grant:r "NT AUTHORITY\SYSTEM:F"(目录同样处理)。另一种成因是 HOME 被工具改指到别处,使 ssh 读了另一个目录下的 config——echo $env:HOME 确认。 |
| audit 显示某些 in-use 行是 STALE,端口总被"占用" | 那是会话被杀/硬关终端后没来得及 down 留下的历史行(分配时会当作占用,避免撞车)。清理:dsh --profile remote audit <host> --clean-stale。 |
开发与测试
npm install # 插件自身依赖(package-lock.json 已入库,构建可复现)
npm test # 单元测试 + 假 ssh shim 集成测试(无需真实服务器)集成测试用一个仿真的 ssh(把远程命令解释到临时「服务器」上,隧道真实转发 TCP),覆盖:分配/登记/释放、并发多人分配、TOCTOU 顺延、本地端口冲突顺延、断线自动重连、跨进程 down 取消、audit stale/orphan/clean。
安全说明
- 隧道与远程 dsh 一律只绑
127.0.0.1(dsh 本身禁止--host 0.0.0.0) - 插件不保存、不传输任何密码/密钥/API key;SSH 全走现有密钥(BatchMode,拒绝密码提示挂起)
- 登记表不记录任何敏感信息(见
docs/registry-format.md· English) - 远程脚本仅在
flock锁内追加/改写登记表与 systemd 单元,不执行其他写入 - 桌面/网页端的
/remote-tunnel/*路由走运行时自己的准入检查(ctx.connection.admit():与/api同一套 Host/Origin 栅栏 + 浏览器会话 cookie 校验),未通过返回 401/403 —— 因为这类路由会下发携带一次性 launch token 的 URL;确实无法携带 cookie 的载体可用auth: false显式关闭(不推荐)
非目标
- 不实现 SSH/SFTP/远程挂载:方案本质是「在服务器上跑 dsh」,隧道只把 HTTP 引回本地
- 不做新 TUI:CLI 子命令 + web 的
/remote斜杠命令
