硬隔离教程(新手版)— 每用户独立 OS 账号

August 26, 2026 · View on GitHub

🧭 ← 返回 README · 前置:基础部署 · 出问题:排查手册

本教程是 deployment.md 的进阶篇。先按那篇把整套跑通、能正常登录和打开 DSH,再回来做这个。 全文用 example.com 举例,请替换成你自己的域名;/root/dsh-server-login 换成你实际 git clone 的目录。

0. 为什么需要硬隔离(先看懂再动手)

默认部署是软隔离:所有用户的 DSH 进程跑在**同一个系统账号(root)**下,每个用户自己的目录设了 0700(只有本人能进)。

问题是:0700 只对别的系统账号有效。同一个 root 账号下的进程之间,权限检查形同虚设——一个用户(的 DSH 进程)可以用 root 直接读另一个用户的文件。

硬隔离就是:给每个用户建一个独立的 Linux 系统账号,让它的 DSH 进程以这个账号运行。这样:

  • 0700 才真正生效(别的账号进不来)。
  • 用户 A 的 DSH 进程,即使用户 B 的目录是 0700,也会被系统直接拒绝。

代价:需要 root 权限 + 每个用户创建时要做一次「建账号 + 改属主」。好在可以自动化(见 §3.1),一次配好就不用管。


1. 确认编排服务已用 root 运行

硬隔离要靠 setpriv 把进程降权到目标账号,这只有 root 能调用。所以编排服务必须以 root 跑。

如果你用了 deployment.md 的 systemd 配置(/etc/systemd/system/dsh-server-login.service),默认就是 root,跳过这步。


2. 打开账号级隔离的开关

在环境变量文件里加两个配置(/etc/dsh-server-login.env):

# 追加这两行
DSH_SERVER_LOGIN_ISOLATION_MODE=account
DSH_SERVER_LOGIN_BASE_UID=100000
变量解释
DSH_SERVER_LOGIN_ISOLATION_MODEaccount开启账号级隔离(默认 soft)。
DSH_SERVER_LOGIN_BASE_UID100000每个用户 uid 的「起始数字」。系统 uid 一般 < 1000,这里从 10 万开始,避免撞系统账号。

改完重启编排服务让配置生效:

systemctl restart dsh-server-login

从这一刻起,编排服务 spawn 每个用户的 DSH 时,会用它内置的 setpriv 命令(setpriv --reuid <uid> --regid <uid> --inh-caps=-all --clear-groups --)把进程降权到该用户账号。


2.1 ⚠️ 前置:dsh 必须装在降权账号能读到的位置(不能装在 /root 下)

硬隔离降权后,子 DSH 以每用户账号(uid > 100000)运行,这些账号读不到 root 的 home 目录 /root

如果 dsh(以及它依赖的 node)是用 nvm 装在 /root/.nvm/... 下的(默认就是),开了 account 之后,子 DSH 一启动就会报 Cannot find module '/root/.nvm/.../bin/dsh',然后每秒崩溃循环。日志长这样:

[dsh-child main] node:internal/modules/cjs/loader:1210
Error: Cannot find module '/root/.nvm/versions/node/.../bin/dsh'

所以开 §2 的开关之前,先把 node + dsh 装到系统级位置(所有账号都能读):

# 1) 系统级安装 node 22(装到 /usr/bin)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs

# 2) 系统级安装 dsh(注意:必须强制 prefix,否则会被 nvm 劫持装回 /root)
NPM_CONFIG_PREFIX=/usr/local /usr/bin/npm install -g @deepseek-ai/dsh
ls -l /usr/local/bin/dsh      # 必须是 /usr/local/bin/dsh,不能在 /root 下

# 3) 环境变量指向系统 dsh
#    /etc/dsh-server-login.env → DSH_SERVER_LOGIN_DSH_BIN=/usr/local/bin/dsh

# 4) systemd 的 PATH 去掉 /root/.nvm/...,改成系统路径
#    (子 DSH 的 #!/usr/bin/env node 才找得到系统 node)
#    /etc/systemd/system/dsh-server-login.service:
#    Environment=PATH=/usr/bin:/usr/local/bin:/usr/sbin:/usr/local/sbin:/sbin:/bin

nvm 劫持 npm 的坑:即使你用 /usr/bin/npm,它仍可能装到 /root/.nvm/...——因为 /root/.npmrc 里被 nvm 写了 prefix=/root/.nvm/versions/node/...。检查:/usr/bin/npm prefix -g(返回 nvm 路径就是被劫持了)。解决:命令前加 NPM_CONFIG_PREFIX=/usr/local 强制覆盖。

另一个降权后读不到的东西:运行时插件 dsh-server-login/runtimegit clone 的目录(如 /dsh_login/dsh-server-login/lib/)里,降权账号也要能读到。检查 ls -ld /dsh_login,如果是 drwx------(只有 root),执行 chmod 755 /dsh_login


3. 为每个用户创建系统账号(核心一步)

这是唯一需要手动做的事:用户注册后,要先给他建系统账号 + 把他目录改成这个账号所有,硬隔离才完整。没做这一步,DSH 会以 root 运行(跟没开硬隔离一样)。

创建脚本(provision-user.sh,root 运行):

#!/usr/bin/env bash
# 用法:provision-user.sh <userId>     (userId 就是数据库里 users 表的 id,管理员界面能看到)
set -euo pipefail

uid="$(dsh-server-login uid-for-user "\$1")"
user="dsh-\$1"

