liyepan 364924fd6b feat(install): take the listen port as an argument
sudo ./install.sh 9000
  sudo ./install.sh --port 9000
  sudo ./install.sh 0.0.0.0:9000

Positional argument wins over DSH_LAN_LISTEN, which wins over 8080. The port is
validated as an integer in 1-65535, extra arguments are rejected, and a running
service gets a restart hint.
2026-10-30 16:59:09 +08:00

dsh-lan

给只监听回环地址的 dsh web 加一个局域网入口。

dsh web 默认绑定 127.0.0.1,并且明确拒绝 --host 0.0.0.0(源码里的理由是:浏览器 UI 直通 shell,暴露到网络等于把远程代码执行送出去)。本项目不修改 dsh 的任何配置、不追加任何启动参数,只在旁边加一个网关卡:dsh 永远只看见 loopback,局域网里的任何一块网卡都能访问。

   手机 / 笔记本(同一个局域网)
   http://172.19.4.240:8080
              │
              │  Host: 172.19.4.240:8080
              │  Origin: http://172.19.4.240:8080
              ▼
        ┌───────────┐
        │  dsh-lan  │   监听 0.0.0.0:8080
        └───────────┘
              │
              │  Host: 127.0.0.1:3080            ← 改写
              │  Origin: http://127.0.0.1:3080   ← 改写
              ▼
        dsh web   127.0.0.1:3080

目录


它解决什么

dsh web 有三道门把局域网挡在外面(以 @deepseek-ai/dsh 0.1.5-rc.1 为例):

层 位置 行为
CLI 检查 dsh-web-app/lib/startup.js --host 0.0.0.0 直接报错退出
绑定 schema dsh-host-webserver 的 Config host 只接受 127.0.0.1 / 0.0.0.0
Host/Origin 栅栏 dsh-client-connection /api 和 WebSocket 升级要求 Host 是 loopback 或白名单,且 Origin 必须等于 Host

dsh-lan 选择不动 dsh,而是替它对外:

  • dsh 的启动命令保持一字不改:dsh web --host 127.0.0.1 --port 3080 --no-open
  • 不写 ~/.dsh 里的任何配置,不加 --trusted-host,不 patch 配置树
  • 网关绑 0.0.0.0,把 Host / Origin 改写成 dsh 认识的 loopback authority

结果:任意网卡 IP 都能访问,不需要预先登记任何地址。


工作原理

1. 静态资源

普通转发,逐字节透传,不缓冲、不重新压缩(DisableCompression)。dsh 的 gzip 和 text/event-stream 原样到达浏览器。

2. /api 与 WebSocket

dsh 的栅栏逻辑是:

  • Host 必须是 loopback,或在 trustedHosts 里
  • 如果请求带 Origin,它的 authority 必须等于 Host
  • Sec-Fetch-Site: cross-site 直接拒

浏览器送来的是 Host: 172.19.4.240:8080,dsh 一定不认。网关把这两个头一起改成 127.0.0.1:3080,栅栏就过了 —— 认证仍然由 dsh 自己的 cookie 负责,网关不替任何人放行。

WebSocket 的升级请求走同一条 Rewrite 路径,所以 Origin 也被改写;升级后的字节流由 Go 反代原生双向搬运。

