Files
dsh_shenxian/dsh-server-docs/02-运维手册.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

20 KiB
Raw Blame History

⚠️ 历史章节(2026-09-08):本章「dsh-web 容器 / /opt/dsh/data」形态已废弃(容器 2026-09-09 删除,架构切到 dshs 多租户)。保留作历史参考;当前运维见 DEPLOY-本部署.md。

六、单实例迁移执行步骤(从当前 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 类认证网关是共享单租户,无法满足严格隔离,不采用。


十二、域名接入记录(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

十四、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:常用命令速查

# 备份 / 恢复
/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":{}}}'

# 测试 session 生成(门户 API 直调,10 分钟)
node /opt/dshs/mksess.cjs

# 普通用户角色化 profile patch(隐藏「模型」设置分区,档案 09;admin 不受影响)
node /opt/dshs/ensure-role-profile-patch.cjs [--restart] <username>
#   新普通用户 SOP:注册→approve→首登一次(建 profile)→ 跑上命令(--restart 使已运行实例生效)

附录 B:参考链接


附录 C:2026-09-11 增补

【历史 vs 现行 · 2026-09-11 档案 53】 正文各章(六~十五)写于 2026-09-08/09,属当时的事实轨迹(含旧域名 dsh.alotbuy.com、旧目录形态 data/ main);现行状态以本附录 C 与 DEPLOY-本部署.md 为准,正文与附录冲突时一律以附录为准。

C.1 域名与证书(档案 22)

项 值
门户 / 用户实例 https://alotbuy.com / https://<用户名>.alotbuy.com(旧域 dsh.alotbuy.com 及子域 301,含用户名映射)
环境变量 DSHS_BASE_DOMAIN=alotbuy.com、COOKIE_DOMAIN=.alotbuy.com(备份 /etc/dshs.env.bak-*)
证书 /etc/letsencrypt/live/alotbuy.com/(alotbuy.com + *.alotbuy.com,DNS-01);旧域证书仅用于 301
签发/续期必须带传播等待 certbot … --dns-cloudflare-propagation-seconds 60(默认 10 s 会失败;renewal conf 已写死该值)
切换脚本 /opt/dsh/switch-domain-alotbuy.sh(改 env → drain → 重启,幂等)
Cloudflare token 仅 DNS 权限;回源实测走 443;*.alotbuy.com 为共享通配(gao/mao/work 在用,勿改)

C.2 nginx 关键配置(改了容易被面板覆盖,务必记住)

指令 作用 失效症状
proxy_buffering off; 让流式响应逐块下发 「看不到流式输出,结果一次性出现」
gzip_proxied any; 压缩反代响应(那个 ~930 KB 插件 bundle) 刷新载入极慢
location ~* ^/assets/ { expires 30d; } 哈希静态资源长缓存 每次刷新重下 ~1.2 MB
set_real_ip_from <CF段> + real_ip_header CF-Connecting-IP; 还原真实客户端 IP(须写在 server 块内) 日志/风控里全是 CF 机房 IP

⚠️ 在宝塔面板里保存站点配置会按模板重写 vhost → 上述自定义指令可能被抹掉;若症状回归,先查这几行。

C.3 平台脚本族

脚本 用途
/opt/dsh/switch-domain-alotbuy.sh 域名切换(含 drain)
/opt/dshs/ensure-role-profile-patch.cjs 角色化 profile patch(非 admin;--force 升级、--restart)
/opt/dshs/scripts/ci.sh 类型检查 + 构建 + 单测(不含需要实例/凭据的 smoke)
/opt/dshs/poc/workspace-scoped-picker/test/poc.mjs 目录选择器插件自检(26 项,须以实例 uid 从可读路径运行)
docs/scripts/docs-sync-check.sh 文档库双端对账(0=全绿)

C.4 排障速查(本手册新增条目)

症状 先查
用户实例异常/被清 `journalctl -u dshs
实例反复起不来 同上(含 crash-loop-circuit-open = 已熔断,需用户重新进入或人工介入)
会话「连接中」/卡顿 先看 nginx 是否仍 proxy_buffering off;再看 /www/wwwlogs/alotbuy.com.log 的状态码与耗时
日志里客户端 IP 全是 CF 段 vhost 的 real_ip_header CF-Connecting-IP 是否被覆盖
证书告警/续期失败 renewal conf 是否含 dns_cloudflare_propagation_seconds = 60
需要看实例资源 `ps -eo pid,user,pcpu,rss,args

注:诊断实例内文件系统时,pgrep -f "dsh --profile web" 会先命中 bwrap 包装进程(root),在其命名空间内探测会得出"全部可读写"的假象——必须取 ps -eo user,args 中 user 为 dsh-* 的子进程(档案 17 §一)。

C.5 本部署未使用/未验证的子系统(升级 dshs 时重点回归,档案 19 §C8)

子系统 文件 本部署情况
Kubernetes 后端 src/supervisor/k8s-spawner.ts(679 行)、deploy/*.yaml 未使用(ISOLATION_MODE=account);升级时需按"未验证"对待
PostgreSQL 后端 src/db/pg.ts(508 行) 未使用(本部署用 sqlite/better-sqlite3)
Leader 选举 src/cluster/leader.ts(254 行) 未使用(单实例部署)
对账 src/supervisor/reconcile.ts 仅 k8s 路径使用

结论:可保留上游能力,但任何涉及它们的改动都不应在生产直接验证;升级后若这些文件报编译错误,优先改为"绕过/排除"而非线上调试。

C.6 上传安全扫描:分级(2026-09-11,档案 19 §C6)

档 行为 规则
P0 阻断 命中即 HTTP 400 拒绝上传 原有 7 条(rm -rf /、curl | sh、读系统凭证/SSH 私钥/云凭证/平台数据目录/.credentials.yaml)+ 新增:nc -e 反弹、/dev/tcp 反弹、base64 解码后直接执行、给系统二进制加 setuid、fork 炸弹
P1 告警 不阻断,写日志 + 作为 findings 返回 动态执行子进程、vm/eval/new Function、敏感 env 读取、超长 base64、外部网络访问、隐藏目录/.git
  • 覆盖面:文本扩展名 19 → 31(含 .toml/.ini/.conf/.env/.rs/.go/.java/.php/.rb/.pl/.lua/.sql/.ps1/.bat…);无扩展名按 shebang 判定;二进制按 0x00 跳过;体积上限 2MB → 8MB。
  • 排查:journalctl -u dshs | grep upload-scan-warnings(P1 告警);P0 直接体现在上传接口的 400 响应里。
  • 冒烟(可复跑):node /tmp/scan-smoke.cjs 思路见档案 19 §C6 记录(P1 不阻断 / P0 阻断 / 良性零误报)。

C.7 用户数据清理(档案 28,cron 自动跑)

任务 时间 阈值/年龄 日志
storage-report.cjs 每小时 05 分 生成 /var/run/dsh-storage-report.json(门户「存储用量」面板的数据源) /var/log/dsh-storage-report.log
ws-cleanup.cjs --apply 每天 04:10 ws ≥ 2048 MB;清 T1 平台产物 + T2 超 90 天顶层脚本(ws/.keep 可豁免 T2) /var/log/dsh-ws-cleanup.log
session-gc.cjs --apply 每天 04:20 sessions ≥ 1024 MB;清超 365 天会话(并同步回收 projcache) /var/log/dsh-session-gc.log
purge-trash.sh 30 每天 04:40 回收站超 30 天 /var/log/dsh-trash-purge.log
  • 所有清理都先 mv 到 <userRoot>/trash/<日期>-<任务>/(30 天可恢复);只有 purge-trash.sh 会真删。
  • 手动排查:node scripts/ws-cleanup.cjs(dry-run 出画像)、node scripts/session-gc.cjs、node scripts/clean-ws-pollution.cjs。
  • 三档口径:T1 平台产物/明确垃圾直接清;T2 顶层一次性脚本 超 90 天 才清;T3 其余(含所有目录)永不自动删。 (域名 / 证书 / nginx / 平台脚本 / 排障速查)

C.8 备份与恢复(2026-09-11 修复:脚本曾有但不可用)

问题:/opt/dsh/backup.sh 存在但无执行权限(-rw-------)且未接任何调度 → 最后一次全量备份停留在 2026-09-08。

现状(已修):

  • 脚本:/opt/dsh/backup.sh(副本入库 scripts/backup-platform.sh),chmod +x,并加固为 ① SQLite 一致性快照(sqlite3 .backup,避免运行中只拷到 -wal 造成库不一致) ② 归档内容扩展:var/lib/dshs(用户数据)+ etc/dshs.env + systemd 单元 + opt/dsh/artifacts(插件包)+ opt/dshs/scripts + etc/cron.d/dsh-* + nftables-dsh-egress.nft + www/server/panel/vhost/nginx(可重建)
  • 调度:/etc/cron.d/dsh-backup —— 每周日 05:00 全量 + 05:30 清理超 60 天的旧备份;日志 /var/log/dsh-backup.log
  • 产物:/opt/dsh/backups/dsh-platform-backup-<TS>.tar.gz(全量)+ …-db-snapshot.tar.gz(DB 快照)

恢复(演练已验证):

B=/opt/dsh/backups/dsh-platform-backup-<TS>.tar.gz
tar -tzf "$B" | head                 # 先看清单
tar -xzf "$B" -C /                  # 原路径还原(谨慎!会覆盖现网文件)
sqlite3 /var/lib/dshs/dshs.db "select count(*) from users;"   # 校验

实测证据:首跑全量 8.7 MB / 6919 条目(users 6858 + DB + env + artifacts + scripts 20 + cron 2 + nginx vhost 22 + systemd 3);恢复演练解出 DB 并查询 users = 2 行 ✓。