跳到主内容
帖子

在绿联 NAS 上部署 DSH,手机可访问

摘要

从 SSH 登录开始,先在绿联 NAS 上用 Docker 部署 DSH;不想用 Docker 的,后面还有 systemd 常驻、固定 50289 入口和 Tailscale 直连方案。手机在同一 Wi-Fi 或外网都能打开。

下面从登录 NAS 开始,按顺序执行。每一步都有命令和检查结果,命令整段复制到 SSH 里。

文中的 NAS_IP、TAILSCALE_IP、NAS_LAN_CIDR 都是占位符,执行前换成你自己的地址,不要把真实 IP 写进文章或截图。

1. 打开 SSH 窗口

SSH 窗口不是 UGOS 网页,是电脑上的命令行窗口。登录成功后,所有命令都在这个窗口里执行。

1.1 准备

UGOS 控制面板 → 应用中心,确认 Docker 已安装;控制面板 → 终端机/SSH,开启 SSH;控制面板 → 网络 → 网络接口,能看到 NAS 的 IPv4 地址。

1.2 Windows 打开 PowerShell

按 Win 键,输入 PowerShell,点“Windows PowerShell”。

或者按 Win + R,输入 powershell,回车。

如果提示 ssh 不是内部或外部命令,去“设置 → 应用 → 可选功能 → 添加功能”,安装 OpenSSH 客户端。

1.3 macOS 打开终端

按 Command + 空格,输入 Terminal,回车。

1.4 登录 NAS

在窗口里执行:

ssh 你的管理员账号@NAS_IP

把 你的管理员账号 换成 UGOS 的登录账号,NAS_IP 换成 NAS 的局域网 IP。

首次连接会问:

Are you sure you want to continue connecting (yes/no)?

输入 yes,回车。然后输入 NAS 密码。密码不会显示,输完直接回车。

登录成功后,行首会变成类似:

你的账号@DXP4800PLUS:~$

看到这个提示符,说明 SSH 窗口已经连上 NAS。

检查当前用户:

whoami

应该输出你的 UGOS 账号。

检查 Docker:

docker --version
docker compose version

两条命令都能输出版本号,再继续。提示 permission denied,在命令前加 sudo,或者把当前账号加入 Docker 组。

1.5 连不上怎么办

  • Connection refused:SSH 没开,回 UGOS 控制面板开启。
  • Connection timed out:IP 不对,或者电脑和 NAS 不在同一个网络。
  • Permission denied:账号或密码不对,注意 UGOS 账号不是 NAS 设备名。

2. 建文件夹

mkdir -p /volume1/docker/dsh
cd /volume1/docker/dsh
pwd

pwd 应该输出:

/volume1/docker/dsh

不是这个路径,说明 cd 没成功,重新执行上一行。

3. 生成配置文件

SSH 窗口就是第 1 步登录后停留的窗口。

需要五个文件。每个代码块是一整段命令,复制后粘到 SSH 窗口,按回车。粘贴用鼠标右键,或按 Ctrl+Shift+V。看到新的命令提示符,就是写好了。

每生成一个文件,执行 ls -lh 文件名 检查。这里的“文件名”换成实际名字,比如 ls -lh Dockerfile。文件大小不是 0,再执行 head -3 文件名,看开头是不是表格里的内容:

文件 检查命令 应该看到
Dockerfile head -3 Dockerfile FROM node:22.23.4-bookworm-slim
nginx.conf head -3 nginx.conf worker_processes auto;
entrypoint.sh head -3 entrypoint.sh #!/usr/bin/env bash
docker-compose.yml head -3 docker-compose.yml services:
.env cat .env DSH_TRUSTED_HOSTS=

3.1 生成 Dockerfile

cat > Dockerfile <<'EOF'
FROM node:22.23.4-bookworm-slim