这是整套方案能成立的关键,值得说清楚:

  • dsh 的浏览器 cookie 名是 Host 值的 SHA-256,payload 里再用 HMAC 签一次 authority
  • 因为每个请求都被改写成 loopback,dsh 看到的名字和 payload 永远自洽
  • 浏览器则是按自己实际访问的 origin(http://172.19.4.240:8080)存这个 cookie

两边各看各的,所以同一个 dsh 进程同时服务 127.0.0.1:3080、172.19.4.240:8080、10.0.0.5:8080 都不冲突 —— 换一块网卡、换一个 IP、甚至以后换一个主机名,都不用改任何配置。

4. 令牌自动登录

dsh 每次启动生成一个新的 32 字节 token,打印在自己的启动行里。dsh-lan 托管启动时解析这一行拿到 token,并把 token 从即将输出到终端的那一行里抹掉 —— 你只会看到:

dsh web: http://127.0.0.1:3080/

浏览器第一次访问 / 时,网关在转发给上游的那一份请求里补上 ?token=,dsh 于是回一个 303 + Set-Cookie,浏览器从此带着 cookie 访问,地址栏始终是干净的 /。令牌不打印、不写日志、不进 URL。

需要它的时候(比如手动接管另一个 dsh):dsh-lan token 会把缓存里的令牌打出来。

这个自动补令牌只对本机网卡地址和 loopback 生效。这条白名单是安全边界而不是便利:如果对任意 Host 都补,一个 DNS rebinding 页面就能让网关给它发一枚全新的会话 cookie。


快速开始

要求:编译期 Go 1.24+;运行期不需要安装任何东西(单二进制、零第三方依赖、离线可编译)。dsh 由你自己装好。

本工具只做两件事:转发流量、捕获启动令牌。它不解析、不探测 dsh 装在哪,也不关心 dsh 用什么运行时 —— 启动时把这条命令行原样交给登录 shell:

$SHELL -lc 'exec dsh web --host 127.0.0.1 --port 3080 --no-open'

终端里能跑,服务里就能跑。dsh 装在哪、依赖什么,与这里无关;系统配置里也不会出现任何解释器路径。

git clone https://git.linuxacme.com/liyepan/deepseek-harness-lan.git
cd deepseek-harness-lan
make build          # 生成 ./dsh-lan
./dsh-lan

输出大致如下:

dsh-lan 0.1.0

  上游      http://127.0.0.1:3080
  监听      0.0.0.0:8080  (所有网卡)
  dsh      /usr/local/bin/dsh web --host 127.0.0.1 --port 3080 --no-open

  局域网地址(任何一块网卡都可用)
    http://172.19.4.240:8080/  (ens33)

  安全提示
    ...

手机连同一个 Wi-Fi,直接打开那个地址就能用。首次访问由网关在转发时补上启动令牌,浏览器拿到 cookie 之后就与普通登录无异,地址栏始终是干净的 /,令牌不会打印到终端,也不会出现在任何地方。

三种使用姿势

场景 命令
什么都不想管 ./dsh-lan(没在跑就拉起 dsh,在跑就挂上去)
dsh 已经手动跑着 ./dsh-lan --attach --token <控制台里那个 token>
换端口 / 只开某块网卡 ./dsh-lan --listen 172.19.4.240:9000

已经手动跑着 dsh、又不想复制 token 的话,还有一条路:把 dsh 控制台里那条 URL 的 127.0.0.1 换成你的局域网 IP 和网关端口,直接粘到浏览器 —— 一次性登录,之后 cookie 自动生效。

装成系统服务

四个脚本,都是 root 跑,互不依赖。全部是纯 POSIX sh,sudo sh install.sh、sudo bash install.sh、sudo ./install.sh 都行(dash 也没有 [[ 依赖):

sudo ./install.sh            # 复制二进制到 /usr/bin,生成 unit,默认监听 0.0.0.0:8080
sudo ./install.sh 9000       # 换端口:监听 0.0.0.0:9000
sudo ./install.sh --port 9000        # 等价写法
sudo ./install.sh 0.0.0.0:9000       # 也可以直接给完整监听地址
sudo ./enable.sh             # 开机自启 + 立即启动,并回显局域网地址
sudo ./disable.sh     # 停止 + 取消自启(文件都还在)
sudo ./uninstall.sh   # 停服务、删单元、删二进制;加 --purge 连令牌缓存一起删

服务以 sudo 的调用者身份运行(User=jarue),工作目录直接写 ~ —— 由 systemd 自己解析成服务用户的家目录,安装脚本不去查、不去猜任何人家的路径。单元文件里没有 node 路径、没有 nvm 路径、没有 PATH、没有 HOME:

[Service]
User=jarue
Type=simple
WorkingDirectory=~
StateDirectory=dsh-lan
StateDirectoryMode=0700
ExecStart=/usr/bin/dsh-lan --listen 0.0.0.0:8080 --state /var/lib/dsh-lan/state.json
Restart=on-failure
RestartSec=3
KillMode=mixed
TimeoutStopSec=20

以 root 直接安装(没有 SUDO_USER)时不会生成 User= 行,服务就是 root 的。

端口优先级:位置参数 > DSH_LAN_LISTEN 环境变量 > 8080。传入的端口必须是 1-65535 的整数,否则脚本直接拒绝并说明用法。

已经装过、服务正在跑时,重装后需要重启才生效(脚本会提示):

sudo ./install.sh 9000 && sudo systemctl restart dsh-lan

换用户/工作目录时用环境变量覆盖后重装:

DSH_LAN_USER=alice DSH_LAN_LISTEN=0.0.0.0:9000 sudo ./install.sh

install.sh 支持 DESTDIR=/some/prefix(不碰系统、不调 systemctl),方便先看一眼生成的文件。


命令与参数

dsh-lan [start] [flags]     启动/接管 dsh,然后对外提供局域网访问
dsh-lan doctor              体检:dsh、端口、网卡、令牌缓存、防火墙提示
dsh-lan token [clear]       查看或清除缓存的启动令牌
dsh-lan version
参数 默认值 说明
--listen 0.0.0.0:8080 网关绑定地址。0.0.0.0 = 所有网卡
--upstream 127.0.0.1:3080 dsh web 的地址
--command dsh web --host 127.0.0.1 --port 3080 --no-open 原样执行的命令行,由登录 shell 执行
--attach 关 绝不启动 dsh,只接管已在运行的实例
--token 空 手动指定启动令牌;空则从 dsh 输出里抓,再退回复用缓存
--state .dsh-lan/state.json 令牌缓存位置(仓库内,已 gitignore)
--allow-host 空 额外允许自动注入令牌的 Host 名,可重复
--no-token-inject 关 关闭自动注入,只用 ?token= 登录
--quiet 关 不打印逐条请求日志

令牌缓存只落在仓库目录里,不写 ~/.dsh、不写系统配置。用 dsh-lan token clear 随时抹掉。


安全模型

能打开这个页面的人,就能在这台机器上以你的身份执行命令、读写文件。 这不是危言耸听,是 dsh web 的设计目标 —— 它就是给人用的 shell 界面。

因此:

  • 只在可信局域网开。家用路由器、公司内网可以;咖啡馆 Wi-Fi 不行。
  • 绝对不要做端口映射、不要挂在公网 IP 上。要外网访问,套 WireGuard / Tailscale,别裸露 HTTP。
  • 没有 TLS。同一网段上的人可以嗅探到 token 和 cookie。这一点和 dsh 自己提供的传输层一致(dsh 的 cookie 也不带 Secure)。
  • 没有多用户隔离。所有连上来的人共用同一个 OS 用户;审批弹窗谁都能点。
  • 网关日志里的 token= 会被替换成 ***,但浏览器历史、剪贴板、分享链接不受网关控制。
  • 自动注入令牌的 Host 白名单(本机网卡 + loopback)是防 DNS rebinding 的,别为了"方便"关掉;真需要别的名字,用 --allow-host 精确加一条。

想更保险:用完 Ctrl+C 关掉;或者用完就把 dsh 也停了。


常见问题

页面 403

说明请求到了 dsh 的栅栏上,但 Host/Origin 没被改写。多半是你直连了 3080,或者中间还有别的代理。检查是不是打开了 http://<ip>:3080 而不是网关端口。

页面 401

栅栏过了,但没认证。原因通常是:你用了 --no-token-inject;或者访问的 Host 不在白名单(比如某个自定义域名);或者缓存里的 token 是上一次 dsh 进程的(每次重启都会换)。用带 ?token= 的链接进去一次即可。

手机打不开,电脑能开

按顺序排查:网关是不是绑在 0.0.0.0 而不是 127.0.0.1(看 banner);防火墙有没有放行(dsh-lan doctor 会提示,例如 sudo ufw allow 8080/tcp);路由器是不是开了 AP 隔离(访客网络常见);手机和电脑是不是真在同一个网段。

8080 被占用

--listen 0.0.0.0:9000 换一个。

dsh 挂了会怎样

网关会跟着退出,并打印 dsh 最后的输出。前台运行的话直接重跑 ./dsh-lan 就行。

能把它丢到后台吗

nohup ./dsh-lan --quiet >>dsh-lan.log 2>&1 &。想开机自启就自己写个 systemd user unit 指向这个二进制(本项目不替你改系统)。


设计取舍

为什么用 Go

标准库的 httputil.ReverseProxy 已经处理好流式转发、FlushInterval、协议升级和 hop-by-hop 头,WebSocket 不需要额外库;编译产物是单文件,运行期零依赖、不挑 Node 版本、不和 dsh 的 node_modules 打架。整个项目只用标准库,go build 离线可完成。

为什么改写 Host,而不是给 dsh 加信任

给 dsh 加 --trusted-host 也能过栅栏,但那就得把 IP 一个个登记进去,DHCP 一变、换个网段、用主机名访问就得重来。改写 Host 之后 dsh 眼里永远只有 loopback,客户端从哪个地址来都无所谓 —— 这才是"允许局域网访问",而不是"允许某个 IP 访问"。

为什么不改 dsh 的配置

dsh 是用户自己装的。改 ~/.dsh 意味着升级、重装、换机器都要重来一遍,而且一旦把 dsh 绑到 0.0.0.0,暴露面就是整台机器的所有网卡(包括 VPN、docker0、公网网卡)。网关的好处是暴露面只有你主动绑的那一个监听。

为什么密码门没做

dsh 自己的 token 已经是 32 字节随机串,再加一层密码在没有 TLS 的前提下收益有限、复杂度不小。要做也应该和 TLS 一起做,见路线图。


开发

cmd/dsh-lan/        入口
internal/cli/       子命令、参数、banner、doctor
internal/gateway/   反向代理:Host/Origin 改写、SSE、WebSocket、日志脱敏
internal/dsh/       进程托管(登录 shell 执行命令)、启动行解析与令牌脱敏
internal/lan/       网卡枚举(显示用 / 白名单用两套)
internal/state/     令牌缓存
make test     # go test ./...
make vet
make build

测试覆盖了三条最容易错的地方:Host/Origin 改写、令牌只注入可信 Host、升级请求不被 recorder 破坏。

已在真实 dsh 上验证的行为

对一个正在运行的 dsh web(127.0.0.1:3080)发起同一组请求:

请求 直连 3080 经网关
/api/*,Host 为局域网 IP 403(栅栏拒绝) 401(栅栏通过,等待认证)
/api/remote.mux WebSocket 升级 — 401(不是 403)
/ 无 cookie 401 401

401 而不是 403,正是栅栏被正确改写后应有的结果。


附录:不装网关的官方做法

如果你能接受"绑所有网卡 + 没有额外控制",dsh 自己的代码路径其实已经准备好,只需在 ~/.dsh/profiles/web/cordis.patch.yml 里覆盖 webserver 那一行的 host(patch 会整段替换 config,所以其余键要一起重写):

- id: webserver
  config:
    host: '0.0.0.0'
    port: !!js ctx.webStartup.port ?? 3080
    compression: gzip
    compressionLevel: 1
    compressionThresholdBytes: 1024

之后 dsh web 会自己打印 (LAN: http://<ip>:3080/?token=...)。这条路不需要本项目,代价是:暴露所有网卡、只能用 IP 字面量访问(不认主机名、不认 IPv6)、没有请求日志、没有令牌注入。本项目没有采用它,原因见上面的设计取舍。


路线图

  • 可选 TLS(自签证书,解决同网段嗅探和 navigator.clipboard 之类需要安全上下文的 API)
  • 终端二维码,手机扫码直连
  • 可选口令门(与 TLS 一起做才有意义)
  • mDNS 广播,让手机用 hostname.local 访问
  • systemd user unit 模板

License

MIT

S
Description
No description provided
Readme MIT
133 KiB
Languages
Go 86.1%
Shell 13.4%
Makefile 0.5%