# dsh-remote-tunnel

中文 | [English](README.md)

[![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)

**Remote Host Tunnel Manager**:把「本地浏览器 → 远程 Linux 服务器上的 dsh web」这条链路自动化——远程端口分配与登记、systemd 守护、SSH 隧道保活、本地 URL 输出、全生命周期管理,并面向多人共用同一台服务器的场景。

- 会话与文件都在**服务器**上(远程 dsh web 的工作区 = 服务器目录),本地只开一条隧道
- 每个使用者自动分到**独立的远程端口**,分配前在服务器上双重检查(真实占用 + 登记表),并发也安全
- 每次分配都在**服务器上的登记表**留档(`/etc/dsh-ports.tsv` 或按权限自动降级),可随时 `audit` 对照审查
- 隧道断线**自动重连**(进程退出后按退避重新拉起),心跳定期刷新登记表
- 本地端口被占自动顺延,并报告占用者进程

## 如果你只是使用者(不开发)

```powershell
# 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

# 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`,不改就能用。

> `remote` CLI 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 <别名>` 直接能进,不弹密码)

## 安装

```powershell
# 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 .
```

## 快速上手

```powershell
# 看看有哪些主机(~/.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`,本插件与隧道不触碰凭据)。

## 命令一览

```
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
```

## 工作原理

1. **远程端口分配(原子)**:一条远程脚本在 `flock` 锁内完成——读登记表的 in-use 集合 + 对区间内每个端口做真实 bind 探测 → 取第一个「两者都空闲」的端口 → 追加 TSV 行 → 回显端口。多账号并发分配互不冲突。
2. **远程守护**:写入 systemd 单元并 `enable --now`。有密码 sudo 时用**系统级**单元(`/etc/systemd/system/dsh-web-<user>.service`,与任务书模板一致);没有 sudo 时自动改用**用户级**单元(`~/.config/systemd/user/dsh-web.service`)+ `loginctl enable-linger`,完全不需要 root。服务器重启自动拉起,崩溃自动重启。
3. **TOCTOU 兜底**:若 dsh 启动时端口被抢(`EADDRINUSE` 出现在单元日志),自动把该端口加入排除集,顺延下一个空闲端口重试(默认最多 5 轮)。
4. **本地隧道**:`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 里的转发。
5. **心跳**:隧道存活期间每 `heartbeatSeconds`(默认 120 秒)在锁内原位刷新登记表 `last_heartbeat`。
6. **释放**:`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` 查看路径):

```yaml
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: []
```

## 多用户共享服务器

| 服务器环境 | 登记表 | 服务守护 |
|---|---|---|
| 成员有密码 sudo | `/etc/dsh-ports.tsv`(sudo 写入) | 系统级单元,一人一个端口 |
| 成员无 sudo,管理员建了 dshports 组 | `/etc/dsh-ports.tsv`(组 0664,免 sudo) | 用户级单元 + linger |
| 什么都没配(现状) | 自动降级 `~/.dsh-ports.tsv`(只含本人记录;`check` 会提示找管理员) | 用户级单元 + linger |

**每个账号各自准备好自己的环境**(一台服务器 N 个用户 = 各自跑一次,幂等):

```powershell
dsh --profile remote bootstrap <host>     # 在自己电脑上,以自己账号 ssh 登录后执行
```

这一步把 Node/`dsh`(装进**该账号自己的** `~/.npm-global`)/`~/.dsh`/linger 全部补齐——它不碰别的账号的任何东西,会话历史也按账号彼此独立。

管理员一次性初始化共享登记表(二选一):

```bash
# A. 成员都有 passwordless sudo
sudo install -m 0644 -o root -g root /dev/null /etc/dsh-ports.tsv
sudo install -m 0644 -o root -g root /dev/null /etc/dsh-ports.tsv.lock

# B. 成员无 sudo:共享组写入
sudo groupadd dshports && sudo usermod -aG dshports alice bob ...
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
# 每个成员的插件配置: registry.sudo: never
```

两个文件都要提前建好:它们位于仅 root 可写的目录里,成员自己无法创建锁文件,
而所有登记操作都要先拿这把锁。方案 B 下只有这两个文件带组写权限(`0664`),
所在目录保持仅 root 即可——每次登记表更新都经由用户级 `mktemp` 中转、原地
改写,既不触碰目录,也不改变文件的属主/组。

两个用户各自 `up` → 自动分到不同远程端口;`audit` 能看出谁占哪个端口、有无 stale/冲突。

## 远程账号初始化(`bootstrap`,按登录账号)

`dsh --profile remote bootstrap <host>` 为 **ssh 别名登录的那个账号**补齐环境:Node ≥ 22.19(缺失时尽量装)、dsh(装进该账号 `~/.npm-global`)、`~/.dsh`、systemd linger、npm-global 的 PATH 条目——幂等,新账号跑一次即可。`--upgrade` 则会强制把 dsh 更新到最新版。

```powershell
dsh --profile remote bootstrap lab              # 准备好当前账号
dsh --profile remote bootstrap lab --upgrade    # 更新远程 dsh 到最新版
```

同一脚本也随包提供,可手动执行(和插件行为完全一致):

```bash
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`。 |

## 开发与测试

```bash
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](docs/registry-format.en.md))
- 远程脚本仅在 `flock` 锁内追加/改写登记表与 systemd 单元,不执行其他写入

## 非目标

- 不实现 SSH/SFTP/远程挂载:方案本质是「在服务器上跑 dsh」,隧道只把 HTTP 引回本地
- 不做新 TUI:CLI 子命令 + web 的 `/remote` 斜杠命令