# 1) 创建系统账号(uid 用插件算出来的同一个值,保证两边一致;不建 home、不能登录)
useradd -u "$uid" -M -s /usr/sbin/nologin "$user"

# 2) 把该用户的目录改成这个账号所有(关键!)
chown -R "$uid:$uid" "/var/lib/dsh-server-login/users/\$1"

echo "provisioned \$1 -> uid $uid"

怎么执行:

chmod +x provision-user.sh
./provision-user.sh <userId>

userId 从哪拿:管理员登录后访问 /api/admin/users(返回 JSON 里每个用户的 id 字段),或直接查数据库 users 表。

3.1 让它自动跑(不用每次手动)

每个用户注册后都手动跑一次太麻烦,这里给几个自动触发方案,按你服务器的环境挑一个:

方案 A:systemd 监控目录(最简单,无需额外软件)

把上面那个脚本存成 /usr/local/bin/provision-user.sh,然后用 systemd 的路径监控:每当某个用户的目录被创建(即注册成功),就自动跑一次脚本

/etc/systemd/system/dsh-provision.path

[Unit]
Description=Watch new user dirs and provision OS accounts

[Path]
PathExistsGlob=/var/lib/dsh-server-login/users/*/home
Unit=dsh-provision.service

[Install]
WantedBy=multi-user.target

/etc/systemd/system/dsh-provision.service

[Unit]
Description=Provision a newly registered user
After=dsh-server-login.service

[Service]
Type=oneshot
# 找出刚注册、还没建账号的用户,挨个建
ExecStart=/usr/local/bin/provision-new-users.sh

/usr/local/bin/provision-new-users.sh(核心:遍历所有用户目录,没建账号的先建,然后每次都 chown):

#!/usr/bin/env bash
set -euo pipefail
for dir in /var/lib/dsh-server-login/users/*/; do
  [ -d "$dir" ] || continue
  id="$(basename "$dir")"
  user="dsh-$id"
  if ! id "$user" &>/dev/null; then
    # 账号不存在才建(uid 用插件算出来的同一个值,保证两边一致)
    uid="$(dsh-server-login uid-for-user "$id")"
    useradd -u "$uid" -M -s /usr/sbin/nologin "$user"
  fi
  uid="$(id -u "$user")"          # 账号已存在就复用它的 uid
  chown -R "$uid:$uid" "$dir"     # 每次都 chown(幂等,把漏掉的属主补上)
  echo "provisioned $id -> uid $uid"
done

⚠️ 别写「账号已存在就跳过」:那样会连 chown 一起跳过——如果某用户的目录后来变成 root 所有,重跑也不会修,他的 DSH 还是会因为读不了自己 home 而崩(外网 502)。所以 chown 要放在判断外面、每次都执行。

启用:

systemctl daemon-reload
systemctl enable --now dsh-provision.path

这样以后用户一注册,目录一出现,systemd 就自动建账号 + 改属主,管理员不用再管。

方案 B:定时任务(简单粗暴,隔几分钟扫一次)

如果不想用 systemd 的 path 监控,就用 cron 每 5 分钟跑一遍同一个 provision-new-users.sh(幂等,跑多少次都安全):

crontab -e
# 加一行:
*/5 * * * * /usr/local/bin/provision-new-users.sh

方案 C:手动(用户少时够用)

用户少、注册不频繁,管理员有空就手动跑 ./provision-user.sh <userId> 也行。但别忘了——漏一个 = 那个用户还在 root 下跑,等于没隔离。

推荐:方案 A 最省心、自动、幂等,一次配好就不用管。


4. 验证硬隔离真的生效

第 1 步:看 DSH 进程的 uid。

先在桌面启动一个用户的 DSH,然后:

# 找到 DSH 进程
ps aux | grep dsh | grep -v grep

# 假设拿到了 pid,看它的真实 uid
ps -o uid,user,cmd -p <pid>

应该看到uid 等于 dsh-server-login uid-for-user <那个userId>(是一个 >100000 的账号),不是 0(root)。如果显示 0,说明没降权,回头检查第 2、3 步。

第 2 步:越权读测试(最关键)。

# 1) 在用户 A 的 home 里放个文件
echo "secret" > /var/lib/dsh-server-login/users/<A的id>/home/s.txt
chmod 600 /var/lib/dsh-server-login/users/<A的id>/home/s.txt

# 2) 用用户 B 的 DSH 去读它(在 B 的 DSH 里执行 cat)
# 正确结果:Permission denied(读不到)

看到 Permission denied = 硬隔离生效。


5. 常见问题

现象原因 / 处理
DSH 还是以 root 跑ISOLATION_MODE=account 没生效(重启了吗?)、或该用户没跑 provision-user.sh。
setpriv: no permission 报错编排服务没以 root 运行。
新用户启动 DSH 报权限错误该用户目录 chown 了吗?重新跑 provision-user.sh。
忘了给某个用户做第 3 步补跑,然后重启该用户的 DSH。
DSH 每秒崩溃,日志 Cannot find module '/root/.nvm/.../bin/dsh'dsh 装在 /root 下,降权账号读不到。按 §2.1 把 dsh 装到系统级位置。
/usr/bin/npm install -g 还是装到 /root/.nvmnpm 全局前缀被 nvm 劫持(/root/.npmrc 里的 prefix)。用 NPM_CONFIG_PREFIX=/usr/local 强制覆盖。