- 变更规模:新增 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/ 知识文件,按口径入库)
35 KiB
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 项):
- 多用户使用:多个编导/账号运营各自登录使用;
- 数据持久化:会话、配置、凭据、工作区不随容器重建丢失;
- 跨用户文件安全:用户之间互相看不到对方的数据与工作区(严格隔离);
- 便于使用/复制/恢复:一个用户 = 一个主目录,备份恢复按目录粒度一键完成;
- 登录方式可扩展:前期用账号密码(注册审核制),后期可按需接入飞书 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 配置与插件天然分层,以下机制是方案成立的前提(无需自研):
-
配置/插件叠加顺序(后层覆盖前层):
插件 bundle 默认(官方/系统) → profile 层 $DSH_HOME/profiles/web/cordis.patch.yml → 用户层 $DSH_HOME/cordis.patch.yml → 命令行 --patch(最高) -
SKILL 发现目录(rank 越小优先级越高,同名高优先覆盖低优先):
rank 目录 性质 100 工作区根 /.dsh/skills/项目级(随项目分发) 200 工作区根 /.agents/skills/项目级备选 300 ~/.dsh/skills/用户个人 400 ~/.agents/skills/用户级全局(系统标准库落点,只读兜底) -
插件安装:
dsh plugin --profile <name> add <源>(npm/GitHub/git/tarball/link),写入该 profile 的 bundles 层与 node_modules,只影响该用户实例。 -
模型 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类认证网关是共享单租户,无法满足严格隔离,不采用。
九、安全边界与已知限制
- dsh 禁
--host 0.0.0.0(RCE 防护),容器内只监听 127.0.0.1 → 必须--network host+ nginx 反代; - 特权 API 强制 loopback-only(settings/credentials.describe 等),即使
--trusted-host也拒非本机访问 → nginx 必须把Host/Origin/Sec-Fetch-*头伪装为127.0.0.1:8900(现有反代模板已含); - Settings/Models 页前端 loopback 检测:非本机浏览器地址访问时页面禁用(
settings are unavailable in this browser)→ 外网用户无法在 UI 自配模型;普通用户模型一律管理员统一配置;要开放自配需对 web 前端打--patch放行(待验证项); - 插件 = 任意代码执行:普通用户不开
dsh plugin add权限;公共插件管理员审核后入镜像; - token 认证:每实例首次启动生成 token,nginx 后建议加一层 Basic Auth 或尽快接 dshs;
.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)
- 域名:无(仅 IP)。海外服务器解析无需备案;买域名后配
@A 记录 +*通配 A 记录 → 47.77.182.89,再签通配证书。域名就绪 = 多用户改造解锁。 - 用户规模:1-3 人(现 1.8G 内存可撑,dshs 每用户一个常驻 DSH 子进程)。
- 候选对比结论:
- dshs:现成密码注册/审核/隔离/网页桌面/每用户加密 key 库;需宿主机 Node ^22.19 + 全局 dsh(spawn 子进程,非 Docker);数据组织为
DATA_ROOT/每用户home,与 users/ 不互通、官方无迁移工具; - 自研 Node 网关 + Docker 每用户:复用现有 users/ + 镜像,nginx 后加认证/路由层;开发量中等;
- dsh-webui-auth:仅单实例加锁,不做多用户隔离,可作过渡。
- dshs:现成密码注册/审核/隔离/网页桌面/每用户加密 key 库;需宿主机 Node ^22.19 + 全局 dsh(spawn 子进程,非 Docker);数据组织为
十二、域名接入记录(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 扩展(预留)
踩坑记录
- db 路径不一致:bootstrap-admin 写入
dshs.db,但服务默认用dshs.db(空)→ systemd ExecStart 必须显式--db - 用户名超长:
dsh-+uuid=41 字符超 Linux 32 限制 → provision 脚本截断为dsh-<uuid去横线前20> - 初始属主不一致:bootstrap 建目录属主 100001,hash uid 为 114801 → 以
uid-for-user输出为准 - 中间路径权限:
/var/lib/dshs与/users需 755(root 700 会挡住降权账号穿透),用户目录本身 700 保持隔离 - 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 优先,全流程零中断):
- SSH 备份 + 移走手写 conf:
cp到/opt/dsh/docs/nginx-dsh.alotbuy.com.conf.bak-20260908,mv出 include 目录(不 reload,服务无感) - MCP
ProxyProjectCreate:domains=dsh.alotbuy.com,proxy_pass=http://127.0.0.1:3080→ 站点 id=2 入面板 DB - MCP
ProxyWriteConfig:把完整精调 conf(HTTP→HTTPS 跳转 ×2 + 443 主域 SSL + 443 通配子域 SSL)覆盖写回同路径 - 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-cce6d1cdb376430480f0uid=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-connectionBrowserAuth)强制:首次访问需?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)→ 首访 401dsh web authentication required;且端口动态分配,实例重启换端口 → 旧 cookie 失效需重新带 token - 已实测全链路可行:门户 sid +
?token=→ 303 种 dsh-auth cookie → 双 cookie 访问https://admin.dsh.alotbuy.com/200(24233B SPA)+/api/llm/listProvidersok: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/listProvidersok: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:参考链接
- npm:https://www.npmjs.com/package/@deepseek-ai/dsh(0.1.2-rc.1)
- 官方仓库:https://github.com/deepseek-ai/deepseek-harness
- CLI 行为参考:apps/cli/reference/README.md(分层优先级/部署默认)
- dsh skills 安装与优先级:
~/.dsh/skills、~/.agents/skills、项目级/.dsh/skills(社区文档,见当日讨论记录) - 社区多租户方案调研(dshs / dsh-remote / dsh-passwords / dsh-im):见当日讨论记录