RUN apt-get update \
    && apt-get install -y --no-install-recommends nginx \
    && rm -rf /var/lib/apt/lists/*

RUN npm install -g @deepseek-ai/dsh@latest

COPY nginx.conf /etc/nginx/nginx.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENV DSH_HOME=/data
EXPOSE 3080
ENTRYPOINT ["/entrypoint.sh"]
EOF

3.2 生成 nginx.conf

cat > nginx.conf <<'EOF'
worker_processes auto;
error_log /dev/stderr warn;
pid /var/run/nginx.pid;

events {
  worker_connections 1024;
}

http {
  include /etc/nginx/mime.types;
  default_type application/octet-stream;

  map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
  }

  server {
    listen 3080;
    server_name _;
    client_max_body_size 64m;

    location / {
      proxy_pass http://127.0.0.1:3081;
      proxy_http_version 1.1;

      proxy_set_header Host $http_host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;

      proxy_buffering off;
      proxy_read_timeout 86400;
      proxy_send_timeout 86400;
    }
  }
}
EOF

3.3 生成 entrypoint.sh

cat > entrypoint.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

TRUST_ARGS=()
for h in ${DSH_TRUSTED_HOSTS:-}; do
  TRUST_ARGS+=(--trusted-host "$h")
done

dsh web --port 3081 "${TRUST_ARGS[@]}" &
DSH_PID=$!

cleanup() {
  kill "$DSH_PID" 2>/dev/null || true
}
trap cleanup EXIT INT TERM

exec nginx -g "daemon off;"
EOF

3.4 生成 docker-compose.yml

cat > docker-compose.yml <<'EOF'
services:
  dsh:
    build: .
    image: dsh-web:local
    container_name: dsh-web
    restart: unless-stopped
    environment:
      TZ: Asia/Shanghai
      DSH_HOME: /data
      DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
      DSH_TRUSTED_HOSTS: ${DSH_TRUSTED_HOSTS:-}
    volumes:
      - dsh-data:/data
    ports:
      - "3080:3080"

volumes:
  dsh-data:
EOF

这里的 ${DEEPSEEK_API_KEY:-} 不用改成你的 Key。它是从 .env 读变量,compose 文件保持原样。

3.5 生成 .env

先看 NAS 的 IP。UGOS 控制面板 → 网络 → 网络接口,能看到 IPv4 地址。

cat > .env <<'EOF'
DSH_TRUSTED_HOSTS="NAS_IP"
DEEPSEEK_API_KEY=
EOF

这段内容不是网页表单,是一段终端命令。DEEPSEEK_API_KEY= 等号后面就是填 Key 的位置;现在等号后面是空的,所以看起来没有地方可填。

把 NAS_IP 换成 NAS 的 IP。

DEEPSEEK_API_KEY 是 DeepSeek 开放平台的 API Key,不是 NAS 密码,也不是 DSH 的登录 token。没有 Key,打开 DeepSeek 开放平台,登录后创建,复制 sk- 开头的那串。账户里要有可用余额。Key 通常只显示一次,丢了就重新创建。

DEEPSEEK_API_KEY= 可以留空,启动后进网页,在设置或模型入口里填,Key 会存进 dsh-data 卷。

也可以现在填。把 3.5 的命令重新执行一遍,改成这样:

cat > .env <<'EOF'
DSH_TRUSTED_HOSTS="NAS_IP"
DEEPSEEK_API_KEY=sk-xxxxxxxx
EOF

DSH_TRUSTED_HOSTS 保留 NAS 的 IP,把 sk-xxxxxxxx 换成你的 Key,不要加引号。改完 .env 要执行 docker compose up -d --force-recreate,环境变量才会重新注入容器;在网页里填则不用重建。

不要把 Key 写进 docker-compose.yml,也不要截图发出去。

检查文件:

ls -lh Dockerfile nginx.conf entrypoint.sh docker-compose.yml .env
cat .env

五个文件都要在,大小不是 0。cat .env 里的 DSH_TRUSTED_HOSTS 要等于 NAS 的 IP。

4. 启动

先检查 Compose 配置:

cd /volume1/docker/dsh
docker compose config

能输出一段展开后的 YAML,没有报错,再构建:

docker compose up -d --build

首次构建要下载 Node 镜像和 npm 包,几分钟到十几分钟都正常。

看日志:

docker compose logs -f dsh

看到 dsh web 的监听地址后,按 Ctrl+C 退出日志。

检查状态:

docker compose ps

状态是 Up 才继续。

也可以在 UGOS 的 Docker 项目页操作:

Docker 项目页

Docker → 项目,点“创建”。

创建项目

项目名随便填;存放路径选 /volume1/docker/dsh;Compose 配置粘贴 docker-compose.yml 的内容。

5. 电脑打开

浏览器访问:

http://NAS_IP:3080

把 IP 换成 NAS 的 IP。

页面能打开后,点设置或模型入口,填 DeepSeek API Key,保存,点测试连接。

没有 API Key,在 DeepSeek 开放平台 创建一个。

如果打开后被跳到带 ?token=... 的地址,把完整地址留着。DSH 重启后 token 会变。

6. 手机在同一个 Wi-Fi

手机连同一个 Wi-Fi,浏览器打开:

http://NAS_IP:3080

打不开,先确认手机和 NAS 在同一网段。手机用 5G、NAS 在局域网,当然打不开。路由器开了 AP 隔离或访客网络,也会打不开。

页面显示 403 Forbidden,改 .env:

DSH_TRUSTED_HOSTS="NAS_IP"

这里填 NAS 的访问地址,不是手机自己的 IP。改完执行:

docker compose up -d --force-recreate

7. 手机在外网

不要把 3080 直接映射到公网。DSH 能在容器里执行命令,没有登录鉴权。

NAS 终端里安装 Tailscale:

curl -fsSL https://tailscale.com/install.sh | sudo sh

启动:

sudo tailscale up \
  --advertise-routes=NAS_LAN_CIDR \
  --accept-dns=false \
  --hostname=ugreen-nas

NAS_LAN_CIDR 换成 NAS 所在网段。

到 Tailscale 管理后台 批准这条子网路由:

Tailscale 子网路由

找到 ugreen-nas,点 Approve。

手机装 Tailscale,登录同一个账号,允许使用子网路由。然后打开:

http://NAS_IP:3080

也可以试 Tailscale 地址:

http://TAILSCALE_IP:3080

TAILSCALE_IP 换成 Tailscale 分配给 NAS 的地址。

把手机实际使用的地址加进 .env:

DSH_TRUSTED_HOSTS="NAS_IP TAILSCALE_IP ugreen-nas"

重建:

docker compose up -d --force-recreate

手机打开页面,发一条消息,能收到流式输出,就算通了。

8. 日常操作

看日志:

docker compose logs -f dsh

重启:

docker compose restart dsh

停止:

docker compose down

更新:

docker compose build --pull --no-cache
docker compose up -d

dsh-data 卷里存会话和设置。不要执行 docker compose down -v,会把数据删掉。

9. 出错时

403 Forbidden。

.env 里的地址和浏览器地址栏不一致。改完执行 docker compose up -d --force-recreate。

页面能开,回答不动。

WebSocket 没通。检查 nginx.conf 里 proxy_set_header Upgrade、Connection 和 proxy_http_version 1.1。

容器一直重启。

docker compose logs --tail=100 dsh

常见原因是 .env 里有空项,或者 DSH 启动参数错了。

构建卡在 npm install。

网络问题。给 Docker 配代理,或者给 npm 换一个能用的 registry。

端口被占用。

把 docker-compose.yml 里的 "3080:3080" 改成 "4080:3080",浏览器访问 http://NAS_IP:4080。

Tailscale 打不开。

确认 NAS 上 Tailscale 在线,确认子网路由已批准。

token 变了。

查日志:

docker compose logs dsh | grep -i token

把最新 URL 里的本机地址换成 NAS 的 IP,再打开。

10. 安全

  • 不要把 3080 映射到公网
  • 不要在路由器上做端口转发
  • 用 Tailscale 访问,不要直接暴露
  • API Key 放在 .env,不要提交到 Git
  • DSH 只挂需要读写的文件夹,不要挂整个 /volume1

11. 不想用 Docker:改成 systemd 常驻

Docker 方案能跑。原生安装少一层容器,改配置不用重建镜像;代价是 unit 文件、权限、日志都要自己维护。

11.1 装独立的 Node

系统自带的 Node 版本可能偏低,装一个独立的:

cd /volume1/docker/dsh
curl -O https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.xz
tar -xJf node-v24.22.1-linux-x64.tar.xz
mv node-v24.22.1-linux-x64 node
./node/bin/node -v

把 npm 全局目录指到自己的盘上,再装 DSH:

mkdir -p /volume1/docker/dsh/npm-global
./node/bin/npm config set prefix /volume1/docker/dsh/npm-global
./node/bin/npm i -g @deepseek-ai/dsh
/volume1/docker/dsh/npm-global/bin/dsh --version

11.2 写 systemd unit

先建目录:

mkdir -p /volume1/docker/dsh/home /volume1/docker/dsh/workspace /volume1/docker/dsh/logs

写 /etc/systemd/system/dsh-web.service:

[Unit]
Description=DeepSeek Harness Web UI
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
UMask=0077
User=1000
Group=100
WorkingDirectory=/volume1/docker/dsh/workspace
Environment=HOME=/volume1/docker/dsh/home
Environment=DSH_HOME=/volume1/docker/dsh/home
Environment=PATH=/volume1/docker/dsh/node/bin:/volume1/docker/dsh/npm-global/bin:/usr/bin:/bin
ExecStart=/volume1/docker/dsh/npm-global/bin/dsh web --host NAS_IP --port 50288 --no-open
Restart=on-failure
RestartSec=3
StandardOutput=append:/volume1/docker/dsh/logs/web.log
StandardError=append:/volume1/docker/dsh/logs/web.log

[Install]
WantedBy=multi-user.target

NAS_IP 换成 NAS 的局域网 IP。

sudo systemctl daemon-reload
sudo systemctl enable --now dsh-web
sudo systemctl status dsh-web

--host 只写一个具体 IP。写 0.0.0.0 会被 DSH 直接拒绝,这不是防火墙拦的。

11.3 三个容易卡住的地方

status=217/USER,服务起不来。

UGOS 的用户名不走标准 NSS,unit 里不要写账号名。用数字:

User=1000
Group=100

启动就退出,日志说凭据权限不对。

.credentials.yaml 的权限太宽。找到它,改成 600:

find /volume1/docker/dsh/home -name '.credentials.yaml' -exec chmod 600 {} \;

unit 里的 UMask=0077 也要保留,否则下次写出来还是宽权限。

打开页面提示工作区初始化失败。

DSH 默认工作区会依赖 xdg-user-dir DOCUMENTS,NAS 上没有这个目录。找到 profile 里的 cordis.patch.yml:

find /volume1/docker/dsh/home -name cordis.patch.yml

把文档目录固定到工作区,例如:

documentsDirectory: /volume1/docker/dsh/workspace

改完重启:

sudo systemctl restart dsh-web

12. 固定入口,不用每次找 token

dsh web 每次启动都会生成新的 token,重启一次地址就变一次,手机书签立刻失效。

加一个只做 302 的小服务,端口 50289。它每次请求都读 logs/web.log 里最后一次出现的 ?token=,然后跳到最新地址:

http://NAS_IP:50289/  →  302  http://NAS_IP:50288/?token=<最新>

这样书签固定为 50289,后端重启不用管。

入口服务还要看请求的 Host:从局域网 IP 来就跳局域网地址,从 Tailscale IP 来就跳 Tailscale 地址。不处理这一点,外面打开会被跳到家里的地址。

/volume1/docker/dsh/entry.py:

#!/usr/bin/env python3
import os
import re
import http.server
import socketserver
from pathlib import Path

LOG = Path("/volume1/docker/dsh/logs/web.log")
PORT = 50289
LAN_HOST = os.environ["DSH_LAN_HOST"]
TS_HOST = os.environ["DSH_TS_HOST"]
PATTERN = re.compile(r"http://[0-9.]+:50288/\?token=[A-Za-z0-9_-]+")


def current_url():
    try:
        text = LOG.read_text(errors="replace")
    except OSError:
        return None
    found = PATTERN.findall(text)
    return found[-1] if found else None


class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        url = current_url()
        if url is None:
            body = b"DSH web is not running or has not printed a token yet.\n"
            self.send_response(503)
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return

        host = self.headers.get("Host", "")
        target = TS_HOST if host.startswith("100.") or host.startswith(TS_HOST) else LAN_HOST
        url = re.sub(r"^http://[^/]+", f"http://{target}:50288", url)

        self.send_response(302)
        self.send_header("Location", url)
        self.send_header("Cache-Control", "no-store")
        self.end_headers()

    def log_message(self, fmt, *args):
        pass


class Server(socketserver.ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True


if __name__ == "__main__":
    with Server(("0.0.0.0", PORT), Handler) as httpd:
        httpd.serve_forever()

对应 unit /etc/systemd/system/dsh-entry.service:

[Unit]
Description=DSH stable entry
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Environment=DSH_LAN_HOST=NAS_IP
Environment=DSH_TS_HOST=TAILSCALE_IP
ExecStart=/usr/bin/python3 /volume1/docker/dsh/entry.py
Restart=on-failure
RestartSec=3

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now dsh-entry

手机书签写:

http://NAS_IP:50289/

13. 手机在外网:直接走 Tailscale 地址

Docker 方案用子网路由。子网路由会被网络管理器删规则,tailscaled 日志里能看到:

systemd-networkd deleted ip rules; restoring Tailscale's

规则一删,手机就找不到 NAS。与其跟 systemd-networkd 抢路由,不如不依赖子网路由,把服务直接暴露在 NAS 的 Tailscale IP 上。

先确认 Tailscale 在线:

sudo /volume1/docker/tailscale/tailscale \
  --socket=/volume1/docker/tailscale/tailscaled.sock status

正常情况下只看到 NAS 和手机,电脑关不关机不影响。

看两个字段:

  • 手机节点的 LastHandshake 是 0001-01-01:控制面连上了,数据通道没建立
  • 日志里有 systemd-networkd deleted ip rules:路由规则被反复删除

路由问题不用继续修。直接在 NAS 上做 TCP 转发,把 TAILSCALE_IP:50288 转到 NAS_IP:50288。有 socat 的话,一条命令:

sudo socat TCP-LISTEN:50288,bind=TAILSCALE_IP,fork,reuseaddr TCP:NAS_IP:50288

长期跑就写成 systemd unit,ExecStart 换成上面的 socat 命令。

还要让 DSH 接受 Tailscale 地址,否则它看到 Host: TAILSCALE_IP:50288 会拒绝:

ExecStart=/volume1/docker/dsh/npm-global/bin/dsh web \
  --host NAS_IP \
  --port 50288 \
  --trusted-host TAILSCALE_IP:50288 \
  --no-open

改完:

sudo systemctl daemon-reload
sudo systemctl restart dsh-web

入口服务按 Host 决定跳转目标,所以手机只需要连 Tailscale:

场景 地址 依赖
家里 Wi-Fi http://NAS_IP:50289/ 同网段即可
外面 / 4G http://TAILSCALE_IP:50289/ 只依赖 Tailscale 隧道

这样路由表怎么抖动都不影响访问。

14. 跑 Codex CLI

DSH 装好后,Codex 也可以放在 NAS 上,写代码不用一直开着电脑。

/volume1/docker/dsh/node/bin/npm i -g @openai/codex
/volume1/docker/dsh/npm-global/bin/codex --version

它需要三个环境变量:

  • CODEX_HOME:配置和登录态放哪
  • HTTPS_PROXY / HTTP_PROXY:NAS 直连不通时走本机代理
  • 运行时加 --skip-git-repo-check:NAS 上没有 git 仓库,不加会报“不在受信任目录”

从 DSH 里调用 Codex(subagent 插件)时,这三个变量要写进插件配置的 env 里。只在 shell 里 export 没用,插件拉起的子进程不继承交互式环境,表现就是“命令行能用、插件里报网络不可达”。

手机访问 Codex,用 ttyd 不合适:手机上敲命令太累,输出也不会自动滚到底。用一个很薄的网页壳,只保留三件事:

  1. 输入框把内容送到 Codex,输出按行流式返回
  2. 历史记录存本地 JSON,刷新不丢
  3. 停止按钮,长任务能中断

命令列表、工具调用这类结构化内容按卡片渲染,比终端里的纯文本好读。

15. 更新时要注意

/volume1/docker/dsh/node/bin/npm i -g @deepseek-ai/dsh@latest
sudo systemctl restart dsh-web

token 会变,但入口服务读日志,书签不用改。

如果改过 --host 或端口,三处要同步:

  • systemd 的 unit 文件
  • 入口服务里写死的后端端口
  • Tailscale 转发器的目标端口

任意一处不同步,表现都是“页面打不开”。

· 4 次查看

更多相关内容

2026年10月21日
PHP2Shell 和 eMudhra 证书漏洞:怎么回事,怎么修
2 次查看
2026年10月19日
API 站被彻底关闭之后
24 次查看
2026年10月19日
GitHub 能做什么:从建仓库到 PR、Pages 和 Actions
0 次查看
2026年10月19日
网站访问速度优化:从排查到分类的实操指南
1 次查看
豫ICP备20014910号-7