Files
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

35 KiB
Raw Permalink Blame History

dsh(DeepSeek Harness)多用户部署与配置改造方案

项 值
版本 v1.0(2026-09-08)
适用对象 服务器 47.77.182.89(root SSH 端口 32022)
软件 @deepseek-ai/dsh 0.1.2-rc.1(镜像 dsh-web:0.1.2)
状态 P0 迁移已执行(2026-09-08 17:30);域名接入完成(2026-09-08 18:00);架构切换为 dshs 多租户 + account 硬隔离(2026-09-08 18:30)
文档位置 本机 D:\AI技能\aliyun-work-space\dsh-server-docs\;服务器 /opt/dsh/docs/

一、背景与目标

dsh Web UI 已单实例部署并跑通(外网 http://47.77.182.89/,模型对话正常)。需要从「单人单实例」升级为「公司多人使用」形态。

目标(5 项):

  1. 多用户使用:多个编导/账号运营各自登录使用;
  2. 数据持久化:会话、配置、凭据、工作区不随容器重建丢失;
  3. 跨用户文件安全:用户之间互相看不到对方的数据与工作区(严格隔离);
  4. 便于使用/复制/恢复:一个用户 = 一个主目录,备份恢复按目录粒度一键完成;
  5. 登录方式可扩展:前期用账号密码(注册审核制),后期可按需接入飞书 OAuth。

二、现状盘点(2026-09-08 服务器实测)

/opt/dsh/
├── backups/20260908_initial/  # 旧备份(已归档)
├── docs/dsh-improvement-plan.md  # 本文档
├── users/main/.dsh/           # ★ 迁移后:容器 /root/.dsh(原 data/)
├── users/main/workspaces/     # ★ 迁移后:容器 /root/workspaces(工作区卷,新约定)
├── .env                       # 系统模型 key(--env-file)
├── backup.sh / restore.sh     # 备份恢复脚本
└── dsh-web-0.1.2.tar          # 镜像 tar(140MB)

容器 dsh-web:Up,--network host,--restart always
挂载:/opt/dsh/users/main/.dsh → /root/.dsh
      /opt/dsh/users/main/workspaces → /root/workspaces
环境:DEEPSEEK_API_KEY(--env-file /opt/dsh/.env)
端口:容器内 127.0.0.1:8900 → nginx 反代 80(Host/Origin 伪装 127.0.0.1)
访问:token 认证(docker logs 取)→ dsh-auth cookie(30 天)

已知问题:容器内工作区 /root/maiyamcn 已丢失(重建容器后被清掉,注册表残留失效路径)——工作区必须卷化持久化。迁移后此问题已解决,但 storages/workspace.json 中旧路径 /root/maiyamcn 记录仍在,需在 Web UI 删除该失效工作区入口。


三、总体架构(目标态)

核心原则:

  • 一个用户 = 一个主目录 + 一个实例:/opt/dsh/users/<uid>/,备份/复制/恢复只对这一个目录操作;
  • 严格隔离:每用户独立实例(容器),独立 ~/.dsh 与工作区,互相不可见;
  • 系统标准层(公共模型/插件/技能)只读共享,用户增量放在个人层,同名覆盖系统层(dsh 原生分层机制,无需自研);
  • 配置分层:dsh 插件 bundle → profile 层 → home 级用户覆盖层 → --patch,天然支持「公司统一 + 个人覆盖」。

目录布局(目标态)

/opt/dsh/
├── .env                      # 系统级模型 key(DEEPSEEK_API_KEY),容器 --env-file 注入
├── std/                      # ★ 系统标准层(只读,管理员维护)
│   ├── agents/skills/        #   → 容器 /root/.agents/skills(rank400 兜底标准技能库)
│   │   └── mcn-video-script/ #     公司标准技能(SKILL.md + references,只读)
│   └── profile-skel/         #   新用户 profile 种子(含公共插件 bundles 声明)
├── users/                    # ★ 每用户 = 一个主目录(实例隔离边界)
│   ├── main/                 # ─ 管理员实例(由现有 data/ 迁移而来)─
│   │   ├── .dsh/             #   → 容器 /root/.dsh(DSH_HOME)
│   │   │   ├── profiles/web/
│   │   │   │   ├── dsh.profile         # bundles 有序清单(官方 + 公共插件)
│   │   │   │   ├── cordis.patch.yml    # profile 层配置
│   │   │   │   └── node_modules/       # 个人插件落点(dsh plugin add,只影响本用户)
│   │   │   ├── cordis.patch.yml        # ★ home 级用户覆盖层(优先级高于 profile 层)
│   │   │   ├── .credentials.yaml       # ★ 个人模型 key(用户在 Models 页填写)
│   │   │   ├── settings.yaml           # 用户全局设置
│   │   │   ├── 08-skills/                 # ★ 个人技能 rank300(可覆盖系统标准同名技能)
│   │   │   ├── sessions/               # 会话数据
│   │   │   └── storages/               # 存储数据
│   │   └── workspaces/       #   → 容器 /root/workspaces
│   │       ├── maiyamcn/               # 工作区项目(=容器内 workspace 根)
│   │       │   ├── .dsh/skills/        # 项目级技能 rank100(最高优先,随项目分发)
│   │       │   ├── .agents/skills/     # 项目级技能备选 rank200
│   │       │   ├── AGENTS.md           # 项目指令
│   │       │   └── ...                 # 项目文件/素材/脚本
│   │       └── <其他项目>/
│   ├── zhangsan/             # ─ 编导 A(与 main 完全同构)─
│   │   ├── .dsh/
│   │   └── workspaces/
│   └── lisi/                 # ─ 编导 B ─
├── backups/                  # 备份产物(backup.sh 打包 users/ → 时间戳 tar.gz)
│   └── 20260908_initial/     # 旧备份归档
├── backup.sh                 # 一键备份
└── restore.sh                # 一键恢复

运行拓扑(多用户)

镜像 dsh-web:0.1.2(官方 bundle + 公共插件 + std 标准技能内置/挂载)
├── dsh-web-main      --network host → 8900   # 管理员
├── dsh-web-zhangsan  --network host → 8901   # 编导 A
└── dsh-web-lisi      --network host → 8902   # 编导 B
    nginx 外网按子域名/路径分流到 127.0.0.1:890x(沿用现有 Host/Origin 伪装模板)

四、dsh 分层机制要点(方案依据)

dsh 配置与插件天然分层,以下机制是方案成立的前提(无需自研):

  1. 配置/插件叠加顺序(后层覆盖前层):

    插件 bundle 默认(官方/系统)
      → profile 层   $DSH_HOME/profiles/web/cordis.patch.yml
      → 用户层       $DSH_HOME/cordis.patch.yml
      → 命令行 --patch(最高)
    
  2. SKILL 发现目录(rank 越小优先级越高,同名高优先覆盖低优先):

    rank 目录 性质
    100 工作区根 /.dsh/skills/ 项目级(随项目分发)
    200 工作区根 /.agents/skills/ 项目级备选
    300 ~/.dsh/skills/ 用户个人
    400 ~/.agents/skills/ 用户级全局(系统标准库落点,只读兜底)
  3. 插件安装:dsh plugin --profile <name> add <源>(npm/GitHub/git/tarball/link),写入该 profile 的 bundles 层与 node_modules,只影响该用户实例。

  4. 模型 provider:即插件;key 走 credentials service(UI Models 页 → .credentials.yaml)或环境变量(DEEPSEEK_API_KEY)。


五、模型 / 插件 / SKILL 归属矩阵

能力 公共(系统层,管理员维护) 个人(用户可改) 项目(随工作区)
模型 .env 统一 key + profile 层默认 provider;用户不自配模型(见安全限制) .credentials.yaml 个人 key(若开放) —
插件 镜像预置公共插件(官方 bundle 同级,人人可用,不可改) profiles/web/node_modules + bundles 声明(dsh plugin add,需网络) —
SKILL std/agents/skills(rank400 只读兜底,同名可被用户覆盖) ~/.dsh/skills(rank300) 工作区 .dsh/skills(100) / .agents/skills(200)

一句话:公共层只读放低优先,个人层可写放高优先——公司统一给兜底,个人自配天然覆盖,无需把标准配置复制 N 份。


六、单实例迁移执行步骤(从当前 data/ 形态 → users/main 形态)

⚠️ 以下命令在服务器执行(root)。当前容器 dsh-web 以 /opt/dsh/data 为 /root/.dsh。

# ===== 1. 停容器 + 重组目录 =====
docker stop dsh-web
mkdir -p /opt/dsh/backups
mv /opt/dsh/backup-20260908 /opt/dsh/backups/20260908_initial   # 旧备份归档保留

mkdir -p /opt/dsh/users/main
mv /opt/dsh/data /opt/dsh/users/main/.dsh                       # data 落入主目录
mkdir -p /opt/dsh/users/main/workspaces

# ===== 2. 固化密钥 + 重建容器(挂载主目录内两点) =====
DK=$(docker inspect dsh-web --format '{{range .Config.Env}}{{println .}}{{end}}' | grep '^DEEPSEEK_API_KEY=' | cut -d= -f2-)
printf 'DEEPSEEK_API_KEY=%s\n' "$DK" > /opt/dsh/.env
chmod 600 /opt/dsh/.env

docker rm dsh-web
docker run -d --name dsh-web --restart always --network host \
  -v /opt/dsh/users/main/.dsh:/root/.dsh \
  -v /opt/dsh/users/main/workspaces:/root/workspaces \
  --env-file /opt/dsh/.env \
  dsh-web:0.1.2 dsh web --host 127.0.0.1 --port 8900 --no-open --trusted-host 47.77.182.89

# ===== 3. 生成备份/恢复脚本 =====
cat > /opt/dsh/backup.sh <<'EOF'
#!/bin/bash
# 一键备份:打包 users/ 全部用户主目录 → backups/时间戳.tar.gz
set -euo pipefail
TS=$(date +%Y%m%d_%H%M%S)
OUT=/opt/dsh/backups/dsh-backup-${TS}.tar.gz
cd /opt/dsh
docker stop dsh-web 2>/dev/null || true
tar -czf "$OUT" users
docker start dsh-web 2>/dev/null || true
echo "备份完成: $OUT"
ls -lh "$OUT"
EOF

cat > /opt/dsh/restore.sh <<'EOF'
#!/bin/bash
# 一键恢复:./restore.sh <备份tar.gz>(当前数据先挪走可回滚)
set -euo pipefail
TAR=${1:?用法: ./restore.sh /opt/dsh/backups/dsh-backup-xxx.tar.gz}
test -f "$TAR" || { echo "备份文件不存在: $TAR"; exit 1; }
TS=$(date +%Y%m%d_%H%M%S)
cd /opt/dsh
docker stop dsh-web 2>/dev/null || true
[ -d users ] && mv users users.old-$TS
tar -xzf "$TAR"
docker rm -f dsh-web 2>/dev/null || true
docker run -d --name dsh-web --restart always --network host \
  -v /opt/dsh/users/main/.dsh:/root/.dsh \
  -v /opt/dsh/users/main/workspaces:/root/workspaces \
  --env-file /opt/dsh/.env \
  dsh-web:0.1.2 dsh web --host 127.0.0.1 --port 8900 --no-open --trusted-host 47.77.182.89
echo "恢复完成(旧数据在 users.old-$TS,确认无误后可删)"
EOF

chmod +x /opt/dsh/backup.sh /opt/dsh/restore.sh

# ===== 4. 验证 =====
docker ps | grep dsh-web
curl -s -X POST http://127.0.0.1:8900/api/llm/listProviders \
  -H 'Content-Type: application/json' \
  -d '{"type":"client-request","rpcId":"v1","method":"llm/listProviders","payload":{"args":{}}}'
# 期望 {"ok":true,"value":[{"id":"deepseek-official",...}]}
/opt/dsh/backup.sh

七、备份与恢复语义

  • 备份 = 打包 users/(全部用户主目录,一个 tar);.env(部署配置)与 std/(可再生成)不进备份;
  • 恢复 = 解压 + 重建容器(restore.sh 已固化容器参数);
  • 复制/迁移用户:拷 /opt/dsh/users/<uid> 到新机同路径即可;
  • 多用户同构扩展:新用户增加 users/<uid>/,脚本无需改动(tar 整体 users/)。

八、多用户扩展(dshs 落地前的过渡方案)

目标:每用户独立实例 + 严格隔离。新增一个用户 = 一套同构目录 + 一个容器 + nginx 分流:

# 以新增 zhangsan 为例(示例脚本 add-user.sh)
UID_NEW=zhangsan
PORT=8901
mkdir -p /opt/dsh/users/${UID_NEW}/.dsh /opt/dsh/users/${UID_NEW}/workspaces
# 从 main 复制 profile 骨架(不含凭据/会话),或从 std/profile-skel 初始化
docker run -d --name dsh-web-${UID_NEW} --restart always --network host \
  -v /opt/dsh/users/${UID_NEW}/.dsh:/root/.dsh \
  -v /opt/dsh/users/${UID_NEW}/workspaces:/root/workspaces \
  --env-file /opt/dsh/.env \
  dsh-web:0.1.2 dsh web --host 127.0.0.1 --port ${PORT} --no-open --trusted-host 47.77.182.89
# nginx:新 server/server_name 分流 → 127.0.0.1:${PORT},沿用 Host/Origin 伪装头模板

说明:web profile 首次启动会自动初始化模板,新用户空 .dsh 直接起容器即可;系统标准技能层由 std/ 挂载提供。

认证路线

阶段 方案 说明
当前 dsh 自带 token(一次性) 仅单人过渡
近期 dshs 密码制基座(每用户独立 DSH 实例 + 注册审核) 多租户严格隔离的正规托管方案
后期 飞书 OAuth 登录 替换/并存 dshs 密码认证,预留扩展点

备选认知:@xgone/dsh-remote / dsh-passwords 类认证网关是共享单租户,无法满足严格隔离,不采用。


九、安全边界与已知限制

  1. dsh 禁 --host 0.0.0.0(RCE 防护),容器内只监听 127.0.0.1 → 必须 --network host + nginx 反代;
  2. 特权 API 强制 loopback-only(settings/credentials.describe 等),即使 --trusted-host 也拒非本机访问 → nginx 必须把 Host/Origin/Sec-Fetch-* 头伪装为 127.0.0.1:8900(现有反代模板已含);
  3. Settings/Models 页前端 loopback 检测:非本机浏览器地址访问时页面禁用(settings are unavailable in this browser)→ 外网用户无法在 UI 自配模型;普通用户模型一律管理员统一配置;要开放自配需对 web 前端打 --patch 放行(待验证项);
  4. 插件 = 任意代码执行:普通用户不开 dsh plugin add 权限;公共插件管理员审核后入镜像;
  5. token 认证:每实例首次启动生成 token,nginx 后建议加一层 Basic Auth 或尽快接 dshs;
  6. .env 含密钥,权限 600,不进备份包(整机迁移需单独携带或重设)。

十、待验证项(Open Questions)

# 问题 影响 验证方式
1 .env 注入的 DEEPSEEK_API_KEY 与用户 credentials 中个人 key 的优先级 决定统一 key 是否为硬兜底 容器内同时存在时调 llm/listProviders 观察
2 web profile 空 .dsh 首启是否能自动初始化模板(供 add-user 流程) 新用户目录初始化方式 用空目录起测试容器观察
3 前端 loopback 检测能否用 --patch 放行 用户是否可自配模型 社区/源码调研 + 试验
4 dshs 对 web Settings 限制的处理方式 密码制基座下用户可配置面 部署后实测
5 容器内 dsh plugin add 走 npmmirror 是否可装 用户/管理员装公共插件渠道 实测 add 一个测试插件

十一、路线图

  • P0 执行本方案第六节迁移命令(data → users/main,双卷挂载,脚本就位)— 2026-09-08 17:30 完成
  • P2 前置调研完成(2026-09-08):dshs 为多租户首选候选;确认硬前提=域名+通配DNS+通配证书(纯 IP 打不开聊天 UI);服务器 1.8G 内存限 1-3 人规模;用户决定:解决域名后再做多用户改造
  • 域名就绪后选型落地:dshs(模式 A 单机,优先)或自研 Node 网关(备选);新用户按 users/ 同构扩展
  • P1 镜像 v2:内置公共插件 + std/agents/skills 标准技能库(rank400 层)
  • P1 系统标准技能库内容确认(MCN 脚本创作技能等随镜像/std/ 分发)
  • P3 飞书 OAuth 登录扩展(预留接口,按需启用)
  • P3 编导工作区模板(新用户自动带账号人设/素材库结构)

多用户改造前提条件(用户决策记录 2026-09-08)

  1. 域名:无(仅 IP)。海外服务器解析无需备案;买域名后配 @ A 记录 + * 通配 A 记录 → 47.77.182.89,再签通配证书。域名就绪 = 多用户改造解锁。
  2. 用户规模:1-3 人(现 1.8G 内存可撑,dshs 每用户一个常驻 DSH 子进程)。
  3. 候选对比结论:
    • dshs:现成密码注册/审核/隔离/网页桌面/每用户加密 key 库;需宿主机 Node ^22.19 + 全局 dsh(spawn 子进程,非 Docker);数据组织为 DATA_ROOT/每用户home,与 users/ 不互通、官方无迁移工具;
    • 自研 Node 网关 + Docker 每用户:复用现有 users/ + 镜像,nginx 后加认证/路由层;开发量中等;
    • dsh-webui-auth:仅单实例加锁,不做多用户隔离,可作过渡。

十二、域名接入记录(2026-09-08)

域名:dsh.alotbuy.com(Cloudflare 代理,海外服务器免备案)

项 状态
DNS dsh.alotbuy.com → Cloudflare(172.67.194.206 / 104.21.44.42),CF 回源服务器 80
通配 *.dsh.alotbuy.com ⚠️ 未配置——dshs 每用户子域路由的前提,落地前需在 CF 加 * A 记录 → 47.77.182.89
HTTPS Cloudflare 边缘证书(源站无 443,CF SSL 需为 Flexible 模式,实测已通)
源站 nginx 新建站点 /www/server/panel/vhost/nginx/dsh.alotbuy.com.conf(监听 80,反代 127.0.0.1:8900,Host 伪装 + 剥 Origin/Sec-Fetch,含 WebSocket 头)
验证 token 登录 303→cookie(host-only 绑 dsh.alotbuy.com)→ 首页 200(24KB)→ API {"ok":true, deepseek-official} ✅

访问方式:https://dsh.alotbuy.com/?token=<token>(浏览器打开一次即种 cookie,30 天有效)

踩坑记录:

  • Windows 原生 curl 写 /tmp/ 路径失败导致 cookie 不落盘 → 必须用 C:/... 路径(历史已知坑,本次复现)
  • 对根路径发无 cookie 裸 WS Upgrade 返回 502 为 dsh 正常拒绝(IP 站点同样 502),非配置问题
  • nginx 新站点需与 47.77.182.89 站同样伪装头(Host 127.0.0.1:8900 + 空 Origin/Sec-Fetch),否则特权 API 403

十三、架构切换:容器 → dshs 多租户 + account 硬隔离(2026-09-08 18:30)

决策:去掉 Docker 单容器方案,全量迁移到 dshs 进程隔离模式(便于 dsh/插件升级迭代 = 一条 npm 命令);用户确认"立即全量迁移 main" + "account 账号级硬隔离"。

最终架构

宿主机:Node v22.23.2 + 全局 dsh 0.1.2-rc.1(/usr/local/bin)
dshs(systemd,127.0.0.1:3080,account 模式)
├── /var/lib/dshs/
│   ├── dshs.db    # 用户/审核数据
│   ├── secret.key             # 每用户 key 库主密钥
│   └── users/<userId>/
│       ├── home/              # DSH_HOME(profiles/sessions/credentials)— 由原 .dsh 迁入
│       ├── ws/                # 工作区(HOME 指向)— 由原 workspaces 迁入
│       └── patches/
nginx:dsh.alotbuy.com + *.dsh.alotbuy.com → 127.0.0.1:3080(端口动态分配由编排器管理)

关键文件清单

文件 作用
/etc/dshs.env BASE_DOMAIN=dsh.alotbuy.com / COOKIE_DOMAIN=.dsh.alotbuy.com / ISOLATION_MODE=account / BASE_UID=100000 / DSH_BIN=/usr/local/bin/dsh
/etc/systemd/system/dshs.service 编排服务(ExecStart 带 --db 指向 dshs.db)
/etc/systemd/system/dsh-provision.path + .service 监控新用户目录自动建 OS 账号
/usr/local/bin/provision-new-users.sh 建号脚本(用户名截断 20 字符,规避 Linux 32 字符限制;每次 chown 幂等)

已验证

  • admin 登录 API ✅(role=admin)
  • account 降权:dsh 可跑(0.1.2-rc.1)、读 /root Permission denied、读自己 home 正常 ✅
  • nginx 主域/子域 → 3080 路由 ✅(登录门户 HTML 返回)
  • 备份/恢复脚本已切换新数据根(backup.sh 打包 /var/lib/dshs + 配置 + systemd 单元)

⚠️ 未完成 / 待办

  • HTTPS 通配证书(2026-09-08 18:40 完成):certbot dns-cloudflare 签 *.dsh.alotbuy.com,nginx 443 + HTTP 跳转,全链路登录验证通过
  • 每用户 API Key:dshs 为每用户独立 key 库(登录后桌面"管理密钥"填),原 .env 统一 key 的模型已不适用(迁移后 admin 需自填 key)
  • 注册普通用户 → 审核 → 桌面/DSH 启动全链路验证(子域 404 需注册后确认)
  • main 旧容器数据 /opt/dsh/users/main 已迁入 admin home,容器已停,可清理
  • 飞书 OAuth 扩展(预留)

踩坑记录

  1. db 路径不一致:bootstrap-admin 写入 dshs.db,但服务默认用 dshs.db(空)→ systemd ExecStart 必须显式 --db
  2. 用户名超长:dsh-+uuid=41 字符超 Linux 32 限制 → provision 脚本截断为 dsh-<uuid去横线前20>
  3. 初始属主不一致:bootstrap 建目录属主 100001,hash uid 为 114801 → 以 uid-for-user 输出为准
  4. 中间路径权限:/var/lib/dshs 与 /users 需 755(root 700 会挡住降权账号穿透),用户目录本身 700 保持隔离
  5. pkill 自杀坑:pkill -f 'lib/cli.js' 会匹配到远程 shell 自身命令行 → 用 systemctl 管理

十四、dsh.alotbuy.com 注册为宝塔面板反代站点(2026-09-08 18:46)

背景:手写 conf(/www/server/panel/vhost/nginx/dsh.alotbuy.com.conf)在面板数据库无站点记录 → 面板重置/批量管理可能误伤。按用户偏好(能走 MCP 尽量走 MCP)用宝塔 MCP 完成注册。

执行链(MCP 优先,全流程零中断):

  1. SSH 备份 + 移走手写 conf:cp 到 /opt/dsh/docs/nginx-dsh.alotbuy.com.conf.bak-20260908,mv 出 include 目录(不 reload,服务无感)
  2. MCP ProxyProjectCreate:domains=dsh.alotbuy.com,proxy_pass=http://127.0.0.1:3080 → 站点 id=2 入面板 DB
  3. MCP ProxyWriteConfig:把完整精调 conf(HTTP→HTTPS 跳转 ×2 + 443 主域 SSL + 443 通配子域 SSL)覆盖写回同路径
  4. SSH nginx -t + reload + 本机 curl 验证

⚠️ 关键坑:面板反代项目 conf 写入路径与手写 conf 同名同路径(/www/server/panel/vhost/nginx/<域名>.conf)——直接 ProxyProjectCreate 会用面板模板覆盖,丢失 SSL/通配子域/301 等精调内容。必须"先移走手写 conf → 创建 → ProxyWriteConfig 写回精调版"三步走。

验证结果:

检查项 结果
SiteList 面板 DB 2 站点:47.77.182.89(id=1)+ dsh.alotbuy.com(id=2)✅
nginx -t + reload OK ✅
https://dsh.alotbuy.com(外网直连源站) 200 ✅
https://*.dsh.alotbuy.com(通配子域,未注册) 404(dshs 正常拒绝,代理/SSL 层通)✅
http://dsh.alotbuy.com 301 → https ✅
conf 保留 4 段精调 server 块完整(certbot 证书路径原样)✅

遗留说明:面板站点列表 SSL 状态显示 enabled:false——证书为 certbot 手动签发(面板不托管),面板只做站点/conf 管理;如需面板托管 SSL 需换面板 Let's Encrypt 流程(不建议,会与通配证书冲突)。

十五、启动 DSH 报"已有运行中"+ 无法对话 排障(2026-09-08 19:00)

现象:admin 登录桌面点"启动 DSH",提示"有运行中的 DSH 请先停止",无跳转,无法对话。

根因一(P0,已修复):DB uid 与系统账号 uid 错配

  • 主 DB(/var/lib/dshs/dshs.db,服务 --db 显式指向)users.uid=100001(bootstrap 写入 baseUid+rowid)
  • 但 OS 账号按 hashUid 建:dsh-cce6d1cdb376430480f0 uid=114801(早期版本 provision 用 hash;后 provision 改为读 DB uid-for-user,但账号没重建)
  • 结果:编排器 spawn 用 DB uid=100001 → setpriv --reuid 100001 → 子进程以不存在的 uid 运行 → 打不开 700 owner=114801 的用户 home → EACCES: mkdir .../home/profiles/web 秒崩
  • 编排器 spawn 事件即置 status=running,子进程崩后自动重启循环 → 前端一直显示"运行中"→ 再次点击启动返回 409 already_running(即用户看到的提示)
  • 修复:UPDATE users SET uid=114801 WHERE id='cce6d1cd-...'(备份在 /opt/dsh/docs/dshs.db.bak-20260908-uidfix)+ systemctl restart dshs + provision 脚本 uid-for-user 补 --db /var/lib/dshs/dshs.db(消除默认库分裂)

根因二(上游集成缺口,未修复):dsh web 强制浏览器 token,编排器"打开"不带 token

  • dsh web(0.1.2-rc.1,dsh-client-connection BrowserAuth)强制:首次访问需 ?token=<launch token>(进程启动随机、打印到 stdout)换取签名 cookie(绑定 authority=127.0.0.1:<port>,30 天)
  • 上游 dshs 0.1.0:launch 返回 url 不带 token,desktop.html "打开 DSH" 直接 window.open(url) → 首访 401 dsh web authentication required;且端口动态分配,实例重启换端口 → 旧 cookie 失效需重新带 token
  • 已实测全链路可行:门户 sid + ?token= → 303 种 dsh-auth cookie → 双 cookie 访问 https://admin.dsh.alotbuy.com/ 200(24233B SPA)+ /api/llm/listProviders ok:true
  • 修复方向(待用户定):A. 小改上游——编排器解析子进程 stdout 的 token,launch/status url 自动拼 ?token=(改 src/supervisor/orchestrator.ts + routes/dsh.ts,tsc 可构建);B. 接受现状——每次启动后手动用 journald 里 token 开一次;C. 等上游修复

排障经验:launch folder 是相对 ws/ 根的路径(空串=根);/api/dsh/status 返回 url 为子域;子域下所有路径(含 /api/dsh/status)都会被代理到实例,故探测实例要用 /api/llm/* 等实例 API;cookie 域 .dsh.alotbuy.com(sid)与 dsh-auth 双 cookie 缺一不可。

十六、方案 A 落地:launch/status 自动携带 dsh launch token(2026-09-08 19:30)

决策:用户选方案 A(小改上游,普通用户无需手动拿 token 即可打开聊天界面)。

改动(3 文件,已 git commit 29c8907,本地分叉 patch):

文件 改动
src/supervisor/spawner.ts Instance 接口加 launchToken?: string 字段
src/supervisor/orchestrator.ts ① trackChild 监听子进程 stdout,正则 dsh web: http://127.0.0.1:\d+/\?token=([A-Za-z0-9_-]+) 解析 token 存入 instance;② 新增 waitForLaunchToken()(轮询最多 20s 等 token 出现,崩溃/停止即返回);③ launch()/restartMain() 返回前等待 token
src/web/routes/dsh.ts dshUrl() 支持可选 token 参数;launch/restart/status 返回的 url 均拼接 ?token=(status 从 main.launchToken 取)

验证结果(全链路):

步骤 结果
launch 返回 {launchToken, url: https://admin.dsh.alotbuy.com/?token=...} ✅
打开带 token url(带 sid,-L 跟随) 200,24233B SPA,自动 303 种 dsh-auth cookie ✅
之后裸开 / 200 ✅
/api/llm/listProviders ok:true, deepseek-official ✅

构建/部署:npm run build(tsc)+ systemctl restart dshs。TS 坑:RegExp.exec 返回 null 非 undefined,判空须 m !== null && m[1] !== undefined。

admin API Key 已配置:从旧 /opt/dsh/.env 取出 sk-81c... 经 /api/me/keys 注入(name=deepseek-main,AES-GCM 加密入库,自动触发实例重启生效)。KEY 为每用户独立——每个用户需在桌面"管理密钥"各自添加;无全局通用 key(原容器 .env 统一 key 模型已废弃)。

十七、权限边界实测 + DB 权限安全加固(2026-09-08)

问题:admin 登录后能否访问服务器所有目录?AI 对话窗口新建工作空间时能否输入系统根目录?

两层防护模型(答案分两层):

层 机制 结论
Web 桌面层 fs-guard resolveWithinRoot():所有文件浏览/上传/新建工作空间的路径解析被锁在用户自身 home/ws 内,拒绝 ../、绝对路径、符号链接逃逸 输入 /、/root 等系统路径会被拉回/拒绝;admin 与普通用户同样受限,不存在"admin 浏览全服务器"入口
AI 进程层 每用户 DSH 实例以降权 uid(setpriv)运行,该进程可执行 shell/agent 工具,不受 fs-guard 约束,能读该 uid 有权限的一切文件 admin 的实例同样是降权 uid,非 root。实测 uid 114801 边界:/root、/etc/shadow、/opt/dsh/.env、secret.key、dsh db 全部 Permission denied ✅;/etc/passwd、/tmp 世界可读可写 ✅

结论:即便 admin 也无法从 AI 对话触达 root-only 敏感文件;真正的服务器管理走 SSH/宝塔 root 通道,与 AI 会话隔离。这是 account 隔离模式的核心价值,无需额外改造。

⚠️ 实测发现并修复的漏洞:DB 权限 644 → 600

项 详情
漏洞 /var/lib/dshs/dshs.db 权限 644 root——任意降权账号可 cat 拖库
风险 库内含全部用户 pass_hash(scrypt)+ api_key_ref(AES-GCM 密文)+ uid/sid → 离线爆破口令 + 窃取 key 密文
修复 chmod 600 /var/lib/dshs/dshs.db* /var/lib/dshs/dshs.db*
验证 降权账号读 db → Permission denied ✅;服务 600 下正常(portal 401 为未认证正常响应)✅

目录级权限收紧(用户反馈"其他用户也能看就不行",2026-09-08 20:30):

普通用户实例同样是降权 uid,盘点发现除主 db 外还有更大泄露面:/opt/dshs 整棵树 755、/opt/dsh/docs/ 内主库备份(含全部 pass_hash+key 密文)、/opt/dsh/backups/ 备份包、/opt/dsh/users/main 旧数据残留均 644/755 可读 → AI 会话可拖取。收紧如下:

路径 改前 改后 说明
/opt/dsh 755 700 覆盖 docs(含 db 备份)、backups、users/main 残留
/opt/dshs 755 700 root 服务运行不受影响(systemd 无 User= 默认 root)
/var/lib/dshs 755 711 保留 o+x:降权进程需穿过根路径进入自己 home,去读防列表泄露
.../users 755 711 同上;用户目录本身 700 保持隔离

验证(uid 114801 实测):读 /opt/dsh/docs/*.md → denied ✅;列 /opt/dshs、数据根 → denied ✅;仍可进入自己 ws/mcnworkspace ✅;systemd active、portal 200、admin 实例 /plugins/events 正常 ✅。

⚠️ 经验固化(Linux 目录权限设计):数据根这类"root 服务 + 降权子进程都要访问"的目录,权限用 711/700(去 r 留 x),不能收 700——降权进程会因无法穿过根路径而崩溃(EACCES)。子目录各自 700 才是真正的隔离边界。

十八、统一 KEY:管理员统一设置,用户只能使用管理员 KEY 和模型(2026-09-08 20:40)

决策(用户确认):① KEY 存放 = 复用 admin 密钥库(桌面/API 自助换 key 即全局生效);② 用户 dsh 界面自配暴露面先实测再定,本次不做 dsh patch。

改动(commit df5cc84,6 文件 +49/-14):

文件 改动
src/web/server.ts resolveApiKey 忽略入参 userId,改为 listPublicUsers().filter(role==='admin') 取 admin 的启用 key——所有用户 spawn 注入同一把管理员 key
src/supervisor/spawner.ts Spawner 接口新增 restartAllMains()
src/supervisor/orchestrator.ts LocalSpawner 实现:遍历内存 mains 逐个 restartMain(单个失败 console.error 不中断广播)
src/supervisor/k8s-spawner.ts K8sSpawner 实现:遍历 db.listInstancesByRole('main') 逐个重启
src/web/routes/auth.ts /api/me/keys GET/POST/select/delete 由 requireAuth 改 requireAdmin(普通用户 403);POST/select 后 restartAllMains() 广播(key 是 spawn 时注入 env 的快照,不重启运行实例不刷新)
web/desktop.html 非 admin 隐藏"管理密钥"按钮;面板文案改"全局 API 密钥(所有用户共用,仅管理员可改)"

验证(全链路实测):

  • admin GET /api/me/keys → 200,deepseek-main 在列 ✅
  • 注册 testuser(pending)→ admin approve(active)→ 登录 ✅
  • 普通用户 GET/POST /api/me/keys → 403 forbidden ✅
  • testuser launch DSH → 打开 200 SPA → llm/listProviders ok:true deepseek-official ——证明普通用户无自配 key 也拿到 admin 全局 key ✅

遗留:用户 dsh 界面内自配 key/模型的能力面未实测(proxy 伪装 loopback 可能放行 dsh Settings)——第二道防线待 P1 实测后再定。testuser 账号保留作普通用户全链路验证种子。

十九、界面删除用户功能(2026-09-09 07:00)

需求:管理界面在禁用/恢复右侧加删除按钮 → 弹窗输入该用户名相同名称确认 → 删除(admin 不可删)。

改动(commit 4943c8c,7 文件 +106/-2):

层 文件 改动
后端路由 src/web/routes/admin.ts 新增 DELETE /api/admin/users/:id:admin → 409;supervisor.stop → db.deleteUser(事务)→ rm users/<id>/ 目录 → userdel dsh-<id去横线20位>(OS 账号,失败容忍)→ audit
DB 接口 src/db/adapter.ts +deleteUser(userId)
SQLite src/db/schema→repo.ts + sqlite.ts 事务按序清 credential_vault/domains/sessions/dsh_instances/audit_log/folder_plugins/workspaces/users(显式删除,不依赖 FK cascade 开关)
Postgres src/db/pg.ts 同序 withTx 实现
前端 web/admin.html + web/desktop.html 非 admin 行尾加 🗑 按钮(红色 ghost);点击 prompt 输入用户名比对确认后 DELETE;admin 行不渲染按钮

验证(全部实测):① 删 admin 自己 → 409 cannot_delete_admin ✅ ② 删 testuser → 200 ✅ ③ 删不存在 → 404 ✅ ④ 残留检查:users/credential_vault/sessions/workspaces/dsh_instances/domains/audit_log 7 表全 0、users/<id>/ 目录不存在、OS 账号 dsh-2d1f22ffcde04874ba1d 已删、用户列表仅剩 admin ✅

注意:userdel 账号名与 provision-new-users.sh 同规则(dsh- + uuid 去横线前 20 位);OS 账号删除失败只容忍不报错(DB/目录已清,残留仅 uid 占用)。testuser 已作为功能测试对象删除。

附录 A:常用命令速查

# 备份 / 恢复
/opt/dsh/backup.sh
/opt/dsh/restore.sh /opt/dsh/backups/dsh-backup-<时间戳>.tar.gz

# 容器日志(看 token / 排障)
docker logs --tail 50 dsh-web

# 进入容器
docker exec -it dsh-web bash

# 插件管理(profile web)
dsh plugin --profile web add <npm包名|github:owner/repo|link:/path>
dsh plugin --profile web remove <包名>
dsh --profile web --dump-config        # 打印生效插件树

# 模型 API 直测(服务器本机)
curl -s -X POST http://127.0.0.1:8900/api/llm/listProviders \
  -H 'Content-Type: application/json' \
  -d '{"type":"client-request","rpcId":"v1","method":"llm/listProviders","payload":{"args":{}}}'

附录 B:参考链接