Files
dsh_shenxian/dsh-server-docs/02-运维手册.md
T
admin bc0dd2c96d docs(dsh-server-docs): mksess 口径校正(DB 直插→PG 直插)+ 技能 dsh-auto-handoff-chain 三处同步建立(序⑰ S1/S2)
- 02-运维手册.md:mksess 命中处补「PG 直插 + 连接串来源」+ D1 勘误指针两行(档案 77 的旧形态说明,正文不改)
- skills/dsh-change-workflow/SKILL.md(146/395/495 三行)、skills/dsh-env-bootstrap/references/常驻规则-快照.md(55 行):同口径校正
- skills/dsh-auto-handoff-chain/:新建文档库副本(SKILL.md v1.3.2 + scripts/chain_report.py),此前该技能三处同步从未建立
- README.md / INDEX.md:登记该技能(技能表 + 「什么时候查什么」+ 技能清单表)

判据:E1 = 0 行、E2 两副本 md5 全同、E3 diff -r = 0 行、E4 登记命中 README 1 / INDEX 2、镜像 47 同值
2026-09-17 17:03:34 +08:00

338 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> ⚠️ **历史章节**(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`。
```bash
# ===== 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 分流:
```bash
# 以新增 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:常用命令速查
```bash
# 备份 / 恢复
/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 生成(**PG 直插**,连接串取自 env / dshs.env / dshs.service.d;10 分钟;user_agent=poc-curl2)
node /opt/dshs/mksess.cjs
# ❗ 勘误(2026-09-17 序⑬ Q1 实测):档案 77 与更早文档对该脚本存储机制的描述属**当时形态**(写 `/var/lib/dshs/dshs.db`);
# 集群化后权威库 = 47 的 PG13(DSHS_DB_URL)、那份文件仅回滚用 ⇒ 现实现 = **PG 直插**,旧描述已失效。
# 普通用户角色化 profile patch(隐藏「模型」设置分区,档案 09;admin 不受影响)
node /opt/dshs/ensure-role-profile-patch.cjs [--restart] <username>
# 新普通用户 SOP:注册→approve→首登一次(建 profile)→ 跑上命令(--restart 使已运行实例生效)
```
## 附录 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):见当日讨论记录
---
## 附录 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 | grep -E "crash-restart|circuit-open|idle-reap"` |
| 实例反复起不来 | 同上(含 `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 | grep "[d]sh --profile"`;scope 名见 journald |
> 注:诊断实例内文件系统时,`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 快照)
**恢复(演练已验证)**:
```bash
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 行 ✓。