初始提交:DSH 多租户平台(dshs)
This commit is contained in:
commit
43976fea6a
167 files changed
+24456
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
# Build context exclusions: keep only what `npm ci` + `tsc` (src, tsconfig,
|
||||||
|
# package manifests) and the runtime (web, cordis.patch.yml) need.
|
||||||
|
node_modules
|
||||||
|
lib
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
data
|
||||||
|
test
|
||||||
|
scripts
|
||||||
|
poc
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# 供应链 CI:构建两个镜像 → 功能冒烟 → Trivy 高危阻断 → push 到 ACR。
|
||||||
|
#
|
||||||
|
# 单 job(build-and-scan):镜像在 job 内 build + load,冒烟 + Trivy 过后,
|
||||||
|
# master 推送时再 tag + push 到阿里云 ACR(用 ACR_USERNAME / ACR_PASSWORD
|
||||||
|
# secret)。仓库:registry.example.com/dsh/
|
||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install + typecheck
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run typecheck
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build control-plane image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: dshs:ci
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Build dsh image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile.dsh
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: dsh:ci
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Smoke — control plane (bootstrap-admin + serve)
|
||||||
|
run: |
|
||||||
|
# bootstrap-admin as the non-root image user (uid 65532), default data root.
|
||||||
|
docker run --rm dshs:ci bootstrap-admin \
|
||||||
|
--username admin --password 'ci-test-pass-123'
|
||||||
|
# Boot the server and publish 3080 so the host can reach it, then probe.
|
||||||
|
docker run --rm -d --name dsh-cp -p 3080:3080 dshs:ci \
|
||||||
|
--host 0.0.0.0 --port 3080
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
curl -fsS http://127.0.0.1:3080/ >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
# On failure, surface the server logs before the step aborts.
|
||||||
|
curl -fsS http://127.0.0.1:3080/ >/dev/null || { docker logs dsh-cp; exit 1; }
|
||||||
|
echo "control plane serving OK"
|
||||||
|
docker logs dsh-cp
|
||||||
|
docker stop dsh-cp
|
||||||
|
|
||||||
|
- name: Smoke — dsh resolves runtime plugin
|
||||||
|
run: |
|
||||||
|
# A patch referencing dshs/runtime must resolve and load:
|
||||||
|
# the plugin's apply() logs "[dshs-runtime] role=...".
|
||||||
|
# printf (not a here-doc) so the YAML stays at column 0 inside a
|
||||||
|
# literal block scalar.
|
||||||
|
printf '%s\n' \
|
||||||
|
'- insert:' \
|
||||||
|
' - id: dshs-runtime' \
|
||||||
|
' name: dshs/runtime' \
|
||||||
|
> /tmp/patch.yml
|
||||||
|
# Foreground + bounded timeout so an early exit still leaves logs.
|
||||||
|
# DSH_HOME mirrors the production layout (§4.3) so the parent-dir
|
||||||
|
# walk reaches /var/lib/dshs/node_modules; run as root so
|
||||||
|
# dsh can create that tree (the per-user uid is set by the Pod spec
|
||||||
|
# at deploy time, not exercised here).
|
||||||
|
timeout 25 docker run --rm --user 0 \
|
||||||
|
-v /tmp/patch.yml:/tmp/patch.yml:ro \
|
||||||
|
-e DSH_HOME=/var/lib/dshs/users/smoke/home \
|
||||||
|
dsh:ci --profile web --patch /tmp/patch.yml --host 127.0.0.1 --port 8080 \
|
||||||
|
> /tmp/dsh.log 2>&1 || true
|
||||||
|
cat /tmp/dsh.log
|
||||||
|
grep -q 'dshs-runtime' /tmp/dsh.log
|
||||||
|
echo "runtime plugin resolved OK"
|
||||||
|
|
||||||
|
- name: Trivy — control plane
|
||||||
|
uses: aquasecurity/[email protected]
|
||||||
|
with:
|
||||||
|
image-ref: dshs:ci
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
exit-code: 1
|
||||||
|
# 只阻断「可修复」的高危;收紧为 false 则连不可修复的也拦。
|
||||||
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
- name: Trivy — dsh
|
||||||
|
uses: aquasecurity/[email protected]
|
||||||
|
with:
|
||||||
|
image-ref: dsh:ci
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
exit-code: 1
|
||||||
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
# --- push to ACR (master push / manual dispatch on master only) ---
|
||||||
|
- name: Login to ACR
|
||||||
|
if: github.ref == 'refs/heads/master'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: registry.example.com
|
||||||
|
username: ${{ secrets.ACR_USERNAME }}
|
||||||
|
password: ${{ secrets.ACR_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Push control plane to ACR
|
||||||
|
if: github.ref == 'refs/heads/master'
|
||||||
|
run: |
|
||||||
|
docker tag dshs:ci \
|
||||||
|
registry.example.com/dsh/dshs:0.2.0
|
||||||
|
docker push \
|
||||||
|
registry.example.com/dsh/dshs:0.2.0
|
||||||
|
|
||||||
|
- name: Push dsh to ACR
|
||||||
|
if: github.ref == 'refs/heads/master'
|
||||||
|
run: |
|
||||||
|
docker tag dsh:ci \
|
||||||
|
registry.example.com/dsh/dsh:0.1.1-rc.2
|
||||||
|
docker push \
|
||||||
|
registry.example.com/dsh/dsh:0.1.1-rc.2
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
node_modules/
|
||||||
|
lib/
|
||||||
|
|
||||||
|
# poc/ 下的平台插件是**源码**(不是 TS 构建产物),其 lib/ 必须入库
|
||||||
|
!poc/*/lib/
|
||||||
|
!poc/*/lib/**
|
||||||
|
data/
|
||||||
|
dist-web/
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
*.local.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# 内部开发文档(不公开)
|
||||||
|
设计初稿.md
|
||||||
|
docs/security-model.md
|
||||||
|
docs/performance.md
|
||||||
|
docs/roadmap.md
|
||||||
|
公众号文稿.md
|
||||||
|
宣传报告.md
|
||||||
|
公众号文稿.txt
|
||||||
|
待办.md
|
||||||
|
docs/k8s.md
|
||||||
|
|
||||||
|
# 改码前的就地备份(不应入库;2026-09-11 曾误提交)
|
||||||
|
*.bak-*
|
||||||
|
|
||||||
|
# 插件打包产物(构建产物,不入库;历史上从未提交过 tgz)
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# 本项目的工作数据(会话/记忆/锁日志),不属于仓库内容
|
||||||
|
.workbuddy/
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# dshs — control-plane orchestrator image.
|
||||||
|
#
|
||||||
|
# Runs the Fastify orchestrator (authentication, per-user DSH supervision,
|
||||||
|
# desktop/domain APIs). In k8s mode (docs/k8s.md) this is a stateless
|
||||||
|
# multi-replica Deployment backed by Postgres + a shared encryption Secret; in
|
||||||
|
# local mode it can also run standalone against SQLite.
|
||||||
|
#
|
||||||
|
# Base image: node:22-slim (Node 22 LTS; engines ^22.19.0). For reproducible
|
||||||
|
# production builds, pin the digest and pass it via --build-arg:
|
||||||
|
# docker pull node:22-slim
|
||||||
|
# docker images --no-trunc node:22-slim --format '{{.Digest}}'
|
||||||
|
# docker build --build-arg NODE_IMAGE=node:22-slim@sha256:<digest> .
|
||||||
|
ARG NODE_IMAGE=node:22-slim
|
||||||
|
|
||||||
|
# --- build stage: compile src/ -> lib/ (needs devDeps + native build tools) ---
|
||||||
|
FROM ${NODE_IMAGE} AS build
|
||||||
|
# better-sqlite3 ships prebuilds; keep build tools as a node-gyp fallback.
|
||||||
|
# Build stage only, so the runtime stage stays slim.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
# Copy manifests + source before `npm ci`: its `prepare` hook runs `npm run
|
||||||
|
# build`, which needs src/ present.
|
||||||
|
COPY package.json package-lock.json tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm ci && npm run build && npm prune --omit=dev
|
||||||
|
|
||||||
|
# --- runtime stage: prod deps + lib + web, non-root ---
|
||||||
|
FROM ${NODE_IMAGE} AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/lib ./lib
|
||||||
|
COPY web ./web
|
||||||
|
COPY cordis.patch.yml ./
|
||||||
|
# npm is build-only: drop it (and its bundled deps) from the runtime image —
|
||||||
|
# they carry their own CVEs (brace-expansion/tar/sigstore/...) and bloat.
|
||||||
|
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
# Non-root uid 65532, matching docs/k8s.md §4.2 (PSA restricted-friendly).
|
||||||
|
RUN groupadd --gid 65532 dsh \
|
||||||
|
&& useradd --uid 65532 --gid dsh --home-dir /home/dsh --create-home --shell /usr/sbin/nologin dsh
|
||||||
|
USER dsh
|
||||||
|
EXPOSE 3080
|
||||||
|
ENTRYPOINT ["node", "lib/cli.js"]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# dsh — per-user DSH pod image (docs/k8s.md §4.3).
|
||||||
|
#
|
||||||
|
# Contains the DeepSeek Harness CLI (@deepseek-ai/dsh) plus the dshs
|
||||||
|
# runtime plugin (dshs/runtime), which the orchestrator injects into
|
||||||
|
# every child DSH via `--patch`. The runtime plugin is placed at
|
||||||
|
# /var/lib/dshs/node_modules so Node's parent-dir walk from any
|
||||||
|
# per-user $DSH_HOME/profiles/<name>/ resolves it. DSH_HOME is
|
||||||
|
# /var/lib/dshs/users/<id>/home (§4.3 / §4.7), so
|
||||||
|
# /var/lib/dshs is a fixed ancestor of every profile — independent
|
||||||
|
# of the harness's fallback-symlink closure. Keep this path in sync with
|
||||||
|
# DSHS_DATA_ROOT if the deployment changes it.
|
||||||
|
#
|
||||||
|
# Pin the base digest via --build-arg (see Dockerfile).
|
||||||
|
ARG NODE_IMAGE=node:22-slim
|
||||||
|
|
||||||
|
# --- build stage: compile dshs -> lib/runtime.js ---
|
||||||
|
FROM ${NODE_IMAGE} AS build
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /build
|
||||||
|
COPY package.json package-lock.json tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm ci && npm run build
|
||||||
|
|
||||||
|
# --- runtime stage ---
|
||||||
|
FROM ${NODE_IMAGE}
|
||||||
|
# The harness CLI (bin `dsh`); published to npm as a prebuilt release candidate.
|
||||||
|
RUN npm i -g @deepseek-ai/[email protected]
|
||||||
|
# Runtime plugin: only lib/ (runtime.js is zero-dependency) + its manifest
|
||||||
|
# (exports["./runtime"]). The control-plane stack (fastify/pg/better-sqlite3)
|
||||||
|
# is not needed in the pod.
|
||||||
|
RUN mkdir -p /var/lib/dshs/node_modules/dshs
|
||||||
|
COPY --from=build /build/lib /var/lib/dshs/node_modules/dshs/lib
|
||||||
|
COPY --from=build /build/package.json /var/lib/dshs/node_modules/dshs/
|
||||||
|
# npm is build-only: drop it after the global install (drops npm's bundled CVEs).
|
||||||
|
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
# Non-root image default; the Pod overrides runAsUser per user (§4.3).
|
||||||
|
RUN groupadd --gid 65532 dsh \
|
||||||
|
&& useradd --uid 65532 --gid dsh --home-dir /home/dsh --create-home --shell /usr/sbin/nologin dsh
|
||||||
|
USER dsh
|
||||||
|
EXPOSE 8080 8081
|
||||||
|
ENTRYPOINT ["dsh"]
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# dshs
|
||||||
|
|
||||||
|
|
||||||
|
> 📌 **本部署的两处文档别混(档案 19 §C10)**
|
||||||
|
> - `docs/`(本仓库内)= **仓库自带 7 篇**(blueprint / deployment / k8s-deploy …)——**不要往这里写我们的改造记录**;
|
||||||
|
> - **本项目的改造文档库**在另一仓库:`[email protected]:maogeigei/dsh_shenxian_doc.git`(服务器镜像 `/opt/dsh/docs`,root 600),
|
||||||
|
> 含档案 01–26、`DEPLOY-本部署.md`(构建/部署/回滚)、`ops/`(nginx 与切换脚本)、`scripts/`(运维工具)。
|
||||||
|
> 运维排障先看 `DEPLOY-本部署.md` 与档案 02 附录 C。
|
||||||
|
|
||||||
|
**面向公网的多租户 DSH 托管平台** —— 部署到一台公网服务器后,多个用户注册并经管理员审核,各自获得一套**相互隔离**的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)环境,随时通过域名安全访问(已适配手机端)。
|
||||||
|
|
||||||
|
> 以 [DSH 插件市场](https://github.com/bradeGithub/DSH-Plugins-Marketplace) 的 cordis-plugin 形态分发,遵守 [STANDARD.md](STANDARD.md)。
|
||||||
|
|
||||||
|
## 它解决什么
|
||||||
|
|
||||||
|
DSH 本身是单用户本地工具:没有认证、没有多租户隔离、Web 远程访问缺认证层。`dshs` 在其之上补一层**服务端登录 + 多租户编排**:
|
||||||
|
|
||||||
|
```
|
||||||
|
用户浏览器
|
||||||
|
│ HTTPS
|
||||||
|
▼
|
||||||
|
nginx(TLS 终结,主域 + *.子域 通配)
|
||||||
|
▼
|
||||||
|
编排服务 dshs(Fastify,单进程)
|
||||||
|
├─ 认证 / 审核 / 管理台 / 网页桌面 / 域名 API
|
||||||
|
└─ 按 Host 或 /u/<userId>/dsh/* 路由
|
||||||
|
└─ 反向代理 → 各用户的 DSH 子进程(只绑 127.0.0.1 动态端口,不出公网)
|
||||||
|
```
|
||||||
|
|
||||||
|
流程:管理员审核注册用户 → 每个用户落到自己的文件桌面 → 按文件夹启动 DSH → 通过域名访问,彼此文件隔离。
|
||||||
|
|
||||||
|
## 核心能力
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| **登录与审核** | `bootstrap-admin` 创建首个管理员;注册后需审核通过才能登录;禁用用户会同时删除其会话并停止运行中的 DSH |
|
||||||
|
| **每用户隔离 DSH** | 主 DSH 常驻对外服务;崩溃时**按需拉起一次守护 DSH** 修复并自动重启主实例 |
|
||||||
|
| **网页桌面** | 文件浏览 / 新建 / 上传;按文件夹启动 DSH;所有路径经「词法包含 + 符号链接分量」双重围栏校验 |
|
||||||
|
| **每文件夹插件** | 自动检测该用户 profile 已安装的插件,按文件夹勾选启用,持久化并注入 cordis patch |
|
||||||
|
| **多形态访问** | 默认子路径 `/u/<userId>/dsh/`;每用户子域名 `<用户名>.<baseDomain>`(HTTP + WebSocket);自定义域名 + nginx `server {}` 生成接口 |
|
||||||
|
| **凭据隔离** | 每用户命名 API 密钥库,AES-256-GCM 加密落库(references-not-secrets);换 key 自动重建实例;spawn 只注入当前启用的 key |
|
||||||
|
|
||||||
|
## 部署形态(二选一)
|
||||||
|
|
||||||
|
同一套代码,靠 `DSHS_DEPLOY_MODE` 切换:
|
||||||
|
|
||||||
|
| | 模式 A:直接部署(单服务器) | 模式 B:K8s + 容器化 |
|
||||||
|
|---|---|---|
|
||||||
|
| 形态 | 单机裸机,`child_process` + setuid/iptables | 多机 ACK,每用户独立 Pod |
|
||||||
|
| 数据 | SQLite(本机文件) | PostgreSQL(CloudNativePG) |
|
||||||
|
| 隔离 | 软隔离 / OS 账号硬隔离 | Pod 网络 + SecurityContext + NetworkPolicy |
|
||||||
|
| 弹性/HA | 无(单点) | 控制面 3 副本 + leader election,DSH Pod 自动重建 |
|
||||||
|
| 交付 | `git clone` + 脚本 | `kubectl apply -f deploy/` |
|
||||||
|
|
||||||
|
模式 B 已完整落地(Phase 0–4):每用户 DSH Pod(dsh + tcp-bridge sidecar)+ file sidecar(8082)+ Headless Service + NetworkPolicy;控制面 3 副本 + Lease 选主 + reconcile + 崩溃接管;NAS(CNFS) 共享卷 + PSA restricted + ResourceQuota。见 [K8s 部署教程](docs/k8s-deployment.md) 与 [踩坑记录](docs/k8s-deploy.md)。
|
||||||
|
|
||||||
|
## 快速开始(模式 A)
|
||||||
|
|
||||||
|
前置:Linux 服务器、Node **^22.19 或 ≥24**、已安装 DSH CLI(`npm i -g @deepseek-ai/dsh`)。完整的生产流程(systemd / DNS / 通配证书 / nginx)见[部署教程](docs/deployment.md)。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://work.alotbuy.com/maogeigei/dsh_shenxian.git
|
||||||
|
cd dshs
|
||||||
|
npm install && npm run build # tsc → lib/
|
||||||
|
|
||||||
|
# 1) 创建首个管理员(用它登录管理台)
|
||||||
|
node lib/cli.js bootstrap-admin --username admin --password '<强密码>' --db ./dev.local.db
|
||||||
|
|
||||||
|
# 2) 启动编排服务
|
||||||
|
node lib/cli.js --port 3080 --db ./dev.local.db
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 `http://127.0.0.1:3080/`:
|
||||||
|
|
||||||
|
1. 用 admin 登录进入管理台;
|
||||||
|
2. 另开浏览器注册一个普通用户 → 回管理台点「通过」;
|
||||||
|
3. 该用户重新登录进入桌面 → 上传文件 / 建文件夹 → 点「在此文件夹启动 DSH」;
|
||||||
|
4. 首次使用在桌面的「管理密钥」里填入自己的 DeepSeek API Key(加密存储;未设置时 DSH 能启动但无法调用模型)。
|
||||||
|
|
||||||
|
> ⚠️ 本地试跑可以验证登录 / 审核 / 桌面 / 启动全链路;但要**打开 DSH 聊天界面**,需要配置域名与每用户子域名(`BASE_DOMAIN` + `COOKIE_DOMAIN` + nginx 通配,见[部署教程](docs/deployment.md)第 6–9 步)——DSH 的 SPA 使用绝对路径,纯子路径方式加载不了静态资源。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
环境变量均可省略;同名 CLI flag(`--port` / `--db` / `--isolation-mode` 等)优先级更高,全部见 `src/config.ts` 与 `node lib/cli.js --help`。
|
||||||
|
|
||||||
|
| 环境变量 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `DSHS_PORT` | `3080` | 编排服务绑定端口(nginx 上游) |
|
||||||
|
| `DSHS_DATA_ROOT` | `~/.dshs` | 每用户 home/workspace 根(生产 `/var/lib/dshs`) |
|
||||||
|
| `DSHS_DSH_BIN` | `dsh` | 子 DSH 可执行文件(建议绝对路径,systemd 下 PATH 精简) |
|
||||||
|
| `DSHS_ISOLATION_MODE` | `soft` | `soft` 软隔离 / `account` 账号级硬隔离(Linux,需 root) |
|
||||||
|
| `DSHS_BASE_UID` | `100000` | 账号级隔离的 uid 基数 |
|
||||||
|
| `DSHS_PORT_GUARD` | `false` | iptables owner-match 端口守卫(Linux+root):阻止同机其他账号直连各用户 DSH 的回环端口;不支持的环境下开启会拒绝启动(fail loud) |
|
||||||
|
| `DSHS_SECURE_COOKIES` | `false` | HTTPS 部署设为 `true`(cookie 加 `Secure`,SameSite=None 以跨子域共享) |
|
||||||
|
| `DSHS_BASE_DOMAIN` | 空 | 每用户子域名的基域(如 `dsh.example.com`);空 = 仅子路径访问 |
|
||||||
|
| `DSHS_COOKIE_DOMAIN` | 空 | 会话 cookie 的 `Domain`(如 `.dsh.example.com`,注意前导点);空 = host-only |
|
||||||
|
| `DSHS_SESSION_TTL` | `604800` | 会话有效期(秒,默认 7 天) |
|
||||||
|
| `DSHS_MAX_UPLOAD` | `25MB` | 上传请求体上限(base64 JSON) |
|
||||||
|
| `DSHS_RESTART_BACKOFF` | `1000` | 子 DSH 崩溃后的自动重启延迟(毫秒) |
|
||||||
|
| `DSHS_ENABLE_PATCH` | `false` | 是否向子 DSH 注入 `--patch`(运行时插件 + 每文件夹插件;旧版 dsh 不支持时可关) |
|
||||||
|
| `DSHS_SECRET` | 自动生成 | 每用户密钥库的加密主密钥;未设置时生成并持久化到 `<dataRoot>/secret.key`(0600) |
|
||||||
|
| `DSHS_DEPLOY_MODE` | `local` | `local` 单机 / `k8s` 每用户 Pod(模式 B) |
|
||||||
|
| `DSHS_DB_URL` | 空 | Postgres DSN;设置即启用 Postgres(k8s 必填) |
|
||||||
|
| `DSHS_NAMESPACE` | `dsh` | (k8s)每用户资源所在命名空间 |
|
||||||
|
| `DSHS_DSH_IMAGE` | 空 | (k8s 必填)每用户 DSH Pod 镜像 |
|
||||||
|
| `DSHS_CONTROL_PLANE_IMAGE` | 空 | (k8s 必填)file sidecar / tcp-bridge / init 容器镜像 |
|
||||||
|
| `DSHS_IMAGE_PULL_SECRET` | `dsh-acr-pull` | (k8s)生成 Pod 的拉镜像 secret |
|
||||||
|
| `DSHS_EGRESS_CIDRS` | 空 | (k8s)每用户 Pod 443 出站白名单 CIDR;空 = 全网(始终排除私网段与 169.254.169.254) |
|
||||||
|
| `DSHS_K8S_SERVICE_ACCOUNT` | `dsh-orchestrator` | (k8s)控制面 ServiceAccount |
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
| 文档 | 内容 | 什么时候读 |
|
||||||
|
|---|---|---|
|
||||||
|
| [部署教程(模式 A)](docs/deployment.md) | 从零到公网可用:Node / DSH / systemd / DNS / HTTPS / nginx | 第一次部署单机版,照着做即可 |
|
||||||
|
| [硬隔离教程](docs/hard-isolation.md) | 每用户独立 OS 账号(`setpriv` 降权),生产环境建议开启 | 模式 A 跑通后的进阶加固 |
|
||||||
|
| [域名配置示例](docs/domain-config.md) | 主域 + 每用户子域 + 自定义域名的 nginx 配置 | 配置域名 / 排查路由问题 |
|
||||||
|
| [常见问题排查](docs/troubleshooting.md) | 502 / 404 / 401 / 403 / SSL / 端口冲突,按现象索引 | 出错了先来这里对现象 |
|
||||||
|
| [K8s 部署教程(模式 B)](docs/k8s-deployment.md) | ACK + CNFS + CNPG 分步部署(多机 HA) | 要部署容器化多机形态 |
|
||||||
|
| [K8s 踩坑记录](docs/k8s-deploy.md) | 模式 B 实机部署的坑与根因(镜像源 / 存储 / 网络 / 备案) | 模式 B 部署卡住时查 |
|
||||||
|
| [技术蓝图](docs/blueprint.md) | 权威技术设计:拓扑 / 双 DSH / 数据模型 / API / 安全模型 | 想了解原理或参与开发 |
|
||||||
|
| [PoC 实验记录](poc/README.md) | k8s 关键假设的四项实测(RWX / socat / NetworkPolicy / CNPG) | 评估模式 B 可行性时 |
|
||||||
|
|
||||||
|
> 学习路线:**模式 A** 先读「部署教程」跑通 → 再看「硬隔离」加固,出问题查「常见问题排查」;**模式 B** 直接从「K8s 部署教程」开始,卡住查「K8s 踩坑记录」。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install # pnpm/npm 均可;Node ^22.19 || >=24
|
||||||
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm test # 构建 + node:test 单测(DB 双后端 / k8s spawner / leader / fs 围栏)
|
||||||
|
npm run smoke # 及 smoke:* 系列:对运行中的服务做端到端冒烟(scripts/smoke*.mjs)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
- **会话**:不透明随机 token,仅 SHA-256 哈希落库;cookie `HttpOnly` + `SameSite`,HTTPS 下加 `Secure`。
|
||||||
|
- **密码**:scrypt 加盐哈希,常数时间比较。
|
||||||
|
- **密钥**:每用户 API key 以 AES-256-GCM 加密落库,主密钥来自 env 或 `<dataRoot>/secret.key`(0600)。
|
||||||
|
- **路径**:所有文件操作先做词法包含校验,再逐段拒绝符号链接分量,防止越出用户根目录。
|
||||||
|
- **隔离分层**:软隔离(默认)→ 账号级硬隔离(OS 账号 + `setpriv` 降权,`0700` 真正生效)→ 端口守卫(iptables owner-match)→ k8s Pod 边界(non-root / drop ALL / 只读 rootfs / NetworkPolicy)。
|
||||||
|
- **审计**:注册 / 登录 / 审核 / 改密钥等动作写入 `audit_log`。
|
||||||
|
|
||||||
|
细节与威胁模型见[技术蓝图 §7](docs/blueprint.md)、[硬隔离教程](docs/hard-isolation.md)。
|
||||||
|
|
||||||
|
## 问题反馈与支持
|
||||||
|
|
||||||
|
这是我开发的第一个正式项目,功能可能还不够完善,非常欢迎大家提出意见和反馈!
|
||||||
|
如果你在使用过程中遇到问题,或者有改进建议,欢迎提交 [Issue](../../issues):
|
||||||
|
|
||||||
|
- 🐛 **遇到 Bug**:请描述你的复现步骤、报错信息、以及运行环境(系统/DSH 版本等)
|
||||||
|
- 💡 **功能建议**:请说明你的使用场景和你期望的效果
|
||||||
|
- 📖 **文档疑问**:指出 README 中看不懂或描述不清的地方
|
||||||
|
|
||||||
|
我会**尽量在 1~2 天内回复**,确认有效的问题会尽快修复并发布更新。
|
||||||
|
感谢你的支持!🌟
|
||||||
+306
@@ -0,0 +1,306 @@
|
|||||||
|
# STANDARD — DSH 插件市场收录与安装规范
|
||||||
|
|
||||||
|
🌐 **语言 / Language:** **中文**(English 版暂未提供)
|
||||||
|
|
||||||
|
> 本规范定义「一个仓库怎么写,才能被 [DSH 插件市场](https://github.com/bradeGithub/DSH-Plugins-Marketplace)正确识别、正确安装、正确显示更新」。
|
||||||
|
> 市场安装管线是**特征驱动**的:它扫描仓库文件形态决定安装方式。本文档把判定规则、每类插件的规范写法、
|
||||||
|
> 以及踩过的坑(附真实案例)固化下来。**照此写,市场即可一键装、可更新、可卸载。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 收录前提
|
||||||
|
|
||||||
|
- 仓库需添加 topic **`dsh-plugin`**(GitHub 仓库页 → Settings → Topics)。
|
||||||
|
- 市场 CI 每 2 小时扫描一次该 topic,自动收录;无需任何人工申请。
|
||||||
|
- 其余 topic 建议(帮助用户搜索与分类):`dsh`、`deepseek-harness`、`agent-preset`、`cordis-plugin`、`dsh-skill` 等。
|
||||||
|
|
||||||
|
### 0.1 真插件最小定义(防 topic 蹭标签)
|
||||||
|
|
||||||
|
`dsh-plugin` topic 是收录入口,**不是**「真插件」的充分条件——非 DSH 仓库打标刷榜是生态已知问题
|
||||||
|
(实测案例:★40k 简历项目 `amruthpillai/reactive-resume`、★28k 的 `volcengine/OpenViking` 曾混入索引)。
|
||||||
|
|
||||||
|
「真插件」的最小硬信号(满足任一即可被识别为可安装内容):
|
||||||
|
|
||||||
|
| 硬信号 | 判定类型 |
|
||||||
|
|---|---|
|
||||||
|
| 根/子目录 `package.json` 声明 DSH 插件能力(`dsh` 字段 / `@deepseek-ai/*` 依赖) | cordis-plugin |
|
||||||
|
| 根 `SKILL.md`(技能本体) | skill |
|
||||||
|
| 根 `preset.yml` + `agent.cordis.yml` | agent-preset |
|
||||||
|
| 根 `install.ps1` / `install.sh` | script(最低门槛) |
|
||||||
|
|
||||||
|
一个都没有 → 市场构建期判定为「非 DSH 插件」并盖红标(高 star 仓库有专项兜底判定);完整判定顺序即 §1 的 10 步表。
|
||||||
|
该定义与 dshbase 等社区目录的收录门槛(仓库公开存在 + bundle 清单 + `dsh-plugin` topic)互认同源。
|
||||||
|
|
||||||
|
## 1. 类型判定总览(作者必读)
|
||||||
|
|
||||||
|
市场按**固定顺序**扫描仓库根目录特征文件,**先命中者生效**:
|
||||||
|
|
||||||
|
| 顺序 | 特征 | 判定类型 | 安装行为 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 根目录同时有 `preset.yml` + `agent.cordis.yml` | agent-preset | 复制到 `~/.dsh/.agent-presets/<id>` |
|
||||||
|
| 2 | 根 `package.json` 声明 DSH 插件能力(`dsh` 字段 / `@deepseek-ai/*` 依赖) | cordis-plugin | 构建/装依赖 → 复制到 profile node_modules → 注册 patch |
|
||||||
|
| 3 | 根目录有 **`install.ps1`**(未声明插件能力) | script | 执行该脚本(安全确认弹窗) |
|
||||||
|
| 4 | 根目录有 **`install.sh`**(未声明插件能力) | script | 执行该脚本(安全确认弹窗) |
|
||||||
|
| 5 | 子目录含完整预设(`preset.yml`+`agent.cordis.yml`) | agent-preset | 逐个复制 |
|
||||||
|
| 6 | 根 `package.json`(未声明 DSH 能力)+ 根 `SKILL.md` | skill | 复制到 `~/.dsh/skills/` |
|
||||||
|
| 7 | 根目录 `SKILL.md`(无 package.json) | skill | 同上 |
|
||||||
|
| 8 | 子目录含插件清单(皮肤/多包仓库) | cordis-plugin | 逐个子包安装 |
|
||||||
|
| 9 | 子目录含技能清单(技能合集) | skill | 逐个安装 |
|
||||||
|
| 10 | 无任何特征 | instructions | 展示 README 手动安装指引 |
|
||||||
|
|
||||||
|
> ⚠️ **最重要的两条规则**:
|
||||||
|
> 1. **第 2 条先于第 3/4 条(显式声明优先,机制兜底)**——声明过 `dsh` 插件能力的仓库即使根目录带 install 脚本也不会被判为脚本型,
|
||||||
|
> cordis 插件附分发脚本是合法形态。但脚本留在根目录仍会**误导用户手动执行**,建议移入 `scripts/` 子目录(见 §6.1)。
|
||||||
|
> 2. `package.json` 的 `dsh` 字段(或 `@deepseek-ai/*` 依赖)是「插件能力声明」——有它才算 cordis 插件,
|
||||||
|
> 否则根 package.json 会被当成普通 npm 项目处理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 类型 A:cordis 插件(推荐主形态)
|
||||||
|
|
||||||
|
**适用**:一切带 JS 运行时的 DSH 插件(服务端工具 / 客户端皮肤 / 事件处理)。
|
||||||
|
|
||||||
|
### 2.1 最小 package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "dsh-my-plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "./lib/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"files": ["lib"],
|
||||||
|
"dsh": {
|
||||||
|
"plugin": true,
|
||||||
|
"kind": "server",
|
||||||
|
"bundle": { "patch": "./cordis.patch.yml" }
|
||||||
|
},
|
||||||
|
"repository": { "type": "git", "url": "https://github.com/you/dsh-my-plugin.git" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段要求:
|
||||||
|
|
||||||
|
| 字段 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| `name` | 合法 npm 包名(`PKG_NAME_PATTERN` 校验;scoped 包 `@scope/name` 允许)。**同名 npm 包互斥**——市场会把 pkg_name 冲突的低 star 仓库隐藏,请用唯一名 |
|
||||||
|
| `version` | 遵循 semver。**每次发版必须 bump**——市场用它做「更新」检测(npm 发布型插件的 npm_version 同理) |
|
||||||
|
| `main` / `exports` | 指向真实存在的入口文件。**入口缺失 + 有 `scripts.build` → 市场视为源码型,弹构建确认** |
|
||||||
|
| `dsh` | **插件能力声明**(有 `dsh` 对象即视为插件)。`dsh.bundle.patch` 指向 cordis patch 清单时,市场安装后自动注册到 profile 的 cordis.patch.yml |
|
||||||
|
| `repository` | 强烈建议填写——已安装识别(同仓库匹配)与市场卡片展示依赖它 |
|
||||||
|
| `dependencies` / `peerDependencies` | 市场安装时执行 `npm install --omit=dev --ignore-scripts`(用户确认后才放开脚本);peer 冲突自动回退 `--legacy-peer-deps`。**DSH 宿主接口包(`@deepseek-ai/dsh-tools`、`dsh-llm`、`dsh-system-prompt`、`dsh-attachment`、`dsh-scope`、`dsh-schema`)只能进 `peerDependencies`,禁止进普通 `dependencies`/`bundledDependencies`**(构建期版本放 `devDependencies`)——否则旧版副本遮蔽宿主,工具调用全挂、内置预设失效(真实案例见 §6.6) |
|
||||||
|
|
||||||
|
### 2.2 源码型 vs 产物型
|
||||||
|
|
||||||
|
- **产物型(推荐)**:仓库提交构建产物(`lib/` 或 dist),`main` 指向已存在文件 → 市场直接复制安装,快且无构建风险。
|
||||||
|
- **源码型**:`scripts.build` 存在且 `main` 文件不在仓库(.gitignore)→ 市场安装时弹「安装依赖并执行构建」确认,
|
||||||
|
用户确认后执行 `npm/pnpm install`(完整依赖含 dev)→ `npm run build` → 复制产物。构建脚本需在无交互环境下可用。
|
||||||
|
|
||||||
|
### 2.3 安装管线(市场自动完成)
|
||||||
|
|
||||||
|
1. 克隆仓库 → 判定 cordis-plugin;
|
||||||
|
2. 需要构建则构建确认 → 装依赖(默认禁第三方脚本);
|
||||||
|
3. 复制到 `~/.dsh/profiles/web/node_modules/<pkg_name>`(排除 .git);
|
||||||
|
4. 入口校验(main 文件存在 / dsh.bundle 声明 / 任意顶层 JS);
|
||||||
|
5. 注册 `cordis.patch.yml`(幂等,行级精确匹配);
|
||||||
|
6. 记录版本 → 重启 DSH 生效。
|
||||||
|
|
||||||
|
### 2.4 多包仓库(皮肤合集等)
|
||||||
|
|
||||||
|
根目录无 package.json 但子目录有插件清单 → 市场按 `findPluginRoots`(深度 3)**逐个安装子包**。
|
||||||
|
注意:子包的 package.json 同样需要 `dsh` 字段或 `@deepseek-ai/*` 依赖(否则不会被识别为插件)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 类型 B:技能(skill)
|
||||||
|
|
||||||
|
**适用**:纯提示词技能(SKILL.md 形态,无 JS 运行时)。
|
||||||
|
|
||||||
|
- 根目录放 **`SKILL.md`**(大小写不敏感);
|
||||||
|
- 可选 frontmatter 声明技能名:`name: my-skill`(小写字母数字连字符),缺失时用仓库名;
|
||||||
|
- 带工具链 package.json(未声明 `dsh`)的仓库:根 SKILL.md 仍按 skill 安装——**不要在 skill 仓库声明 `dsh` 字段**,否则会判成插件而漏装技能。
|
||||||
|
- 注意:`.git` / 点目录 / `node_modules` / vendored 目录(如 `upstream/`)里的 SKILL.md 会被忽略,不会误装。
|
||||||
|
|
||||||
|
## 4. 类型 C:agent 预设
|
||||||
|
|
||||||
|
**适用**:agent 预设包(preset 形态)。
|
||||||
|
|
||||||
|
- 同时含 `preset.yml` + `agent.cordis.yml` → 判定 agent-preset;
|
||||||
|
- 预设目录可放子目录(如 `preset/`,深度 3 内),市场逐个复制到 `~/.dsh/.agent-presets/<目录名>`;
|
||||||
|
- 若同时想装插件逻辑:把 JS 部分做成 cordis 插件(两个独立仓库,或插件仓库子目录放预设——判定顺序 4 在 5 之前,根目录**同时有**插件清单与子目录预设时,预设优先)。
|
||||||
|
|
||||||
|
## 5. 类型 D:安装脚本型(install.ps1 / install.sh)
|
||||||
|
|
||||||
|
**适用**:无法用上述形态表达的安装逻辑(系统级配置、外部依赖编排)。
|
||||||
|
|
||||||
|
脚本契约(市场克隆仓库后在仓库根执行):
|
||||||
|
|
||||||
|
1. **自包含**:市场只克隆 git 仓库、不构建。脚本不能依赖构建产物(`lib/`、`dist/` 等 .gitignore 内容);需要构建请在脚本内完成(`bash scripts/build.sh`)。
|
||||||
|
2. **幂等**:重复执行安全——已注册/已复制的部分自动跳过。
|
||||||
|
3. **双平台**:`install.ps1`(Windows,pwsh)与 `install.sh`(bash)按平台二选一;只提供一个则另一平台报错。
|
||||||
|
4. **环境解析**:`$env:DSH_HOME` / `$HOME` 判定 profile 目录;profile 不存在时明确报错。
|
||||||
|
5. **安全提示**:用户安装时会看到「执行第三方脚本有风险」确认弹窗——README 里如实说明脚本做什么。
|
||||||
|
6. **卸载**:脚本型安装无法自动回滚(市场卸载只删记录与克隆缓存),脚本自身效果需作者提供反向操作说明。
|
||||||
|
|
||||||
|
> ⚠️ **脚本型与 cordis 插件二选一**:如果项目本质是 cordis 插件(有 package.json + `dsh` 声明),
|
||||||
|
> **不要**在根目录放 install.ps1/install.sh——见 §6.1。脚本型安装没有版本检测、没有更新按钮、没有自动卸载。
|
||||||
|
|
||||||
|
## 6. 反模式与真实案例
|
||||||
|
|
||||||
|
### 6.1 根目录 install 脚本与 cordis 声明并存(dsh-paper-tutor 案例)
|
||||||
|
|
||||||
|
作者把 cordis 插件(`dsh.plugin=true` 声明齐全)的便捷安装脚本 `install.ps1`/`install.sh` 放在**仓库根**:
|
||||||
|
- 旧版判定顺序命中脚本特征 → script 型,跳过 cordis 管线;
|
||||||
|
- 脚本本地模式又依赖构建产物 `lib/index.js`(仓库未提交)→ 直接报错,**用户点安装必然失败**。
|
||||||
|
|
||||||
|
**现状(机制兜底)**:判定顺序已改为「`dsh` 声明优先于 install 脚本」——声明过插件能力的仓库即使脚本留在根目录也会正确按 cordis-plugin 安装(自动完成「构建确认 → 装依赖 → 复制 → 注册 patch」)。**但脚本仍建议移入 `scripts/` 子目录**:留在根目录会误导用户手动执行,且对「未声明 dsh 的脚本型仓库」而言根目录脚本仍是判定特征。
|
||||||
|
|
||||||
|
### 6.2 描述漂移导致分类跳变(dsh-TUI 案例)
|
||||||
|
|
||||||
|
市场用 `description` + `name` + `topics` 关键词做分类(coding/notify/memory/…)。某插件原分类 `coding`,
|
||||||
|
作者在简介里加了一句「DSH 官方公众号收录…WeChat featured」→ 命中 notify 规则 → 分类跳变,测试报警。
|
||||||
|
|
||||||
|
**作者须知**:简介里的宣传性词汇(微信/通知/商店/榜单)会影响分类。分类只影响市场展示栏目,不影响安装。
|
||||||
|
若被误分,可在市场仓库提 issue 申请人工覆写(`CATEGORY_OVERRIDES`)。
|
||||||
|
|
||||||
|
### 6.3 版本不 bump → 更新检测失效
|
||||||
|
|
||||||
|
市场的「更新」检测对比仓库 package.json 的 `version`(npm 型对比 npm dist-tags)。**只改代码不发版**会让
|
||||||
|
「更新」按钮永远不出现(用户只能卸载重装)。发版规则:改代码 → bump version → push(tag 可选)。
|
||||||
|
|
||||||
|
### 6.4 自己注册 patch → 双加载崩溃(issue #39)
|
||||||
|
|
||||||
|
插件安装时市场**自动**注册 cordis.patch.yml。插件不要在运行时/安装脚本里再注册自己的 patch 条目
|
||||||
|
(profile bundles 加载 + patch 双注册 → webserver 重复路由 → 启动崩溃)。市场安装的自己会跳过重复注册。
|
||||||
|
|
||||||
|
### 6.5 pkg_name 撞名 → 被隐藏
|
||||||
|
|
||||||
|
同名 npm 包在 node_modules 里互斥(互相覆盖)。市场对 pkg_name 冲突的仓库**只显示 star 高的一个**。
|
||||||
|
取名时请查一下 npm/registry 是否已被占用。
|
||||||
|
|
||||||
|
### 6.6 宿主接口包打成普通依赖 → 遮蔽宿主(dsh-excel-chat 案例)
|
||||||
|
|
||||||
|
某插件把 `@deepseek-ai/dsh-tools` / `dsh-llm` / `dsh-system-prompt` / `dsh-attachment` 声明为普通 `dependencies`——
|
||||||
|
安装「成功」、插件也能加载,但这些**旧版副本被提升到 profile 顶层并优先于宿主加载**,导致:
|
||||||
|
- 所有工具调用失败(`Cannot read properties of undefined (reading 'prepare')`)
|
||||||
|
- DSH 内置 `minimal` 预设无法挂载(`ctx.systemPrompt.suppressRuntimeContext is not a function`)
|
||||||
|
|
||||||
|
**正确做法**:宿主接口包一律 `peerDependencies`(版本范围对齐当前 DSH),构建所需放 `devDependencies`。
|
||||||
|
市场安装时会静态检出普通依赖中的宿主包并弹确认警示(可拒绝);但**市场警示不能替代平台修复**——同版本独立副本仍可能模块身份冲突,需要 DSH 宿主优先解析机制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 自测清单(提收录前跑一遍)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 判定类型(预期之外的结果就是坑)
|
||||||
|
git clone <你的仓库> /tmp/x && 检查根目录特征文件对照 §1 表格
|
||||||
|
|
||||||
|
# 2. cordis 插件:入口与构建
|
||||||
|
node -e "const p=require('/tmp/x/package.json');console.log(p.dsh, p.main, require('fs').existsSync('/tmp/x/'+p.main))"
|
||||||
|
# 预期:dsh 对象存在;main 文件存在(产物型)或 scripts.build 存在(源码型)
|
||||||
|
|
||||||
|
# 3. 技能:SKILL.md 在根目录,frontmatter name 合法
|
||||||
|
|
||||||
|
# 4. 脚本型:两种平台脚本都有;无构建产物依赖;幂等(连跑 2 次无副作用)
|
||||||
|
|
||||||
|
# 5. 描述自查:无与插件本质无关的分类敏感词(微信/通知/商店/榜单…)
|
||||||
|
|
||||||
|
# 6. version 已 bump(与上次发版不同)
|
||||||
|
|
||||||
|
# 7. 披露自查:云端依赖 / 数据外发 / API key 存储 / 法域已在 SKILL.md frontmatter 或 package.json disclosure 字段如实声明(见 §9 字段契约)。
|
||||||
|
# 完整命令块见 skill-compliance 的 docs/disclosure-selfcheck.md(7a 云端依赖 / 7b 凭据 / 7c 权限 /
|
||||||
|
# 7d 端点一致性 / 7e 法域保留 / 7f 宿主依赖硬规则);机器可读规则集
|
||||||
|
# disclosure-selfcheck-rules.json(DISCL-001~006 + DEP-001)可由 skill-compliance v1.4.0 自动执行。
|
||||||
|
|
||||||
|
# 8.(可选)已跑过发布合规检查(如 skill-compliance:金融敏感词/免责声明/安全红线/广告法极限词 + 披露规则集)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 市场行为速查
|
||||||
|
|
||||||
|
| 能力 | cordis-plugin | skill | agent-preset | script |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 一键安装 | ✅ | ✅ | ✅ | ✅(确认弹窗) |
|
||||||
|
| 版本检测 / 更新按钮 | ✅(package.json version;npm 型按 dist-tags) | ❌ | ❌ | ❌ |
|
||||||
|
| 自动卸载 | ✅(删目录 + 移除 patch) | ✅ | ✅ | ⚠️ 仅删记录(脚本效果不可回滚) |
|
||||||
|
| 依赖安装 | ✅(默认禁脚本,可确认放开) | — | — | 脚本自理 |
|
||||||
|
| 构建 | ✅(源码型弹确认) | — | — | 脚本自理 |
|
||||||
|
| 安全确认 | 依赖脚本确认(如有) | 无 | 无 | 第三方脚本风险确认 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 发布披露清单(合规层最小契约)
|
||||||
|
|
||||||
|
> 识别层管「怎么装」,验证层管「装了能不能信」,**披露层管「装之前该不该装、数据去了哪」**。
|
||||||
|
> 以下披露项是作者契约的一部分——在 SKILL.md frontmatter(snake_case)或 package.json `disclosure`
|
||||||
|
> 字段(camelCase)中如实声明即可;市场通过**披露开放数据层**(`wwumit/skills-catalog` 的
|
||||||
|
> `catalog.json`,讨论 #2269 三方对齐的「方案 B」)构建期抓取盖章,客户端卡片显示「披露 ✓」徽章
|
||||||
|
> (悬停可见 云端/本地、端点、凭据、法域、保留策略摘要)。
|
||||||
|
|
||||||
|
### 字段契约(DISCLOSURE v0.2,与 wwumit 三方对齐)
|
||||||
|
|
||||||
|
| 披露项(必填分级) | frontmatter 声明形态 | 市场索引形态(catalog.json 输出) | 要求 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **D1 云端依赖**(必填) | `cloud: false` | `cloud` (bool) | 是否发数据到云端;端点列在 `network` |
|
||||||
|
| **D1 网络端点** | `network: []` | `network` (string[]) | 数据目的地,如 `["https://compliancehub.cn"]` |
|
||||||
|
| **D2 离线模式**(建议) | `offline_mode: true` | `offlineMode` (bool) | 是否存在完全离线路径 |
|
||||||
|
| **D3 凭据处理**(必填) | `api_keys: [{env, storage}]` | `apiKeys` ({env, storage}[]) | key 获取方式/存储位置(`file-0600` 等枚举)/是否落日志 |
|
||||||
|
| **D4 权限声明**(必填) | `permissions:` frontmatter | — | 网络/文件系统/环境变量读写范围 |
|
||||||
|
| **D5 法域标签**(建议) | `jurisdiction: []` | `jurisdiction` (string[]) | PIPL(CN)/CCPA(US-CA)/GDPR(EU) 等 |
|
||||||
|
| **D6 数据保留**(建议) | `retention: "session"` | `retention` (string) | none / session / server |
|
||||||
|
|
||||||
|
- 版本化:开放数据层顶层 `disclosureSchemaVersion`(当前 `"0.2"`)独立于验证层 `schemaVersion`;不匹配时市场**整体跳过不盖章**(fail-closed)
|
||||||
|
- 映射键:`fullName`(发布仓 `owner/name`),与验证层 verified.json 同款匹配逻辑;数据层另提供仓级 `repos[].cloudSkills` 索引与技能级 `skillFullName`——市场按仓库盖章时聚合云端技能详情(端点/凭据/法域去重合并、retention 取最严),同仓混合披露不再失真
|
||||||
|
- 示例见 [DISCLOSURE_PROPOSAL.md](https://github.com/wwumit/skills-catalog/blob/main/docs/DISCLOSURE_PROPOSAL.md)(wwumit v0.2 提案)
|
||||||
|
|
||||||
|
### 自测与检查(机器可读)
|
||||||
|
|
||||||
|
- **规则集**:[disclosure-selfcheck-rules.json](https://github.com/wwumit/skills-tools/blob/main/skills/skill-compliance/docs/disclosure-selfcheck-rules.json)(schema v1)——7 条规则(DISCL-001~006 + DEP-001):id / 严重级 / 必填标记(D1/D3/D4 与 DEP-001 宿主依赖为必填)/ check_command / 判定说明,供检查器与 CI 直接消费
|
||||||
|
- **命令块**:[disclosure-selfcheck.md](https://github.com/wwumit/skills-tools/blob/main/skills/skill-compliance/docs/disclosure-selfcheck.md)(7a~7f)——作者提收录前手动跑
|
||||||
|
- **自动执行**:`skill-compliance` v1.4.0(`comply.py check`)全自动跑同一套规则,JSON 输出含 disclosure 摘要
|
||||||
|
- **三态衔接**:规则集的「缺必填」判定 = 市场卡片「⚠️ 缺必填项」态的机器依据;「无 disclosure 但有网络调用」=「❓ 未声明」态的依据(市场侧消费端已备好,接入时即用)
|
||||||
|
|
||||||
|
参考实现:`skill-compliance`(规则库 JSON → 检查 → 评分 → 报告,含金融敏感词/免责声明/安全红线/广告法极限词 + 披露完整性检查)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 验证层对接(verification 字段契约)
|
||||||
|
|
||||||
|
> 「识别层管怎么装」之后是「**验证层管装了能不能信**」——运行时验证结论由社区验证工具产出,
|
||||||
|
> 市场在构建期抓取开放数据层并盖章到索引条目,客户端卡片显示「✓ 已验证」徽章(悬停可见
|
||||||
|
> 验证方/时间/证据摘要,点击直达逐条判定明细)。
|
||||||
|
|
||||||
|
### 字段契约(registry.json 条目,平铺)
|
||||||
|
|
||||||
|
| 字段 | 含义 | 来源 |
|
||||||
|
|---|---|---|
|
||||||
|
| `verdict` | `pass`(开放数据层只收录通过验证的条目;fail 结论由报告本体承载) | 开放数据层条目 |
|
||||||
|
| `verifiedBy` | 验证工具与版本(如 `[email protected]`) | 开放数据层条目 |
|
||||||
|
| `verifiedAt` | 验证时间(时效判断依据) | 开放数据层条目 |
|
||||||
|
| `reportUrl` | 验证报告链接(逐条规则判定明细) | 开放数据层条目 |
|
||||||
|
| `schemaVersion` | 契约版本;不匹配时市场**整体跳过不盖章**(fail-closed,防演进破坏解析) | 开放数据层顶层 |
|
||||||
|
| `waterfall` / `toolsResult` | 摘要证据:waterfall 命中数(如 `7/7`)/ 工具真实执行是否成功 | 开放数据层条目 |
|
||||||
|
|
||||||
|
### 数据流
|
||||||
|
|
||||||
|
1. [dsh-plugin-verify](https://github.com/qing3a/dsh-plugin-verify)(社区验证工具,deepseek-harness discussion #2269 对接)产出 `reports/*.json`——报告以 **`fullName`**(插件仓库 `owner/name`)为稳定映射键;
|
||||||
|
2. 验证仓库根 `verified.json`(开放数据层)聚合全部已验证条目;
|
||||||
|
3. 市场 CI 每次构建抓取 `verified.json` → 按 `fullName` 匹配索引条目盖章(旧版条目回退从 `repo` URL 解析 owner/name);
|
||||||
|
4. 客户端卡片显示「✓ 已验证」徽章,点击直达报告。
|
||||||
|
|
||||||
|
作者须知:验证是**第三方中立证据**,盖章 ≠ 市场背书;`verifiedAt` 决定时效——报告随插件演进过期,新版本需重新验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 外部参考(与官方/社区文档的分工)
|
||||||
|
|
||||||
|
本规范只覆盖**「市场识别层」**:仓库怎么写才能被市场正确收录/安装/更新。
|
||||||
|
更深层的「DSH 框架插件怎么写」(bundle manifest、patch 行、Service/客户端 API)请看:
|
||||||
|
|
||||||
|
- **官方**:[《打包与安装插件》publish.zh.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.zh.md)——bundle/profile 两个 manifest、加载顺序、patch 覆盖规则(本文档 §2 的 `dsh.bundle.patch` 即源于此)
|
||||||
|
- **社区**:[make-dsh-plugin skill](https://github.com/vlln/plugin-registry)——官方 bundle 形态选择表(`dsh.bundle`/`dsh.client`/`dsh.skills`/`dsh.mcpServers`)、验证纪律、gotchas
|
||||||
|
- **社区**:[dsh-plugin-development skill](https://github.com/NanmiCoder/dsh-agent-teams/blob/main/.dsh/skills/dsh-plugin-development/SKILL.md)——运行面判断(host/client)、官方模板参考
|
||||||
|
- **精选列表**:[awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)——社区精选与安全免责声明(安装第三方代码的风险提示)
|
||||||
|
|
||||||
|
*维护者注:本文件与 `lib/index.js` 的 `detectType` / `installRepo` 实现一一对应;改动判定逻辑时须同步更新本表。*
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
(function () {
|
||||||
|
if (window.__dshAssist) return
|
||||||
|
window.__dshAssist = 1
|
||||||
|
|
||||||
|
function portalOrigin() {
|
||||||
|
try {
|
||||||
|
var l = location.hostname.split('.')
|
||||||
|
if (l.length > 2) return location.protocol + '//' + l.slice(1).join('.')
|
||||||
|
} catch (e) {}
|
||||||
|
return location.origin
|
||||||
|
}
|
||||||
|
var P = portalOrigin()
|
||||||
|
|
||||||
|
function mk(tag, style, text) {
|
||||||
|
var e = document.createElement(tag)
|
||||||
|
if (style) e.setAttribute('style', style)
|
||||||
|
if (text !== undefined) e.textContent = text
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
function j(u) {
|
||||||
|
return fetch(u, { credentials: 'include' }).then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status)
|
||||||
|
return r.json()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function fmtSize(n) {
|
||||||
|
if (typeof n !== 'number') return ''
|
||||||
|
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB'
|
||||||
|
if (n >= 1024) return (n / 1024).toFixed(0) + ' KB'
|
||||||
|
return n + ' B'
|
||||||
|
}
|
||||||
|
|
||||||
|
var CHIP =
|
||||||
|
'cursor:pointer;border:1px solid rgba(127,127,127,.45);background:rgba(127,127,127,.16);color:var(--dsw-alias-label-primary,#e8e8ec);border-radius:999px;padding:5px 10px;font:12px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif'
|
||||||
|
var PANEL =
|
||||||
|
'position:fixed;right:10px;bottom:52px;z-index:2147483000;width:min(620px,92vw);max-height:72vh;overflow:auto;background:var(--dsw-alias-bg-layer-2,#1e1e24);color:var(--dsw-alias-label-primary,#e8e8ec);border:1px solid rgba(127,127,127,.35);border-radius:12px;box-shadow:0 10px 34px rgba(0,0,0,.35);padding:12px 14px;font:13px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif'
|
||||||
|
var BTN = 'cursor:pointer;border:1px solid rgba(127,127,127,.4);background:transparent;color:inherit;border-radius:8px;padding:3px 8px;font:12px/1.4 inherit'
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
var p = document.getElementById('__dshAssistPanel')
|
||||||
|
if (p) p.remove()
|
||||||
|
}
|
||||||
|
function panel(title, render) {
|
||||||
|
closePanel()
|
||||||
|
var p = mk('div', PANEL)
|
||||||
|
p.id = '__dshAssistPanel'
|
||||||
|
var head = mk('div', 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px')
|
||||||
|
head.appendChild(mk('strong', '', title))
|
||||||
|
var x = mk('button', 'cursor:pointer;border:0;background:transparent;color:inherit;font-size:14px', '✕')
|
||||||
|
x.onclick = closePanel
|
||||||
|
head.appendChild(x)
|
||||||
|
p.appendChild(head)
|
||||||
|
var body = mk('div')
|
||||||
|
p.appendChild(body)
|
||||||
|
document.body.appendChild(p)
|
||||||
|
render(body)
|
||||||
|
}
|
||||||
|
function row(label, hint) {
|
||||||
|
var d = mk('div', 'padding:5px 0;border-bottom:1px solid rgba(127,127,127,.14)')
|
||||||
|
d.appendChild(mk('div', 'font-weight:600', label))
|
||||||
|
if (hint) d.appendChild(mk('div', 'opacity:.72;font-size:12px;white-space:pre-wrap', hint))
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 我的文件:浏览工作区 + 下载(走平台代理,不暴露宿主路径)──
|
||||||
|
function openFiles(path) {
|
||||||
|
panel('我的文件(工作区)', function (b) {
|
||||||
|
b.textContent = '加载中…'
|
||||||
|
j(P + '/api/desktop/tree?path=' + encodeURIComponent(path || ''))
|
||||||
|
.then(function (d) {
|
||||||
|
b.innerHTML = ''
|
||||||
|
var bar = mk('div', 'display:flex;gap:8px;align-items:center;margin-bottom:8px')
|
||||||
|
var up = mk('button', BTN, '⬆ 上级')
|
||||||
|
up.onclick = function () {
|
||||||
|
var parts = (path || '').split('/').filter(Boolean)
|
||||||
|
parts.pop()
|
||||||
|
openFiles(parts.join('/'))
|
||||||
|
}
|
||||||
|
bar.appendChild(up)
|
||||||
|
bar.appendChild(mk('span', 'opacity:.7;font-size:12px', '/' + (path || '')))
|
||||||
|
b.appendChild(bar)
|
||||||
|
var entries = (d && d.entries) || []
|
||||||
|
entries.sort(function (a, c) {
|
||||||
|
if ((a.type === 'dir') !== (c.type === 'dir')) return a.type === 'dir' ? -1 : 1
|
||||||
|
return String(a.name).localeCompare(String(c.name))
|
||||||
|
})
|
||||||
|
if (!entries.length) b.appendChild(mk('div', 'opacity:.7', '(空目录)'))
|
||||||
|
entries.forEach(function (e) {
|
||||||
|
var full = (path ? path + '/' : '') + e.name
|
||||||
|
var line = mk('div', 'display:flex;justify-content:space-between;gap:10px;align-items:center;padding:5px 0;border-bottom:1px solid rgba(127,127,127,.14)')
|
||||||
|
var name = mk('span', 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap', (e.type === 'dir' ? '📁 ' : '📄 ') + e.name)
|
||||||
|
name.title = full
|
||||||
|
line.appendChild(name)
|
||||||
|
var right = mk('span', 'display:flex;gap:8px;align-items:center;flex:0 0 auto')
|
||||||
|
if (e.type === 'dir') {
|
||||||
|
var open = mk('button', BTN, '打开')
|
||||||
|
open.onclick = function () {
|
||||||
|
openFiles(full)
|
||||||
|
}
|
||||||
|
right.appendChild(open)
|
||||||
|
} else {
|
||||||
|
right.appendChild(mk('span', 'opacity:.65;font-size:12px', fmtSize(e.size)))
|
||||||
|
var a = mk('a', BTN + ';text-decoration:none', '下载')
|
||||||
|
a.href = P + '/api/fs/download?path=' + encodeURIComponent(full)
|
||||||
|
a.setAttribute('download', e.name)
|
||||||
|
right.appendChild(a)
|
||||||
|
}
|
||||||
|
line.appendChild(right)
|
||||||
|
b.appendChild(line)
|
||||||
|
})
|
||||||
|
var foot = mk('div', 'margin-top:10px;opacity:.65;font-size:12px')
|
||||||
|
foot.textContent = 'AI 产出的文件都在你自己的工作区里;下载走平台代理,不需要服务器路径。'
|
||||||
|
b.appendChild(foot)
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
b.textContent = '读取失败:' + err.message
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 能力清单:读平台生成的 abilities JSON(与实例内 skill 同源)──
|
||||||
|
function openCaps() {
|
||||||
|
panel('实例能力清单', function (b) {
|
||||||
|
b.textContent = '加载中…'
|
||||||
|
j(P + '/api/capabilities')
|
||||||
|
.then(function (c) {
|
||||||
|
b.innerHTML = ''
|
||||||
|
b.appendChild(row('权限档位', c.permissionMode + ' —— ' + (c.note || '')))
|
||||||
|
b.appendChild(row('可读', (c.read && c.read.allowed || []).join(';') + '\n不可读:' + ((c.read && c.read.denied) || []).join(';')))
|
||||||
|
b.appendChild(row('可写', (c.write && c.write.allowed || []).join(';') + '\n不可写:' + ((c.write && c.write.denied) || []).join(';')))
|
||||||
|
b.appendChild(
|
||||||
|
row(
|
||||||
|
'网络',
|
||||||
|
'出网:' + ((c.network && c.network.egress) || '') + '\n不可达:' + ((c.network && c.network.denied) || []).join(';') + '\n' + ((c.network && c.network.hint) || ''),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
var t = c.tools || {}
|
||||||
|
b.appendChild(row('可用工具', Object.keys(t).map(function (k) { return k + '=' + t[k] }).join(' ')))
|
||||||
|
var sk = c.skills || {}
|
||||||
|
b.appendChild(row('已注册技能', '共享层:' + (((sk.shared) || []).join(', ') || '(空)') + '\n个人层:' + JSON.stringify(sk.perUser || {}) + '\n' + (sk.hint || '')))
|
||||||
|
if (c.fileDelivery) b.appendChild(row('把文件交给用户', (c.fileDelivery.hint || '') + '\n❌ ' + ((c.fileDelivery.avoid) || []).join(';')))
|
||||||
|
b.appendChild(mk('div', 'margin-top:8px;opacity:.6;font-size:12px', '生成时间:' + (c.generatedAt || '—')))
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
b.textContent = '读取失败:' + err.message + '(能力清单可能尚未生成)'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 档位提示:老会话仍 workspace-write 时给一条可操作的横幅 ──
|
||||||
|
function banner(text, key) {
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(key)) return
|
||||||
|
} catch (e) {}
|
||||||
|
var d = mk(
|
||||||
|
'div',
|
||||||
|
'position:fixed;left:50%;transform:translateX(-50%);top:10px;z-index:2147483000;max-width:min(760px,94vw);background:rgba(180,120,20,.16);border:1px solid rgba(200,140,30,.5);color:var(--dsw-alias-label-primary,#e8e8ec);border-radius:10px;padding:9px 12px;font:13px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;display:flex;gap:10px;align-items:flex-start',
|
||||||
|
)
|
||||||
|
d.appendChild(mk('div', 'flex:1;white-space:pre-wrap', text))
|
||||||
|
var x = mk('button', 'cursor:pointer;border:0;background:transparent;color:inherit;font-size:14px;flex:0 0 auto', '✕')
|
||||||
|
x.onclick = function () {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(key, '1')
|
||||||
|
} catch (e) {}
|
||||||
|
d.remove()
|
||||||
|
}
|
||||||
|
d.appendChild(x)
|
||||||
|
document.body.appendChild(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPermission() {
|
||||||
|
j(P + '/api/dsh/session-permission')
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r || !r.stale) return
|
||||||
|
banner(
|
||||||
|
'当前会话的权限档位是「' +
|
||||||
|
r.session.preset +
|
||||||
|
'」,而本机没有可用的沙箱后端 —— AI 将无法执行任何命令(bash 会被拒绝)。\n' +
|
||||||
|
'修法:在 dsh 界面把权限档位切到「完全权限」(或新建一个会话,新会话默认已是完全权限)。',
|
||||||
|
'__dshPermWarn:' + (r.session && r.session.sessionId ? r.session.sessionId : 'x'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(function () {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
if (!document.body) return
|
||||||
|
if (document.getElementById('__dshAssistBar')) return
|
||||||
|
var bar = mk('div', 'position:fixed;right:10px;bottom:10px;z-index:2147483000;display:flex;gap:6px')
|
||||||
|
bar.id = '__dshAssistBar'
|
||||||
|
var f = mk('button', CHIP, '📁 我的文件')
|
||||||
|
f.onclick = function () {
|
||||||
|
openFiles('')
|
||||||
|
}
|
||||||
|
var c = mk('button', CHIP, '🧭 能力')
|
||||||
|
c.onclick = openCaps
|
||||||
|
bar.appendChild(f)
|
||||||
|
bar.appendChild(c)
|
||||||
|
document.body.appendChild(bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tries = 0
|
||||||
|
var timer = setInterval(function () {
|
||||||
|
tries++
|
||||||
|
mount()
|
||||||
|
if (tries > 20) clearInterval(timer)
|
||||||
|
}, 1500)
|
||||||
|
mount()
|
||||||
|
checkPermission()
|
||||||
|
})()
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
/* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 状态机(单一来源,档案 81 · R1-②)—— 本文件是「回到页面」链路**唯一**的策略实现。
|
||||||
|
*
|
||||||
|
* 事件(触发面) 判定 动作
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* ① 切回页面 / 首次进入 探针(平台 /api/dsh/status + 实例 GET /) 坏 → 恢复;好 → 撤提示条
|
||||||
|
* (visibilitychange / pageshow) 提示条「正在检查工作区连接…」
|
||||||
|
* ② 页面自己发出的请求 401 传输层已透明重放(档案 51);仍失败 → 恢复
|
||||||
|
* 或 404 not_running
|
||||||
|
* ③ 心跳(**页面可见**时每 25 s) soft 模式:**连续两次**失败才动作 恢复(滞后 ≤ 50 s)
|
||||||
|
* ④ EventSource / WebSocket 断开 soft 模式:同上 恢复(比心跳更早)
|
||||||
|
* ⑤ 有界保护 窗口 10 min 内恢复 > 3 次 停手:明确失败态 + 手动「重试」
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 恢复动作:覆盖层「工作区正在恢复…」→ POST /api/dsh/enter → 拿到新 token 的直达地址
|
||||||
|
* → **原地 replace(保留 path/hash)**;拿不到 url → 原地 reload。
|
||||||
|
* 冷却/告警:平台侧 orchestrator 负责崩溃熔断与告警(档案 78);本文件只管"页面这一侧"。
|
||||||
|
* ⚠️ 改这个文件**不需要**再动 proxy.ts(档案 81 R1-① 已把脚本外置),但改完必须跑
|
||||||
|
* `npm run verify`(会做 node --check + 内联回退检查)。
|
||||||
|
* ──────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
if (window.__dshRecover) return;
|
||||||
|
window.__dshRecover = 1;
|
||||||
|
|
||||||
|
// 一层覆盖层,两种用途:
|
||||||
|
// pending —— 请求长时间未返回(服务端正在拉起实例)→ 显示"正在启动…",请求结束后撤掉
|
||||||
|
// expired —— 401(实例被回收重建,launch token 已轮换)→ 显示后整页 reload
|
||||||
|
// expired 是终态,不可被 pending 覆盖、也不主动撤除(随后就 reload 了)。
|
||||||
|
var state = 'none';
|
||||||
|
var box = null;
|
||||||
|
var ticker = null;
|
||||||
|
|
||||||
|
function ensureCss() {
|
||||||
|
if (document.getElementById('__dshRecoverCss')) return;
|
||||||
|
var st = document.createElement('style');
|
||||||
|
st.id = '__dshRecoverCss';
|
||||||
|
// 档案 77 视觉升级:整套样式一次性注入(含 prefers-reduced-motion 回退)。
|
||||||
|
// ⚠️ 本文件是注入脚本的模板字面量 —— 内容不得含反引号 / 美元花括号;
|
||||||
|
// 故 CSS 里的字体名一律用单引号('Segoe UI'),JS 字符串用双引号。
|
||||||
|
st.textContent = [
|
||||||
|
"@keyframes __dshr-spin{to{transform:rotate(360deg)}}",
|
||||||
|
"@keyframes __dshr-spin-rev{to{transform:rotate(-360deg)}}",
|
||||||
|
"@keyframes __dshr-core{0%,100%{transform:scale(.82);opacity:.6}50%{transform:scale(1.12);opacity:1}}",
|
||||||
|
"@keyframes __dshr-halo{0%,100%{opacity:.45;transform:scale(.96)}50%{opacity:.9;transform:scale(1.04)}}",
|
||||||
|
"@keyframes __dshr-scan{0%{transform:translateX(-115%)}100%{transform:translateX(275%)}}",
|
||||||
|
"@keyframes __dshr-dot{0%,100%{opacity:.25}50%{opacity:1}}",
|
||||||
|
"@keyframes __dshr-sheen{0%{background-position:190% 0}100%{background-position:-90% 0}}",
|
||||||
|
".__dsh-ov{",
|
||||||
|
"position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;",
|
||||||
|
"background:radial-gradient(900px 460px at 50% 26%, rgba(88,166,255,.13), transparent 62%),radial-gradient(760px 420px at 50% 84%, rgba(163,113,247,.13), transparent 62%),rgba(9,11,16,.9);",
|
||||||
|
"}",
|
||||||
|
".__dsh-card{",
|
||||||
|
"position:relative;display:flex;flex-direction:column;align-items:center;gap:13px;",
|
||||||
|
"padding:26px 34px 22px;border-radius:16px;",
|
||||||
|
"background:linear-gradient(180deg, rgba(26,31,43,.86), rgba(17,21,30,.86));",
|
||||||
|
"box-shadow:0 1px 0 rgba(255,255,255,.06) inset,0 0 0 1px rgba(120,150,210,.16),0 18px 48px rgba(0,0,0,.5);",
|
||||||
|
"-webkit-backdrop-filter:blur(9px);backdrop-filter:blur(9px);",
|
||||||
|
"}",
|
||||||
|
".__dsh-orb{position:relative;width:86px;height:86px}",
|
||||||
|
".__dsh-halo{",
|
||||||
|
"position:absolute;inset:-20px;border-radius:50%;",
|
||||||
|
"background:radial-gradient(circle, rgba(88,166,255,.30), rgba(163,113,247,.17) 46%, transparent 70%);",
|
||||||
|
"animation:__dshr-halo 2.6s ease-in-out infinite;",
|
||||||
|
"}",
|
||||||
|
".__dsh-arc{",
|
||||||
|
"position:absolute;inset:0;border-radius:50%;",
|
||||||
|
"background:conic-gradient(from 0deg, rgba(88,166,255,0) 0deg, rgba(88,166,255,.06) 110deg, #58a6ff 296deg, #a371f7 342deg, rgba(163,113,247,0) 360deg);",
|
||||||
|
"-webkit-mask:radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));",
|
||||||
|
"mask:radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));",
|
||||||
|
"animation:__dshr-spin 1.5s linear infinite;",
|
||||||
|
"}",
|
||||||
|
".__dsh-arc2{position:absolute;inset:13px;border-radius:50%;border:1px dashed rgba(163,113,247,.42);animation:__dshr-spin-rev 7s linear infinite}",
|
||||||
|
".__dsh-ring{position:absolute;inset:24px;border-radius:50%;border:1px solid rgba(120,160,230,.20)}",
|
||||||
|
".__dsh-core{",
|
||||||
|
"position:absolute;left:50%;top:50%;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;",
|
||||||
|
"background:radial-gradient(circle, #eaf3ff 0%, #66b0ff 46%, rgba(88,166,255,0) 74%);",
|
||||||
|
"box-shadow:0 0 12px rgba(88,166,255,.85),0 0 34px rgba(163,113,247,.45);",
|
||||||
|
"animation:__dshr-core 1.6s ease-in-out infinite;",
|
||||||
|
"}",
|
||||||
|
".__dsh-dots{position:absolute;inset:0;animation:__dshr-spin 3.4s linear infinite}",
|
||||||
|
".__dsh-dots i{position:absolute;left:50%;top:-2px;width:5px;height:5px;margin-left:-2.5px;border-radius:50%;background:#a371f7;box-shadow:0 0 8px #a371f7;animation:__dshr-dot 1.4s ease-in-out infinite}",
|
||||||
|
".__dsh-dots i:nth-child(2){top:auto;bottom:-2px;animation-delay:.35s;background:#58a6ff;box-shadow:0 0 8px #58a6ff}",
|
||||||
|
".__dsh-dots i:nth-child(3){left:-2px;top:50%;margin:-2.5px 0 0 0;animation-delay:.7s;background:#7ee0c0;box-shadow:0 0 8px #7ee0c0}",
|
||||||
|
".__dsh-msg{",
|
||||||
|
"font:14.5px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;color:#e9f1fa;letter-spacing:.2px;text-align:center;",
|
||||||
|
"background:linear-gradient(90deg, rgba(233,241,250,.94) 0%, #ffffff 48%, rgba(233,241,250,.94) 96%);",
|
||||||
|
"-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;",
|
||||||
|
"background-size:230% 100%;animation:__dshr-sheen 3s linear infinite;",
|
||||||
|
"}",
|
||||||
|
".__dsh-label{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.16em;color:rgba(150,175,215,.72);text-transform:uppercase}",
|
||||||
|
".__dsh-track{position:relative;width:208px;height:3px;border-radius:3px;background:rgba(255,255,255,.09);overflow:hidden}",
|
||||||
|
".__dsh-track i{position:absolute;top:0;bottom:0;width:42%;border-radius:3px;background:linear-gradient(90deg, transparent, #58a6ff 45%, #a371f7 70%, transparent);animation:__dshr-scan 1.45s ease-in-out infinite}",
|
||||||
|
"@media (prefers-reduced-motion: reduce){",
|
||||||
|
".__dsh-arc,.__dsh-arc2,.__dsh-dots,.__dsh-dots i,.__dsh-core,.__dsh-halo,.__dsh-track i,.__dsh-msg{animation:none!important}",
|
||||||
|
"}"
|
||||||
|
].join(String.fromCharCode(10));
|
||||||
|
(document.head || document.documentElement).appendChild(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
// spinner 只创建一次,之后仅改文案 —— 否则每次重设 innerHTML 都会重建元素,
|
||||||
|
// 旋转动画被打断,看起来像"卡住不动"。
|
||||||
|
function ensureBox() {
|
||||||
|
if (box) return;
|
||||||
|
ensureCss();
|
||||||
|
box = document.createElement('div');
|
||||||
|
box.id = '__dshRecover';
|
||||||
|
box.className = '__dsh-ov';
|
||||||
|
// 「AI 核心」加载体:扫描弧(conic 渐变)+ 反向虚线环 + 脉冲核心 + 三颗轨道粒子。
|
||||||
|
// 全部 createElement 构建(不用 innerHTML —— 实例页可能启用 Trusted Types)。
|
||||||
|
var card = document.createElement('div');
|
||||||
|
card.className = '__dsh-card';
|
||||||
|
var orb = document.createElement('div');
|
||||||
|
orb.className = '__dsh-orb';
|
||||||
|
var parts = ['__dsh-halo', '__dsh-arc', '__dsh-arc2', '__dsh-ring', '__dsh-core'];
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = parts[i];
|
||||||
|
orb.appendChild(el);
|
||||||
|
}
|
||||||
|
var dots = document.createElement('div');
|
||||||
|
dots.className = '__dsh-dots';
|
||||||
|
for (var j = 0; j < 3; j++) dots.appendChild(document.createElement('i'));
|
||||||
|
orb.appendChild(dots);
|
||||||
|
var tx = document.createElement('div');
|
||||||
|
tx.id = '__dshRecoverMsg';
|
||||||
|
tx.className = '__dsh-msg';
|
||||||
|
var lb = document.createElement('div');
|
||||||
|
lb.className = '__dsh-label';
|
||||||
|
lb.textContent = 'DSH · auto recovery';
|
||||||
|
var tr = document.createElement('div');
|
||||||
|
tr.className = '__dsh-track';
|
||||||
|
tr.appendChild(document.createElement('i'));
|
||||||
|
card.appendChild(orb);
|
||||||
|
card.appendChild(tx);
|
||||||
|
card.appendChild(lb);
|
||||||
|
card.appendChild(tr);
|
||||||
|
box.appendChild(card);
|
||||||
|
(document.body || document.documentElement).appendChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMsg(msg) {
|
||||||
|
var m = document.getElementById('__dshRecoverMsg');
|
||||||
|
if (m) m.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPending(base) {
|
||||||
|
if (state === 'expired') return;
|
||||||
|
state = 'pending';
|
||||||
|
ensureBox();
|
||||||
|
var t0 = Date.now();
|
||||||
|
setMsg(base + '(已等待 0 秒)');
|
||||||
|
if (ticker) clearInterval(ticker);
|
||||||
|
ticker = setInterval(function () {
|
||||||
|
if (state !== 'pending') return;
|
||||||
|
setMsg(base + '(已等待 ' + Math.floor((Date.now() - t0) / 1000) + ' 秒)');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePending() {
|
||||||
|
if (state !== 'pending') return;
|
||||||
|
state = 'none';
|
||||||
|
if (ticker) {
|
||||||
|
clearInterval(ticker);
|
||||||
|
ticker = null;
|
||||||
|
}
|
||||||
|
if (box && box.parentNode) box.parentNode.removeChild(box);
|
||||||
|
box = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 顶部轻提示条(不遮罩)────────────────────────────────────────────────
|
||||||
|
// 用户明确反馈「回到页面根本没看到过提示」——所以「看得见」本身就是要交付的东西。
|
||||||
|
// 只在"离开 ≥20 秒又回来"时出现;瞬时切换不打扰。至少显示 900ms 再撤,
|
||||||
|
// 否则一闪而过等于没提示。
|
||||||
|
var bar = null;
|
||||||
|
var barAt = 0;
|
||||||
|
var barTimer = null;
|
||||||
|
|
||||||
|
function ensureBar() {
|
||||||
|
if (bar) return;
|
||||||
|
ensureCss();
|
||||||
|
bar = document.createElement('div');
|
||||||
|
bar.id = '__dshConnBar';
|
||||||
|
bar.setAttribute(
|
||||||
|
'style',
|
||||||
|
'position:fixed;top:0;left:0;right:0;z-index:2147483646;padding:7px 12px;' +
|
||||||
|
'text-align:center;font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;' +
|
||||||
|
'color:#fff;background:rgba(24,95,165,.95)'
|
||||||
|
);
|
||||||
|
(document.body || document.documentElement).appendChild(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBar(msg) {
|
||||||
|
if (state === 'pending' || state === 'expired') return;
|
||||||
|
ensureBar();
|
||||||
|
barAt = Date.now();
|
||||||
|
bar.textContent = msg;
|
||||||
|
if (barTimer) {
|
||||||
|
clearTimeout(barTimer);
|
||||||
|
barTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideBar() {
|
||||||
|
if (!bar) return;
|
||||||
|
var wait = Math.max(0, 900 - (Date.now() - barAt));
|
||||||
|
if (barTimer) clearTimeout(barTimer);
|
||||||
|
barTimer = setTimeout(function () {
|
||||||
|
barTimer = null;
|
||||||
|
if (bar && bar.parentNode) bar.parentNode.removeChild(bar);
|
||||||
|
bar = null;
|
||||||
|
}, wait + 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expire() {
|
||||||
|
if (state === 'expired') return;
|
||||||
|
hideBar();
|
||||||
|
hidePending();
|
||||||
|
state = 'expired';
|
||||||
|
ensureBox();
|
||||||
|
setMsg('正在重新连接你的工作区…');
|
||||||
|
setTimeout(function () {
|
||||||
|
location.reload();
|
||||||
|
}, 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApi(u) {
|
||||||
|
return String(u).indexOf('/api/') >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 实例不可用(空闲回收 / 被关闭 / 崩溃)→ 自动唤醒并重建连接(档案 72)────────────
|
||||||
|
// 为什么需要:原来只认 401。实例被回收后代理对非导航请求返回 404 not_running,
|
||||||
|
// 脚本不认识 → 页面上的请求静默失败、SSE 静默断流 → 用户只能**手动刷新**。
|
||||||
|
//
|
||||||
|
// 注入脚本跑在实例子域上,而平台接口(/api/dsh/*)在门户域 → 必须跨域调用;
|
||||||
|
// server.ts 的 CORS 白名单允许 baseDomain 及其子域 + Allow-Credentials,故带 cookie 可用。
|
||||||
|
function portalOrigin() {
|
||||||
|
var l = location.hostname.split('.');
|
||||||
|
if (l.length > 2) return location.protocol + '//' + l.slice(1).join('.');
|
||||||
|
return location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
var recovering = false;
|
||||||
|
// 保留原始 fetch:探针与探活走它,避免被下面的 watch() 当成"业务请求"而误亮覆盖层。
|
||||||
|
var rawFetch = window.fetch;
|
||||||
|
|
||||||
|
// ── 就地恢复(档案 77)───────────────────────────────────────────────────
|
||||||
|
// 不再跳门户过渡页 wake.html:那会换域名(子域 → 门户),且它的 next 是空的,
|
||||||
|
// 恢复后落回根地址、页面状态全丢 —— 用户明确反馈这样"不自然"。
|
||||||
|
// 改为:本页显示覆盖层 → 调一次平台 /api/dsh/enter(会拉起实例并返回**带新 token**
|
||||||
|
// 的直达地址)→ 原地跳回,并尽量保留当前路径与 hash。
|
||||||
|
function withPath(u) {
|
||||||
|
try {
|
||||||
|
var n = new URL(u, location.href);
|
||||||
|
if (n.hostname === location.hostname && location.pathname !== '/') n.pathname = location.pathname;
|
||||||
|
if (location.hash) n.hash = location.hash;
|
||||||
|
return n.href;
|
||||||
|
} catch (e) {
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 有界恢复(档案 81 · R1-③)──────────────────────────────────────────────
|
||||||
|
// 现场事故(2026-09-13 · guest):实例被 OOM 反复杀 → 页面「恢复 → 起来 → 又被杀」**无限循环**
|
||||||
|
// (每 ~35 s 一次 GET /)。恢复逻辑本身没错,错在**没有上限**。
|
||||||
|
// 规则:窗口内恢复次数超限后**停止自动恢复**,停在明确的失败态 + 手动「重试」(不自动 reload)。
|
||||||
|
var RECOVER_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
var RECOVER_MAX = 3;
|
||||||
|
var recFirstAt = 0;
|
||||||
|
var recCount = 0;
|
||||||
|
|
||||||
|
function overRecoverBudget() {
|
||||||
|
var now = Date.now();
|
||||||
|
if (now - recFirstAt > RECOVER_WINDOW_MS) {
|
||||||
|
recFirstAt = now;
|
||||||
|
recCount = 0;
|
||||||
|
}
|
||||||
|
recCount += 1;
|
||||||
|
return recCount > RECOVER_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRetryButton() {
|
||||||
|
var box = document.getElementById('__dshRecover');
|
||||||
|
if (!box || document.getElementById('__dshRetryBtn')) return;
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.id = '__dshRetryBtn';
|
||||||
|
b.type = 'button';
|
||||||
|
b.textContent = '重试';
|
||||||
|
b.setAttribute(
|
||||||
|
'style',
|
||||||
|
'margin-top:14px;padding:8px 18px;border:0;border-radius:8px;cursor:pointer;' +
|
||||||
|
'font:14px/1 system-ui,-apple-system,sans-serif;color:#fff;' +
|
||||||
|
'background:linear-gradient(135deg,#58a6ff,#a371f7);box-shadow:0 0 18px rgba(88,166,255,.35)'
|
||||||
|
);
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
recFirstAt = 0; // 手动重试 = 重置预算
|
||||||
|
recCount = 0;
|
||||||
|
recovering = false;
|
||||||
|
b.parentNode && b.parentNode.removeChild(b);
|
||||||
|
recover('正在重新连接…');
|
||||||
|
});
|
||||||
|
box.appendChild(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showExhausted() {
|
||||||
|
recovering = true; // 保持在"不再自动恢复"的状态
|
||||||
|
hideBar();
|
||||||
|
ensureBox(); // 只建覆盖层,不启动 showPending 的秒表
|
||||||
|
setMsg('工作区暂时不可用(已连续尝试恢复 ' + RECOVER_MAX + ' 次)。可能是实例反复重启或资源不足 —— 可点下方「重试」,或稍后再来。');
|
||||||
|
addRetryButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function recover(reason) {
|
||||||
|
if (recovering) return;
|
||||||
|
if (overRecoverBudget()) {
|
||||||
|
showExhausted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recovering = true;
|
||||||
|
hideBar();
|
||||||
|
showPending(reason || '工作区正在恢复,请稍候…');
|
||||||
|
var go = function (url) {
|
||||||
|
if (url) {
|
||||||
|
setMsg('已就绪,正在返回你的工作区…');
|
||||||
|
setTimeout(function () {
|
||||||
|
location.replace(withPath(url));
|
||||||
|
}, 700);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMsg('正在重新连接…');
|
||||||
|
setTimeout(function () {
|
||||||
|
location.reload();
|
||||||
|
}, 700);
|
||||||
|
};
|
||||||
|
rawFetch.call(window, portalOrigin() + '/api/dsh/enter', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
})
|
||||||
|
.then(function (j) {
|
||||||
|
go(j && (j.url || j.redirect));
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
go(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 探针:页面 ↔ 实例的连通性(档案 77 新增,解决"看不到提示")────────────
|
||||||
|
// 为什么需要:门户口 /api/dsh/status 的 running 只说明「进程在不在」,
|
||||||
|
// 而它把 starting(正在启动)也算在跑(dsh.ts 的 alive())。实测每次崩溃后新实例
|
||||||
|
// 1 秒内就起来 ⇒ 用户回到页面时几乎总是 starting/running ⇒ 旧判据永远不动手。
|
||||||
|
// 探针 GET / 走实例自己的鉴权入口:200 = 这个页面确实能用;超时 / 3xx / 异常 = 已脱节。
|
||||||
|
// 返回 404/405(官方改了入口形态)→ 'unknown' → 退回旧判据,**绝不误报**。
|
||||||
|
function poke() {
|
||||||
|
var ctl = typeof AbortController === 'function' ? new AbortController() : null;
|
||||||
|
var t = setTimeout(function () {
|
||||||
|
try {
|
||||||
|
if (ctl) ctl.abort();
|
||||||
|
} catch (e) {}
|
||||||
|
}, 2500);
|
||||||
|
return rawFetch
|
||||||
|
.call(window, '/', {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store',
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: ctl ? ctl.signal : undefined,
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
clearTimeout(t);
|
||||||
|
if (r.status === 200) return 'ok';
|
||||||
|
if (r.status === 404 || r.status === 405) return 'unknown';
|
||||||
|
return 'bad';
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
clearTimeout(t);
|
||||||
|
return 'bad';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主动探活。必要性:实例被回收后,页面上的存量请求不一定失败(SSE 静默断流),
|
||||||
|
// 所以"用户回到页面"这件事本身要主动问一次。
|
||||||
|
// withNotice:真的离开过一会儿又回来 → 先亮顶部提示条(看得见的反馈)。
|
||||||
|
var lastProbe = 0;
|
||||||
|
// soft = true:**静默触发面**(心跳 / 流断)用 —— 必须**连续两次**失败才动手。
|
||||||
|
// 为什么:恢复 = 原地 location.replace,页面内未保存的输入会丢;一次网络抖动不该触发它。
|
||||||
|
var softFails = 0;
|
||||||
|
function probe(withNotice, soft) {
|
||||||
|
if (recovering) return;
|
||||||
|
if (Date.now() - lastProbe < 15000) return;
|
||||||
|
lastProbe = Date.now();
|
||||||
|
if (withNotice) showBar('正在检查工作区连接…');
|
||||||
|
var go = function (reason) {
|
||||||
|
if (soft !== true) {
|
||||||
|
recover(reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
softFails += 1;
|
||||||
|
if (softFails >= 2) recover(reason);
|
||||||
|
};
|
||||||
|
rawFetch
|
||||||
|
.call(window, portalOrigin() + '/api/dsh/status', { credentials: 'include' })
|
||||||
|
.then(function (r) {
|
||||||
|
return r.ok ? r.json() : null;
|
||||||
|
})
|
||||||
|
.then(function (j) {
|
||||||
|
if (!j) {
|
||||||
|
hideBar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (j.running === false) {
|
||||||
|
go('工作区已休眠,正在唤醒…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return poke().then(function (verdict) {
|
||||||
|
if (verdict === 'ok' || verdict === 'unknown') {
|
||||||
|
softFails = 0;
|
||||||
|
hideBar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
go('工作区正在恢复,请稍候…');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
hideBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hit(u, s, readBody) {
|
||||||
|
if (s === 401 && isApi(u)) {
|
||||||
|
expire();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 404 not_running = 实例已被回收或关闭。原先没人管这种情况,用户只能手动刷新。
|
||||||
|
if (s === 404 && isApi(u) && typeof readBody === 'function') {
|
||||||
|
readBody(function (text) {
|
||||||
|
if (String(text).indexOf('not_running') >= 0) {
|
||||||
|
recover('工作区正在恢复,请稍候…');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端在"实例未就绪"时会 hold 住请求(最长 20 秒)等拉起,期间浏览器端原本没有任何反馈
|
||||||
|
// —— 用户点了重连却看不出在等什么。这里给挂起的 API 请求加计时:≥3 秒就亮出覆盖层。
|
||||||
|
// 不会误伤流式接口:SSE / ReadableStream 在**响应头到达**时 promise 就已 resolve,计时已经清掉。
|
||||||
|
var SLOW_MS = 3000;
|
||||||
|
|
||||||
|
function watch(u) {
|
||||||
|
if (!isApi(u)) return function () {};
|
||||||
|
var shown = false;
|
||||||
|
var t = setTimeout(function () {
|
||||||
|
shown = true;
|
||||||
|
showPending('实例正在启动,请稍候…');
|
||||||
|
}, SLOW_MS);
|
||||||
|
return function () {
|
||||||
|
clearTimeout(t);
|
||||||
|
if (shown) hidePending();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var of = window.fetch;
|
||||||
|
if (of) {
|
||||||
|
window.fetch = function (i) {
|
||||||
|
var u = typeof i === 'string' ? i : (i && i.url) || '';
|
||||||
|
var end = watch(u);
|
||||||
|
return of.apply(this, arguments).then(
|
||||||
|
function (r) {
|
||||||
|
end();
|
||||||
|
try {
|
||||||
|
hit(u, r.status, function (cb) {
|
||||||
|
// clone 后再读:不能消费原响应的 body,否则调用方拿不到数据
|
||||||
|
try {
|
||||||
|
r.clone()
|
||||||
|
.text()
|
||||||
|
.then(cb)
|
||||||
|
.catch(function () {});
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
return r;
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
end();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var oo = XMLHttpRequest.prototype.open;
|
||||||
|
var os = XMLHttpRequest.prototype.send;
|
||||||
|
XMLHttpRequest.prototype.open = function (m, u) {
|
||||||
|
this.__u = u;
|
||||||
|
return oo.apply(this, arguments);
|
||||||
|
};
|
||||||
|
XMLHttpRequest.prototype.send = function () {
|
||||||
|
var x = this;
|
||||||
|
var end = watch(x.__u);
|
||||||
|
x.addEventListener('loadend', function () {
|
||||||
|
end();
|
||||||
|
try {
|
||||||
|
hit(x.__u, x.status, function (cb) {
|
||||||
|
try {
|
||||||
|
cb(String(x.responseText || ''));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
return os.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 「用户回到会话页面」= 切回标签页 / 点回窗口 / 从缓存恢复页面。
|
||||||
|
// 只有"真的离开过一会儿又回来"(≥20 秒)才亮顶部提示条 —— 瞬时切换不打扰;
|
||||||
|
// 但无论亮不亮,探针都会跑:一旦判定页面已与实例脱节就恢复并给覆盖层。
|
||||||
|
var LEFT_MS = 20000;
|
||||||
|
var leftAt = 0;
|
||||||
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
var away = leftAt > 0 && Date.now() - leftAt >= LEFT_MS;
|
||||||
|
leftAt = 0;
|
||||||
|
probe(away);
|
||||||
|
} else {
|
||||||
|
leftAt = Date.now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('focus', function () {
|
||||||
|
probe(false);
|
||||||
|
});
|
||||||
|
window.addEventListener('pageshow', function (e) {
|
||||||
|
if (e.persisted) probe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 档案 77 补丁②(2026-09-13):补齐「连接悄悄断掉」的两条触发面 ─────────
|
||||||
|
// 现场故障:用户**一直盯着页面**(不切标签、不 focus),实例侧连接断了(模型流挂掉),
|
||||||
|
// 页面显示「连接异常」,而上面那些事件一个都不会来 ⇒ 既不提示也不恢复,只能手动刷新。
|
||||||
|
// 说明:原设计(档案 77 §五 方案 B「不做周期性探活」)的前提是"用户在场却不操作没有收益",
|
||||||
|
// 实测证明该前提不成立 —— 于是补两条**静默**触发面;两者都走 soft 模式(连续两次失败才恢复)。
|
||||||
|
// ① 心跳:页面**可见**时每 25 秒静默探一次(不可见时完全不付出成本)
|
||||||
|
// ② 流断:包装 EventSource / WebSocket 的 error / close,立即探一次(比心跳更早发现)
|
||||||
|
var HEARTBEAT_MS = 25000;
|
||||||
|
setInterval(function () {
|
||||||
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
probe(false, true);
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
|
||||||
|
function wrapStreams() {
|
||||||
|
var ES = window.EventSource;
|
||||||
|
if (typeof ES === 'function') {
|
||||||
|
var ES2 = function (url, opts) {
|
||||||
|
var es = new ES(url, opts);
|
||||||
|
es.addEventListener('error', function () {
|
||||||
|
probe(false, true);
|
||||||
|
});
|
||||||
|
return es;
|
||||||
|
};
|
||||||
|
ES2.prototype = ES.prototype;
|
||||||
|
if (ES.CONNECTING !== undefined) {
|
||||||
|
ES2.CONNECTING = ES.CONNECTING;
|
||||||
|
ES2.OPEN = ES.OPEN;
|
||||||
|
ES2.CLOSED = ES.CLOSED;
|
||||||
|
}
|
||||||
|
window.EventSource = ES2;
|
||||||
|
}
|
||||||
|
var WS = window.WebSocket;
|
||||||
|
if (typeof WS === 'function') {
|
||||||
|
var WS2 = function (url, protocols) {
|
||||||
|
var ws = protocols === undefined ? new WS(url) : new WS(url, protocols);
|
||||||
|
ws.addEventListener('close', function () {
|
||||||
|
probe(false, true);
|
||||||
|
});
|
||||||
|
ws.addEventListener('error', function () {
|
||||||
|
probe(false, true);
|
||||||
|
});
|
||||||
|
return ws;
|
||||||
|
};
|
||||||
|
WS2.prototype = WS.prototype;
|
||||||
|
if (WS.CONNECTING !== undefined) {
|
||||||
|
WS2.CONNECTING = WS.CONNECTING;
|
||||||
|
WS2.OPEN = WS.OPEN;
|
||||||
|
WS2.CLOSING = WS.CLOSING;
|
||||||
|
WS2.CLOSED = WS.CLOSED;
|
||||||
|
}
|
||||||
|
window.WebSocket = WS2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrapStreams();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- insert:
|
||||||
|
- id: dshs
|
||||||
|
name: dshs
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: dsh
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# CloudNativePG Cluster — 控制面数据层(Phase 2 测试:1 实例)。
|
||||||
|
# 生产 HA 用 instances: 3(见 docs/k8s.md §4.5)。storageClass 用拓扑感知
|
||||||
|
# WaitForFirstConsumer,避免 ESSD 云盘与 Pod 跨可用区挂载失败。
|
||||||
|
apiVersion: postgresql.cnpg.io/v1
|
||||||
|
kind: Cluster
|
||||||
|
metadata:
|
||||||
|
name: dsh-pg
|
||||||
|
namespace: dsh
|
||||||
|
spec:
|
||||||
|
instances: 1
|
||||||
|
# ghcr.io 在阿里云被墙/极慢,Postgres 镜像走国内镜像源。
|
||||||
|
imageName: ghcr.m.daocloud.io/cloudnative-pg/postgresql:18.4-system-trixie
|
||||||
|
storage:
|
||||||
|
size: 20Gi
|
||||||
|
storageClass: alicloud-disk-topology-alltype
|
||||||
|
bootstrap:
|
||||||
|
initdb:
|
||||||
|
database: dsh
|
||||||
|
owner: dsh
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# 控制面 Deployment + Service(Phase 3:deployMode=k8s 起每用户 DSH Pod)。
|
||||||
|
# 依赖:secret `dsh-pg`(key: url = Postgres DSN)、`dsh-secret`(key: key =
|
||||||
|
# 共享加密密钥)、imagePullSecret `dsh-acr-pull`、ServiceAccount
|
||||||
|
# `dsh-orchestrator`(deploy/03-rbac.yaml)。镜像由 CI push 到 ACR。
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: dsh-orchestrator
|
||||||
|
namespace: dsh
|
||||||
|
spec:
|
||||||
|
replicas: 3
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: dsh-orchestrator
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: dsh-orchestrator
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: dsh-acr-pull
|
||||||
|
serviceAccountName: dsh-orchestrator
|
||||||
|
containers:
|
||||||
|
- name: orchestrator
|
||||||
|
image: registry.example.com/dsh/dshs:0.2.0
|
||||||
|
imagePullPolicy: Always
|
||||||
|
args: ["--host", "0.0.0.0"]
|
||||||
|
env:
|
||||||
|
- name: DSHS_DB_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: dsh-pg
|
||||||
|
key: url
|
||||||
|
- name: DSHS_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: dsh-secret
|
||||||
|
key: key
|
||||||
|
- name: DSHS_SECURE_COOKIES
|
||||||
|
value: "true"
|
||||||
|
- name: DSHS_BASE_DOMAIN
|
||||||
|
value: "dsh.example.com"
|
||||||
|
- name: DSHS_COOKIE_DOMAIN
|
||||||
|
value: ".dsh.example.com"
|
||||||
|
- name: DSHS_DEPLOY_MODE
|
||||||
|
value: "k8s"
|
||||||
|
- name: DSHS_NAMESPACE
|
||||||
|
value: "dsh"
|
||||||
|
- name: DSHS_DSH_IMAGE
|
||||||
|
value: "registry.example.com/dsh/dsh:0.1.1-rc.2"
|
||||||
|
- name: DSHS_CONTROL_PLANE_IMAGE
|
||||||
|
value: "registry.example.com/dsh/dshs:0.2.0"
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
ports:
|
||||||
|
- containerPort: 3080
|
||||||
|
volumeMounts:
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "250m"
|
||||||
|
memory: "256Mi"
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: "1Gi"
|
||||||
|
volumes:
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: dsh-orchestrator
|
||||||
|
namespace: dsh
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: dsh-orchestrator
|
||||||
|
ports:
|
||||||
|
- port: 3080
|
||||||
|
targetPort: 3080
|
||||||
|
type: ClusterIP
|
||||||
|
---
|
||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: dsh-orchestrator
|
||||||
|
namespace: dsh
|
||||||
|
spec:
|
||||||
|
minAvailable: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: dsh-orchestrator
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 控制面 ServiceAccount + RBAC(docs/k8s.md §5.3):dsh-orchestrator 在 dsh
|
||||||
|
# 命名空间里建/删每用户 Pod/Service/NetworkPolicy/Secret/Job/PVC,读事件,
|
||||||
|
# lease 供 leader election。
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: dsh-orchestrator
|
||||||
|
namespace: dsh
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: dsh-orchestrator
|
||||||
|
namespace: dsh
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods", "services", "secrets", "persistentvolumeclaims", "configmaps", "events"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||||
|
- apiGroups: ["batch"]
|
||||||
|
resources: ["jobs"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||||
|
- apiGroups: ["networking.k8s.io"]
|
||||||
|
resources: ["networkpolicies"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||||
|
- apiGroups: ["coordination.k8s.io"]
|
||||||
|
resources: ["leases"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: dsh-orchestrator
|
||||||
|
namespace: dsh
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: dsh-orchestrator
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: dsh-orchestrator
|
||||||
|
namespace: dsh
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 每用户 DSH 共享的 RWX 卷(docs/k8s.md §4.3/§4.7)。ACK 上 RWX 用阿里云 NAS
|
||||||
|
# (需先有 alicloud-nas StorageClass + NAS 文件系统;单机测试阶段可先不建,
|
||||||
|
# K8sSpawner 的 Pod 会因挂不上卷而 Pending,但生命周期逻辑可验)。
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: dsh-users
|
||||||
|
namespace: dsh
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteMany"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 50Gi
|
||||||
|
storageClassName: alicloud-nas
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 阿里云 NAS StorageClass(ACK + CNFS)。每用户 DSH 共享的 RWX 卷走 NAS
|
||||||
|
# (deploy/04-pvc.yaml 引用 alicloud-nas)。通过 CNFS CR(storage.alibabacloud.com/v1beta1
|
||||||
|
# ContainerNetworkFileSystem,控制台「容器网络文件系统」创建,名为 `nas`)引用
|
||||||
|
# 已就绪的 NAS 文件系统,provisioner 自动在 `/` 下为每个 PVC 建独立子目录。
|
||||||
|
# 见 docs/k8s-deploy.md §5.5:ACK 上 Longhorn 被 NAS/CNFS 取代。
|
||||||
|
apiVersion: storage.k8s.io/v1
|
||||||
|
kind: StorageClass
|
||||||
|
metadata:
|
||||||
|
name: alicloud-nas
|
||||||
|
provisioner: nasplugin.csi.alibabacloud.com
|
||||||
|
reclaimPolicy: Delete
|
||||||
|
parameters:
|
||||||
|
volumeAs: subpath
|
||||||
|
containerNetworkFileSystem: nas
|
||||||
|
path: "/"
|
||||||
|
archiveOnDelete: "false"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 命名空间配额 + 默认资源区间(docs/k8s.md §3.6 / Phase 3「上线即配」)。
|
||||||
|
# 防单个用户/大量用户耗尽集群。数值按 2×4C8G + 1 自动扩节点起步规模估计,
|
||||||
|
# 上量后按 §11.1 对象数监控调。
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ResourceQuota
|
||||||
|
metadata:
|
||||||
|
name: dsh-quota
|
||||||
|
namespace: dsh
|
||||||
|
spec:
|
||||||
|
hard:
|
||||||
|
requests.cpu: "6"
|
||||||
|
requests.memory: "12Gi"
|
||||||
|
limits.cpu: "12"
|
||||||
|
limits.memory: "24Gi"
|
||||||
|
count/pods: "60"
|
||||||
|
count/services: "80"
|
||||||
|
count/networkpolicies: "120"
|
||||||
|
count/jobs: "40"
|
||||||
|
count/secrets: "120"
|
||||||
|
count/configmaps: "120"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: LimitRange
|
||||||
|
metadata:
|
||||||
|
name: dsh-limits
|
||||||
|
namespace: dsh
|
||||||
|
spec:
|
||||||
|
limits:
|
||||||
|
# 默认值覆盖没有显式声明资源的 Pod;上限兜底防失控。
|
||||||
|
- type: Container
|
||||||
|
default:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "512Mi"
|
||||||
|
defaultRequest:
|
||||||
|
cpu: "250m"
|
||||||
|
memory: "256Mi"
|
||||||
|
max:
|
||||||
|
cpu: "4"
|
||||||
|
memory: "8Gi"
|
||||||
|
min:
|
||||||
|
cpu: "10m"
|
||||||
|
memory: "16Mi"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Pod Security Admission:命名空间 enforce=restricted(docs/k8s.md 选型定案
|
||||||
|
# §0 / Phase 3)。准入时拒绝 privileged/hostPath/hostNetwork/提权 capability。
|
||||||
|
#
|
||||||
|
# ⚠️ 时序(严格,docs/k8s.md §4.9):必须在 08-bootstrap.yaml 的权限提升 Job
|
||||||
|
# 跑完 **之后** 再 apply 本文件——先打 restricted 标签,非 root 的 bootstrap
|
||||||
|
# Job 就写不了 PVC 根,用户目录永远建不出来。
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: dsh
|
||||||
|
labels:
|
||||||
|
pod-security.kubernetes.io/enforce: restricted
|
||||||
|
pod-security.kubernetes.io/enforce-version: latest
|
||||||
|
pod-security.kubernetes.io/audit: restricted
|
||||||
|
pod-security.kubernetes.io/audit-version: latest
|
||||||
|
pod-security.kubernetes.io/warn: restricted
|
||||||
|
pod-security.kubernetes.io/warn-version: latest
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 一次性权限提升 Job:把共享 RWX PVC 根 chmod 1777(world-writable + sticky),
|
||||||
|
# 让后续各用户的非 root initContainer 能在根下建自己的 <userId>/ 目录
|
||||||
|
# (docs/k8s.md §4.9 第 1 步)。initContainer 挂的是 PVC **根**,不做 subPath。
|
||||||
|
#
|
||||||
|
# ⚠️ 只在 namespace 打 PSA restricted **之前** apply(见 07-psa.yaml 头注释)。
|
||||||
|
# 跑完可删,或留着(restartPolicy Never + 一次性效果,幂等)。
|
||||||
|
#
|
||||||
|
# 用控制面镜像(node:22-slim,已推 ACR)而非 busybox:集群拉不动 docker.io。
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: dsh-users-bootstrap
|
||||||
|
namespace: dsh
|
||||||
|
spec:
|
||||||
|
ttlSecondsAfterFinished: 300
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: dsh-acr-pull
|
||||||
|
containers:
|
||||||
|
- name: chmod-root
|
||||||
|
image: registry.example.com/dsh/dshs:0.2.0
|
||||||
|
command: ["sh", "-c", "chmod 1777 /mnt"]
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 0 # 需 root 才能 chmod;本 Job 跑在 PSA restricted 之前
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: data-root
|
||||||
|
mountPath: /mnt
|
||||||
|
volumes:
|
||||||
|
- name: data-root
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: dsh-users
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 控制面 NetworkPolicy(docs/k8s.md §4.2 尾部)。若 namespace 走 default-deny
|
||||||
|
# (Cilium/Terway DataPath V2 下可能默认放行,则本文件为显式白名单、无害)。
|
||||||
|
# ingress:Traefik/Ingress → 3080;egress:Postgres:5432 + K8s API:443 + DNS:53
|
||||||
|
# + 每用户 DSH sidecar:8081 + 每用户 file sidecar:8082。控制面不回调任何公网。
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: dsh-orchestrator
|
||||||
|
namespace: dsh
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: dsh-orchestrator
|
||||||
|
policyTypes: [Ingress, Egress]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: ingress-nginx
|
||||||
|
- namespaceSelector: {}
|
||||||
|
ports:
|
||||||
|
- port: 3080
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
cnpg.io/cluster: dsh-pg
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
- to:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: dsh
|
||||||
|
ports:
|
||||||
|
- port: 8081
|
||||||
|
- to:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: dsh-files
|
||||||
|
ports:
|
||||||
|
- port: 8082
|
||||||
|
- to:
|
||||||
|
- namespaceSelector: {}
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
k8s-app: kube-dns
|
||||||
|
ports:
|
||||||
|
- port: 53
|
||||||
|
protocol: UDP
|
||||||
|
- port: 53
|
||||||
|
protocol: TCP
|
||||||
|
# K8s API server(托管的 control plane 在集群外,走公网/内网 API endpoint,
|
||||||
|
# 端口 6443/443)。这里放全出口 6443+443 以便访问 API server;如需更严,
|
||||||
|
# 按集群 API endpoint IP/CIDR 收窄。
|
||||||
|
- to:
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 0.0.0.0/0
|
||||||
|
except: [169.254.169.254/32]
|
||||||
|
ports:
|
||||||
|
- port: 6443
|
||||||
|
- port: 443
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 给每个节点下发 NFS 客户端 sunrpc 调优参数(用户提供的腾讯云侧经验值):
|
||||||
|
# options sunrpc tcp_slot_table_entries=128
|
||||||
|
# options sunrpc tcp_max_slot_table_entries=128
|
||||||
|
# 写到 /etc/modprobe.d/sunrpc.conf,节点下次加载 sunrpc 模块时生效(持久)。
|
||||||
|
# 高并发 NFS 下可避免 "RPC: task blocked"。放在 kube-system,跑完可删。
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: DaemonSet
|
||||||
|
metadata:
|
||||||
|
name: sunrpc-tuning
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: sunrpc-tuning
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: sunrpc-tuning
|
||||||
|
spec:
|
||||||
|
hostPID: false
|
||||||
|
containers:
|
||||||
|
- name: write
|
||||||
|
image: registry.example.com/dsh/dshs:0.2.0
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
mkdir -p /host/etc/modprobe.d
|
||||||
|
printf 'options sunrpc tcp_slot_table_entries=128\noptions sunrpc tcp_max_slot_table_entries=128\n' > /host/etc/modprobe.d/sunrpc.conf
|
||||||
|
echo "wrote sunrpc.conf:"; cat /host/etc/modprobe.d/sunrpc.conf
|
||||||
|
sleep infinity
|
||||||
|
securityContext:
|
||||||
|
privileged: true
|
||||||
|
volumeMounts:
|
||||||
|
- name: modprobe
|
||||||
|
mountPath: /host/etc/modprobe.d
|
||||||
|
volumes:
|
||||||
|
- name: modprobe
|
||||||
|
hostPath:
|
||||||
|
path: /etc/modprobe.d
|
||||||
|
type: DirectoryOrCreate
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 技术蓝图 — DSH 服务端登录插件
|
||||||
|
|
||||||
|
> 🧭 [← 返回 README](../README.md) · 部署(模式 A):[deployment](deployment.md) · 部署(模式 B):[k8s-deployment](k8s-deployment.md)
|
||||||
|
|
||||||
|
多租户托管平台,让用户通过域名安全访问自己的 DeepSeek Harness(DSH)实例。本文是权威技术设计;实现与本文冲突时以本文为准,并同步回改。
|
||||||
|
|
||||||
|
## 1. 运行拓扑
|
||||||
|
|
||||||
|
```
|
||||||
|
用户浏览器
|
||||||
|
├─(HTTPS)─> dsh.<域名> → 编排服务 Fastify(认证/管理/桌面/域名,/api/*)
|
||||||
|
└─(HTTPS)─> <用户名>.dsh.<域名> → 编排服务按 Host 头路由到该用户 DSH(HTTP + WebSocket)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **主域 `dsh.<域名>`**:编排服务自己的登录 / 管理台 / 桌面 / API。
|
||||||
|
- **每用户子域 `<用户名>.dsh.<域名>`**:编排服务按 Host 头解析用户名 → 校验会话 cookie → 反向代理到该用户 DSH 的 `127.0.0.1:<动态端口>`(HTTP + WebSocket 隧道)。
|
||||||
|
- 每用户 DSH 只绑回环端口、不直接暴露公网;端口表随 spawn/respawn 即时更新,nginx 用通配 `*.dsh.<域名>` 透传即可,无需每次 reload。
|
||||||
|
- 编排服务是独立 Node 进程,`child_process.spawn('dsh --profile web --host 127.0.0.1 --port <随机>', ...)` 拉起每用户 DSH(主 + 按需守护)。
|
||||||
|
|
||||||
|
## 2. 打包与启动
|
||||||
|
|
||||||
|
- **主入口**:独立 `dshs` bin(`node lib/cli.js`)直接跑 Fastify + SQLite + 进程编排。
|
||||||
|
- **市场识别**:根 `package.json` 的 `dsh` 字段(`plugin`/`kind`/`bundle.patch`)+ `cordis.patch.yml`;不 import 任何 `@deepseek-ai/*` 宿主包,peerDependencies 为空,规避宿主包遮蔽。
|
||||||
|
- **cordis 入口 `apply()` 是带守卫空操作**:默认无副作用,装进任意 profile 都不起服务器。
|
||||||
|
- **产物型分发**:提交 `lib/`(构建产物),`prepare` = `npm run build` 供 git 安装自构建。
|
||||||
|
|
||||||
|
## 3. spawn 每用户 DSH
|
||||||
|
|
||||||
|
```ts
|
||||||
|
spawn(dshBinPath, ['--profile', 'web', '--patch', mainPatchPath, '--host', '127.0.0.1', '--port', String(port)], {
|
||||||
|
cwd: workspacePath, // 用户在桌面选的文件夹
|
||||||
|
env: { ...scrubEnv(process.env), HOME: workspacePath, DSH_HOME: homeDir, DEEPSEEK_API_KEY: userApiKey },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: false,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- env 擦除镜像 harness 的 `scrubbedParentEnv`/`SENSITIVE_ENV_PATTERN` 思路:只向子进程显式注入已解析 key。
|
||||||
|
- 端口是 CLI flag(`--port`),**不是** env、**不是** patch(踩坑记录见 [troubleshooting.md](troubleshooting.md)「端口冲突」)。
|
||||||
|
- 进程树 teardown 自行实现:SIGTERM → grace → SIGKILL。
|
||||||
|
|
||||||
|
## 4. 双 DSH「共享对话 + 崩溃接管」
|
||||||
|
|
||||||
|
**共享状态 = 每用户 `$DSH_HOME` 里的持久会话日志**(append-only;`session-persistence` 落盘)。
|
||||||
|
|
||||||
|
- **主 DSH** 独占实时会话并持续 append;绑定回环端口对外服务。
|
||||||
|
- **守护 DSH** 是**按需拉起的一次性 headless DSH**(不常驻):主 DSH 崩溃时、或需执行 post-restart 命令时,编排服务才 spawn 一次;它与主 DSH 同 home/同 workspace,通过 `loadStoredFrom(id, fromSeq)` 读同一日志,修复/resume 后退出。
|
||||||
|
|
||||||
|
**崩溃接管闭环**(主 DSH 崩溃时,编排服务拉起一次守护 DSH 并自动重启主 DSH):
|
||||||
|
|
||||||
|
1. **诊断**:读退出码 + stderr 尾部 + 会话日志尾部,判定崩溃点。
|
||||||
|
2. **修复会话日志**:`interruptedTurnClosers`(`packages/core/session/src/repair.ts`)+ `session-persistence.load`/`commitRepair` 把中断 turn 合成 `tool/result`/`step/end`/`turn/end{interrupted}`,产出可恢复的合法转录。
|
||||||
|
3. **修复根因**:守护 DSH 以 agent 身份(对共享 workspace 有工具权限)修文件/配置、摘坏插件、杀卡死子进程。
|
||||||
|
4. **接手会话**:`ctx.sessionPersistence.prepare`/`load`(或 `ctx.sessions.create({seed})`)恢复修复后的日志,接续对话成为新主 DSH;随后可选重拉 fresh 主 DSH 并退回守护位。
|
||||||
|
|
||||||
|
**计划内重启(装插件)**:主 DSH 退出前把「post-restart 自动命令」写成 JSON 落到 `$DSH_HOME`,守护 DSH 执行重启后命令。
|
||||||
|
|
||||||
|
**关键澄清**:「接手」= 顺序 failover(恢复同一持久日志续接对话),非两个活体同时驱动同一 turn——这是 harness 的 resume 语义,无需自建双向活体通道。
|
||||||
|
|
||||||
|
## 5. 数据模型(SQLite,migration v1)
|
||||||
|
|
||||||
|
- v1 使用:`users`、`sessions`、`workspaces`、`folder_plugins`、`dsh_instances`、`audit_log`。
|
||||||
|
- 预留:`domains`、`credential_vault`(references-not-secrets,密文引用不落明文)。
|
||||||
|
|
||||||
|
字段与约束见 `src/db/schema.ts`。
|
||||||
|
|
||||||
|
## 6. API 面
|
||||||
|
|
||||||
|
| 组 | 路由 | 脚手架状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| Auth | `POST /api/auth/register\|login\|logout`、`GET /api/auth/me` | 已实现(P1) |
|
||||||
|
| Admin | `GET /api/admin/users`、`POST /api/admin/users/:id/approve\|disable\|enable` | 已实现(P1;disable 会同时删会话 + 停 DSH) |
|
||||||
|
| Desktop/FS | `GET /api/desktop/tree`、`POST /api/fs/mkdir\|upload\|create` | 已实现(P2;路径经词法 + 符号链接双重围栏) |
|
||||||
|
| 凭据库 | `GET/POST /api/me/keys`、`POST /api/me/keys/:id/select`、`DELETE /api/me/keys/:id` | 已实现(每用户命名密钥,AES-256-GCM 加密落库) |
|
||||||
|
| DSH | `POST /api/dsh/launch\|stop\|restart`、`GET /api/dsh/status`、`GET /u/:slug/dsh/*` | 已实现(P3/P5,HTTP 代理;main+watchdog 编排层) |
|
||||||
|
| Plugin | `GET /api/plugins`、`POST /api/plugins/select` | 已实现(P4) |
|
||||||
|
| Domain/nginx | `GET/PUT /api/domain`、`POST /api/nginx/regen`、`GET /api/admin/domains`、`POST /api/admin/domains/:id/verify` | 已实现(P6,管理员手动验证;ACME 待接入) |
|
||||||
|
| 静态 | `GET /*`(占位 SPA) | 已接 |
|
||||||
|
|
||||||
|
## 7. 安全模型
|
||||||
|
|
||||||
|
默认软隔离(每用户 `$DSH_HOME` + session `cwd` + 沙箱写隔离);Linux 生产建议开启账号级硬隔离(每用户 OS 账号),见 [hard-isolation.md](hard-isolation.md)。两个兜底:
|
||||||
|
|
||||||
|
- **端口守卫(portGuard)**:iptables OUTPUT owner-match 规则,阻止同机其他账号直连各用户 DSH 的回环端口、绕过编排服务认证;不支持的环境下开启即拒绝启动。
|
||||||
|
- **路径围栏**:所有文件面先做词法包含校验(`resolveWithinRoot`),再逐段 `lstat` 拒绝符号链接分量,防止经工作区内的链接越出用户根。
|
||||||
|
|
||||||
|
## 8. 分阶段路线
|
||||||
|
|
||||||
|
P1–P7 已完成:登录审核、桌面/FS、单 DSH 启动、每文件夹插件、守护/双 DSH、域名/nginx、硬隔离。模式 B(K8s + Postgres + leader election + reconcile,每用户 Pod)已落地:清单见 `deploy/`,部署教程见 [k8s-deployment.md](k8s-deployment.md)。
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# 部署教程(新手版)— DSH 服务端登录插件
|
||||||
|
|
||||||
|
> 🧭 [← 返回 README](../README.md) · 进阶加固:[硬隔离](hard-isolation.md) · 出问题:[排查手册](troubleshooting.md) · 域名细节:[域名配置示例](domain-config.md)
|
||||||
|
|
||||||
|
这份教程假设你**第一次**部署这类服务。每一步都写清楚「做什么、为什么、怎么做、应该看到什么」。照着顺序做,大约 15–30 分钟能把整套跑通。
|
||||||
|
|
||||||
|
> 全文用 `example.com` 当例子,**请把所有 `example.com` 换成你自己的域名**。比如你的域名是 `dsh.baidu.com`,那么 `dsh.example.com` 就是 `dsh.baidu.com`,`*.dsh.example.com` 就是 `*.dsh.baidu.com`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 先搞懂几个词(看不懂也没关系,后面会一直用到)
|
||||||
|
|
||||||
|
| 词 | 大白话解释 |
|
||||||
|
|---|---|
|
||||||
|
| **服务器** | 一台一直开机的电脑(这里指 Linux 云服务器),你的服务跑在上面。 |
|
||||||
|
| **域名** | 人类好记的名字,比如 `example.com`。它最终会被翻译成服务器的 IP。 |
|
||||||
|
| **DNS** | 负责「域名 → IP」翻译的系统。你在域名商那里改 DNS 记录,就是告诉全世界「这个名字指向那台服务器」。 |
|
||||||
|
| **子域名** | 在域名前面再加一段。比如 `dsh.example.com`、`carol.dsh.example.com` 都是 `example.com` 的子域名。 |
|
||||||
|
| **端口** | 一台服务器上的不同「门牌号」。一个服务占一个端口。 |
|
||||||
|
| **nginx** | 一个「反向代理」软件:站在门口,把外面来的请求按域名转发给里面跑的服务。 |
|
||||||
|
| **HTTPS / 证书** | 让浏览器显示「🔒 安全」的加密层。证书要针对具体域名签发。 |
|
||||||
|
| **环境变量** | 给程序传配置的方式,形如 `名字=值`。 |
|
||||||
|
| **进程** | 正在运行的一个程序。 |
|
||||||
|
|
||||||
|
## 0.1 整体长什么样(先有个全局印象)
|
||||||
|
|
||||||
|
```
|
||||||
|
你的用户(浏览器)
|
||||||
|
│
|
||||||
|
│ 访问 dsh.example.com(登录、管理、桌面)
|
||||||
|
│ 访问 carol.dsh.example.com(carol 这个用户的 DSH 聊天界面)
|
||||||
|
▼
|
||||||
|
nginx(门口,按域名分发 + 加 HTTPS 锁)
|
||||||
|
▼
|
||||||
|
编排服务 dshs(本插件,跑在 127.0.0.1:3080)
|
||||||
|
├─ 管登录、审核、桌面
|
||||||
|
└─ 按域名把 carol 的请求转发给「carol 的 DSH 进程」
|
||||||
|
└─ DSH 进程(DeepSeek Harness,跑在随机的本机端口上)
|
||||||
|
```
|
||||||
|
|
||||||
|
**两个东西别搞混**:
|
||||||
|
|
||||||
|
- **`dshs`(编排服务)**:我们这个插件,管「登录、审核、桌面、按域名转发」。
|
||||||
|
- **`dsh`(DSH)**:DeepSeek Harness,真正的 AI 聊天界面。它由编排服务**自动**为每个用户启动,你不需要手动跑它。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 部署前准备(清单)
|
||||||
|
|
||||||
|
1. 一台 Linux 服务器,能 `root` 登录(推荐 Ubuntu 22.04+)。
|
||||||
|
2. 一个自己的域名,能登录域名商后台改 DNS 记录。
|
||||||
|
3. 大概半小时。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 第 1 步:安装 Node.js
|
||||||
|
|
||||||
|
本插件需要 Node 22 以上。用 nvm 装最省心:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1) 安装 nvm(Node 版本管理器)
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
|
# 2) 让当前终端生效(或关掉重开一个终端)
|
||||||
|
source ~/.bashrc
|
||||||
|
# 3) 安装 Node 22
|
||||||
|
nvm install 22
|
||||||
|
# 4) 验证
|
||||||
|
node -v # 应该显示 v22.x.x
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 第 2 步:安装 DSH(DeepSeek Harness)
|
||||||
|
|
||||||
|
这是聊天界面的本体。用 npm 装它的 CLI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install -g @deepseek-ai/dsh
|
||||||
|
```
|
||||||
|
|
||||||
|
> 如果这个包名不对(DSH 还在预发布阶段,安装方式可能变),以 DeepSeek Harness 官方文档为准。**装完验证一句话**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dsh --version # 能打印版本号就是装好了
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 第 3 步:安装本插件(dshs)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1) 下载源码
|
||||||
|
git clone https://work.alotbuy.com/maogeigei/dsh_shenxian.git
|
||||||
|
cd dshs
|
||||||
|
|
||||||
|
# 2) 装依赖 + 编译
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
装完这个目录里会有个 `lib/`(编译产物)和 `node_modules/`(依赖)。
|
||||||
|
|
||||||
|
> **让子 DSH 能加载本插件的运行时插件**:本插件会通过 `--patch` 给每个用户的 DSH 挂一个运行时插件(负责注入「守护 DSH」上下文、绑定端口等)。前提是 `dshs` 要装进 DSH 的 profile:
|
||||||
|
>
|
||||||
|
> ```sh
|
||||||
|
> dsh plugin --profile web add /dsh_login/dshs
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 不做这步,运行时插件加载不了,守护 DSH 的上下文注入就不会生效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 第 4 步:创建管理员账号
|
||||||
|
|
||||||
|
管理员是第一个账号,由他审核其他注册用户。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 先加载环境变量(让数据库路径一致,见下面警告)
|
||||||
|
source /etc/dshs.env
|
||||||
|
node lib/cli.js bootstrap-admin --username admin --password '你的强密码'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--username admin`:管理员用户名(可换)。
|
||||||
|
- `--password '你的强密码'`:管理员密码(换成你自己的,别用弱密码)。
|
||||||
|
|
||||||
|
看到 `admin "admin" created (...)` 就成功了。
|
||||||
|
|
||||||
|
> ⚠️ **数据库路径必须全程一致**:管理员、编排服务、systemd 用的必须是**同一个数据库**。
|
||||||
|
> 本教程统一用 `<DATA_ROOT>/dshs.db`(= `/var/lib/dshs/dshs.db`),所以**建管理员和启动服务都不加 `--db`、都先 `source /etc/dshs.env`**。
|
||||||
|
> 如果你在某处加了 `--db 别的路径`,那建管理员和 systemd **必须加同一个路径**,否则登录不进。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 第 5 步:配置环境变量 + 启动编排服务
|
||||||
|
|
||||||
|
先写一个环境变量文件,把配置集中放一起,方便以后改:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat > /etc/dshs.env <<'EOF'
|
||||||
|
DSHS_PORT=3080
|
||||||
|
DSHS_DATA_ROOT=/var/lib/dshs
|
||||||
|
DSHS_BASE_DOMAIN=dsh.example.com
|
||||||
|
DSHS_COOKIE_DOMAIN=.dsh.example.com
|
||||||
|
DSHS_SECURE_COOKIES=true
|
||||||
|
DSHS_DSH_BIN=/root/.nvm/versions/node/v22.23.2/bin/dsh
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
逐个解释:
|
||||||
|
|
||||||
|
| 变量 | 值 | 为什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| `DSHS_PORT` | `3080` | 编排服务自己监听的端口(nginx 会转发到这个端口)。 |
|
||||||
|
| `DSHS_DATA_ROOT` | `/var/lib/dshs` | 每个用户的文件/配置存哪里。 |
|
||||||
|
| `DSHS_BASE_DOMAIN` | `dsh.example.com` | **关键**:告诉编排服务「子域名长什么样」——`<用户名>.dsh.example.com`。 |
|
||||||
|
| `DSHS_COOKIE_DOMAIN` | `.dsh.example.com` | **关键**:登录 cookie 加这个 `Domain`,才能被子域名共享。注意前面的**点**。 |
|
||||||
|
| `DSHS_SECURE_COOKIES` | `true` | 走 HTTPS,cookie 必须标 `Secure`。 |
|
||||||
|
| `DSHS_DSH_BIN` | `/root/.nvm/versions/node/v22.23.2/bin/dsh` | 编排服务用它启动每个用户的 DSH。**用绝对路径**,别写 `dsh`——systemd 的 PATH 找不到(否则报 `spawn dsh ENOENT`)。版本号按你实际 nvm 版本改:`ls ~/.nvm/versions/node/`。 |
|
||||||
|
|
||||||
|
> 🔐 **可选加固(生产建议)**:在 env 文件里再加一行 `DSHS_PORT_GUARD=true`。它用 iptables 的 OUTPUT owner-match 规则,禁止同机其他账号直连各用户 DSH 的回环端口(编排服务自身不受影响)。仅 Linux + root 有效;在不支持的环境下开启会直接拒绝启动,避免「以为有防护其实没有」。
|
||||||
|
|
||||||
|
> 🔑 **API 密钥是每用户自己的密钥库**:不再在环境变量里配平台 key。每个用户登录后,在桌面 → DSH 窗口点「**管理密钥**」(在「管理插件」旁)添加多个**命名的**密钥(AES-256-GCM 加密存在该用户自己的数据里),并可**选择启用哪一个**;spawn 时只注入该用户当前启用的 key。未设置 key 的用户,其 DSH 能启动但无法调用模型。
|
||||||
|
|
||||||
|
### 6.1 先手动跑一次(验证能起来)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
source /etc/dshs.env
|
||||||
|
node lib/cli.js
|
||||||
|
```
|
||||||
|
|
||||||
|
看到 `dshs listening on http://127.0.0.1:3080` 就是起来了。`Ctrl+C` 停掉,下面配成开机自启。
|
||||||
|
|
||||||
|
### 6.2 配成开机自启(systemd)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat > /etc/systemd/system/dshs.service <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=DSH server login orchestrator
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=/root/dshs
|
||||||
|
EnvironmentFile=/etc/dshs.env
|
||||||
|
Environment=PATH=/root/.nvm/versions/node/v22.23.2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
ExecStart=/root/.nvm/versions/node/v22.23.2/bin/node lib/cli.js
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now dshs
|
||||||
|
systemctl status dshs # 看到 active (running) 就对了
|
||||||
|
```
|
||||||
|
|
||||||
|
> 把 `WorkingDirectory` 换成你实际 `git clone` 的目录(上面假设是 `/root/dshs`)。
|
||||||
|
|
||||||
|
> ⚠️ **两个 nvm + systemd 必踩的坑**(不这样写就会起不来):
|
||||||
|
> 1. **`ExecStart` 必须用 nvm node 的绝对路径**,不能写 `/usr/bin/env node`——systemd 的 PATH 没有 nvm,`node` 会解析到别的版本,导致 `better-sqlite3` 原生模块报 `ERR_DLOPEN_FAILED`(ABI 版本不匹配,编排服务起不来)。
|
||||||
|
> 2. **必须把 nvm 的 bin 加进 PATH**(上面那行 `Environment=PATH=...`)——否则编排服务 spawn 子 DSH 时 `dsh` 找不到(`spawn dsh ENOENT`),而且 `dsh` 脚本内部也是 `#!/usr/bin/env node`,同样需要 `node` 在 PATH。
|
||||||
|
> 上面所有 nvm 路径按你实际版本改:`ls ~/.nvm/versions/node/`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 第 6 步:配置 DNS
|
||||||
|
|
||||||
|
登录你的域名商后台,加两条 **A 记录**,都指向服务器的 IP:
|
||||||
|
|
||||||
|
| 类型 | 主机记录 | 值 |
|
||||||
|
|---|---|---|
|
||||||
|
| A | `dsh` | 你的服务器 IP |
|
||||||
|
| A | `*.dsh` | 你的服务器 IP |
|
||||||
|
|
||||||
|
- `dsh` → 让 `dsh.example.com` 指向服务器。
|
||||||
|
- `*.dsh`(通配)→ 让 `carol.dsh.example.com`、`bob.dsh.example.com` 等任意子域名都指向服务器。
|
||||||
|
|
||||||
|
改完等几分钟 DNS 生效。验证(在本机跑,把 IP 换成你的服务器 IP):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ping dsh.example.com # 应该解析到你的服务器 IP
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 第 7 步:签发 HTTPS 证书(通配证书)
|
||||||
|
|
||||||
|
子域名多、又不能挨个签,所以签一张**通配证书**(`*.dsh.example.com`)。通配证书必须用 **DNS 验证**(证明你真的拥有这个域名)。
|
||||||
|
|
||||||
|
以 Cloudflare 为例:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1) 装 Cloudflare 的 certbot 插件
|
||||||
|
apt install -y certbot python3-certbot-dns-cloudflare
|
||||||
|
|
||||||
|
# 2) 准备一个存 API 令牌的文件(去 Cloudflare 后台建一个 DNS 编辑权限的 token)
|
||||||
|
cat > /etc/cloudflare.ini <<'EOF'
|
||||||
|
dns_cloudflare_api_token = 你的token
|
||||||
|
EOF
|
||||||
|
chmod 600 /etc/cloudflare.ini
|
||||||
|
|
||||||
|
# 3) 签发(注意两条 -d:通配 + 主域)
|
||||||
|
certbot certonly --dns-cloudflare \
|
||||||
|
--dns-cloudflare-credentials /etc/cloudflare.ini \
|
||||||
|
-d '*.dsh.example.com' -d 'dsh.example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
> 如果你不用 Cloudflare,改用对应插件(阿里云 `--dns-aliyun`、腾讯云 `--dns-tencentcloud`、DNSPod `--dns-dnspod` 等),原理一样。
|
||||||
|
>
|
||||||
|
> ⚠️ Cloudflare 免费套餐只代理二级及以下的域名;若你的通配域名到了第三级(如 `*.dsh.example.com`),需要把该记录的 Cloudflare 代理关掉(灰云,仅 DNS)。这会暴露服务器真实 IP,请自行权衡风险。
|
||||||
|
|
||||||
|
签好后证书在这里:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls /etc/letsencrypt/live/dsh.example.com/
|
||||||
|
# 应看到 fullchain.pem 和 privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 第 8 步:配置 nginx
|
||||||
|
|
||||||
|
先装 nginx:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
apt install -y nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
新建配置文件:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat > /etc/nginx/conf.d/dshs.conf <<'EOF'
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主域:登录 / 管理台 / 桌面
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name dsh.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/dsh.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/dsh.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3080;
|
||||||
|
proxy_set_header Host $host; # 关键:保留真实域名
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 通配子域:每个用户的 DSH
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name *.dsh.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/dsh.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/dsh.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3080;
|
||||||
|
proxy_set_header Host $host; # 关键:保留原始子域名
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**最重要的两行**(做错就会各种 404/401):
|
||||||
|
|
||||||
|
- `server_name dsh.example.com` 和 `server_name *.dsh.example.com`:区分主域和子域。
|
||||||
|
- `proxy_set_header Host $host`:**别删**,它把原始域名透传给编排服务,子域路由靠它。如果你写成 `Host dsh.example.com`(固定值)或干脆不写,所有子域都会被当成主域,路由就乱了。
|
||||||
|
|
||||||
|
改完重载:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 第 9 步:(可选)账号级硬隔离
|
||||||
|
|
||||||
|
> **新手可跳过这一步**,默认的「软隔离」已经能跑、能用。硬隔离是给生产环境防「一个用户偷看另一个用户文件」用的进阶项,需要 root 权限。想做就按 [hard-isolation.md](hard-isolation.md) 一步步操作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 第 10 步:从头验证一遍
|
||||||
|
|
||||||
|
按这个顺序走一遍,每步看到对应结果就说明那一步对了:
|
||||||
|
|
||||||
|
1. **打开主域**:浏览器访问 `https://dsh.example.com` → 看到登录页(不是 502/404)。
|
||||||
|
2. **注册一个用户**:点「注册」,填用户名(比如 `carol`)+ 密码 → 提示「等待审核」。
|
||||||
|
3. **审核**:用管理员账号登录 → 管理台 → 点「通过」carol。
|
||||||
|
4. **用户登录**:carol 登录 → 进入桌面(文件浏览器)。
|
||||||
|
5. **启动 DSH**:桌面点「在此文件夹启动 DSH」→ 显示「运行中」。
|
||||||
|
6. **打开 DSH**:点「打开 DSH」→ 跳到 `https://carol.dsh.example.com/` → 看到 DSH 聊天界面(不是 502/404/401)。
|
||||||
|
|
||||||
|
> ⏳ **冷启动**:真实 DSH 启动要 **几秒到十几秒**(源码启动约 4s)。点「启动」后 `status` 会先显示 `running`,但端口要等插件树 boot 完才绑上——**别立刻点「打开 DSH」,等 10 秒再开**,否则会 502。
|
||||||
|
|
||||||
|
### 如果某一步卡住了
|
||||||
|
|
||||||
|
对照 [troubleshooting.md](troubleshooting.md) 找现象 → 根因 → 修法。最常见的几个:
|
||||||
|
|
||||||
|
- 502:DSH 刚启动还在「冷启动」(等 10 秒),或 DSH 没起来。
|
||||||
|
- 404:`BASE_DOMAIN` 没设、或 nginx 的 `Host` 头被改掉了。
|
||||||
|
- 401:cookie 没到子域(`COOKIE_DOMAIN` 没设或没带前导点),或浏览器里是旧 cookie(删掉重新登录)。
|
||||||
|
- 403:DSH 的信任栅栏不认请求(`Origin` 头问题,已在内置代理里处理)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 以后怎么更新插件
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /root/dshs
|
||||||
|
git pull
|
||||||
|
npm run build # 重新编译(改过代码就必须跑)
|
||||||
|
systemctl restart dshs
|
||||||
|
```
|
||||||
|
|
||||||
|
> 重要:**光 `git pull` 不够**,一定记得 `npm run build`——因为跑的是编译产物 `lib/`,不编译的话改动不会生效。
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# 域名配置示例 — DSH 服务端登录插件
|
||||||
|
|
||||||
|
> 🧭 [← 返回 README](../README.md) · 基础部署:[deployment](deployment.md) · 出问题:[排查手册](troubleshooting.md)
|
||||||
|
|
||||||
|
本文给出域名与 nginx 的配置示例。**注意**:默认访问方式已改为**每用户子域名**(`<用户名>.dsh.<域名>`,HTTP + WebSocket 均已支持)——因为 DSH 的 SPA 用绝对路径、子路径方案不兼容。通配 nginx 配置见 [deployment.md §6](deployment.md),常见问题见 [troubleshooting.md](troubleshooting.md)。下面的「子路径」示例仅作遗留参考。
|
||||||
|
|
||||||
|
## 1. 访问拓扑
|
||||||
|
|
||||||
|
```
|
||||||
|
用户浏览器
|
||||||
|
└─(HTTPS)─> nginx(边缘:TLS 终结 + host 路由)
|
||||||
|
└─(反向代理)─> 编排服务 Fastify (127.0.0.1:3080)
|
||||||
|
├─ /api/* /login /register /admin /desktop (编排服务自管)
|
||||||
|
└─ /u/<userId>/dsh/* ──> 127.0.0.1:<动态端口> (每用户 DSH)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 每用户 DSH 只绑定**回环端口**(`127.0.0.1:<动态端口>`),不直接暴露公网;端口由编排服务在启动时分配。
|
||||||
|
- nginx 只做边缘 TLS 与转发,把 `/u/*` 原样透传给编排服务即可——**每用户 DSH 的端口映射由编排服务自己维护**,nginx 无需在每次 DSH 重启时 reload。
|
||||||
|
|
||||||
|
## 2. 默认域名(所有用户共享一个域名)
|
||||||
|
|
||||||
|
用子路径区分用户:`https://dsh.example.com/u/<userId>/dsh/`。
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# /etc/nginx/conf.d/dshs.conf
|
||||||
|
|
||||||
|
# WebSocket 升级头(DSH Web UI 依赖)
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream dsh_orchestrator {
|
||||||
|
server 127.0.0.1:3080; # 编排服务绑定端口(DSHS_PORT)
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name dsh.example.com;
|
||||||
|
return 301 https://$host$request_uri; # 强制 HTTPS
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name dsh.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/dsh.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/dsh.example.com/privkey.pem;
|
||||||
|
|
||||||
|
# 编排服务 trustProxy=true,会读取以下头还原真实 IP / 协议
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://dsh_orchestrator;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 3600s; # 长连接(DSH 对话 / WebSocket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:上面只配置了默认域名一条规则;`/u/<userId>/dsh/` 的转发到哪台每用户 DSH,由编排服务内部决定,nginx 不关心。
|
||||||
|
|
||||||
|
## 3. 自定义域名(每用户专属域名)
|
||||||
|
|
||||||
|
设计目标:每个用户可用自己的域名直达自己的 DSH,例如 `https://alice.example.com` → alice 的 DSH。
|
||||||
|
|
||||||
|
### 3.1 手动映射(当前可手写生效)
|
||||||
|
|
||||||
|
由于编排服务已经支持 `/u/<userId>/dsh/*`,可先在 nginx 手写一条把自定义域名根路径重写到子路径:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name alice.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/alice.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/alice.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
# 把自定义域名根路径重写到 alice 的 DSH 子路径
|
||||||
|
proxy_pass http://127.0.0.1:3080/u/<alice-user-id>/dsh/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `proxy_pass` 带 URI(以 `/` 结尾)时,nginx 会把匹配到的 `/` 替换为 `/u/<alice-user-id>/dsh/`,
|
||||||
|
> 于是 `https://alice.example.com/foo` → `http://127.0.0.1:3080/u/<alice-user-id>/dsh/foo`。
|
||||||
|
> 编排服务再剥掉 `/u/<userId>/dsh` 前缀转发给该用户的 DSH。
|
||||||
|
|
||||||
|
### 3.2 自动生成(已实现)
|
||||||
|
|
||||||
|
`POST /api/nginx/regen` 会根据 `domains` 表为每个已验证的自定义域名生成一个上面的 `server {}` 块,
|
||||||
|
由 [src/nginx/generate.ts](../src/nginx/generate.ts) 的 `renderServerBlock(domain, upstreamPort)` 渲染,运维写入
|
||||||
|
`/etc/nginx/conf.d/` 后 `nginx -s reload`。
|
||||||
|
|
||||||
|
## 4. HTTPS 证书(certbot / ACME)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 默认域名
|
||||||
|
sudo certbot --nginx -d dsh.example.com
|
||||||
|
|
||||||
|
# 自定义域名(每个用户域各自签发)
|
||||||
|
sudo certbot --nginx -d alice.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
证书签发后 certbot 会自动改写对应 `server {}` 的 `ssl_certificate*`。证书的自动签发/续期(ACME)暂未接入:目前域名由管理员在管理台手动标记「已验证」,签发仍用 certbot 手动跑。后续计划把这一步与 `PUT /api/domain`(DNS/HTTP 挑战验证)串起来,实现「用户填域名 → 自动验证 → 自动签发 → 生成并热加载 nginx 配置」。
|
||||||
|
|
||||||
|
## 5. WebSocket 说明
|
||||||
|
|
||||||
|
DSH Web UI 使用 WebSocket。编排服务的反向代理已支持 WebSocket 隧道([proxy.ts](../src/supervisor/proxy.ts) 的 `app.server` `upgrade` 处理器),按 Host 头路由到该用户 DSH,与 HTTP 一致。nginx 侧只需 `map $http_upgrade` + `Upgrade/Connection` 头透传。子域名下 WS 走 `wss://<用户名>.dsh.<域名>/api/...`。
|
||||||
|
|
||||||
|
## 6. 域名配置 API(已实现;ACME 自动验证待接入)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/domain` | 查询当前用户的域名 + nginx 配置 |
|
||||||
|
| `PUT` | `/api/domain` | 设置自定义域名,生成对应的 nginx `server {}` 块(`verified` 重置为 0) |
|
||||||
|
| `POST` | `/api/nginx/regen` | 重新生成并预览 nginx `server {}` 块 |
|
||||||
|
| `GET` | `/api/admin/domains` | 管理员:列出所有自定义域名 |
|
||||||
|
| `POST` | `/api/admin/domains/:id/verify` | 管理员:手动标记域名「已验证」(DNS 归属校验暂未自动化) |
|
||||||
|
|
||||||
|
实现见 [src/web/routes/domain.ts](../src/web/routes/domain.ts) 与 [src/nginx/generate.ts](../src/nginx/generate.ts)。
|
||||||
|
|
||||||
|
## 7. 生产环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `DSHS_PORT` | 编排服务绑定端口(nginx 上游需一致),默认 `3080` |
|
||||||
|
| `DSHS_DATA_ROOT` | 每用户 home / workspace 根,生产建议 `/var/lib/dshs` |
|
||||||
|
| `DSHS_SECURE_COOKIES` | **HTTPS 部署必须设为 `true`**(否则会话 cookie 不带 `Secure`) |
|
||||||
|
| `DSHS_DSH_BIN` | 子 DSH 可执行文件(默认 `dsh`) |
|
||||||
|
|
||||||
|
编排服务 `host` 默认 `127.0.0.1`(只监听回环,由 nginx 作为唯一公网入口),保持默认即可。
|
||||||
|
|
||||||
|
## 8. 安全注意
|
||||||
|
|
||||||
|
- 每用户 DSH 只绑回环端口;不要把它们改成 `0.0.0.0`,否则绕过认证直接暴露。
|
||||||
|
- 会话 cookie 在 HTTPS 下必须启用 `Secure`(见 §7)。
|
||||||
|
- 编排服务 `trustProxy=true`,仅在与 nginx 同机且信任其 `X-Forwarded-*` 头时使用;不要把它直接暴露公网。
|
||||||
|
- 自定义域名的归属校验(DNS/HTTP 挑战)在 P6 落地前,**不要**开放 `PUT /api/domain` 给普通用户随意映射他人路径。
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# 硬隔离教程(新手版)— 每用户独立 OS 账号
|
||||||
|
|
||||||
|
> 🧭 [← 返回 README](../README.md) · 前置:[基础部署](deployment.md) · 出问题:[排查手册](troubleshooting.md)
|
||||||
|
|
||||||
|
> 本教程是 [deployment.md](deployment.md) 的进阶篇。先按那篇把整套跑通、能正常登录和打开 DSH,再回来做这个。
|
||||||
|
> 全文用 `example.com` 举例,请替换成你自己的域名;`/root/dshs` 换成你实际 `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](deployment.md) 的 systemd 配置(`/etc/systemd/system/dshs.service`),默认就是 root,跳过这步。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 打开账号级隔离的开关
|
||||||
|
|
||||||
|
在环境变量文件里加两个配置(`/etc/dshs.env`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 追加这两行
|
||||||
|
DSHS_ISOLATION_MODE=account
|
||||||
|
DSHS_BASE_UID=100000
|
||||||
|
```
|
||||||
|
|
||||||
|
| 变量 | 值 | 解释 |
|
||||||
|
|---|---|---|
|
||||||
|
| `DSHS_ISOLATION_MODE` | `account` | 开启账号级隔离(默认 `soft`)。 |
|
||||||
|
| `DSHS_BASE_UID` | `100000` | 每个用户 uid 的「起始数字」。系统 uid 一般 < 1000,这里从 10 万开始,避免撞系统账号。 |
|
||||||
|
|
||||||
|
改完**重启编排服务**让配置生效:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl restart dshs
|
||||||
|
```
|
||||||
|
|
||||||
|
> 从这一刻起,编排服务 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 装到系统级位置**(所有账号都能读):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 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/dshs.env → DSHS_DSH_BIN=/usr/local/bin/dsh
|
||||||
|
|
||||||
|
# 4) systemd 的 PATH 去掉 /root/.nvm/...,改成系统路径
|
||||||
|
# (子 DSH 的 #!/usr/bin/env node 才找得到系统 node)
|
||||||
|
# /etc/systemd/system/dshs.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` 强制覆盖。
|
||||||
|
|
||||||
|
> **另一个降权后读不到的东西**:运行时插件 `dshs/runtime` 在 `git clone` 的目录(如 `/dsh_login/dshs/lib/`)里,降权账号也要能读到。检查 `ls -ld /dsh_login`,如果是 `drwx------`(只有 root),执行 `chmod 755 /dsh_login`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 为每个用户创建系统账号(核心一步)
|
||||||
|
|
||||||
|
这是**唯一需要手动做的事**:用户注册后,要先给他建系统账号 + 把他目录改成这个账号所有,硬隔离才完整。**没做这一步,DSH 会以 root 运行(跟没开硬隔离一样)。**
|
||||||
|
|
||||||
|
创建脚本(`provision-user.sh`,root 运行):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# 用法:provision-user.sh <userId> (userId 就是数据库里 users 表的 id,管理员界面能看到)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
uid="$(dshs 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/dshs/users/$1"
|
||||||
|
|
||||||
|
echo "provisioned $1 -> uid $uid"
|
||||||
|
```
|
||||||
|
|
||||||
|
怎么执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Watch new user dirs and provision OS accounts
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
PathExistsGlob=/var/lib/dshs/users/*/home
|
||||||
|
Unit=dsh-provision.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/systemd/system/dsh-provision.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Provision a newly registered user
|
||||||
|
After=dshs.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# 找出刚注册、还没建账号的用户,挨个建
|
||||||
|
ExecStart=/usr/local/bin/provision-new-users.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`/usr/local/bin/provision-new-users.sh`(核心:遍历所有用户目录,没建账号的先建,然后**每次都 chown**):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
for dir in /var/lib/dshs/users/*/; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
id="$(basename "$dir")"
|
||||||
|
user="dsh-$id"
|
||||||
|
if ! id "$user" &>/dev/null; then
|
||||||
|
# 账号不存在才建(uid 用插件算出来的同一个值,保证两边一致)
|
||||||
|
uid="$(dshs 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 要放在判断**外面**、每次都执行。
|
||||||
|
|
||||||
|
启用:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now dsh-provision.path
|
||||||
|
```
|
||||||
|
|
||||||
|
这样以后**用户一注册,目录一出现,systemd 就自动建账号 + 改属主**,管理员不用再管。
|
||||||
|
|
||||||
|
**方案 B:定时任务(简单粗暴,隔几分钟扫一次)**
|
||||||
|
|
||||||
|
如果不想用 systemd 的 path 监控,就用 cron 每 5 分钟跑一遍同一个 `provision-new-users.sh`(幂等,跑多少次都安全):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
crontab -e
|
||||||
|
# 加一行:
|
||||||
|
*/5 * * * * /usr/local/bin/provision-new-users.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**方案 C:手动(用户少时够用)**
|
||||||
|
|
||||||
|
用户少、注册不频繁,管理员有空就手动跑 `./provision-user.sh <userId>` 也行。**但别忘了**——漏一个 = 那个用户还在 root 下跑,等于没隔离。
|
||||||
|
|
||||||
|
> **推荐**:方案 A 最省心、自动、幂等,一次配好就不用管。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 验证硬隔离真的生效
|
||||||
|
|
||||||
|
**第 1 步:看 DSH 进程的 uid。**
|
||||||
|
|
||||||
|
先在桌面启动一个用户的 DSH,然后:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 找到 DSH 进程
|
||||||
|
ps aux | grep dsh | grep -v grep
|
||||||
|
|
||||||
|
# 假设拿到了 pid,看它的真实 uid
|
||||||
|
ps -o uid,user,cmd -p <pid>
|
||||||
|
```
|
||||||
|
|
||||||
|
**应该看到**:`uid` 等于 `dshs uid-for-user <那个userId>`(是一个 >100000 的账号),**不是** 0(root)。如果显示 0,说明没降权,回头检查第 2、3 步。
|
||||||
|
|
||||||
|
**第 2 步:越权读测试(最关键)。**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1) 在用户 A 的 home 里放个文件
|
||||||
|
echo "secret" > /var/lib/dshs/users/<A的id>/home/s.txt
|
||||||
|
chmod 600 /var/lib/dshs/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/.nvm` | npm 全局前缀被 nvm 劫持(`/root/.npmrc` 里的 `prefix`)。用 `NPM_CONFIG_PREFIX=/usr/local` 强制覆盖。 |
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
# K8s 踩坑记录(模式 B)— 部署流程 + 根因排查
|
||||||
|
|
||||||
|
> 🧭 [← 返回 README](../README.md) · 分步教程:[K8s 部署教程](k8s-deployment.md)
|
||||||
|
|
||||||
|
> 记录模式 B 实机部署踩到的坑与根因:先在**阿里云 2C2G 单机 k3s** 上 PoC 验证,后在 **ACK 智能托管**上完整落地(§5–§8 含部署流程实录)。
|
||||||
|
> 想直接照着部署,从 [k8s-deployment.md](k8s-deployment.md) 开始;本文按「症状 → 根因 → 修复」组织,用于卡住时对查。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 环境 / k3s 安装
|
||||||
|
|
||||||
|
### 1.1 cgroup v1 导致 k3s v1.36+ kubelet 拒启
|
||||||
|
|
||||||
|
- **症状**:k3s v1.36 启动几秒后退出,`systemctl` 反复 auto-restart。日志:
|
||||||
|
```
|
||||||
|
Shutdown request received: "kubelet exited: ... kubelet is configured to not run on a host using cgroup v1 ..."
|
||||||
|
```
|
||||||
|
- **根因**:K8s 1.36 移除了 cgroup v1 支持;阿里云 Linux 3(RHEL8 系)默认跑 cgroup v1。
|
||||||
|
- **修复(二选一)**:
|
||||||
|
- 启用 cgroup v2(改内核参数 + **重启**):
|
||||||
|
```bash
|
||||||
|
grubby --update-kernel=ALL --args="systemd.unified_cgroup_hierarchy=1"
|
||||||
|
grubby --info=ALL | grep args # 确认参数已写入
|
||||||
|
reboot
|
||||||
|
# 重启后验证
|
||||||
|
stat -fc %T /sys/fs/cgroup/ # 输出 cgroup2fs 才对
|
||||||
|
```
|
||||||
|
- 或降级 k3s v1.31(还支持 cgroup v1)。
|
||||||
|
|
||||||
|
### 1.2 swap 未关
|
||||||
|
|
||||||
|
- **症状**:kubelet 起不来(`fail-swap-on` 默认 true)。
|
||||||
|
- **修复**:
|
||||||
|
```bash
|
||||||
|
swapoff -a
|
||||||
|
# 持久化(可选):注释 /etc/fstab 里的 swap 行
|
||||||
|
sed -i '/[[:space:]]swap[[:space:]]/s/^/#/' /etc/fstab
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 SELinux 依赖缺失(RHEL8 系)
|
||||||
|
|
||||||
|
- **症状**:k3s 安装脚本报:
|
||||||
|
```
|
||||||
|
nothing provides container-selinux >= 3:2.191.0-1 needed by k3s-selinux-...
|
||||||
|
```
|
||||||
|
- **修复**:跳过 SELinux RPM(PoC 不需要):
|
||||||
|
```bash
|
||||||
|
curl -sfL https://rancher-mirror.rancher.cn/k3s/k3s-install.sh | \
|
||||||
|
INSTALL_K3S_MIRROR=cn INSTALL_K3S_SKIP_SELINUX_RPM=true sh -s - --disable traefik --disable metrics-server --disable servicelb --disable local-storage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 镜像源(中国区)
|
||||||
|
|
||||||
|
### 2.1 docker hub 被墙
|
||||||
|
|
||||||
|
- **症状**:`registry-1.docker.io` 403 / 超时,busybox/node/socat 镜像拉不动。
|
||||||
|
- **修复**:给 k3s 的 containerd 配国内镜像 `/etc/rancher/k3s/registries.yaml`:
|
||||||
|
```yaml
|
||||||
|
mirrors:
|
||||||
|
docker.io:
|
||||||
|
endpoint:
|
||||||
|
- "https://docker.m.daocloud.io"
|
||||||
|
- "https://docker.1ms.run"
|
||||||
|
```
|
||||||
|
然后 `systemctl restart k3s`。
|
||||||
|
- **注意**:镜像拉取**极慢**(一个 29MB 镜像拉了 13 分钟),批量拉取要有耐心或换更快的镜像源。
|
||||||
|
|
||||||
|
### 2.2 raw.githubusercontent 被墙
|
||||||
|
|
||||||
|
- **症状**:`kubectl apply -f https://raw.githubusercontent.com/longhorn/...` 拉不到 manifest。
|
||||||
|
- **修复**:加 ghproxy 前缀:
|
||||||
|
```bash
|
||||||
|
curl -sL "https://ghfast.top/https://raw.githubusercontent.com/longhorn/longhorn/v1.7.2/deploy/longhorn.yaml" -o /tmp/longhorn.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 alpine/socat tag 写错
|
||||||
|
|
||||||
|
- **症状**:`alpine/socat:1.8.0.0-r0` 拉取 403。
|
||||||
|
- **根因**:该 tag 不存在。
|
||||||
|
- **修复**:用 `alpine/socat:1.8.0.0`(或 `latest`)。
|
||||||
|
⚠️ 内部设计稿 §4.3 里写的 `1.8.0.0-r0` 需按此改正;本项目已改用自带的 Node `tcp-bridge`(见 §7.5),不再依赖 socat 镜像。
|
||||||
|
|
||||||
|
### 2.4 ghcr.io 直连被墙(CNPG/Longhorn 镜像)
|
||||||
|
|
||||||
|
- **症状**:CloudNativePG operator 镜像 `ghcr.io/cloudnative-pg/cloudnative-pg` 卡 "Pulling" 十几分钟不动。
|
||||||
|
- **修复**:`registries.yaml` 里给 `ghcr.io` 也配镜像:
|
||||||
|
```yaml
|
||||||
|
ghcr.io:
|
||||||
|
endpoint:
|
||||||
|
- "https://ghcr.m.daocloud.io"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 CloudNativePG `poolers` CRD apply 报 annotation 超长
|
||||||
|
|
||||||
|
- **症状**:`kubectl apply -f cnpg.yaml` 报 `CRD poolers.postgresql.cnpg.io is invalid: metadata.annotations: Too long`,operator 崩溃 `no matches for kind "Pooler"`。
|
||||||
|
- **根因**:client-side apply 写的 `last-applied-configuration` annotation 超过 256KB。
|
||||||
|
- **修复**:`kubectl apply --server-side --force-conflicts -f cnpg.yaml`(server-side 不写那个超长 annotation)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Longhorn
|
||||||
|
|
||||||
|
### 3.1 磁盘保留比例过高
|
||||||
|
|
||||||
|
- **症状**:卷副本创建失败 `No available disk candidates`,卷状态 `faulted`。
|
||||||
|
- **根因**:Longhorn 默认**保留 30% 磁盘**;磁盘 88% 满时「可用 < 保留量」,拒绝调度副本。
|
||||||
|
- **修复**:把保留/最小可用降到 5%:
|
||||||
|
```bash
|
||||||
|
kubectl -n longhorn-system patch settings.longhorn.io storage-reserved-percentage-for-default-disk --type=merge -p '{"value":"5"}'
|
||||||
|
kubectl -n longhorn-system patch settings.longhorn.io storage-minimal-available-percentage --type=merge -p '{"value":"5"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 iscsid 未启动
|
||||||
|
|
||||||
|
- **症状**:卷卡在 `attaching`,消费 Pod 一直 `ContainerCreating`。
|
||||||
|
- **修复**(装完 `iscsi-initiator-utils` 后记得启动):
|
||||||
|
```bash
|
||||||
|
dnf install -y iscsi-initiator-utils
|
||||||
|
systemctl enable --now iscsid
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 单节点 3 副本调度不了
|
||||||
|
|
||||||
|
- **症状**:2 个副本 `Failed to schedule replica`,卷卡 `attaching`。
|
||||||
|
- **根因**:Longhorn 默认 **hard anti-affinity**,同一卷的副本必须在不同节点;单节点只能调度 1 个。
|
||||||
|
- **修复**:副本降到 1:
|
||||||
|
```bash
|
||||||
|
kubectl -n longhorn-system patch volumes.longhorn.io <volume-name> --type=merge -p '{"spec":{"numberOfReplicas":1}}'
|
||||||
|
kubectl -n longhorn-system patch settings.longhorn.io default-replica-count --type=merge -p '{"value":"1"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 RWX 卷需 nfs-utils
|
||||||
|
|
||||||
|
- **症状**:RWX 卷挂载失败(RWX 走 share-manager / NFS ganesha)。
|
||||||
|
- **修复**:装 `nfs-utils`(提供 `mount.nfs`):
|
||||||
|
```bash
|
||||||
|
dnf install -y nfs-utils
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 内存预算(重要)
|
||||||
|
|
||||||
|
- Longhorn 自身 ~600M。**2C2G 上 k3s + Longhorn 已占 ~1.4G**,无法再跑 CloudNativePG 3 实例(需 ~1.5G)。
|
||||||
|
- **PoC item 4(CNPG + pgbench)需 ≥4G 机器**,2G 必 OOM。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 方案修正(PoC 实测推翻/修正的结论)
|
||||||
|
|
||||||
|
### 4.1 flannel 实际强制 NetworkPolicy(§3.5 修正)
|
||||||
|
|
||||||
|
- 实测 k3s v1.31 flannel 下 NetworkPolicy **开箱即用**(k3s 内嵌 kube-router netpol 控制器,不再以 DaemonSet 形式;attacker 被拒,删策略后立刻恢复连通)。
|
||||||
|
- **§3.5「k3s 默认 flannel 不强制」不成立**。
|
||||||
|
- Cilium 的选型理由应改为:**L7 策略 / eBPF 性能 / Hubble 可观测**(这些 kube-router 没有),而不是「基础强制」。
|
||||||
|
|
||||||
|
### 4.2 gid ≠ fsGroup(§6.0 措辞)
|
||||||
|
|
||||||
|
- 实测 init Job 建目录 `0700` 属主 uid 正确,但 **gid=0**(不是 fsGroup=100001)——因 §4.3 的 Pod 只设 `runAsUser`+`fsGroup`、没设 `runAsGroup`,进程默认 gid=0。
|
||||||
|
- **不影响 0700 安全边界**(0700 已把 group 权限关成 `---`)。若要对齐 gid,需加 `runAsGroup:<uid>`。
|
||||||
|
|
||||||
|
### 4.3 socat tag(§4.3 修正)
|
||||||
|
|
||||||
|
- `alpine/socat:1.8.0.0-r0` → `alpine/socat:1.8.0.0`。见 §2.3。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 完整部署流程(ACK 实跑记录,2026-08-23)
|
||||||
|
|
||||||
|
> 在阿里云 **ACK 智能托管模式** 上跑通控制面 Phase 2 的完整步骤。k8s 方案里的
|
||||||
|
> 「3 server HA / kube-vip / MetalLB / Cilium」在 ACK 上由托管能力替代(§5.5 映射)。
|
||||||
|
> 每用户 DSH Pod(Phase 3)、cert-manager、Helm chart 尚未落地,仍待补。
|
||||||
|
|
||||||
|
### 5.1 集群与基础组件
|
||||||
|
|
||||||
|
1. 建 ACK 集群(智能托管模式):**网络插件 DataPath V2(eBPF)+ 勾选 NetworkPolicy**;服务转发模式 IPVS。
|
||||||
|
2. 装 CNPG operator:manifest 用 ghproxy 下载(§2.2),`kubectl apply --server-side --force-conflicts`(§2.5 annotation 超长坑)。
|
||||||
|
3. **ghcr.io 换源**:CNPG operator 与 Postgres 镜像在阿里云拉不动(§2.4),换 `ghcr.m.daocloud.io`。
|
||||||
|
|
||||||
|
### 5.2 部署步骤
|
||||||
|
|
||||||
|
1. `kubectl apply -f deploy/00-namespace.yaml`
|
||||||
|
2. `kubectl apply -f deploy/01-dsh-pg.yaml`(Postgres 集群;storageClass 用拓扑感知 `alicloud-disk-topology-alltype`,size ≥20GiB)
|
||||||
|
3. 等 `dsh-pg` 进入 `Cluster in healthy state`;读连接串:
|
||||||
|
`kubectl -n dsh get secret dsh-pg-app -o jsonpath='{.data.uri}' | base64 -d`
|
||||||
|
4. 推镜像到 ACR:CI master push 自动推(需 `ACR_USERNAME`/`ACR_PASSWORD` secret),或 workflow_dispatch。
|
||||||
|
5. 建三个 secret(不在 deploy/ YAML 里,因含动态值):
|
||||||
|
- `dsh-acr-pull`(`docker-registry` 类型,ACR 凭证)
|
||||||
|
- `dsh-secret`(共享加密密钥,`key`)
|
||||||
|
- `dsh-pg`(`url` = 上面读到的 URI)
|
||||||
|
6. `kubectl apply -f deploy/02-control-plane.yaml`
|
||||||
|
7. bootstrap admin:`kubectl -n dsh exec deploy/dsh-orchestrator -- node lib/cli.js bootstrap-admin --username admin --password '<p>'`
|
||||||
|
8. 按 §5.3 验收。
|
||||||
|
|
||||||
|
### 5.3 验收清单(Phase 2)
|
||||||
|
|
||||||
|
- 注册 → 审核 → 登录 → 管理台/域名 API 全通。
|
||||||
|
- 访问控制:普通用户访问管理台 403、域名 API 200。
|
||||||
|
- kill 一个控制面副本 → Deployment 自动重建(3/3)、Service 不中断。
|
||||||
|
- 数据在 Postgres,删副本/重启不丢。
|
||||||
|
|
||||||
|
### 5.4 踩坑记录(ACK 实测新增)
|
||||||
|
|
||||||
|
| 坑 | 修复 |
|
||||||
|
|---|---|
|
||||||
|
| ghcr.io 被墙:CNPG operator / Postgres 镜像拉不动 | 换 `ghcr.m.daocloud.io`(§2.4) |
|
||||||
|
| ESSD 最小 20GiB:`size: 10Gi` 报 `less than minimum 20GiB` | storage `size: 20Gi` |
|
||||||
|
| CNPG 重建集群密码漂移:`dsh-pg-app` 重新生成密码 | 读最新 URI 重建 `dsh-pg` secret;生产用固定密码 |
|
||||||
|
| 控制面启动依赖 Postgres 就绪:ECONNREFUSED 崩 | 等 Postgres healthy 再部署,或接受 CrashLoopBackOff 重试 |
|
||||||
|
| Auto Mode 节点有 taint,CNPG pod 调度失败 | GOATScaler 自动扩出无 taint 节点 |
|
||||||
|
|
||||||
|
### 5.5 ACK 与 k8s 方案的映射
|
||||||
|
|
||||||
|
| k8s.md 定案 | ACK 等价 |
|
||||||
|
|---|---|
|
||||||
|
| 3 server HA + kube-vip | 托管控制面(免费) |
|
||||||
|
| MetalLB(L2 ARP 云上不生效) | SLB / ALB |
|
||||||
|
| Cilium | Terway DataPath V2(eBPF + NetworkPolicy) |
|
||||||
|
| Longhorn RWX / RWO | NAS / ESSD 云盘 |
|
||||||
|
| CloudNativePG | CloudNativePG(自建)或 RDS PG 高可用版 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PoC 实测基线(item 4:CNPG on Longhorn)
|
||||||
|
|
||||||
|
**机器**:阿里云 4C16G,Ubuntu 24.04,SSD 40G。k3s v1.31 + Longhorn v1.7.2(副本 1)+ CNPG v1.24.1,3 实例 healthy。
|
||||||
|
|
||||||
|
**pgbench(scale 5,30s,4 clients / 2 threads)**:
|
||||||
|
|
||||||
|
| 负载 | tps | latency average | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| select-only(`-S`) | 19164.7 | 0.209 ms | 纯读,数据 ~70MB 全进内存缓冲,未打到磁盘 |
|
||||||
|
| TPC-B 混合(含写) | 705.3 | 5.671 ms | 写落到 Longhorn 磁盘,**这才是 I/O 基线** |
|
||||||
|
|
||||||
|
**结论**:CNPG on Longhorn 跑通(3 实例、pgbench 正常)。写路径 latency ~5.6ms / tps ~705,是 Longhorn 单副本的 I/O 成本。要对照「节点本地 NVMe + PG 主备」判断是否可接受,需换 NVMe 盘 + 更大 scale(让数据集超过内存)再压一次。
|
||||||
|
|
||||||
|
**另:pgbench 手动跑法**(`kubectl run --rm -i` 在后台/非 tty 会话会卡 stdin,别用):
|
||||||
|
```bash
|
||||||
|
kubectl run pgbench-init --restart=Never --image=postgres:16 -n <ns> \
|
||||||
|
--env PGHOST=<cluster>-rw --env PGUSER=dsh --env PGPASSWORD=<pw> --env PGDATABASE=dsh \
|
||||||
|
-- pgbench -i -s 5
|
||||||
|
# 等 pod phase=Succeeded 后 kubectl logs;跑完 kubectl delete pod
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Phase 3 集群联调踩坑(2026-08-23,第二阶段)
|
||||||
|
|
||||||
|
> 控制面 `deployMode=k8s` + leader election + file sidecar + 每用户 Pod 在 ACK
|
||||||
|
> 上联调的实跑记录。前一个阶段(§5)只把控制面 3 副本 + CNPG 跑通了。
|
||||||
|
|
||||||
|
### 7.1 部署顺序(缺一步就挂,按序执行)
|
||||||
|
|
||||||
|
1. `deploy/00-namespace.yaml` → `01-dsh-pg.yaml`(Postgres healthy)→ 读 `dsh-pg-app` URI 建 `dsh-pg`/`dsh-secret`/`dsh-acr-pull` secret。
|
||||||
|
2. **`deploy/03-rbac.yaml`**(SA + Role + lease 权限)。漏了它,控制面 Deployment 报
|
||||||
|
`FailedCreate: serviceaccount "dsh-orchestrator" not found`,滚动更新卡死。
|
||||||
|
3. `deploy/02-control-plane.yaml`(含 `imagePullPolicy: Always`,同名 tag 否则节点
|
||||||
|
缓存旧镜像)。
|
||||||
|
4. NAS:控制台建 **CNFS**(容器网络文件系统)→ 应用 `deploy/05-storage.yaml` 引 CNFS
|
||||||
|
→ `04-pvc.yaml` → `08-bootstrap.yaml`(chmod 1777 PVC 根)→ **最后** `07-psa.yaml`。
|
||||||
|
5. 每用户 Pod 依赖 `dsh-acr-pull`(§7.3)。
|
||||||
|
|
||||||
|
### 7.2 NAS:用 CNFS,别手写 `server` 参数
|
||||||
|
|
||||||
|
- 手写 `alicloud-nas` StorageClass 的 `server` 必须是**挂载目标域 + `:/`**,且
|
||||||
|
**不含 FileSystemId 前缀**。我给错成 `<FSID>.<NAS_MOUNT_TARGET>`
|
||||||
|
(带 FSID 前缀)→ provisioner 报 `CreateDir: IllegalCharacters`;去掉 FSID 前缀
|
||||||
|
后 PVC 才 Bound。
|
||||||
|
- 但挂载目标还会变(重建 NAS 后后缀漂移),正确姿势是控制台建 **CNFS**
|
||||||
|
(`storage.alibabacloud.com/v1beta1 ContainerNetworkFileSystem`),StorageClass 用
|
||||||
|
`containerNetworkFileSystem: nas` 参数引用,provisioner 自动拿到当前挂载目标。
|
||||||
|
- bootstrap Job 第一次卡 `ContainerCreating` 无事件 = NFS 挂载挂起(挂载目标错/VPC 不通);
|
||||||
|
目标对了会报 `mount.nfs: Connection reset by peer`(通常是安全组/访问组或目标刚就绪)。
|
||||||
|
|
||||||
|
### 7.3 每用户 Pod 镜像拉取与资源
|
||||||
|
|
||||||
|
- 控制面镜像在**私有 ACR**,生成的 files Pod / DSH Pod / watchdog Job **必须带
|
||||||
|
`imagePullSecrets: [dsh-acr-pull]`**——控制面 Deployment 有,但生成的 Pod 不继承。
|
||||||
|
漏了就是 `Init:ImagePullBackOff insufficient_scope`。代码里 config `imagePullSecret`
|
||||||
|
默认 `dsh-acr-pull`,已在 K8sSpawner 生成的三类 Pod/Job 全部带上。
|
||||||
|
- bootstrap Job 也需 `imagePullSecrets` + 用控制面镜像(busybox 从 docker.io 拉不动)。
|
||||||
|
- files sidecar 内存 64Mi 跑不起 node:22-slim + Fastify(OOMKilled → Pod 不 Ready →
|
||||||
|
Headless DNS 无 A 记录 → 控制面 ENOTFOUND),提到 256Mi。
|
||||||
|
- files sidecar 以用户 uid 跑且无 home,`homedir()` 落到 `/`,`resolveConfig` 会尝试
|
||||||
|
`mkdir /.dshs` → EACCES 崩。`runFileService` 已钉 `dataRoot=/tmp` + 占位
|
||||||
|
`encryptionSecret` 跳过写文件。
|
||||||
|
|
||||||
|
### 7.5 docker.io 被墙 → socat sidecar 换成 Node tcp-bridge
|
||||||
|
|
||||||
|
- ACK 节点拉不动 docker.io(busybox/alpine/socat 都超时),而 `alpine/socat` 正是
|
||||||
|
每用户 DSH Pod 的 sidecar 镜像。改为控制面镜像里的 `tcp-bridge` 子命令(`net`
|
||||||
|
纯转发 8081→8080),DSH Pod 只依赖 ACR 里已有的 `dsh` + `dshs`
|
||||||
|
两个镜像 + `imagePullSecret`,彻底去掉 docker.io 依赖。
|
||||||
|
- 测鉴权/带 cookie 的接口用 `--resolve` 走域名,别用 `kubectl port-forward`:设了
|
||||||
|
`cookieDomain=.dsh.example.com` 后 cookie 不会发到 `127.0.0.1`。
|
||||||
|
|
||||||
|
### 7.6 每用户 Pod 的 uid 与就绪探针
|
||||||
|
|
||||||
|
- **uid 错位**:注册/建 admin 若先 `initUserRoot`(建 files Pod)再 `createUser`,此时
|
||||||
|
用户不存在,`resolveUid` 回退 hash uid,而 `createUser` 给的是 `baseUid+row_id`——
|
||||||
|
files Pod 用 hash uid 建 0700 目录,DSH Pod 用 row_id uid 访问 → EACCES。已改成先
|
||||||
|
建用户再 initUserRoot。
|
||||||
|
- **就绪探针**:DSH 只监听 loopback,`tcpSocket 8080` 探的是 Pod IP、永远不 Ready →
|
||||||
|
Headless 无 A 记录 → 子域 502。改成容器内 `node -e http.get(127.0.0.1:8080)` exec 探针。
|
||||||
|
- **子域代理端口**:Headless Service(clusterIP: None)没有 kube-proxy 做 DNAT,代理
|
||||||
|
必须直连 Pod 的 targetPort(sidecar 8081),不能连 Service port 80——否则 502。
|
||||||
|
`endpointFor` 返回 8081。
|
||||||
|
- **cookie 跨子域**:`dsh.<domain>` 登录后跳 `<user>.dsh.<domain>`,`SameSite=Lax` 实测
|
||||||
|
不带 cookie → 401;改 `SameSite=None; Secure`(App 已 HTTPS-only)+ `secureCookies=true`。
|
||||||
|
- **代理 502**:控制面 keep-alive 池缓存旧 DSH Pod IP,重建 Pod 后命中旧 IP 副本 → 502;
|
||||||
|
连接出错时 `agent.destroy()` + `agent:false` 重试一次重新 DNS。
|
||||||
|
|
||||||
|
### 7.4 域名入口被阿里云备案拦截
|
||||||
|
|
||||||
|
- 腾讯云入口机 nginx 反代到 ACK 公网 SLB(`<SLB_IP>`)时,`Host: dsh.example.com`
|
||||||
|
被阿里云返回 `403 Non-compliance ICP Filing`(`Server: Beaver`)——域名在腾讯云备案、
|
||||||
|
未在阿里云备案,走阿里云公网 SLB 的 80/443 被备案校验拦。直连 SLB IP(不带域名 Host)
|
||||||
|
则 200 正常。
|
||||||
|
- 解法:给域名在阿里云做 ICP 备案,或入口走 NodePort/专线绕开公网 SLB 备案层,或
|
||||||
|
控制面域名解析子域改用「SLB 直连 + 腾讯云 nginx 透传 Host」之外的方案(待定)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Phase 4 加固 / 可观测(2026-08-23)
|
||||||
|
|
||||||
|
### 8.1 代码加固(已落地,随镜像生效)
|
||||||
|
|
||||||
|
- `readOnlyRootFilesystem: true` + `emptyDir` `/tmp`:DSH dsh/sidecar、file sidecar、
|
||||||
|
watchdog、控制面容器全部只读 rootfs,`/tmp` 走 emptyDir(PSA restricted 纵深)。
|
||||||
|
- 空闲回收:reconcile(仅 leader)对每个期望 main 检查 `hasActiveSession`,会话全过期
|
||||||
|
→ `stop` Pod + 删期望态,不自动拉起(复用 `sessionTtlSeconds`,默认 7 天)。
|
||||||
|
- egress 收敛:`DSHS_EGRESS_CIDRS`(逗号分隔 CIDR)非空时,每用户 DSH Pod 的
|
||||||
|
443 egress 从 `0.0.0.0/0` 收敛为白名单(仍 except 内网)。默认空 = 保持现状。
|
||||||
|
⚠️ DeepSeek API 走 CDN,IP 会漂移,收敛前先 `dig +short api.deepseek.com` 核对并定期重查。
|
||||||
|
|
||||||
|
### 8.2 etcd encryption-at-rest(ACK 托管,控制台操作)
|
||||||
|
|
||||||
|
1. ACK 控制台 → 集群 → 托管控制面 → 开启 **etcd 加密**(encryption-at-rest)。
|
||||||
|
2. 开启后**必须全量重写存量 Secret**,否则旧 Secret 仍明文:
|
||||||
|
```bash
|
||||||
|
kubectl get secrets -A -o json | kubectl replace -f -
|
||||||
|
```
|
||||||
|
3. 验证(应只剩 `k8s:enc:aescbc:v1:` 前缀的密文):
|
||||||
|
```bash
|
||||||
|
kubectl get secrets --all-namespaces -o json | grep -v 'k8s:enc:aescbc' || echo "all encrypted"
|
||||||
|
```
|
||||||
|
覆盖 `dsh-key-*`(每用户 API key)、`dsh-pg`(DB DSN)、`dsh-secret`(共享加密密钥)。
|
||||||
|
|
||||||
|
### 8.3 可观测(Prometheus + Loki + 告警)
|
||||||
|
|
||||||
|
- **指标**:控制面已可暴露 `/metrics`(未接入则用 prom-client 加一个);ACK 托管 Prometheus
|
||||||
|
(ARMS)在控制台开通后,用 ServiceMonitor 抓 `dsh-orchestrator` 的 3080。
|
||||||
|
关键指标:控制面副本数、Postgres 连接数、每用户 Pod 数、崩溃计数。
|
||||||
|
- **日志**:Loki + Promtail(或 ACK SLS)接入控制面与每用户 Pod 日志。
|
||||||
|
- **告警规则**(Prometheus):控制面副本 < 3、Postgres 不可写、DSH Pod 崩溃率、NAS 容量水位。
|
||||||
|
示例(自建 Prometheus 时用):
|
||||||
|
```yaml
|
||||||
|
apiVersion: monitoring.coreos.com/v1
|
||||||
|
kind: PrometheusRule
|
||||||
|
metadata: { name: dsh-alerts, namespace: dsh }
|
||||||
|
spec:
|
||||||
|
groups:
|
||||||
|
- name: dsh
|
||||||
|
rules:
|
||||||
|
- alert: DshControlPlaneDown
|
||||||
|
expr: count(up{app="dsh-orchestrator"}) < 3
|
||||||
|
for: 5m
|
||||||
|
- alert: DshPostgresUnavailable
|
||||||
|
expr: pg_up == 0
|
||||||
|
for: 2m
|
||||||
|
```
|
||||||
|
注:托管 Prometheus/Loki 实际接入需在 ACK 控制台开通,本轮交付清单与步骤。
|
||||||
|
|
||||||
|
### 8.4 Web 安全审计(代码审计结论,2026-08-23)
|
||||||
|
|
||||||
|
- **XSS(已修)**:`desktop.html`/`admin.html` 把文件名 `e.name`、插件名/描述
|
||||||
|
`plugin.name`/`plugin.description`、密钥名 `k.name`、用户名 `u.username` 直接拼进
|
||||||
|
`innerHTML`/`insertAdjacentHTML`。文件名与插件描述不受字符集约束 → 存储型 XSS。
|
||||||
|
已加 `esc()` 转义 `<>&"'` 后再拼接。
|
||||||
|
- **输入校验(已确认安全)**:用户名 `^[a-zA-Z0-9_-]+$`、域名 `isValidDomain`
|
||||||
|
(小写字母数字+连字符,防 nginx 注入)、密钥名 `^[A-Za-z0-9\-_ .]{1,32}$`、路径
|
||||||
|
`resolveWithinRoot`/`safeFilename`。
|
||||||
|
- **CSRF(残余低风险,未修)**:会话 cookie 为 `SameSite=None` 后跨站请求也会带
|
||||||
|
cookie,但所有状态变更端点都是 `application/json` + 无 CORS 头,跨站 fetch 会触发
|
||||||
|
preflight 被浏览器拦;真正暴露的是无 body 的 `POST /api/auth/logout`(logout CSRF,
|
||||||
|
低危)。后续若要加固,给状态变更端点加 CSRF token / 自定义头校验。
|
||||||
|
- **OWASP ZAP 基线扫描**:未在本轮执行(需 ZAP 工具/容器)。已定位的 XSS 已人工修,
|
||||||
|
ZAP 可作为 CI 门禁后续接入。
|
||||||
|
|
||||||
|
### 8.5 联调后期修的 4 个根因 bug(2026-08-23)
|
||||||
|
|
||||||
|
| 症状 | 根因 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| leader 不接管,lease 挂旧 pod 数小时 | client-node `patchNamespacedLease` 走 JSON Patch(要数组),传对象被 Go 拒 400 被静默吞 | 改 `replaceNamespacedLease` 全量 PUT + RV 乐观并发 |
|
||||||
|
| admin DSH 反复 502 | `endpointFor` 返回 Service DNS,A 记录 ~30s TTL,Pod 重启后这 30s 内仍解析旧 IP | endpointFor 直接返回 `podIP`,每次请求实时读 Pod |
|
||||||
|
| 模型无法请求(EAI_AGAIN) | ACK DNS 是 `node-local-dns`(label `k8s-app=node-local-dns`),NP 规则却选 `k8s-app=kube-dns` → DNS 全被拦 | NP DNS egress 改 `0.0.0.0/0`(可移植) |
|
||||||
|
| keepAlive 池销毁后不可复用 | `agent.destroy()` 只销毁不替换,后续请求首跳全失败 | destroy 后 `new Agent()` 替换,再短连接重试一次 |
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# K8s 部署教程(模式 B)— DSH 服务端登录插件
|
||||||
|
|
||||||
|
> 🧭 [← 返回 README](../README.md) · 卡住了:[踩坑记录](k8s-deploy.md) · 模式 A 教程:[deployment](deployment.md)
|
||||||
|
|
||||||
|
> 把 `dshs` 部署成**多机 HA、每用户独立 Pod** 的形态。本文是**可复制的分步部署教程**;
|
||||||
|
> 实机逐条踩坑与根因排查见 [k8s-deploy.md](k8s-deploy.md)。
|
||||||
|
>
|
||||||
|
> 实测环境:阿里云 ACK 智能托管(华东2)+ CNFS(NAS) + CloudNativePG + 私有 ACR。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 前置条件
|
||||||
|
|
||||||
|
- 一个 **ACK 智能托管集群**(网络插件 DataPath V2 / 勾选 NetworkPolicy;节点 ≥ 3 或启用自动扩容)。
|
||||||
|
- 一个 **NAS 文件系统**,并在集群里建 **CNFS**(控制台「容器网络文件系统」,名字记为 `nas`)。
|
||||||
|
- 一个 **私有 ACR 仓库**(本文用 `registry.example.com/dsh` 作占位)。
|
||||||
|
- 一个域名(本文用 `dsh.example.com` 作占位)+ 通配 TLS 证书(cert-manager 或 LB 证书)。
|
||||||
|
|
||||||
|
已安装:`kubectl`、`cnpg` operator(`kubectl apply --server-side -f cnpg.yaml`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 镜像
|
||||||
|
|
||||||
|
两种方式二选一:
|
||||||
|
|
||||||
|
- **CI(推荐)**:推代码到 master,GitHub Actions 自动 build + Trivy + push 两个镜像到 ACR。
|
||||||
|
- **手动**:
|
||||||
|
```sh
|
||||||
|
docker build -t <acr>/dshs:0.2.0 .
|
||||||
|
docker build -t <acr>/dsh:0.1.1-rc.2 -f Dockerfile.dsh .
|
||||||
|
docker push <acr>/dshs:0.2.0
|
||||||
|
docker push <acr>/dsh:0.1.1-rc.2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Namespace + Postgres
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl apply -f deploy/00-namespace.yaml
|
||||||
|
kubectl apply -f deploy/01-dsh-pg.yaml
|
||||||
|
# 等 healthy:
|
||||||
|
kubectl -n dsh wait --for=condition=Ready cluster/dsh-pg --timeout=600s
|
||||||
|
```
|
||||||
|
|
||||||
|
读连接串(后面建 `dsh-pg` secret 用):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl -n dsh get secret dsh-pg-app -o jsonpath='{.data.uri}' | base64 -d
|
||||||
|
# postgresql://dsh:<password>@dsh-pg-rw.dsh:5432/dsh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Secrets(含动态值,不进 YAML)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# ① ACR 拉镜像凭证(生成 Pod 拉私有镜像用)
|
||||||
|
kubectl -n dsh create secret docker-registry dsh-acr-pull \
|
||||||
|
--docker-server=registry.example.com \
|
||||||
|
--docker-username='<ACR_USERNAME>' --docker-password='<ACR_PASSWORD>'
|
||||||
|
|
||||||
|
# ② 共享加密密钥(迁移时填旧 dataRoot/secret.key 内容,否则已加密的 API key 解不开)
|
||||||
|
kubectl -n dsh create secret generic dsh-secret --from-literal=key='<32字节hex>'
|
||||||
|
|
||||||
|
# ③ Postgres DSN(用 §2 读到的 uri)
|
||||||
|
kubectl -n dsh create secret generic dsh-pg --from-literal=url='postgresql://dsh:[email protected]:5432/dsh'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. RBAC + 控制面
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl apply -f deploy/03-rbac.yaml # SA + Role(含 leases + list/watch,leader election 需要)
|
||||||
|
kubectl apply -f deploy/02-control-plane.yaml
|
||||||
|
kubectl -n dsh rollout status deploy/dsh-orchestrator --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
初始化管理员:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl -n dsh exec deploy/dsh-orchestrator -- node lib/cli.js bootstrap-admin --username admin --password '<强密码>'
|
||||||
|
```
|
||||||
|
|
||||||
|
> 控制面 3 副本,只有 leader 跑 reconcile(Lease 选主)。`deploy/02-control-plane.yaml` 里把
|
||||||
|
> `DSHS_BASE_DOMAIN` / `DSHS_COOKIE_DOMAIN` / 镜像地址改成你自己的。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 存储:NAS(CNFS) + PVC + bootstrap + PSA(严格时序)
|
||||||
|
|
||||||
|
> ⚠️ **顺序不能乱**:先建 PVC → 特权 bootstrap `chmod 1777` → 最后打 PSA restricted。先打 PSA,
|
||||||
|
> 非 root 的 initContainer 就写不了 PVC 根,用户目录永远建不出来。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# ① StorageClass(引用控制台建好的 CNFS `nas`)
|
||||||
|
kubectl apply -f deploy/05-storage.yaml
|
||||||
|
# ② 每用户共享 RWX PVC
|
||||||
|
kubectl apply -f deploy/04-pvc.yaml
|
||||||
|
kubectl -n dsh wait --for=jsonpath='{.status.phase}=Bound' pvc/dsh-users --timeout=300s
|
||||||
|
# ③ 特权 bootstrap:PVC 根 chmod 1777(world-writable + sticky)
|
||||||
|
kubectl apply -f deploy/08-bootstrap.yaml
|
||||||
|
kubectl -n dsh wait --for=condition=complete job/dsh-users-bootstrap --timeout=180s
|
||||||
|
# ④ 打 PSA restricted(放在最后)
|
||||||
|
kubectl apply -f deploy/07-psa.yaml
|
||||||
|
# ⑤ 配额(防单用户耗尽集群)
|
||||||
|
kubectl apply -f deploy/06-quota.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 入口(域名 + TLS)
|
||||||
|
|
||||||
|
- 暴露控制面:给 `dsh-orchestrator` 加一个 **LoadBalancer Service**(`port 80 → targetPort 3080`),
|
||||||
|
或用 **Ingress**(Traefik / nginx-ingress)统一入口。
|
||||||
|
- DNS:把 `dsh.example.com` 与 `*.dsh.example.com` 解析到 LB 的 IP(或 Ingress 域名)。
|
||||||
|
- TLS:cert-manager 签通配证书(DNS-01),或直接用 LB/ALB 的证书。
|
||||||
|
- ⚠️ 域名经阿里云公网 SLB/ALB 暴露时需先做 **ICP 备案**,否则 80/443 会被备案校验拦(403)。
|
||||||
|
|
||||||
|
LoadBalancer Service 参考:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata: { name: dsh-orchestrator-lb, namespace: dsh }
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer
|
||||||
|
selector: { app: dsh-orchestrator }
|
||||||
|
ports: [{ port: 80, targetPort: 3080 }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验收清单
|
||||||
|
|
||||||
|
1. `kubectl -n dsh get pods`:`dsh-orchestrator` 3/3、`dsh-pg-1` 1/1、`dsh-files-*`/`dsh-*`(按需出现)。
|
||||||
|
2. leader 单活:`kubectl -n dsh get lease dsh-orchestrator` holder 非空、`leaseTransitions` 随接管递增。
|
||||||
|
3. 域名全链路:注册 → 管理员审核 → 登录 → 桌面 tree/建文件夹/上传 → 启动 DSH → `https://<user>.dsh.example.com/` 200。
|
||||||
|
4. 模型请求:DSH 里配 key 后能解析并访问 `api.deepseek.com`(DNS + 443 出站通)。
|
||||||
|
5. 隔离:任意非控制面 Pod 访问某用户 DSH 的 8081/8082 被 NetworkPolicy 拒。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 配置参考(控制面 env)
|
||||||
|
|
||||||
|
| env | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `DSHS_DEPLOY_MODE` | `local` | `k8s` 启用每用户 Pod |
|
||||||
|
| `DSHS_DB_URL` | 空 | Postgres DSN(k8s 必填) |
|
||||||
|
| `DSHS_SECRET` | 空 | 共享加密密钥 |
|
||||||
|
| `DSHS_NAMESPACE` | `dsh` | 每用户资源命名空间 |
|
||||||
|
| `DSHS_DSH_IMAGE` | 空 | 每用户 DSH 镜像 |
|
||||||
|
| `DSHS_CONTROL_PLANE_IMAGE` | 空 | file sidecar / tcp-bridge 镜像 |
|
||||||
|
| `DSHS_IMAGE_PULL_SECRET` | `dsh-acr-pull` | 生成 Pod 的拉镜像 secret |
|
||||||
|
| `DSHS_BASE_DOMAIN` | 空 | 子域路由基域 |
|
||||||
|
| `DSHS_COOKIE_DOMAIN` | 空 | cookie `Domain` |
|
||||||
|
| `DSHS_SECURE_COOKIES` | `false` | HTTPS 设 `true` |
|
||||||
|
| `DSHS_EGRESS_CIDRS` | 空 | 443 出站白名单(空=0.0.0.0/0) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 常见问题 → [k8s-deploy.md](k8s-deploy.md)
|
||||||
|
|
||||||
|
- 部署顺序、NAS/CNFS、uid 错位、就绪探针、代理端口、leader 接管、DNS、备案、keepAlive 502 等全部踩坑与根因已记录在 `k8s-deploy.md` §5–§8。
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 常见问题排查 — DSH 服务端登录插件
|
||||||
|
|
||||||
|
> 🧭 [← 返回 README](../README.md) · 部署教程:[deployment](deployment.md) · 硬隔离:[hard-isolation](hard-isolation.md)
|
||||||
|
|
||||||
|
按「现象 → 根因 → 修法」组织,都是实际部署中踩过的坑。
|
||||||
|
|
||||||
|
## 502:启动 DSH 后打开是 Bad Gateway
|
||||||
|
|
||||||
|
- **现象**:桌面显示「运行中」,但打开 DSH(子域名)返回 502(nginx)。
|
||||||
|
- **根因**:子 DSH 没在编排服务分配的回环端口上监听——要么还在冷启动,要么 spawn 即崩。
|
||||||
|
- **排查**:
|
||||||
|
```sh
|
||||||
|
ps aux | grep dsh # 有没有子进程
|
||||||
|
ss -tulpn | grep <端口> # 有没有监听
|
||||||
|
```
|
||||||
|
- **冷启动**:真实 DSH 冷启动 ~4s(源码启动)。编排服务在 `spawn` 事件就标「running」,但端口要等插件树 boot 完才绑。等 10 秒再开 / 再查 `ss`。
|
||||||
|
- **spawn 即崩**:看编排服务终端的子进程 stderr(stderr 会被 pipe 过去)。常见:`--port` 改动没部署(→ EADDRINUSE)、缺 `DEEPSEEK_API_KEY` 等。
|
||||||
|
|
||||||
|
## 启动 DSH 报 `spawn dsh ENOENT`
|
||||||
|
|
||||||
|
- **现象**:`/api/dsh/status` 显示 `status: "crashed"`、`lastError: "spawn dsh ENOENT"`;`ps` 里没有任何 dsh 子进程;再点启动报「已有运行中的 DSH」(崩溃循环留下的残留)。
|
||||||
|
- **根因**:systemd 的 PATH 精简,`dsh`(只装在 nvm 下)解析不到。`dsh` 脚本内部也是 `#!/usr/bin/env node`,同样需要 nvm 在 PATH。
|
||||||
|
- **修法**:
|
||||||
|
1. env 里 `DSHS_DSH_BIN=/root/.nvm/versions/node/v22.23.2/bin/dsh`(绝对路径,版本按 `ls ~/.nvm/versions/node/` 改)。
|
||||||
|
2. systemd 单元加 `Environment=PATH=/root/.nvm/versions/node/v22.23.2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`。
|
||||||
|
3. `systemctl daemon-reload && systemctl restart dshs`,再先 stop 再 launch。
|
||||||
|
|
||||||
|
## 编排服务起不来:better-sqlite3 报 ERR_DLOPEN_FAILED / ABI 版本不匹配
|
||||||
|
|
||||||
|
- **现象**:`journalctl -u dshs` 里 `ERR_DLOPEN_FAILED`、`was compiled against ... NODE_MODULE_VERSION ... This version requires ...`;systemd 反复重启失败。
|
||||||
|
- **根因**:`npm install` 用 nvm Node 编译了 `better-sqlite3` 原生模块,但 systemd 的 `ExecStart=/usr/bin/env node` 解析到**另一个 Node 版本**,ABI 对不上。
|
||||||
|
- **修法**:`ExecStart` 用 nvm node 绝对路径(如 `/root/.nvm/versions/node/v22.23.2/bin/node lib/cli.js`),并 `npm rebuild better-sqlite3` 用同一个 node。
|
||||||
|
|
||||||
|
## 端口冲突:子 DSH 和编排服务抢 3080
|
||||||
|
|
||||||
|
- **现象**:手动跑 `dsh web` 报 `EADDRINUSE 0.0.0.0:3080`。
|
||||||
|
- **根因**:编排服务默认绑 3080,子 DSH(harness)默认也绑 3080。
|
||||||
|
- **关键机制**(读 harness 源码确认):harness 的 web 服务端口读 **`--port` 这个 CLI flag**(`web-startup` 插件解析 → `webStartup` 服务 → webserver),**不是** env、**不是** patch。`--cwd` 也不是合法 flag。
|
||||||
|
- **修法**:spawn 子 DSH 用 `dsh --profile web --host 127.0.0.1 --port <随机端口>`(已内置)。
|
||||||
|
|
||||||
|
## 404:打开 DSH 后静态资源全 404
|
||||||
|
|
||||||
|
- **现象**:HTML 能加载,但 `/assets/*`、`/favicon.svg`、`/manifest.webmanifest` 全 404;`manifest` 从域名根去取。
|
||||||
|
- **根因**:DSH 的 SPA(Vite)资源用**绝对路径**(`/assets/*`、`/api/*`),假设自己挂在域名根 `/`。子路径 `/u/<id>/dsh/*` 下,这些绝对路径会打到域名根 → 404;连 `/api` 也会打到编排服务自己的 API。**子路径方案与这个 SPA 从根上不兼容**。
|
||||||
|
- **修法**:改**每用户子域名** `<用户名>.<baseDomain>`,SPA 挂在自己域名根,绝对路径天然成立(含 HTTP 与 WebSocket 均已由编排服务转发)。
|
||||||
|
|
||||||
|
## 403:DSH 功能请求报 transport failure / HTTP 403(如 /api/settings.describe)
|
||||||
|
|
||||||
|
- **现象**:DSH 页面能加载,但功能 API(`/api/settings.describe`、`/api/host.describe` 等)返回 403,前端报 "transport failure for /api/xxx: HTTP 403"。
|
||||||
|
- **根因**:harness 的 `/api` 浏览器信任栅栏(`api-request-trust.ts`)检查 Origin——`origin.host` 必须等于 `host.host`。代理把 `host` 改成 loopback(`127.0.0.1:port`)却原样转发了浏览器的 `origin`(子域名)→ 不匹配 → 403。
|
||||||
|
- **修法**:代理到 DSH 时剥掉 `origin` / `referer` / `sec-fetch-*` / `x-forwarded-*`,只保留 loopback `host`(已内置在 [proxy.ts](../src/supervisor/proxy.ts) 的 `buildUpstreamHeaders`)。
|
||||||
|
|
||||||
|
## ERR_SSL_VERSION_OR_CIPHER_MISMATCH
|
||||||
|
|
||||||
|
- **根因**:子域名没有覆盖它的证书(只有主域单域证书)。
|
||||||
|
- **修法**:DNS 通配 + 通配证书(DNS challenge):
|
||||||
|
```sh
|
||||||
|
certbot certonly --dns-cloudflare -d '*.dsh.example.com' -d 'dsh.example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 401:子域名/接口返回 {"error":"unauthorized"}
|
||||||
|
|
||||||
|
- **根因**:session cookie 是 host-only(没带 `Domain`),到不了子域名;或浏览器里还是**改配置之前登录**的旧 cookie。
|
||||||
|
- **修法**:
|
||||||
|
1. 设 `DSHS_COOKIE_DOMAIN=.dsh.example.com`(注意前导点),重启。
|
||||||
|
2. **重新登录**拿带 `Domain` 的新 cookie。
|
||||||
|
|
||||||
|
## 登录后跳管理员界面、无法保持登录
|
||||||
|
|
||||||
|
- **现象**:登录后按 admin 角色跳到 `admin.html`,但 admin.html 又弹登录框、再登不上。
|
||||||
|
- **根因**:浏览器里存的是旧 cookie(改 `Domain`/`Secure` 之前登录的),不再匹配当前配置。
|
||||||
|
- **修法**:删掉浏览器里的 `sid` cookie(DevTools → Application → Cookies → 删除 `dsh.example.com` 域下的 `sid`)重新登录。或本地测试时 `unset DSHS_COOKIE_DOMAIN DSHS_SECURE_COOKIES` 让 cookie 回到 host-only + 非 Secure。
|
||||||
|
|
||||||
|
## nginx 把 Host 头改成了 upstream 名
|
||||||
|
|
||||||
|
- **现象**:编排服务日志里 `host: dsh_orchestrator`。
|
||||||
|
- **根因**:nginx 默认 `proxy_set_header Host $proxy_host`(upstream 名)。
|
||||||
|
- **修法**:在 location 里加 `proxy_set_header Host $host`(子域名路由依赖真实 Host 头)。
|
||||||
|
|
||||||
|
## 编排服务日志在哪
|
||||||
|
|
||||||
|
- 有 systemd 单元:`journalctl -u dshs -f`。
|
||||||
|
- 手动跑(`node lib/cli.js`):日志(含子 DSH 的 stdout/stderr,已被 pipe)在那个终端里。
|
||||||
|
|
||||||
|
## SEO 警告:`<html lang>` / `<title>` / `viewport` 缺失
|
||||||
|
|
||||||
|
- **现象**:Lighthouse 报这三条。
|
||||||
|
- **根因**:来自 **DSH 自己的聊天界面 SPA**(harness 前端),不是本插件页面(本插件的 login/desktop/admin 都写了这些)。
|
||||||
|
- **处理**:无害、不影响功能。要修需改 harness 前端或由 runtime 插件 `tapIndex` 注入,暂缓。
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* ensure-role-profile-patch.cjs — 按角色给 dsh profile 注入 cordis patch。
|
||||||
|
*
|
||||||
|
* 背景(2026-09-09):普通用户在设置面板不应看到「模型」分区(模型 KEY 由管理员
|
||||||
|
* 经门户统一管控,档案 03;dsh 官方 web profile 的模型 provider 目录在此环境不可用,
|
||||||
|
* 且避免普通用户误配自用 key 绕过统一 key)。cordis patch 支持对 client 插件行
|
||||||
|
* `disabled: true`(dsh-app-boot applyEntryPatches:非 insert patch 按 id 合入
|
||||||
|
* overrides)——生效位置 = profile 层 `cordis.patch.yml`(实例启动时打包 client
|
||||||
|
* bundle,改后必须重启实例才生效;patchReload:live 对 client 增减不生效,已实测)。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node ensure-role-profile-patch.cjs # 全部非 admin 用户
|
||||||
|
* node ensure-role-profile-patch.cjs guest # 指定用户名(可多个)
|
||||||
|
* node ensure-role-profile-patch.cjs --restart guest # 写后重启其实例(kill main,
|
||||||
|
* # 由 watchdog 拉新)
|
||||||
|
* 幂等:cordis.patch.yml 已含管理标记头或非默认空内容 → 跳过不覆盖。
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { readFileSync, writeFileSync, existsSync } = require('node:fs')
|
||||||
|
const { join } = require('node:path')
|
||||||
|
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||||
|
|
||||||
|
const DB_PATH = '/var/lib/dshs/dshs.db'
|
||||||
|
const PROFILE = 'web' // 当前唯一 profile
|
||||||
|
const MARK = '# dshs role patch'
|
||||||
|
// --force:已由本脚本管理但内容落后(缺新版禁用项)时,整体升级为当前块。
|
||||||
|
const FORCE = process.argv.includes('--force')
|
||||||
|
const DISABLE_MODELS_BLOCK = [
|
||||||
|
'# dshs role patch: 普通用户隐藏模型分区 + 收归核心插件开关(档案 15)',
|
||||||
|
'# admin 保留;由 ensure-role-profile-patch.cjs 管理,勿手改',
|
||||||
|
'# 148 个 @deepseek-ai 官方插件均为运行骨架,用户禁用任一都可能搞坏实例,',
|
||||||
|
'# 故插件栏 / 插件清单 / cordis 面板只对 admin 开放;ui-skill、ui-permission 保留给用户。',
|
||||||
|
'- id: ui-settings-models',
|
||||||
|
' name: "@deepseek-ai/dsh-client-ui-settings-models"',
|
||||||
|
' disabled: true',
|
||||||
|
'- id: ui-settings-plugins',
|
||||||
|
' name: "@deepseek-ai/dsh-client-ui-settings-plugins"',
|
||||||
|
' disabled: true',
|
||||||
|
'- id: ui-settings-plugin-inventory',
|
||||||
|
' name: "@deepseek-ai/dsh-client-ui-settings-plugin-inventory"',
|
||||||
|
' disabled: true',
|
||||||
|
'- id: ui-cordis',
|
||||||
|
' name: "@deepseek-ai/dsh-client-ui-cordis"',
|
||||||
|
' disabled: true',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
function isEmptyPatch(content) {
|
||||||
|
const body = content
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l !== '' && !l.startsWith('#'))
|
||||||
|
return body.length === 0 || body.join('') === '[]'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUserPatch(user) {
|
||||||
|
const patchPath = join(user.home_dir, 'profiles', PROFILE, 'cordis.patch.yml')
|
||||||
|
if (!existsSync(patchPath)) {
|
||||||
|
return { user: user.username, action: 'NO_PROFILE', detail: 'profile 尚未创建(用户未首登 spawn);请先登录一次再跑' }
|
||||||
|
}
|
||||||
|
const current = readFileSync(patchPath, 'utf8')
|
||||||
|
if (current.includes(MARK)) {
|
||||||
|
if (FORCE && !current.includes('ui-settings-plugins')) {
|
||||||
|
writeFileSync(patchPath, DISABLE_MODELS_BLOCK, 'utf8')
|
||||||
|
return { user: user.username, action: 'upgraded', detail: '已升级为新版禁用块(含核心插件开关收归)' }
|
||||||
|
}
|
||||||
|
return { user: user.username, action: 'skip', detail: '已由本脚本管理' }
|
||||||
|
}
|
||||||
|
if (!isEmptyPatch(current)) return { user: user.username, action: 'skip', detail: '用户已定制 cordis.patch.yml,不覆盖' }
|
||||||
|
writeFileSync(patchPath, DISABLE_MODELS_BLOCK, 'utf8')
|
||||||
|
return { user: user.username, action: 'wrote', detail: '已写入 disable models patch' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartUserInstance(user) {
|
||||||
|
// kill main 实例 → orchestrator 崩溃自愈(scheduleRestart,指数退避)拉起新实例
|
||||||
|
// (读取新 patch 打包 bundle)。注:watchdog 因 enablePatch=false 不会启动(档案 20)。
|
||||||
|
const out = execFileSync('pgrep', ['-f', `--profile ${PROFILE}`], { encoding: 'utf8' }).trim()
|
||||||
|
const pids = out === '' ? [] : out.split('\n')
|
||||||
|
// 该用户实例 cwd = users/<id>/ws(与 DB home_dir 同根),用 uid 匹配
|
||||||
|
const uid = user.uid !== null && user.uid !== undefined ? String(user.uid) : null
|
||||||
|
const envCmd = 'ps -o pid,user,args -C node | grep -E "dsh --profile web"'
|
||||||
|
let killed = 0
|
||||||
|
try {
|
||||||
|
const lines = execFileSync('bash', ['-c', envCmd], { encoding: 'utf8' }).trim().split('\n')
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.trim().match(/^\s*(\d+)\s+(\S+)/)
|
||||||
|
if (!m) continue
|
||||||
|
if (uid !== null && m[2] === `dsh-${user.id.replace(/-/g, '').slice(0, 20)}`) {
|
||||||
|
execFileSync('kill', [m[1]])
|
||||||
|
killed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// pgrep 无匹配等
|
||||||
|
}
|
||||||
|
return killed
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const restart = args.includes('--restart')
|
||||||
|
const usernames = args.filter((a) => !a.startsWith('--'))
|
||||||
|
const db = new Database(DB_PATH, { readonly: true })
|
||||||
|
let rows
|
||||||
|
if (usernames.length > 0) {
|
||||||
|
rows = usernames.map((u) => db.prepare('SELECT id, username, role, home_dir, uid FROM users WHERE username=?').get(u)).filter(Boolean)
|
||||||
|
} else {
|
||||||
|
rows = db.prepare("SELECT id, username, role, home_dir, uid FROM users WHERE role != 'admin'").all()
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log('no non-admin user found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const user of rows) {
|
||||||
|
const r = ensureUserPatch(user)
|
||||||
|
if (restart && r.action === 'wrote') {
|
||||||
|
const k = restartUserInstance(user)
|
||||||
|
r.detail += `;已 kill 实例进程 ${k} 个(watchdog 将拉起新实例)`
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const Database = require('/opt/dshs/node_modules/better-sqlite3');
|
||||||
|
const db = new Database('/var/lib/dshs/dshs.db');
|
||||||
|
const admin = db.prepare('SELECT id FROM users WHERE role=? LIMIT 1').get('admin');
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
db.prepare('INSERT INTO sessions (token_hash,user_id,created_at,expires_at,ip,user_agent) VALUES (?,?,?,?,?,?)').run(hash, admin.id, Date.now(), Date.now() + 600 * 1000, '127.0.0.1', 'poc-curl2');
|
||||||
|
console.log(token);
|
||||||
|
db.close();
|
||||||
Generated
+2299
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"name": "dshs",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "面向公网的多租户 DSH 托管平台:登录审核、每用户隔离的 DSH 环境(主 + 按需守护)、桌面与域名访问。",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./lib/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./lib/index.js",
|
||||||
|
"./runtime": "./lib/runtime.js"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"dshs": "./lib/cli.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"lib",
|
||||||
|
"web",
|
||||||
|
"cordis.patch.yml",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"prepare": "npm run build",
|
||||||
|
"dev": "node lib/cli.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"verify": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs",
|
||||||
|
"test": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js",
|
||||||
|
"smoke": "node scripts/smoke.mjs",
|
||||||
|
"smoke:admin": "node scripts/smoke-admin.mjs",
|
||||||
|
"smoke:auth": "node scripts/smoke-auth.mjs",
|
||||||
|
"smoke:fs": "node scripts/smoke-fs.mjs",
|
||||||
|
"smoke:file-service": "node scripts/smoke-file-service.mjs",
|
||||||
|
"smoke:dsh": "node scripts/smoke-dsh.mjs",
|
||||||
|
"smoke:domain": "node scripts/smoke-domain.mjs",
|
||||||
|
"smoke:isolation": "node scripts/smoke-isolation.mjs",
|
||||||
|
"smoke:subdomain": "node scripts/smoke-subdomain.mjs"
|
||||||
|
},
|
||||||
|
"dsh": {
|
||||||
|
"plugin": true,
|
||||||
|
"kind": "server",
|
||||||
|
"bundle": {
|
||||||
|
"patch": "./cordis.patch.yml"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disclosure": {
|
||||||
|
"cloud": true,
|
||||||
|
"network": [
|
||||||
|
"https://api.deepseek.com"
|
||||||
|
],
|
||||||
|
"apiKeys": [
|
||||||
|
{
|
||||||
|
"env": "DEEPSEEK_API_KEY",
|
||||||
|
"storage": "sqlite-encrypted-ref"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"permissions": {
|
||||||
|
"filesystem": "per-user-0700",
|
||||||
|
"network": "egress",
|
||||||
|
"env": "read-listed"
|
||||||
|
},
|
||||||
|
"jurisdiction": [
|
||||||
|
"PIPL(CN)"
|
||||||
|
],
|
||||||
|
"retention": "server"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/rate-limit": "^10.0.0",
|
||||||
|
"@fastify/static": "^10.1.3",
|
||||||
|
"@kubernetes/client-node": "^2.0.0",
|
||||||
|
"better-sqlite3": "^11.10.0",
|
||||||
|
"fastify": "^5.0.0",
|
||||||
|
"pg": "^8.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.19.0 || >=24"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://work.alotbuy.com/maogeigei/dsh_shenxian.git"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# §4.9 步骤 1:一次性特权 bootstrap,把 PVC 根 world-writable + sticky。
|
||||||
|
# 必须在启用 PSA restricted 之前跑(此 namespace 不打 restricted 标签)。
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: dsh-users-bootstrap
|
||||||
|
namespace: dsh-poc
|
||||||
|
spec:
|
||||||
|
ttlSecondsAfterFinished: 300
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: bootstrap
|
||||||
|
image: busybox:1.36
|
||||||
|
command: ["sh", "-c", "chmod 1777 /mnt && ls -ld /mnt"]
|
||||||
|
securityContext:
|
||||||
|
privileged: true
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /mnt
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: dsh-users
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# §4.9 步骤 2:非 root init Job,目标 uid 建用户目录并 chmod 0700。
|
||||||
|
# 验证非 root uid 能在 PVC 根写目录(依赖 bootstrap 的 chmod 1777)。
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: dsh-u1-init
|
||||||
|
namespace: dsh-poc
|
||||||
|
spec:
|
||||||
|
ttlSecondsAfterFinished: 300
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 100001
|
||||||
|
fsGroup: 100001
|
||||||
|
seccompProfile: { type: RuntimeDefault }
|
||||||
|
containers:
|
||||||
|
- name: init
|
||||||
|
image: busybox:1.36
|
||||||
|
command: ["sh", "-c", "mkdir -p /mnt/u1/ws /mnt/u1/home && chmod 0700 /mnt/u1 && ls -ldn /mnt/u1"]
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities: { drop: ["ALL"] }
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /mnt
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: dsh-users
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: dsh-users
|
||||||
|
namespace: dsh-poc
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteMany]
|
||||||
|
storageClassName: longhorn
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NS=dsh-poc
|
||||||
|
UID_=100001
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
pass() { echo "PASS: $*"; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
|
||||||
|
kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||||
|
|
||||||
|
echo "== 1. PVC bound =="
|
||||||
|
kubectl apply -f "$HERE/pvc.yaml" >/dev/null
|
||||||
|
kubectl wait -n "$NS" --for=jsonpath='{.status.phase}'=Bound pvc/dsh-users --timeout=120s
|
||||||
|
pass "PVC dsh-users bound (Longhorn RWX)"
|
||||||
|
|
||||||
|
echo "== 2. bootstrap (privileged chmod 1777) =="
|
||||||
|
kubectl apply -f "$HERE/bootstrap-job.yaml" >/dev/null
|
||||||
|
kubectl wait -n "$NS" --for=condition=complete job/dsh-users-bootstrap --timeout=120s
|
||||||
|
BOOT_LOG="$(kubectl logs -n "$NS" job/dsh-users-bootstrap)"
|
||||||
|
echo "$BOOT_LOG"
|
||||||
|
echo "$BOOT_LOG" | grep -q 'drwxrwxrwt' && pass "PVC root is world-writable+sticky" || fail "bootstrap chmod 1777 did not stick"
|
||||||
|
|
||||||
|
echo "== 3. init Job (non-root uid builds 0700 dir) =="
|
||||||
|
kubectl apply -f "$HERE/init-job.yaml" >/dev/null
|
||||||
|
kubectl wait -n "$NS" --for=condition=complete job/dsh-u1-init --timeout=120s
|
||||||
|
INIT_LOG="$(kubectl logs -n "$NS" job/dsh-u1-init)"
|
||||||
|
echo "$INIT_LOG"
|
||||||
|
# ls -ldn output: drwx------ 2 100001 100001 ... /mnt/u1
|
||||||
|
echo "$INIT_LOG" | grep -qE 'drwx------.*100001' && pass "u1 dir is 0700 owned by uid $UID_" || fail "init Job could not create/chown u1 (non-root uid cannot write PVC root)"
|
||||||
|
|
||||||
|
echo "== 4. test Pod (subPath + runAsUser read/write) =="
|
||||||
|
kubectl apply -f "$HERE/test-pod.yaml" >/dev/null
|
||||||
|
kubectl wait -n "$NS" --for=jsonpath='{.status.phase}'=Succeeded pod/dsh-u1-test --timeout=120s
|
||||||
|
TEST_LOG="$(kubectl logs -n "$NS" pod/dsh-u1-test)"
|
||||||
|
echo "$TEST_LOG"
|
||||||
|
echo "$TEST_LOG" | grep -q '^hello$' && pass "subPath write/read works" || fail "subPath mount read/write failed"
|
||||||
|
echo "$TEST_LOG" | grep -qE 'drwx------.*100001' && pass "mounted dir is 0700 uid $UID_" || fail "mounted dir owner/perm wrong"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "ALL ITEM-1 CHECKS PASSED"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# §4.3:subPath 叶子挂载 + runAsUser,验证目标 uid 能读写自己的目录。
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: dsh-u1-test
|
||||||
|
namespace: dsh-poc
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 100001
|
||||||
|
fsGroup: 100001
|
||||||
|
seccompProfile: { type: RuntimeDefault }
|
||||||
|
containers:
|
||||||
|
- name: test
|
||||||
|
image: busybox:1.36
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
echo hello > /var/lib/dshs/users/u1/ws/probe.txt
|
||||||
|
cat /var/lib/dshs/users/u1/ws/probe.txt
|
||||||
|
ls -ldn /var/lib/dshs/users/u1
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities: { drop: ["ALL"] }
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/dshs/users/u1
|
||||||
|
subPath: u1
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: dsh-users
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Minimal fake DSH for the socat-WS PoC: plain HTTP on 127.0.0.1:8080 plus a
|
||||||
|
// WebSocket echo (handshake + one text-frame round-trip) using only node built-ins.
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
|
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||||
|
const acceptKey = (key) => createHash('sha1').update(key + GUID).digest('base64')
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
||||||
|
res.end('fake-dsh\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
server.on('upgrade', (req, socket) => {
|
||||||
|
const key = req.headers['sec-websocket-key']
|
||||||
|
if (!key) {
|
||||||
|
socket.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket.write(
|
||||||
|
'HTTP/1.1 101 Switching Protocols\r\n' +
|
||||||
|
'Upgrade: websocket\r\n' +
|
||||||
|
'Connection: Upgrade\r\n' +
|
||||||
|
`Sec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`,
|
||||||
|
)
|
||||||
|
socket.on('data', (buf) => {
|
||||||
|
const opcode = buf[0] & 0x0f
|
||||||
|
if (opcode === 0x8) {
|
||||||
|
socket.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (opcode !== 0x1 && opcode !== 0x2) return
|
||||||
|
const masked = (buf[1] & 0x80) !== 0
|
||||||
|
let len = buf[1] & 0x7f
|
||||||
|
let offset = 2
|
||||||
|
if (len === 126) {
|
||||||
|
len = buf.readUInt16BE(2)
|
||||||
|
offset = 4
|
||||||
|
} else if (len === 127) {
|
||||||
|
return // not exercised in the PoC
|
||||||
|
}
|
||||||
|
let maskKey
|
||||||
|
if (masked) {
|
||||||
|
maskKey = buf.subarray(offset, offset + 4)
|
||||||
|
offset += 4
|
||||||
|
}
|
||||||
|
const payload = Buffer.from(buf.subarray(offset, offset + len))
|
||||||
|
if (masked && maskKey) {
|
||||||
|
for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i % 4]
|
||||||
|
}
|
||||||
|
// Echo back as an unmasked text frame.
|
||||||
|
const out = Buffer.alloc(2 + payload.length)
|
||||||
|
out[0] = 0x81
|
||||||
|
out[1] = payload.length
|
||||||
|
payload.copy(out, 2)
|
||||||
|
socket.write(out)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(8080, '127.0.0.1', () => console.log('fake-dsh listening on 127.0.0.1:8080'))
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# §4.3:dsh(loopback 8080) + socat sidecar(0.0.0.0:8081 → 127.0.0.1:8080)。
|
||||||
|
# dsh 用一次性 node 镜像跑 fake-dsh.mjs(真实 DSH 不参与本 PoC)。
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: dsh-ws-test
|
||||||
|
namespace: dsh-poc
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
seccompProfile: { type: RuntimeDefault }
|
||||||
|
containers:
|
||||||
|
- name: dsh
|
||||||
|
image: node:22-alpine
|
||||||
|
command: ["node", "/app/fake-dsh.mjs"]
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities: { drop: ["ALL"] }
|
||||||
|
volumeMounts:
|
||||||
|
- name: script
|
||||||
|
mountPath: /app
|
||||||
|
- name: sidecar
|
||||||
|
image: alpine/socat:1.8.0.0
|
||||||
|
args: ["TCP-LISTEN:8081,fork,reuseaddr", "TCP:127.0.0.1:8080"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 8081
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities: { drop: ["ALL"] }
|
||||||
|
seccompProfile: { type: RuntimeDefault }
|
||||||
|
volumes:
|
||||||
|
- name: script
|
||||||
|
configMap:
|
||||||
|
name: fake-dsh
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NS=dsh-poc
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
pass() { echo "PASS: $*"; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
|
||||||
|
kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||||
|
kubectl create configmap fake-dsh --from-file="$HERE/fake-dsh.mjs" -n "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||||
|
kubectl apply -f "$HERE/pod.yaml" >/dev/null
|
||||||
|
kubectl wait -n "$NS" --for=condition=Ready pod/dsh-ws-test --timeout=120s
|
||||||
|
|
||||||
|
kubectl port-forward -n "$NS" pod/dsh-ws-test 18081:8081 >/tmp/dsh-poc-pf.log 2>&1 &
|
||||||
|
PF=$!
|
||||||
|
trap 'kill $PF 2>/dev/null || true' EXIT
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo "== HTTP through socat (8081 -> 8080) =="
|
||||||
|
RESP="$(curl -s --max-time 5 http://127.0.0.1:18081/)"
|
||||||
|
echo "$RESP"
|
||||||
|
echo "$RESP" | grep -q 'fake-dsh' && pass "HTTP reaches fake-dsh through socat" || fail "HTTP did not reach fake-dsh"
|
||||||
|
|
||||||
|
echo "== WebSocket through socat =="
|
||||||
|
node "$HERE/ws-client.mjs" 127.0.0.1:18081
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "ALL ITEM-2 CHECKS PASSED"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Minimal WebSocket client: handshake + one masked text frame + echo check.
|
||||||
|
// Usage: node ws-client.mjs <host>:<port>
|
||||||
|
import { connect } from 'node:net'
|
||||||
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
|
const [host, portStr] = process.argv[2].split(':')
|
||||||
|
const port = Number(portStr)
|
||||||
|
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||||
|
const key = randomBytes(16).toString('base64')
|
||||||
|
|
||||||
|
const socket = connect({ host, port }, () => {
|
||||||
|
socket.write(
|
||||||
|
'GET / HTTP/1.1\r\n' +
|
||||||
|
`Host: ${host}:${port}\r\n` +
|
||||||
|
'Upgrade: websocket\r\n' +
|
||||||
|
'Connection: Upgrade\r\n' +
|
||||||
|
`Sec-WebSocket-Key: ${key}\r\n` +
|
||||||
|
'Sec-WebSocket-Version: 13\r\n\r\n',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
let headBuf = Buffer.alloc(0)
|
||||||
|
let echoBuf = Buffer.alloc(0)
|
||||||
|
let phase = 'handshake'
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
if (phase === 'handshake') {
|
||||||
|
headBuf = Buffer.concat([headBuf, chunk])
|
||||||
|
const idx = headBuf.indexOf('\r\n\r\n')
|
||||||
|
if (idx === -1) return
|
||||||
|
const head = headBuf.subarray(0, idx).toString()
|
||||||
|
if (!/^HTTP\/1\.1 101/.test(head)) return fail(`no 101 (${head.split('\r\n')[0]})`)
|
||||||
|
const m = head.match(/sec-websocket-accept:\s*(\S+)/i)
|
||||||
|
const expected = createHash('sha1').update(key + GUID).digest('base64')
|
||||||
|
if (!m || m[1] !== expected) return fail('bad Sec-WebSocket-Accept')
|
||||||
|
console.log('PASS: 101 handshake + Sec-WebSocket-Accept')
|
||||||
|
phase = 'echo'
|
||||||
|
const payload = Buffer.from('ping')
|
||||||
|
const frame = Buffer.alloc(2 + 4 + payload.length)
|
||||||
|
frame[0] = 0x81
|
||||||
|
frame[1] = 0x80 | payload.length
|
||||||
|
const mask = Buffer.from([0x12, 0x34, 0x56, 0x78])
|
||||||
|
mask.copy(frame, 2)
|
||||||
|
for (let i = 0; i < payload.length; i++) frame[2 + 4 + i] = payload[i] ^ mask[i % 4]
|
||||||
|
socket.write(frame)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
echoBuf = Buffer.concat([echoBuf, chunk])
|
||||||
|
// Echoed unmasked text frame: b0=0x81, b1=len, then payload.
|
||||||
|
if (echoBuf.length < 2) return
|
||||||
|
const len = echoBuf[1] & 0x7f
|
||||||
|
if (echoBuf.length < 2 + len) return
|
||||||
|
const text = echoBuf.subarray(2, 2 + len).toString()
|
||||||
|
if (text !== 'ping') return fail(`echo mismatch (${text})`)
|
||||||
|
console.log('PASS: echo round-trip through socat')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
function fail(msg) {
|
||||||
|
console.error('FAIL: ' + msg)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
socket.on('error', (e) => fail(e.message))
|
||||||
|
setTimeout(() => fail('timeout'), 10000)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 仅控制面(app=dsh-orchestrator)可入 DSH Pod 的 8081(§4.4 单向放行)。
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: allow-controlplane-to-dsh
|
||||||
|
namespace: dsh-poc
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: dsh
|
||||||
|
policyTypes: [Ingress]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: dsh-orchestrator
|
||||||
|
ports:
|
||||||
|
- port: 8081
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 命名空间内默认拒绝所有 ingress(§3.5 / §4.4)。依赖 CNI 强制(Cilium)。
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: default-deny-ingress
|
||||||
|
namespace: dsh-poc
|
||||||
|
spec:
|
||||||
|
podSelector: {}
|
||||||
|
policyTypes: [Ingress]
|
||||||
|
# 空 ingress = 拒绝一切入站
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 目标 DSH Pod(app=dsh)+ 控制面源(app=dsh-orchestrator)+ 攻击源(app=attacker)。
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: dsh-target
|
||||||
|
namespace: dsh-poc
|
||||||
|
labels: { app: dsh, user: u1 }
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
containers:
|
||||||
|
- name: dsh
|
||||||
|
image: node:22-alpine
|
||||||
|
command: ["node", "/app/target.mjs"]
|
||||||
|
ports: [{ containerPort: 8081 }]
|
||||||
|
volumeMounts:
|
||||||
|
- name: script
|
||||||
|
mountPath: /app
|
||||||
|
volumes:
|
||||||
|
- name: script
|
||||||
|
configMap:
|
||||||
|
name: dsh-target-script
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: controlplane
|
||||||
|
namespace: dsh-poc
|
||||||
|
labels: { app: dsh-orchestrator }
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: src
|
||||||
|
image: busybox:1.36
|
||||||
|
command: ["sleep", "3600"]
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: attacker
|
||||||
|
namespace: dsh-poc
|
||||||
|
labels: { app: attacker }
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: src
|
||||||
|
image: busybox:1.36
|
||||||
|
command: ["sleep", "3600"]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NS=dsh-poc
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
pass() { echo "PASS: $*"; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
|
||||||
|
kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||||
|
kubectl create configmap dsh-target-script --from-file="$HERE/target.mjs" -n "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||||
|
|
||||||
|
echo "== apply pods =="
|
||||||
|
kubectl apply -f "$HERE/pods.yaml" >/dev/null
|
||||||
|
kubectl wait -n "$NS" --for=condition=Ready pod/dsh-target --timeout=120s
|
||||||
|
kubectl wait -n "$NS" --for=condition=Ready pod/controlplane --timeout=120s
|
||||||
|
kubectl wait -n "$NS" --for=condition=Ready pod/attacker --timeout=120s
|
||||||
|
|
||||||
|
echo "== apply NetworkPolicy (default-deny + allow control-plane) =="
|
||||||
|
kubectl apply -f "$HERE/default-deny.yaml" >/dev/null
|
||||||
|
kubectl apply -f "$HERE/allow-controlplane.yaml" >/dev/null
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
IP="$(kubectl get -n "$NS" pod dsh-target -o jsonpath='{.status.podIP}')"
|
||||||
|
URL="http://$IP:8081/"
|
||||||
|
echo "target pod IP: $IP"
|
||||||
|
|
||||||
|
echo "== control-plane -> dsh (must succeed) =="
|
||||||
|
if kubectl exec -n "$NS" controlplane -- wget -q -T 5 -O- "$URL" 2>/dev/null | grep -q 'dsh-ok'; then
|
||||||
|
pass "control-plane reaches dsh:8081"
|
||||||
|
else
|
||||||
|
fail "control-plane could NOT reach dsh:8081 (policy or CNI not enforced as expected)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== attacker -> dsh (must be blocked) =="
|
||||||
|
if kubectl exec -n "$NS" attacker -- wget -q -T 5 -O- "$URL" >/dev/null 2>&1; then
|
||||||
|
fail "attacker reached dsh:8081 — NetworkPolicy NOT enforced (flannel? check CNI)"
|
||||||
|
else
|
||||||
|
pass "attacker is blocked from dsh:8081 (default-deny enforced)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "ALL ITEM-3 CHECKS PASSED"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Target "DSH" for the NetworkPolicy PoC: HTTP 200 on 0.0.0.0:8081.
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
|
||||||
|
createServer((req, res) => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
||||||
|
res.end('dsh-ok\n')
|
||||||
|
}).listen(8081, '0.0.0.0', () => console.log('target listening on 0.0.0.0:8081'))
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# §4.5:CloudNativePG 3 实例,Longhorn RWO(每实例独立卷,复制由 PG 自身做)。
|
||||||
|
# imageName 走 operator 默认镜像;若你的 operator 未设默认,取消注释并指定版本。
|
||||||
|
apiVersion: postgresql.cnpg.io/v1
|
||||||
|
kind: Cluster
|
||||||
|
metadata:
|
||||||
|
name: dsh-pg-poc
|
||||||
|
namespace: dsh-poc
|
||||||
|
spec:
|
||||||
|
instances: 3
|
||||||
|
# imageName: ghcr.io/cloudnative-pg/postgresql:16.2
|
||||||
|
storage:
|
||||||
|
size: 1Gi
|
||||||
|
storageClass: longhorn
|
||||||
|
bootstrap:
|
||||||
|
initdb:
|
||||||
|
database: dsh
|
||||||
|
owner: dsh
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NS=dsh-poc
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||||
|
kubectl apply -f "$HERE/cluster.yaml" >/dev/null
|
||||||
|
|
||||||
|
echo "== wait for CloudNativePG cluster ready (3 instances) =="
|
||||||
|
READY=0
|
||||||
|
for _ in $(seq 1 72); do
|
||||||
|
READY="$(kubectl get -n "$NS" cluster dsh-pg-poc -o jsonpath='{.status.readyInstances}' 2>/dev/null || echo 0)"
|
||||||
|
[ "$READY" = "3" ] && break
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
if [ "$READY" != "3" ]; then
|
||||||
|
echo "FAIL: cluster not ready in time (readyInstances=$READY)"
|
||||||
|
kubectl get -n "$NS" cluster dsh-pg-poc -o yaml
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PASS: cluster ready (3 instances)"
|
||||||
|
|
||||||
|
PGHOST=dsh-pg-poc-rw
|
||||||
|
PGUSER="$(kubectl get -n "$NS" secret dsh-pg-poc-app -o jsonpath='{.data.username}' | base64 -d)"
|
||||||
|
PGPASSWORD="$(kubectl get -n "$NS" secret dsh-pg-poc-app -o jsonpath='{.data.password}' | base64 -d)"
|
||||||
|
PGDATABASE="$(kubectl get -n "$NS" secret dsh-pg-poc-app -o jsonpath='{.data.dbname}' | base64 -d)"
|
||||||
|
|
||||||
|
echo "== pgbench init (scale 5) =="
|
||||||
|
kubectl run -n "$NS" pgbench-init --rm -i --restart=Never --image=postgres:16 \
|
||||||
|
--env "PGHOST=$PGHOST" --env "PGUSER=$PGUSER" --env "PGPASSWORD=$PGPASSWORD" --env "PGDATABASE=$PGDATABASE" \
|
||||||
|
-- pgbench -i -s 5 2>&1 | tail -3
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== pgbench select-only (30s, c4/j2) =="
|
||||||
|
kubectl run -n "$NS" pgbench-run --rm -i --restart=Never --image=postgres:16 \
|
||||||
|
--env "PGHOST=$PGHOST" --env "PGUSER=$PGUSER" --env "PGPASSWORD=$PGPASSWORD" --env "PGDATABASE=$PGDATABASE" \
|
||||||
|
-- pgbench -S -T 30 -c 4 -j 2 2>&1 | grep -E 'latency average|including connections establishing' || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "记录上面的 tps / latency average 作为 Longhorn 上的基线(对照节点 NVMe 判断 §4.5 是否可接受)"
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
# Phase 3 核心路径 PoC(不写业务代码)
|
||||||
|
|
||||||
|
> 🧭 [← 返回 README](../README.md) · 结论落地为:[K8s 部署教程](../docs/k8s-deployment.md)
|
||||||
|
|
||||||
|
> 对应内部设计稿 §7 的四项风险验证。**不引入任何业务代码**——全部用一次性
|
||||||
|
> 镜像(busybox / node / socat / CloudNativePG)验证基础设施假设。四项全部通过,
|
||||||
|
> 才允许进入 Phase 0 镜像化 / Phase 2/3 落地,避免 Phase 3 返工。
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
- 一个 k3s 集群(`k3s --cluster-init` 3 server + N worker 皆可,单机 k3s 也能跑 PoC)。
|
||||||
|
- 已装且健康:
|
||||||
|
- **Longhorn**(item 1、4 依赖;item 1 是硬前提,必须 `longhorn.io` StorageClass 可用)
|
||||||
|
- **Cilium**(item 3 依赖;NetworkPolicy 必须被强制)
|
||||||
|
- **CloudNativePG** operator(item 4 依赖)
|
||||||
|
- **cert-manager / MetalLB / kube-vip** 与本 PoC 无关,暂不需要
|
||||||
|
- `kubectl` 指向该集群(`kubectl get nodes` 有输出)。
|
||||||
|
- 本地有 `bash` + `curl`(item 2/3 用);item 2 的 WS 客户端用 `node`(本机或容器内均可)。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl get nodes
|
||||||
|
kubectl get storageclass longhorn # item 1/4 前提
|
||||||
|
kubectl get pods -n kube-system -o wide | grep -E 'cilium|longhorn|csi'
|
||||||
|
kubectl get crd clusters.postgresql.cnpg.io # item 4 前提
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 1 — Longhorn RWX + subPath + runAsUser/fsGroup(§4.9 / §6.0 的 PoC)
|
||||||
|
|
||||||
|
**验证的未知点**:`fsGroup` 是否会 chown subPath 叶子?非 root 目标 uid 能否在 PVC
|
||||||
|
根建目录、`0700` 属主是否正确、子目录挂载后能否读写。
|
||||||
|
|
||||||
|
> ⚠️ 这是 `docs/k8s.md` §4.9 标明的「PoC 第一项必须验证」。若失败,按 §4.9 的
|
||||||
|
> 回退:bootstrap 阶段用特权 Job 完成 `chmod 1777`(在启 PSA restricted **之前**),
|
||||||
|
> 时序固定为「初始化 PVC → 启 PSA → 允许用户 Pod」。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd 01-longhorn-subpath
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**通过标准**(脚本自动断言):
|
||||||
|
|
||||||
|
1. bootstrap Job(特权)`chmod 1777 /mnt` 成功。
|
||||||
|
2. init Job(非 root,`runAsUser/fsGroup = 100001`)能 `mkdir -p /mnt/u1/{ws,home}` 且
|
||||||
|
`chmod 0700 /mnt/u1` 成功——即**非 root uid 能写 PVC 根**(`chmod 1777` 生效)。
|
||||||
|
3. 测试 Pod(`runAsUser=100001`,`subPath: u1`)能写/读文件,且 `stat` 显示目录属主
|
||||||
|
uid=100001、权限 0700。
|
||||||
|
4. (可选)另一个 uid(100002)无法读该目录——`subPath` 叶子 + DAC 的越权边界。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 2 — socat 桥 WebSocket 透明转发(§3.2 / §4.3)
|
||||||
|
|
||||||
|
**验证的未知点**:DSH 只监听 loopback,`socat TCP-LISTEN:8081 → 127.0.0.1:8080` 的纯
|
||||||
|
TCP 转发对 WebSocket Upgrade 是否透明。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd 02-socat-ws
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**通过标准**:
|
||||||
|
|
||||||
|
1. 通过 sidecar 的 8081 能拿到 fake-dsh 的 HTTP 200(TCP 基础通)。
|
||||||
|
2. WebSocket Upgrade 握手经 8081 返回 `101 Switching Protocols`,且
|
||||||
|
`Sec-WebSocket-Accept` 校验正确(证明 Upgrade 请求与响应都原样穿透 socat)。
|
||||||
|
3. 一条文本帧 echo 往返成功(双向透明)。
|
||||||
|
|
||||||
|
> 若 2/3 不透明,回退:换 `nginx`/`netcat` sidecar(同端口转发),见 §9。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 3 — NetworkPolicy 默认拒绝 + 控制面→DSH 单向(§3.5 / §4.4)
|
||||||
|
|
||||||
|
**验证的未知点**:Cilium 是否强制 NetworkPolicy;default-deny 下「仅控制面可达 DSH
|
||||||
|
8081」的单向放行是否正确,跨来源访问被拒。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd 03-networkpolicy
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**通过标准**:
|
||||||
|
|
||||||
|
1. 同 namespace 内打了 `app=dsh-orchestrator` 的「控制面」Pod 能连通 DSH 8081。
|
||||||
|
2. 同 namespace 内**其它** Pod(`app=attacker`)访问 DSH 8081 **被拒**(超时/拒绝)。
|
||||||
|
3. 这也覆盖 §3.5 的「每次 NetworkPolicy 变更后跑自动化验证」——本脚本即该验证的雏形。
|
||||||
|
|
||||||
|
> 若 flannel(k3s 默认)下 2 仍通,说明 CNI 未强制策略,必须切 Cilium(§11.2)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 4 — CloudNativePG on Longhorn 的 pgbench 基线(§4.5)
|
||||||
|
|
||||||
|
**验证的未知点**:Postgres 对延迟/IOPS 敏感,Longhorn RWO 复制是否拖慢同步复制,
|
||||||
|
延迟是否可接受。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd 04-cnpg-pgbench
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**通过标准**(人工判读,无固定阈值,取决于硬件):
|
||||||
|
|
||||||
|
1. CloudNativePG 3 实例主备建立、`Ready`。
|
||||||
|
2. `pgbench -S`(select-only)tps 与延迟与本机 NVMe 基线对比,落在可接受区间。
|
||||||
|
§4.5 结论:不够则回退「节点本地 NVMe + PG 主备」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 通过后
|
||||||
|
|
||||||
|
四项全过 → 记录结果(tps/latency 数字、Longhorn 权限行为结论)到本 README 尾部,
|
||||||
|
再进入 Phase 0(镜像化)。任一项失败 → 按对应 §9 回退方案调整后重跑。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 打包排除项(2026-09-12 加)
|
||||||
|
#
|
||||||
|
# 背景:0.2.5 打包时,目录里上一版留下的 business-plugins-0.2.4.tgz 被一并打进了产物
|
||||||
|
# (package/business-plugins-0.2.4.tgz),包体从 8.9 KB 虚涨到 19.3 KB,且会把旧版本
|
||||||
|
# 永久带进用户的 node_modules。用忽略规则根治,而不是每次记得手工先删。
|
||||||
|
*.tgz
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# @dsh-local/business-plugins — bundle patch (host row only)
|
||||||
|
# 功能插件启停:功能全在 client 面(settings.section),host 面仅加载标记。
|
||||||
|
# 无 inject(defensive)——index.js 的 apply 不使用任何 ctx 服务。
|
||||||
|
|
||||||
|
- insert:
|
||||||
|
- id: business-plugins
|
||||||
|
name: '@dsh-local/business-plugins'
|
||||||
@@ -0,0 +1,895 @@
|
|||||||
|
// @dsh-local/business-plugins — client half (dsh 0.1.2-rc.1, v0.2.4)
|
||||||
|
//
|
||||||
|
// dsh client bundles 以普通脚本形式执行,向 window.__ModuleLoader__ 注册 factory;
|
||||||
|
// 首次 import 时惰性物化 factory(require) -> exports。require 解析平台 seed 词
|
||||||
|
// (react/jsx-runtime)与图行(其他 dsh.client 包)。**严格镜像官方 client bundle
|
||||||
|
// 的 CJS-style factory 形态,只导出 apply + inject,绝不 exports.default**(R3:
|
||||||
|
// exports.default 会让 loader 的 ESM/CJS interop 把 bare apply FUNCTION 当作插件体,
|
||||||
|
// 无 inject 声明点 → ctx.slots 抛 cannot get property "slots" without inject)。
|
||||||
|
//
|
||||||
|
// v0.2.0(档案 37 · 批次 3):
|
||||||
|
// · 配色全部改用官方 design token `--dsw-*`(原来硬编码深色)→ **自动跟随 dsh 主题**
|
||||||
|
// (亮/暗/第三方主题都适配),不再需要自己实现主题切换。
|
||||||
|
// · 文案走官方 locale:ctx.locale.register(NS,{zh,en}) + ctx.locale.bind(NS) → 中英双语。
|
||||||
|
// · 交互:搜索过滤 + 每行状态徽章(已启用/未启用)+ **被自动禁用插件的行内提示与重试**
|
||||||
|
// + 空态/加载态。
|
||||||
|
// · 应用成功且实例重启后,**自动探活并刷新页面**(重启会换端口,刷新即可接上新实例)。
|
||||||
|
//
|
||||||
|
// v0.2.4(档案 67 · 对齐 06-工作台UI规范.md):
|
||||||
|
// · **信息层次反转**:主视觉改为插件的**用途说明**(候选池入库时已优先取官方目录的中文),
|
||||||
|
// 插件名 / 版本退为副行小字 —— 与门户 plugins 页同一决策(「一眼知道这插件干什么」)。
|
||||||
|
// · 字号对齐规范 §2.2 阶梯(section 语境取 14 / 13 / 12,不照搬页面级 16px 基准);
|
||||||
|
// 徽章改 `2px 8px` r10、按钮改 `.btn-sm` 量级、行距与内边距按规范 §2.4 序列。
|
||||||
|
// · 空态/加载态按规范 §4.9 改为「标题 + 说明」层次,不再是一行灰字。
|
||||||
|
// · 新增注入式 CSS 提供行 hover 反馈与按钮 hover(规范 §4.3 / §5);disposer 随 fiber 移除。
|
||||||
|
//
|
||||||
|
// v0.2.5(档案 67 补 · 按 `impeccable` 的 craft-floor 复核后修正;检测器未随技能包提供,人工过检):
|
||||||
|
// · **去掉 rejected 提示的彩色 `border-left`** —— craft-floor 明令:卡片/列表项/提示上
|
||||||
|
// 不得出现 >1px 的彩色 border-left/right。改为整块浅底 + 圆角,危险语义由标题文字色承担。
|
||||||
|
// · **对比度**:所有必读文字从 `--dsw-alias-label-tertiary` 提到 `secondary`
|
||||||
|
// (tertiary 在亮色下约 3.5:1,低于 craft-floor 的 4.5:1 底线)。
|
||||||
|
// · **层次**:主视觉加 `fontWeight: 500`,与 12px 副行拉开 weight 台阶(原先只差 2px 字号,
|
||||||
|
// 属于 craft-floor 点名的「scale and weight steps 不明显」)。
|
||||||
|
// · **动效**:transition 由 `.15s ease` 改为 `.18s cubic-bezier(.16,1,.3,1)`(exponential ease-out)。
|
||||||
|
//
|
||||||
|
// v0.2.7(档案 75 维度 B 落地 · 2026-09-13 用户要求):
|
||||||
|
// · **卡片加内存预估**:每张卡片多一行「预估内存 ≈ N MiB」小徽章,卡片加高(minHeight 112)
|
||||||
|
// 让「用途说明 / 包名 / 预估内存 / 状态徽章」四层排得开。
|
||||||
|
// · **列表上方新增「内存预估」状态条**:实心段 = 当前已启用插件的预估占用,
|
||||||
|
// 半透明段 = 本次勾选带来的增量;超过单实例上限(384 MiB)时整条转红并提示。
|
||||||
|
// · **口径**:`MEM_BASE_MIB`(-dsh 本体+官方插件基线) + `MEM_TABLE`(逐插件实测加载成本);
|
||||||
|
// **是预估值,不是实时读数**(客户端拿不到 cgroup 实测值,需平台加只读路由才能做实时 —— 见档案 75)。
|
||||||
|
//
|
||||||
|
// 功能:在 dsh 设置面板注册「功能管理」section —— 列出候选池 + 每个插件启用状态,
|
||||||
|
// 批量勾选后点「应用更改」,确认弹窗(提示重启会中断会话)→ POST 门户
|
||||||
|
// /api/plugins/mine/apply(跨子域 fetch,credentials include;门户已加 CORS)。
|
||||||
|
// 门户端启用=复制 bundle 到该用户 profile 并加入 bundles,禁用=从 bundles 移除,
|
||||||
|
// 然后重启该用户实例生效;**启用会让实例起不来的插件会被门户单独摘掉并标记**。
|
||||||
|
|
||||||
|
window.__ModuleLoader__.load({
|
||||||
|
id: "@dsh-local/business-plugins",
|
||||||
|
factory: (require) => {
|
||||||
|
var module = { exports: {} };
|
||||||
|
var exports = module.exports;
|
||||||
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||||||
|
var jsxRuntime = require("react/jsx-runtime");
|
||||||
|
var React = require("react");
|
||||||
|
|
||||||
|
// 门户主域:去掉实例子域 label(admin.alotbuy.com -> alotbuy.com)。
|
||||||
|
function portalHost() {
|
||||||
|
try {
|
||||||
|
var labels = window.location.hostname.split(".");
|
||||||
|
if (labels.length > 2) {
|
||||||
|
return window.location.protocol + "//" + labels.slice(1).join(".");
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 官方 design token(dsh 主题变量)。刻意**不硬编码颜色**:dsw-* 由主题提供,
|
||||||
|
// 亮/暗/第三方主题自动适配;fallback 仅在该 token 缺失时兜底。
|
||||||
|
// 注:06-工作台UI规范 §2.1 给的是亮色基线色值,本 section 嵌在 dsh 面板内,
|
||||||
|
// 跟随宿主主题优先,故取 token + 规范色值作 fallback。
|
||||||
|
var T = {
|
||||||
|
text: "var(--dsw-alias-label-primary, #24292f)",
|
||||||
|
sub: "var(--dsw-alias-label-secondary, #4e5969)",
|
||||||
|
dim: "var(--dsw-alias-label-tertiary, #6b7280)",
|
||||||
|
border: "var(--dsw-alias-border-l2, #e3e6ea)",
|
||||||
|
field: "var(--dsw-alias-bg-layer-3, #f5f6f8)",
|
||||||
|
primary: "var(--dsw-alias-brand-primary, #2f6fed)",
|
||||||
|
danger: "var(--dsw-alias-state-error-primary, #d93026)",
|
||||||
|
warn: "var(--dsw-alias-state-warning-primary, #bf8700)",
|
||||||
|
success: "var(--dsw-alias-state-success-primary, #1a7f37)"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 内存预估模型(档案 75「维度 B 资源成本」的前端落地)────────────────────
|
||||||
|
// 口径:**预估值** = 空载基线 + Σ(已启用插件的加载成本)。客户端拿不到 cgroup 实测值,
|
||||||
|
// 因此这里是估算而非实时读数;数值来源 = 2026-09-13 隔离 cgroup 实测
|
||||||
|
//(`node --expose-gc` 逐项 import 的 rss 增量,见 `.workbuddy/memory/2026-09-13.md`)。
|
||||||
|
/** 单实例 cgroup 上限(MiB)—— 档案 58 定档;改它必须同时改编排器的 `MemoryMax`。 */
|
||||||
|
var MEM_LIMIT_MIB = 384;
|
||||||
|
/** 空载基线(MiB):dsh 本体 + 148 个官方插件 + 平台自研 ×3(smaps 实测 ≈ 285)。 */
|
||||||
|
var MEM_BASE_MIB = 285;
|
||||||
|
/** 逐插件加载成本(MiB,实测 rss 增量)。不在此表的按 MEM_FALLBACK_MIB 计。 */
|
||||||
|
var MEM_TABLE = {
|
||||||
|
"dsh-univer-office": 64,
|
||||||
|
"dsh-plugin-mcn-suite": 39
|
||||||
|
};
|
||||||
|
/** 未实测插件的兜底值 —— 平台自研轻量插件实测合计仅 1.7 MiB。 */
|
||||||
|
var MEM_FALLBACK_MIB = 2;
|
||||||
|
/** 越过该比例即提前警示,给页缓存与用户自己的任务留余量。 */
|
||||||
|
var MEM_TIGHT_RATIO = 0.85;
|
||||||
|
|
||||||
|
function memMiB(p) {
|
||||||
|
if (p && p.name && Object.prototype.hasOwnProperty.call(MEM_TABLE, p.name)) {
|
||||||
|
return MEM_TABLE[p.name];
|
||||||
|
}
|
||||||
|
return MEM_FALLBACK_MIB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把一组插件里 `key` 为真的那些的预估内存求和。 */
|
||||||
|
function sumMiB(list, key) {
|
||||||
|
var total = 0;
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
if (list[i][key]) total += memMiB(list[i]);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本插件的 locale 命名空间(官方 i18n)。 */
|
||||||
|
var NS = "business-plugins";
|
||||||
|
/** 简体中文字典(key 集的事实来源)。 */
|
||||||
|
var zh = {
|
||||||
|
"section.label": "功能管理",
|
||||||
|
"loading": "加载中…",
|
||||||
|
"loadingDesc": "正在从门户读取候选池…",
|
||||||
|
"emptyTitle": "暂无功能插件",
|
||||||
|
"empty": "需管理员先在门户「插件管理」里投放,之后可在此启用",
|
||||||
|
"noMatch": "没有匹配的插件",
|
||||||
|
"search": "搜索插件名或用途",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"clear": "清空",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"disabled": "未启用",
|
||||||
|
"selected": "已选",
|
||||||
|
"apply": "应用更改",
|
||||||
|
"applying": "应用中…",
|
||||||
|
"confirm.title": "确认应用更改?",
|
||||||
|
"confirm": "将重启当前 dsh 实例以生效。重启会中断当前会话(正在进行的对话会断开)。",
|
||||||
|
"confirm.cancel": "取消",
|
||||||
|
"confirm.ok": "确认应用",
|
||||||
|
"confirm.overTitle": "⚠ 本次勾选将超出单实例内存上限",
|
||||||
|
"nochange": "没有需要更改的插件",
|
||||||
|
"queued": "排队中…",
|
||||||
|
"installing": "正在安装",
|
||||||
|
"configuring": "正在配置",
|
||||||
|
"restarting": "正在重启实例",
|
||||||
|
"probing": "正在检查实例",
|
||||||
|
"isolating": "正在定位问题插件",
|
||||||
|
"applied": "✓ 已应用",
|
||||||
|
"reloading": "实例重启中,页面将自动刷新…",
|
||||||
|
"rejected.title": "以下插件与实例不兼容,已自动禁用",
|
||||||
|
"retry": "重试",
|
||||||
|
"requestFailed": "请求失败",
|
||||||
|
"loadFailed": "加载失败",
|
||||||
|
"mem.title": "内存预估",
|
||||||
|
"mem.est": "预估(实测基准,非实时读数)",
|
||||||
|
"mem.now": "当前",
|
||||||
|
"mem.planned": "勾选后",
|
||||||
|
"mem.limit": "上限",
|
||||||
|
"mem.left": "剩余",
|
||||||
|
"mem.over": "将超出上限",
|
||||||
|
"mem.tight": "接近上限",
|
||||||
|
"mem.safe": "余量充足",
|
||||||
|
"mem.overWarn": "勾选后将超出单实例内存上限,实例可能起不来。建议先停用部分插件再应用。",
|
||||||
|
"mem.perCard": "预估内存"
|
||||||
|
};
|
||||||
|
/** English dictionary, key-set complete against zh. */
|
||||||
|
var en = {
|
||||||
|
"section.label": "Feature management",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"loadingDesc": "Reading the candidate pool from the portal…",
|
||||||
|
"emptyTitle": "No feature plugins yet",
|
||||||
|
"empty": "An admin must publish them in the portal's plugin management first",
|
||||||
|
"noMatch": "No matching plugins",
|
||||||
|
"search": "Search by name or purpose",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"clear": "Clear",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"selected": "selected",
|
||||||
|
"apply": "Apply changes",
|
||||||
|
"applying": "Applying…",
|
||||||
|
"confirm.title": "Apply the changes?",
|
||||||
|
"confirm": "The dsh instance will restart, which interrupts the current session (any running conversation is disconnected).",
|
||||||
|
"confirm.cancel": "Cancel",
|
||||||
|
"confirm.ok": "Apply",
|
||||||
|
"confirm.overTitle": "⚠ This selection exceeds the per-instance memory limit",
|
||||||
|
"nochange": "No changes to apply",
|
||||||
|
"queued": "Queued…",
|
||||||
|
"installing": "Installing",
|
||||||
|
"configuring": "Configuring",
|
||||||
|
"restarting": "Restarting instance",
|
||||||
|
"probing": "Checking instance health",
|
||||||
|
"isolating": "Locating the failing plugin",
|
||||||
|
"applied": "✓ Applied",
|
||||||
|
"reloading": "Instance is restarting — the page will refresh automatically…",
|
||||||
|
"rejected.title": "These plugins are incompatible with the instance and were disabled automatically",
|
||||||
|
"retry": "Retry",
|
||||||
|
"requestFailed": "Request failed",
|
||||||
|
"loadFailed": "Failed to load",
|
||||||
|
"mem.title": "Memory estimate",
|
||||||
|
"mem.est": "estimate from measured baselines, not a live reading",
|
||||||
|
"mem.now": "now",
|
||||||
|
"mem.planned": "after selection",
|
||||||
|
"mem.limit": "limit",
|
||||||
|
"mem.left": "left",
|
||||||
|
"mem.over": "over the limit",
|
||||||
|
"mem.tight": "close to the limit",
|
||||||
|
"mem.safe": "headroom is fine",
|
||||||
|
"mem.overWarn": "The selection exceeds the per-instance memory limit and the instance may fail to start. Disable some plugins first.",
|
||||||
|
"mem.perCard": "est. memory"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required services (cordis fiber inject). `locale` is required for the
|
||||||
|
* dictionaries; the slot registry for the settings section.
|
||||||
|
*/
|
||||||
|
var inject = ["slots", "locale"];
|
||||||
|
|
||||||
|
/** 应用成功后探活并刷新(重启会换端口;探通了再 reload,避免白屏)。 */
|
||||||
|
function waitAndReload(attempts) {
|
||||||
|
if (attempts <= 0) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(window.location.origin + "/", { cache: "no-store" })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.ok) window.location.reload();
|
||||||
|
else setTimeout(function () { waitAndReload(attempts - 1); }, 1500);
|
||||||
|
})
|
||||||
|
.catch(function () { setTimeout(function () { waitAndReload(attempts - 1); }, 1500); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(ctx) {
|
||||||
|
ctx.effect(function () {
|
||||||
|
return ctx.locale.register(NS, { zh: zh, en: en });
|
||||||
|
}, "business-plugins: dictionaries");
|
||||||
|
// 内联样式表达不了 `:hover`,而 06-工作台UI规范要求「列表行 hover 反馈」(§4.3)
|
||||||
|
// 与「hover 才提示可点」(§5)。这里注入一小段 CSS,disposer 随 fiber 移除 ——
|
||||||
|
// 不引外部样式表,也不污染宿主全局(选择器统一带 bp- 前缀)。
|
||||||
|
ctx.effect(function () {
|
||||||
|
var el = document.createElement("style");
|
||||||
|
el.setAttribute("data-bp-style", "1");
|
||||||
|
el.textContent = [
|
||||||
|
".bp-row{transition:background .18s cubic-bezier(.16,1,.3,1),border-color .18s cubic-bezier(.16,1,.3,1),box-shadow .18s cubic-bezier(.16,1,.3,1)}",
|
||||||
|
".bp-row:hover{border-color:var(--dsw-alias-brand-primary, #2f6fed);box-shadow:0 4px 12px rgba(47,111,237,.15)}",
|
||||||
|
".bp-btn{transition:border-color .18s cubic-bezier(.16,1,.3,1),color .18s cubic-bezier(.16,1,.3,1)}",
|
||||||
|
".bp-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary, #2f6fed);color:var(--dsw-alias-brand-primary, #2f6fed)}",
|
||||||
|
".bp-row input[type=checkbox]{cursor:pointer;margin:0}"
|
||||||
|
].join("");
|
||||||
|
document.head.appendChild(el);
|
||||||
|
return function () { el.remove(); };
|
||||||
|
}, "business-plugins: styles");
|
||||||
|
var t = ctx.locale.bind(NS);
|
||||||
|
|
||||||
|
function BusinessPluginsSection() {
|
||||||
|
var useState = React.useState;
|
||||||
|
var useEffect = React.useEffect;
|
||||||
|
var pluginsState = useState([]);
|
||||||
|
var plugins = pluginsState[0];
|
||||||
|
var setPlugins = pluginsState[1];
|
||||||
|
var loadingState = useState(true);
|
||||||
|
var loading = loadingState[0];
|
||||||
|
var setLoading = loadingState[1];
|
||||||
|
var busyState = useState(false);
|
||||||
|
var busy = busyState[0];
|
||||||
|
var setBusy = busyState[1];
|
||||||
|
var msgState = useState("");
|
||||||
|
var msg = msgState[0];
|
||||||
|
var setMsg = msgState[1];
|
||||||
|
var queryState = useState("");
|
||||||
|
var query = queryState[0];
|
||||||
|
var setQuery = queryState[1];
|
||||||
|
var rejectedState = useState([]);
|
||||||
|
var rejected = rejectedState[0];
|
||||||
|
var setRejected = rejectedState[1];
|
||||||
|
// 「应用更改」的页内确认弹窗开关(替代 window.confirm)。
|
||||||
|
var confirmState = useState(false);
|
||||||
|
var confirmOpen = confirmState[0];
|
||||||
|
var setConfirmOpen = confirmState[1];
|
||||||
|
// 「服务端当前已启用」的 id 快照 —— 用于把「当前占用」与「勾选后会有多少」分开算。
|
||||||
|
// 没有它就没法在用户勾选时显示「增量」(p.enabled 会被 toggle 就地改掉)。
|
||||||
|
var savedState = useState([]);
|
||||||
|
var savedIds = savedState[0];
|
||||||
|
var setSavedIds = savedState[1];
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
fetch(portalHost() + "/api/plugins/mine", { credentials: "include" })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
var list = d.plugins || [];
|
||||||
|
setPlugins(list);
|
||||||
|
// 记下「服务端认为已启用」的那批 → 内存条据此区分「当前」与「勾选后」。
|
||||||
|
setSavedIds(list.filter(function (p) { return p.enabled; }).map(function (p) { return p.id; }));
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(function () { setLoading(false); setMsg(t("loadFailed")); });
|
||||||
|
}
|
||||||
|
useEffect(function () { load(); }, []);
|
||||||
|
|
||||||
|
function toggle(id) {
|
||||||
|
setPlugins(plugins.map(function (p) {
|
||||||
|
if (p.id === id) return Object.assign({}, p, { enabled: !p.enabled });
|
||||||
|
return p;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开**页内确认弹窗**(不再用 `window.confirm`)。
|
||||||
|
*
|
||||||
|
* 2026-09-13 用户要求:所有弹窗改成页面弹窗,视觉参考 MCN 工作台
|
||||||
|
* (`dsh-plugin-mcn` 的 `fixed inset:0` 遮罩 + 居中面板 + 点遮罩关闭 +
|
||||||
|
* `stopPropagation` + ✕,见其 `modalTitle/modalText/modalBtns` 三段结构)。
|
||||||
|
*/
|
||||||
|
function askApply() {
|
||||||
|
setConfirmOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户已在弹窗里点「确认应用」:真正的提交(原 `applyChanges` 主体)。 */
|
||||||
|
function doApply() {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
setBusy(true);
|
||||||
|
setMsg(t("queued"));
|
||||||
|
setRejected([]);
|
||||||
|
fetch(portalHost() + "/api/plugins/mine/apply", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ plugins: plugins.map(function (p) { return { id: p.id, enabled: p.enabled }; }) })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d.error) { setBusy(false); setMsg(t("requestFailed") + ":" + d.error); return; }
|
||||||
|
if (d.noop) { setBusy(false); setMsg(t("nochange")); return; }
|
||||||
|
pollTask(d.taskId);
|
||||||
|
})
|
||||||
|
.catch(function () { setBusy(false); setMsg(t("requestFailed")); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步任务:轮询「阶段 + 已用秒数」(B 方案,不滚日志)。
|
||||||
|
function pollTask(taskId) {
|
||||||
|
var start = Date.now();
|
||||||
|
var timer = setInterval(function () {
|
||||||
|
fetch(portalHost() + "/api/plugins/mine/task/" + taskId, { credentials: "include" })
|
||||||
|
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
||||||
|
.then(function (task) {
|
||||||
|
var secs = Math.floor((Date.now() - start) / 1000);
|
||||||
|
var stage = task.stage || t("configuring");
|
||||||
|
if (task.status === "pending" || task.status === "running") {
|
||||||
|
setMsg("⏳ " + stage + "(" + secs + "s)");
|
||||||
|
} else if (task.status === "success") {
|
||||||
|
clearInterval(timer);
|
||||||
|
var bad = task.rejected || [];
|
||||||
|
setRejected(bad);
|
||||||
|
setBusy(false);
|
||||||
|
if (bad.length > 0) {
|
||||||
|
setMsg(t("applied") + " — " + t("rejected.title"));
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
setMsg(t("applied") + " · " + t("reloading"));
|
||||||
|
waitAndReload(3);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearInterval(timer);
|
||||||
|
setBusy(false);
|
||||||
|
setMsg("✗ " + (task.error || t("requestFailed")) + "(已回滚到改动前的状态)");
|
||||||
|
// 2026-09-12 修复:失败后必须重新拉取真实状态。否则本地 plugins 仍停留在
|
||||||
|
// 用户勾选后的样子 → 徽章显示「已启用」,与「安装失败」自相矛盾(用户实测报障)。
|
||||||
|
// 后端失败分支已 restoreProfile() 回滚,load() 即可取回正确状态。
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { /* 网络抖动忽略,继续下一轮 */ });
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 06-工作台UI规范 §2.2 字号阶梯:section 语境取「正文 14 / 次要 13 / 辅助 12」三档
|
||||||
|
//(不照搬页面级 16px 基准 —— 本 section 嵌在 dsh 设置面板内,非独立页)。
|
||||||
|
var wrap = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "10px",
|
||||||
|
padding: "4px 2px",
|
||||||
|
color: T.text,
|
||||||
|
fontSize: "14px"
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 规范 §4.9 空态/加载态:应有「标题 + 说明」层次,而非一行灰字。 */
|
||||||
|
function StateBlock(props) {
|
||||||
|
return jsxRuntime.jsxs("div", {
|
||||||
|
style: {
|
||||||
|
padding: "28px 20px", display: "flex", flexDirection: "column",
|
||||||
|
alignItems: "center", gap: "6px", textAlign: "center"
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
jsxRuntime.jsx("div", {
|
||||||
|
key: "t",
|
||||||
|
style: { fontSize: "15px", fontWeight: 500, color: T.text },
|
||||||
|
children: props.title
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("div", {
|
||||||
|
key: "d",
|
||||||
|
style: { fontSize: "13px", color: T.sub, lineHeight: 1.6 },
|
||||||
|
children: props.desc
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内存预估状态条(置于列表上方 —— 2026-09-13 用户要求)。
|
||||||
|
*
|
||||||
|
* 三段视觉:**实心** = 「服务端当前已启用」那批的预估占用;**半透明** = 本次勾选
|
||||||
|
* 带来的增量;超过单实例上限时整条转红并给出文字警告。全程只读本地数据、不发请求。
|
||||||
|
*/
|
||||||
|
function MemBar(props) {
|
||||||
|
var limit = props.limit;
|
||||||
|
var nowMiB = props.nowMiB;
|
||||||
|
var plannedMiB = props.plannedMiB;
|
||||||
|
var over = plannedMiB > limit;
|
||||||
|
var tight = !over && plannedMiB > limit * MEM_TIGHT_RATIO;
|
||||||
|
var accent = over ? T.danger : (tight ? T.warn : T.success);
|
||||||
|
var nowPct = Math.max(0, Math.min(100, (nowMiB / limit) * 100));
|
||||||
|
var planPct = Math.max(0, Math.min(100, (plannedMiB / limit) * 100));
|
||||||
|
var delta = plannedMiB - nowMiB;
|
||||||
|
var stateText = over ? t("mem.over") : (tight ? t("mem.tight") : t("mem.safe"));
|
||||||
|
var line = t("mem.now") + " " + nowMiB + " MiB";
|
||||||
|
if (delta !== 0) {
|
||||||
|
line += " → " + t("mem.planned") + " " + plannedMiB + " MiB";
|
||||||
|
} else {
|
||||||
|
line += " / " + t("mem.limit") + " " + limit + " MiB";
|
||||||
|
}
|
||||||
|
return jsxRuntime.jsxs("div", {
|
||||||
|
style: {
|
||||||
|
display: "flex", flexDirection: "column", gap: "6px", padding: "10px 12px",
|
||||||
|
borderRadius: "10px", background: T.field,
|
||||||
|
border: "1px solid " + (over ? T.danger : T.border)
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
jsxRuntime.jsxs("div", {
|
||||||
|
key: "h",
|
||||||
|
style: { display: "flex", alignItems: "baseline", gap: "8px", flexWrap: "wrap" },
|
||||||
|
children: [
|
||||||
|
jsxRuntime.jsx("span", {
|
||||||
|
key: "t",
|
||||||
|
style: { fontSize: "13px", fontWeight: 500, color: T.text },
|
||||||
|
children: t("mem.title")
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("span", {
|
||||||
|
key: "n",
|
||||||
|
style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" },
|
||||||
|
children: line
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("span", {
|
||||||
|
key: "s",
|
||||||
|
style: { fontSize: "12px", color: accent, marginLeft: "auto", flexShrink: 0 },
|
||||||
|
children: (over ? "⚠ " : "") + stateText
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("span", {
|
||||||
|
key: "e",
|
||||||
|
style: { fontSize: "12px", color: T.dim, flexBasis: "100%" },
|
||||||
|
children: t("mem.est")
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsxs("div", {
|
||||||
|
key: "bar",
|
||||||
|
style: {
|
||||||
|
position: "relative", height: "8px", borderRadius: "4px",
|
||||||
|
background: T.border, overflow: "hidden"
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
jsxRuntime.jsx("div", {
|
||||||
|
key: "plan",
|
||||||
|
style: {
|
||||||
|
position: "absolute", left: 0, top: 0, bottom: 0,
|
||||||
|
width: planPct + "%", background: accent, opacity: 0.28
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("div", {
|
||||||
|
key: "now",
|
||||||
|
style: {
|
||||||
|
position: "absolute", left: 0, top: 0, bottom: 0,
|
||||||
|
width: nowPct + "%", background: accent, opacity: 0.85
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
over
|
||||||
|
? jsxRuntime.jsx("div", {
|
||||||
|
key: "w",
|
||||||
|
style: { fontSize: "12px", color: T.danger, lineHeight: 1.6 },
|
||||||
|
children: t("mem.overWarn")
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return jsxRuntime.jsx("div", {
|
||||||
|
style: wrap,
|
||||||
|
children: jsxRuntime.jsx(StateBlock, { title: t("loading"), desc: t("loadingDesc") })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (plugins.length === 0) {
|
||||||
|
return jsxRuntime.jsx("div", {
|
||||||
|
style: wrap,
|
||||||
|
children: jsxRuntime.jsx(StateBlock, { title: t("emptyTitle"), desc: t("empty") })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var kw = query.trim().toLowerCase();
|
||||||
|
var shown = kw === ""
|
||||||
|
? plugins
|
||||||
|
: plugins.filter(function (p) {
|
||||||
|
return (p.name || "").toLowerCase().indexOf(kw) >= 0 || (p.description || "").toLowerCase().indexOf(kw) >= 0;
|
||||||
|
});
|
||||||
|
var selected = plugins.filter(function (p) { return p.enabled; }).length;
|
||||||
|
// 内存预估:基线 + Σ 插件成本。
|
||||||
|
// · nowMiB = 服务端快照里「已启用」的那批(savedIds)→ 代表**当前**占用;
|
||||||
|
// · plannedMiB = 当前勾选状态(用户点一下就变)→ 代表**应用后会是多少**。
|
||||||
|
var nowMiB = MEM_BASE_MIB + plugins.reduce(function (sum, p) {
|
||||||
|
return sum + (savedIds.indexOf(p.id) >= 0 ? memMiB(p) : 0);
|
||||||
|
}, 0);
|
||||||
|
var plannedMiB = MEM_BASE_MIB + sumMiB(plugins, "enabled");
|
||||||
|
var memOver = plannedMiB > MEM_LIMIT_MIB;
|
||||||
|
|
||||||
|
var inputStyle = {
|
||||||
|
flex: "1",
|
||||||
|
minWidth: "180px",
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid " + T.border,
|
||||||
|
background: T.field,
|
||||||
|
color: T.text,
|
||||||
|
fontSize: "14px",
|
||||||
|
outline: "none"
|
||||||
|
};
|
||||||
|
// 规范 §4.1:次要动作用白底 + border(`.btn` / `.btn-sm` 量级),非主色实心。
|
||||||
|
var ghostBtnStyle = {
|
||||||
|
padding: "5px 12px", borderRadius: "8px", cursor: "pointer",
|
||||||
|
border: "1px solid " + T.border, background: "transparent", color: T.sub, fontSize: "13px"
|
||||||
|
};
|
||||||
|
var rows = [];
|
||||||
|
var cards = [];
|
||||||
|
|
||||||
|
// 内存预估状态条:**卡片列表上方**(2026-09-13 用户要求)——
|
||||||
|
// 当前占用 / 勾选后预估 / 是否超上限,让用户在点「应用」之前就知道后果。
|
||||||
|
rows.push(jsxRuntime.jsx(MemBar, {
|
||||||
|
key: "__membar",
|
||||||
|
limit: MEM_LIMIT_MIB,
|
||||||
|
nowMiB: nowMiB,
|
||||||
|
plannedMiB: plannedMiB
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 工具行:搜索 + 全选/清空 + 计数(规范 §2.4:工具条内部 gap 8~10px)
|
||||||
|
rows.push(jsxRuntime.jsxs("div", {
|
||||||
|
key: "__toolbar",
|
||||||
|
style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" },
|
||||||
|
children: [
|
||||||
|
jsxRuntime.jsx("input", {
|
||||||
|
key: "q",
|
||||||
|
value: query,
|
||||||
|
placeholder: t("search"),
|
||||||
|
onChange: function (e) { setQuery(e.target.value); },
|
||||||
|
style: inputStyle
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("button", {
|
||||||
|
key: "all",
|
||||||
|
type: "button",
|
||||||
|
className: "bp-btn",
|
||||||
|
onClick: function () {
|
||||||
|
setPlugins(plugins.map(function (p) { return Object.assign({}, p, { enabled: true }); }));
|
||||||
|
},
|
||||||
|
style: ghostBtnStyle,
|
||||||
|
children: t("selectAll")
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("button", {
|
||||||
|
key: "none",
|
||||||
|
type: "button",
|
||||||
|
className: "bp-btn",
|
||||||
|
onClick: function () {
|
||||||
|
setPlugins(plugins.map(function (p) { return Object.assign({}, p, { enabled: false }); }));
|
||||||
|
},
|
||||||
|
style: ghostBtnStyle,
|
||||||
|
children: t("clear")
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("span", {
|
||||||
|
key: "cnt",
|
||||||
|
style: { color: T.sub, fontSize: "13px", marginLeft: "auto" },
|
||||||
|
children: selected + " / " + plugins.length + " " + t("selected")
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (shown.length === 0) {
|
||||||
|
rows.push(jsxRuntime.jsx("div", {
|
||||||
|
key: "__nomatch",
|
||||||
|
style: { padding: "20px 0", textAlign: "center", color: T.sub, fontSize: "13px" },
|
||||||
|
children: t("noMatch")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < shown.length; i++) {
|
||||||
|
(function (p) {
|
||||||
|
var bad = rejected.filter(function (r) { return r.id === p.id; })[0];
|
||||||
|
var desc = p.description && String(p.description).trim() !== "" ? String(p.description).trim() : "";
|
||||||
|
// 主视觉:用途说明(候选池入库时已优先取官方目录中文);无说明时回退为包名。
|
||||||
|
var primary = desc !== "" ? desc : p.name + (p.version ? " v" + p.version : "");
|
||||||
|
// 副行:包名 + 版本(有说明时才渲染,避免与主视觉重复)。
|
||||||
|
var meta = p.name + (p.version ? " v" + p.version : "");
|
||||||
|
cards.push(jsxRuntime.jsxs("label", {
|
||||||
|
key: p.id,
|
||||||
|
className: "bp-row",
|
||||||
|
style: {
|
||||||
|
display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer",
|
||||||
|
padding: "14px 16px", borderRadius: "12px", minWidth: 0, minHeight: "112px",
|
||||||
|
background: "var(--dsw-alias-bg-layer-1, #ffffff)",
|
||||||
|
border: "1px solid " + (bad ? T.danger : T.border)
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
jsxRuntime.jsx("input", {
|
||||||
|
key: "cb",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: !!p.enabled,
|
||||||
|
onChange: function () { toggle(p.id); }
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsxs("span", {
|
||||||
|
key: "nm",
|
||||||
|
style: { flex: "1", minWidth: "0", display: "flex", flexDirection: "column", gap: "2px" },
|
||||||
|
children: [
|
||||||
|
// 主视觉 = 用途说明(规范 §5「信息层次」;与门户 plugins 页同一决策)
|
||||||
|
jsxRuntime.jsx("span", {
|
||||||
|
key: "p1",
|
||||||
|
style: {
|
||||||
|
fontSize: "14px", fontWeight: 500, color: T.text, lineHeight: 1.45,
|
||||||
|
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"
|
||||||
|
},
|
||||||
|
title: primary,
|
||||||
|
children: primary
|
||||||
|
}),
|
||||||
|
// 副行 = 包名 + 版本(长文本截断三件套 + title,规范 §5)
|
||||||
|
desc !== ""
|
||||||
|
? jsxRuntime.jsx("span", {
|
||||||
|
key: "p2",
|
||||||
|
style: {
|
||||||
|
fontSize: "12px", color: T.sub,
|
||||||
|
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"
|
||||||
|
},
|
||||||
|
title: meta,
|
||||||
|
children: meta
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
// 预估内存(2026-09-13 用户要求):数值越大越"贵",用文字色分级
|
||||||
|
// (≥60 MiB 红 / ≥30 MiB 黄 / 其余次要色),一眼能看出谁是大头。
|
||||||
|
jsxRuntime.jsx("span", {
|
||||||
|
key: "mem",
|
||||||
|
style: { marginTop: "4px" },
|
||||||
|
children: jsxRuntime.jsx("span", {
|
||||||
|
style: {
|
||||||
|
padding: "1px 8px", borderRadius: "10px",
|
||||||
|
border: "1px solid " + T.border, background: T.field,
|
||||||
|
color: memMiB(p) >= 60 ? T.danger : (memMiB(p) >= 30 ? T.warn : T.sub)
|
||||||
|
},
|
||||||
|
children: t("mem.perCard") + " ≈ " + memMiB(p) + " MiB"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("span", {
|
||||||
|
key: "bd",
|
||||||
|
style: {
|
||||||
|
fontSize: "12px", padding: "2px 8px", borderRadius: "10px", flexShrink: 0,
|
||||||
|
color: p.enabled ? T.success : T.dim,
|
||||||
|
border: "1px solid " + (p.enabled ? T.success : T.border)
|
||||||
|
},
|
||||||
|
children: p.enabled ? t("enabled") : t("disabled")
|
||||||
|
}),
|
||||||
|
bad
|
||||||
|
? jsxRuntime.jsx("span", {
|
||||||
|
key: "bad",
|
||||||
|
style: { fontSize: "12px", color: T.danger, flexShrink: 0 },
|
||||||
|
title: String(bad.reason || ""),
|
||||||
|
children: "⚠"
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
})(shown[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卡片网格:一行 2 个(用户 2026-09-12 要求「插件列表改为卡片形式,一行放 2 个卡片」)
|
||||||
|
// 单独包一层 grid —— 不能直接改外层 wrap(它同时装标题/搜索/按钮/提示等纵向块)。
|
||||||
|
rows.push(jsxRuntime.jsx("div", {
|
||||||
|
key: "__grid",
|
||||||
|
style: { display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: "10px" },
|
||||||
|
children: cards
|
||||||
|
}));
|
||||||
|
|
||||||
|
rows.push(jsxRuntime.jsxs("div", {
|
||||||
|
key: "__actions",
|
||||||
|
style: { display: "flex", alignItems: "center", gap: "10px", marginTop: "4px" },
|
||||||
|
children: [
|
||||||
|
// 规范 §4.1:主行动用 primary 实心(`.btn` 量级,section 内 14px)
|
||||||
|
jsxRuntime.jsx("button", {
|
||||||
|
key: "apply",
|
||||||
|
type: "button",
|
||||||
|
onClick: askApply,
|
||||||
|
disabled: busy,
|
||||||
|
style: {
|
||||||
|
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||||
|
padding: "7px 16px", borderRadius: "8px", border: "1px solid " + T.primary,
|
||||||
|
background: T.primary, color: "#ffffff", cursor: busy ? "default" : "pointer",
|
||||||
|
opacity: busy ? 0.5 : 1, fontSize: "14px"
|
||||||
|
},
|
||||||
|
children: busy ? t("applying") : t("apply")
|
||||||
|
}),
|
||||||
|
rejected.length > 0
|
||||||
|
? jsxRuntime.jsx("button", {
|
||||||
|
key: "retry",
|
||||||
|
type: "button",
|
||||||
|
className: "bp-btn",
|
||||||
|
onClick: askApply,
|
||||||
|
disabled: busy,
|
||||||
|
style: Object.assign({}, ghostBtnStyle, { opacity: busy ? 0.5 : 1 }),
|
||||||
|
children: t("retry")
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
// impeccable craft-floor 明令禁止「卡片/列表项/提示上 >1px 的彩色 border-left/right」
|
||||||
|
// —— 改为整块浅底 + 圆角,危险语义由标题文字色承担(不靠侧边色条)。
|
||||||
|
rows.push(jsxRuntime.jsxs("div", {
|
||||||
|
key: "__rejected",
|
||||||
|
style: {
|
||||||
|
padding: "10px 12px", borderRadius: "8px",
|
||||||
|
color: T.sub, fontSize: "13px", lineHeight: "1.6", background: T.field
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
jsxRuntime.jsxs("div", {
|
||||||
|
key: "h",
|
||||||
|
style: { fontWeight: 500, color: T.danger, marginBottom: "2px" },
|
||||||
|
children: ["⚠ ", t("rejected.title")]
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("div", { key: "b", children: rejected.map(function (r) { return r.id; }).join("、") })
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (msg) {
|
||||||
|
rows.push(jsxRuntime.jsx("div", { key: "__msg", style: { color: T.sub, fontSize: "13px" }, children: msg }));
|
||||||
|
}
|
||||||
|
// ── 页内确认弹窗(替代 window.confirm;视觉对齐 MCN 工作台弹窗)──────────
|
||||||
|
// 结构照 MCN:遮罩(fixed inset:0 + rgba(0,0,0,.35) + zIndex 2000 + 居中)→ 面板
|
||||||
|
// (radius 12 / padding 18·22 / maxHeight 82vh / 阴影)→ 标题行(✕ 关闭)→ 正文 →
|
||||||
|
// 内存块(**超限时红底 + 警告**,用户 2026-09-13 明确要求)→ 按钮行(取消 / 确认)。
|
||||||
|
// 点遮罩关闭、点面板 stopPropagation(与 MCN 同款交互)。
|
||||||
|
if (confirmOpen) {
|
||||||
|
var overNow = plannedMiB > MEM_LIMIT_MIB;
|
||||||
|
rows.push(jsxRuntime.jsxs("div", {
|
||||||
|
key: "__confirm",
|
||||||
|
style: {
|
||||||
|
position: "fixed", inset: 0, background: "rgba(0,0,0,.35)", zIndex: 2000,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", padding: "24px"
|
||||||
|
},
|
||||||
|
onClick: function () { setConfirmOpen(false); },
|
||||||
|
children: jsxRuntime.jsxs("div", {
|
||||||
|
style: {
|
||||||
|
background: "var(--dsw-alias-bg-layer-1, #ffffff)",
|
||||||
|
borderRadius: "12px", maxWidth: "480px", width: "100%", maxHeight: "82vh",
|
||||||
|
overflow: "auto", padding: "18px 22px",
|
||||||
|
boxShadow: "0 8px 30px rgba(0,0,0,.18)",
|
||||||
|
display: "flex", flexDirection: "column", gap: "10px"
|
||||||
|
},
|
||||||
|
onClick: function (e) { e.stopPropagation(); },
|
||||||
|
children: [
|
||||||
|
jsxRuntime.jsxs("div", {
|
||||||
|
key: "t",
|
||||||
|
style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" },
|
||||||
|
children: [
|
||||||
|
jsxRuntime.jsx("span", {
|
||||||
|
key: "s",
|
||||||
|
style: { fontSize: "15px", fontWeight: 500, color: T.text },
|
||||||
|
children: t("confirm.title")
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("button", {
|
||||||
|
key: "x",
|
||||||
|
type: "button",
|
||||||
|
title: t("confirm.cancel"),
|
||||||
|
onClick: function () { setConfirmOpen(false); },
|
||||||
|
style: {
|
||||||
|
border: "none", background: "none", fontSize: "18px", lineHeight: 1,
|
||||||
|
cursor: "pointer", color: T.dim, padding: "2px 4px"
|
||||||
|
},
|
||||||
|
children: "✕"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("div", {
|
||||||
|
key: "b",
|
||||||
|
style: { fontSize: "13px", color: T.sub, lineHeight: 1.6 },
|
||||||
|
children: t("confirm")
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsxs("div", {
|
||||||
|
key: "m",
|
||||||
|
style: {
|
||||||
|
padding: "10px 12px", borderRadius: "10px",
|
||||||
|
background: overNow ? "rgba(217,48,38,.06)" : T.field,
|
||||||
|
border: "1px solid " + (overNow ? T.danger : T.border),
|
||||||
|
display: "flex", flexDirection: "column", gap: "4px"
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
jsxRuntime.jsx("div", {
|
||||||
|
key: "h",
|
||||||
|
style: { fontSize: "13px", fontWeight: 500, color: overNow ? T.danger : T.text },
|
||||||
|
children: overNow ? t("confirm.overTitle") : t("mem.title")
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("div", {
|
||||||
|
key: "n",
|
||||||
|
style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" },
|
||||||
|
children: t("mem.now") + " " + nowMiB + " MiB → " + t("mem.planned") + " " + plannedMiB
|
||||||
|
+ " MiB / " + t("mem.limit") + " " + MEM_LIMIT_MIB + " MiB"
|
||||||
|
}),
|
||||||
|
overNow
|
||||||
|
? jsxRuntime.jsx("div", {
|
||||||
|
key: "w",
|
||||||
|
style: { fontSize: "12px", color: T.danger, lineHeight: 1.6 },
|
||||||
|
children: t("mem.overWarn")
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
jsxRuntime.jsx("div", {
|
||||||
|
key: "e",
|
||||||
|
style: { fontSize: "12px", color: T.dim },
|
||||||
|
children: t("mem.est")
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsxs("div", {
|
||||||
|
key: "f",
|
||||||
|
style: {
|
||||||
|
display: "flex", justifyContent: "flex-end", alignItems: "center", gap: "8px",
|
||||||
|
paddingTop: "10px", borderTop: "1px solid " + T.border
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
jsxRuntime.jsx("button", {
|
||||||
|
key: "c",
|
||||||
|
type: "button",
|
||||||
|
className: "bp-btn",
|
||||||
|
onClick: function () { setConfirmOpen(false); },
|
||||||
|
style: ghostBtnStyle,
|
||||||
|
children: t("confirm.cancel")
|
||||||
|
}),
|
||||||
|
jsxRuntime.jsx("button", {
|
||||||
|
key: "k",
|
||||||
|
type: "button",
|
||||||
|
onClick: doApply,
|
||||||
|
disabled: busy,
|
||||||
|
style: {
|
||||||
|
padding: "7px 16px", borderRadius: "8px", cursor: busy ? "default" : "pointer",
|
||||||
|
border: "1px solid " + (overNow ? T.danger : T.primary),
|
||||||
|
background: overNow ? T.danger : T.primary, color: "#ffffff",
|
||||||
|
fontSize: "14px", opacity: busy ? 0.5 : 1
|
||||||
|
},
|
||||||
|
children: busy ? t("applying") : t("confirm.ok")
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return jsxRuntime.jsx("div", { style: wrap, children: rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.slots.inject("settings.section", function () {
|
||||||
|
return ctx.slots.register(
|
||||||
|
{
|
||||||
|
name: "settings.section",
|
||||||
|
id: "business-plugins",
|
||||||
|
order: 101,
|
||||||
|
label: function () { return t("section.label"); }
|
||||||
|
},
|
||||||
|
BusinessPluginsSection
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.apply = apply;
|
||||||
|
exports.inject = inject;
|
||||||
|
return module.exports;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// @dsh-local/business-plugins — host plugin (dsh 0.1.2-rc.1)
|
||||||
|
// 功能插件启停:功能全在 client 面(settings.section),host 面仅写加载标记,
|
||||||
|
// 证明 bundle 被 cordis 实例化。无任何 ctx 服务依赖,无副作用可抛出。
|
||||||
|
|
||||||
|
import { appendFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const MARKER = ".dsh-biz-plugins.log";
|
||||||
|
|
||||||
|
function log(line) {
|
||||||
|
try {
|
||||||
|
const home = process.env.DSH_HOME || process.env.HOME || ".";
|
||||||
|
appendFileSync(join(home, MARKER), `${new Date().toISOString()} ${line}\n`);
|
||||||
|
} catch {
|
||||||
|
// marker 写入绝不拖垮 profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apply(_ctx) {
|
||||||
|
log(`apply pid=${process.pid}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@dsh-local/business-plugins",
|
||||||
|
"version": "0.2.8",
|
||||||
|
"description": "功能管理(原「功能插件」)section for dsh web profile — v0.2.8(2026-09-13):**所有弹窗改页内弹窗** —— 弃用 window.confirm,改为 fixed 遮罩 + 居中面板 + 点遮罩/✕ 关闭(视觉与交互对齐 MCN 工作台 dsh-plugin-mcn 的 modalTitle/modalText/modalBtns 三段结构);**超限时弹窗内红底警告块**(标题 + 说明 + 内存数字),确认按钮转危险色,未点确认不发请求。v0.2.7(档案 75 维度 B 落地 · 2026-09-13):卡片新增「预估内存 ≈ N MiB」徽章(按实测成本分色:≥60 红 / ≥30 黄)、卡片加高(minHeight 112 + padding 14/16)便于四层信息排布;**列表上方新增「内存预估」状态条** —— 实心段=当前已启用插件预估占用、半透明段=本次勾选增量、超单实例上限(384 MiB)整条转红并给文字警告,确认弹窗也带上内存预估。口径 = 空载基线 285 MiB + 逐插件实测加载成本(隔离 cgroup 的 rss 增量),**是预估值不是实时读数**。候选池列表 + 搜索/状态徽章 + 批量启用/禁用 + 确认弹窗 + 重启后自动刷新。v0.2.4(档案 67):对齐 06-工作台UI规范 —— **信息层次反转**(主视觉改为插件用途说明,插件名/版本退为副行小字;中文说明由候选池入库时优先取官方目录)、字号阶梯 14/13/12、徽章与按钮改规范量级、空态改「标题 + 说明」、新增列表行 hover 反馈。v0.2.2:分区名由「功能插件」改为「功能管理」。v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。",
|
||||||
|
"type": "module",
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./lib/index.js",
|
||||||
|
"./client": "./lib/client.js"
|
||||||
|
},
|
||||||
|
"dsh": {
|
||||||
|
"bundle": {
|
||||||
|
"patch": "./cordis.patch.yml"
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"platform": "web",
|
||||||
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-ui-settings-general"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# @dsh-local/workspace-scoped-picker
|
||||||
|
|
||||||
|
会话内「添加工作区」目录选择器:把列举/建目录的根收敛为**当前用户自有目录**(`<userRoot>/ws`),
|
||||||
|
所有用户含 admin 一视同仁。见文档库档案 18。
|
||||||
|
|
||||||
|
## 生效机制(2026-09-11 实证修订)
|
||||||
|
|
||||||
|
| 尝试 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| bundle patch 用「同 id 换 name」覆盖官方 `directory-picker` 行 | ❌ 不生效(官方 `-auto` 行原样保留) |
|
||||||
|
| profile `cordis.patch.yml` 用「同 id 换 name」覆盖 | ❌ 不生效(dump-config 实测未应用) |
|
||||||
|
| **profile 层:`insert` 自建行 + 对官方行 `disabled: true`** | ✅ 采用(`disabled` 是档案 09 已验证的机制) |
|
||||||
|
|
||||||
|
生效写法(写入 `<profile>/cordis.patch.yml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- insert:
|
||||||
|
- id: workspace-scoped-picker
|
||||||
|
name: "@dsh-local/workspace-scoped-picker"
|
||||||
|
- id: directory-picker
|
||||||
|
name: "@deepseek-ai/dsh-host-directory-picker-auto"
|
||||||
|
disabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装(必须走 pnpm)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <profile dir>
|
||||||
|
HOME=<userRoot>/ws pnpm add file:<userRoot>/ws/workspace-scoped-picker-0.1.0.tgz
|
||||||
|
# 手放 node_modules 无效:pnpm-lock.yaml 才是安装账本
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖解析
|
||||||
|
|
||||||
|
唯一外部物是官方 seam 基类,用**绝对路径动态 import** 取得(不复制/不改官方包):
|
||||||
|
`/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js`,
|
||||||
|
可用 `DSH_SEAM_DIRECTORY_PICKER` 覆盖。
|
||||||
|
|
||||||
|
## 自检
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DSH_WORKSPACE_ROOT=/tmp/picker-test node test/poc.mjs # 26 项断言;须从实例 uid 可读的路径运行
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:源码在 `/opt/dshs/poc/`(700 root),实例 uid 读不到 → 安装时必须**复制**到 profile。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# @dsh-local/workspace-scoped-picker — bundle patch(**空补丁,勿在此插入行**)
|
||||||
|
#
|
||||||
|
# 2026-09-11 事故记录:本文件曾写成 insert `workspace-scoped-picker` 行,而 profile 层
|
||||||
|
# 的 cordis.patch.yml 也 insert 同一 id → 加载器报
|
||||||
|
# "duplicate loader entry id: workspace-scoped-picker" → 实例启动失败并崩溃循环(已熔断)。
|
||||||
|
#
|
||||||
|
# 结论:**本包的行由 profile 层单点插入**(平台安装脚本写入,与官方行的 disabled 一起),
|
||||||
|
# 本 bundle patch 保持空,避免双写。
|
||||||
|
[]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* ensure-workspace-picker-patch.cjs —— 把「工作区目录选择器收敛」平台段幂等写入各用户 profile patch。
|
||||||
|
*
|
||||||
|
* 背景(档案 18 v3,2026-09-11 实测):
|
||||||
|
* · 官方 dsh-web-app 的 `directory-picker` 行(@…-auto) 在启动时**连带挂载客户端对话框**
|
||||||
|
* (@deepseek-ai/dsh-client-ui-directory-picker-browse);把它 disabled 会让对话框消失(点不动)。
|
||||||
|
* · 该 client 包自带 dsh.client 元数据,可**单独作为一行**插入 → 保留官方对话框 UI。
|
||||||
|
* · host 面(seam) 改由自建 @dsh-local/workspace-scoped-picker 提供:根固定为 <userRoot>/ws,
|
||||||
|
* 越界(/etc、..、他人目录、符号链接逃逸)一律拒绝 → 路径框里手输也出不去。
|
||||||
|
* · 另由该插件的 client 面注入 CSS,隐藏「改路径」入口(crumbEditZone/Glyph)。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node ensure-workspace-picker-patch.cjs # 全部用户(幂等)
|
||||||
|
* node ensure-workspace-picker-patch.cjs --dry-run # 只打印计划
|
||||||
|
* node ensure-workspace-picker-patch.cjs --restart # 写后 kill 实例(由崩溃自愈拉起,读新 patch)
|
||||||
|
* node ensure-workspace-picker-patch.cjs admin guest # 指定用户
|
||||||
|
*
|
||||||
|
* 幂等:以 BEGIN/END 标记包裹平台段;已存在即跳过(不改内容)。
|
||||||
|
* 注意:本脚本**只追加平台段**,不触碰既有内容(角色 patch 等原样保留)。
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { existsSync, readFileSync, writeFileSync } = require('node:fs')
|
||||||
|
const { join } = require('node:path')
|
||||||
|
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||||
|
|
||||||
|
const DB_PATH = '/var/lib/dshs/dshs.db'
|
||||||
|
const PROFILE = 'web'
|
||||||
|
const BEGIN = '# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker-patch.cjs)'
|
||||||
|
const END = '# <<< platform: workspace-scoped-picker'
|
||||||
|
const DRY = process.argv.includes('--dry-run')
|
||||||
|
const RESTART = process.argv.includes('--restart')
|
||||||
|
const only = process.argv.slice(2).filter((a) => !a.startsWith('--'))
|
||||||
|
|
||||||
|
const BLOCK = [
|
||||||
|
BEGIN,
|
||||||
|
'# 目录选择器收敛:官方对话框 UI 保留(单独插入 client 面),host 面换成受限实现(根=自有 ws)',
|
||||||
|
'- insert:',
|
||||||
|
' - id: workspace-scoped-picker',
|
||||||
|
' name: "@dsh-local/workspace-scoped-picker"',
|
||||||
|
' - id: ui-directory-picker-browse',
|
||||||
|
' name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"',
|
||||||
|
'- id: directory-picker',
|
||||||
|
' name: "@deepseek-ai/dsh-host-directory-picker-auto"',
|
||||||
|
' disabled: true',
|
||||||
|
END,
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH, { readonly: true })
|
||||||
|
const users = db
|
||||||
|
.prepare('SELECT username, uid, home_dir FROM users')
|
||||||
|
.all()
|
||||||
|
.filter((u) => only.length === 0 || only.includes(u.username))
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const patchPath = join(user.home_dir, 'profiles', PROFILE, 'cordis.patch.yml')
|
||||||
|
if (!existsSync(patchPath)) {
|
||||||
|
console.log(` ${user.username}: NO_PROFILE(用户未首登 spawn,先登录一次)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const current = readFileSync(patchPath, 'utf8')
|
||||||
|
if (current.includes(BEGIN)) {
|
||||||
|
console.log(` ${user.username}: skip(平台段已存在)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const body = current.trim() === '' || current.trim() === '[]' ? '' : current.trimEnd() + '\n\n'
|
||||||
|
const next = body + BLOCK
|
||||||
|
if (DRY) {
|
||||||
|
console.log(` ${user.username}: [dry-run] 将写入 ${patchPath}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writeFileSync(patchPath, next, 'utf8')
|
||||||
|
try {
|
||||||
|
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
|
||||||
|
} catch {}
|
||||||
|
console.log(` ${user.username}: wrote(已追加平台段)${RESTART ? ' + 重启实例' : ''}`)
|
||||||
|
if (RESTART) {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('ps', ['-eo', 'pid,user', '--no-headers'], { encoding: 'utf8' })
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const [pid, uname] = line.trim().split(/\s+/)
|
||||||
|
if (pid && uname === `dsh-${user.uid}`) {
|
||||||
|
try {
|
||||||
|
process.kill(Number(pid))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
console.log('done')
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* ensure-workspace-picker.cjs —— 全量/新用户「目录选择器收敛」自动铺开(幂等)
|
||||||
|
*
|
||||||
|
* 做两件事(缺一不可):
|
||||||
|
* ① 把受限目录选择器插件装进该用户的 profile(每个用户 profile 独立,必须逐用户 pnpm add)
|
||||||
|
* ② 把「平台段」写进该用户的 <profile>/cordis.patch.yml(让 dsh 启动时加载插件并 disable 官方 picker)
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node ensure-workspace-picker.cjs # 全部用户(幂等),产物取 /opt/dsh/artifacts 下最新
|
||||||
|
* node ensure-workspace-picker.cjs --tgz <path> # 指定插件 tgz
|
||||||
|
* node ensure-workspace-picker.cjs --dry-run # 只打印计划
|
||||||
|
* node ensure-workspace-picker.cjs --restart # 写完后 kill 实例(崩溃自愈会用新配置拉起)
|
||||||
|
* node ensure-workspace-picker.cjs admin guest # 指定用户名
|
||||||
|
* node ensure-workspace-picker.cjs --user-id <uuid> # 指定用户(编排器自愈用,档案 18 v3 第二层)
|
||||||
|
*
|
||||||
|
* 幂等性:
|
||||||
|
* · 平台段以 BEGIN/END 标记包裹;已存在即跳过(不改内容)
|
||||||
|
* · 插件按已装版本比对;版本一致即跳过安装
|
||||||
|
* 安全:只追加平台段、不触碰既有内容(角色 patch 等);失败逐用户隔离,不影响他人。
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const {
|
||||||
|
chmodSync,
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
} = require('node:fs')
|
||||||
|
const { dirname, join, resolve } = require('node:path')
|
||||||
|
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||||
|
|
||||||
|
const DB = '/var/lib/dshs/dshs.db'
|
||||||
|
const ARTIFACTS = '/opt/dsh/artifacts'
|
||||||
|
const PROFILE = 'web'
|
||||||
|
const BEGIN = '# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker.cjs)'
|
||||||
|
const END = '# <<< platform: workspace-scoped-picker'
|
||||||
|
// marker 宽松匹配:历史上有过 `…picker-patch.cjs` 的旧标记形式,必须同样识别(2026-09-11 事故)
|
||||||
|
const BEGIN_RE = /^# >>> platform: workspace-scoped-picker/
|
||||||
|
const END_RE = /^# <<< platform: workspace-scoped-picker/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 剥离**所有**平台段(任意标记形式),返回剩余正文与剥离份数(用于自愈重复段)。
|
||||||
|
*
|
||||||
|
* 2026-09-11 事故修复(D1):同时丢掉**裸 \`[]\` 文档行**。
|
||||||
|
* dsh 新建 profile 的模板是「3 行注释 + []」;注释会被保留 → 正文变成
|
||||||
|
* \`# …\\n[]\`,既不是空串也不是 \`'[]'\` → 下游 \`body === '[]'\` 判定失效 →
|
||||||
|
* 空数组文档被原样留下、平台段又追加在其后 → 同一 YAML 流出现两个文档且无 \`---\`
|
||||||
|
* → dsh 启动报 \`YAMLException: end of the stream or a document separator is expected\`
|
||||||
|
* → **实例永远起不来**。空数组本身就是"无 patch",丢掉永远安全。
|
||||||
|
*/
|
||||||
|
function stripPlatformSegments(text) {
|
||||||
|
const kept = []
|
||||||
|
let skipping = false
|
||||||
|
let removed = 0
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (BEGIN_RE.test(line)) {
|
||||||
|
skipping = true
|
||||||
|
removed += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (skipping) {
|
||||||
|
if (END_RE.test(line)) skipping = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line.trim() === '[]') continue // ← D1:裸空数组文档行,丢弃
|
||||||
|
kept.push(line)
|
||||||
|
}
|
||||||
|
return { body: kept.join('\n').trim(), removed }
|
||||||
|
}
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
const DRY = argv.includes('--dry-run')
|
||||||
|
const RESTART = argv.includes('--restart')
|
||||||
|
const valueOf = (flag) => {
|
||||||
|
const i = argv.indexOf(flag)
|
||||||
|
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : ''
|
||||||
|
}
|
||||||
|
const tgzFlag = valueOf('--tgz')
|
||||||
|
const onlyIds = valueOf('--user-id') === '' ? [] : [valueOf('--user-id')]
|
||||||
|
// 位置参数 = 用户名(排除各 flag 的取值)
|
||||||
|
const flagValues = new Set([tgzFlag, ...onlyIds].filter((v) => v !== ''))
|
||||||
|
const only = argv.filter((a) => !a.startsWith('--') && !flagValues.has(a))
|
||||||
|
|
||||||
|
/** 读出既有 node_modules 记录的 storeDir(不存在 → 空串)。详细理由见 scripts/ensure-biz-plugins.cjs 同名函数。 */
|
||||||
|
function existingStoreDir(profileDir) {
|
||||||
|
try {
|
||||||
|
const txt = readFileSync(join(profileDir, 'node_modules', '.modules.yaml'), 'utf8')
|
||||||
|
const m = /^storeDir:\s*(.+)$/m.exec(txt)
|
||||||
|
return m ? m[1].trim() : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清掉「指向不存在文件的 `file:` 依赖」,返回被删清单。
|
||||||
|
* 与 scripts/ensure-biz-plugins.cjs 同名函数同源:依赖断裂时 `pnpm add` 会在解析阶段 ENOENT。
|
||||||
|
*/
|
||||||
|
function pruneBrokenFileDeps(pkgPath) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||||
|
const deps = pkg.dependencies ?? {}
|
||||||
|
const removed = []
|
||||||
|
for (const [k, v] of Object.entries(deps)) {
|
||||||
|
if (typeof v !== 'string' || !v.startsWith('file:')) continue
|
||||||
|
const abs = resolve(dirname(pkgPath), v.slice('file:'.length))
|
||||||
|
if (!existsSync(abs)) {
|
||||||
|
delete deps[k]
|
||||||
|
removed.push(`${k} → ${v}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed.length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
return removed
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 `dependencies` 里的 `@dsh-local/*` 补回 `dsh.profile.bundles`。
|
||||||
|
*
|
||||||
|
* 为什么必须做(2026-09-12 实测事故):`ensure-biz-plugins.cjs` 的 reconcile 有过滤规则
|
||||||
|
* `bundles.filter(b => b.startsWith('@deepseek-ai/') || deps.includes(b))` —— 一旦本包的
|
||||||
|
* dep 被 prune 掉,它就会**连带把本包从 bundles 剔除** ⇒ 档案 18 的「目录选择器收敛为仅见
|
||||||
|
* 自有目录」(R5 安全收敛)**静默失效**。本脚本原先无 reconcile,补不回来,只能手工改。
|
||||||
|
*/
|
||||||
|
function reconcileOwnBundles(profileDir) {
|
||||||
|
const p = join(profileDir, 'package.json')
|
||||||
|
const pkg = JSON.parse(readFileSync(p, 'utf8'))
|
||||||
|
const deps = Object.keys(pkg.dependencies ?? {})
|
||||||
|
const bundles = pkg.dsh?.profile?.bundles ?? []
|
||||||
|
const kept = bundles.filter((b) => b.startsWith('@deepseek-ai/') || deps.includes(b))
|
||||||
|
for (const d of deps) if (d.startsWith('@dsh-local/') && !kept.includes(d)) kept.push(d)
|
||||||
|
pkg.dsh = pkg.dsh ?? {}
|
||||||
|
pkg.dsh.profile = pkg.dsh.profile ?? {}
|
||||||
|
pkg.dsh.profile.bundles = kept
|
||||||
|
writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTgz() {
|
||||||
|
if (tgzFlag !== '') return tgzFlag
|
||||||
|
const files = readdirSync(ARTIFACTS)
|
||||||
|
.filter((f) => /^workspace-scoped-picker-.*\.tgz$/.test(f))
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
||||||
|
if (files.length === 0) throw new Error(`未在 ${ARTIFACTS} 找到插件产物`)
|
||||||
|
return join(ARTIFACTS, files[files.length - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
const TGZ = pickTgz()
|
||||||
|
const VER = (TGZ.match(/workspace-scoped-picker-(.+)\.tgz$/) || [])[1] || 'unknown'
|
||||||
|
|
||||||
|
const BLOCK = [
|
||||||
|
BEGIN,
|
||||||
|
'# 目录选择器收敛(档案 18 v3):官方对话框 UI 保留(单独插入 client 面),',
|
||||||
|
'# host 面换成受限实现(根=自有 ws,越界拒绝);插件 client 面再注入 CSS 隐藏「改路径」入口。',
|
||||||
|
'- insert:',
|
||||||
|
' - id: workspace-scoped-picker',
|
||||||
|
' name: "@dsh-local/workspace-scoped-picker"',
|
||||||
|
' - id: ui-directory-picker-browse',
|
||||||
|
' name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"',
|
||||||
|
'- id: directory-picker',
|
||||||
|
' name: "@deepseek-ai/dsh-host-directory-picker-auto"',
|
||||||
|
' disabled: true',
|
||||||
|
END,
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const db = new Database(DB, { readonly: true })
|
||||||
|
const users = db
|
||||||
|
.prepare('SELECT id, username, uid, home_dir FROM users')
|
||||||
|
.all()
|
||||||
|
.filter(
|
||||||
|
(u) =>
|
||||||
|
(only.length === 0 && onlyIds.length === 0) || only.includes(u.username) || onlyIds.includes(u.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`插件产物: ${TGZ}(版本 ${VER})`)
|
||||||
|
for (const user of users) {
|
||||||
|
const profileDir = join(user.home_dir, 'profiles', PROFILE)
|
||||||
|
const patchPath = join(profileDir, 'cordis.patch.yml')
|
||||||
|
// 2026-09-11 修复:不再把 tgz 复制进用户工作区、也不再让 pnpm 把 store/cache 写进 ws。
|
||||||
|
// 旧做法(HOME=<ws> pnpm add file:<ws>/xxx.tgz)会在 ws 里生成 .local/(pnpm store)、
|
||||||
|
// .cache/(metadata)与 *.tgz —— 实测污染 admin ws 达 17MB / 2045 个文件。
|
||||||
|
// 现在:直接用 artifacts 里的 tgz 绝对路径(root 可读、全局只读),store/cache 显式指向 <home>。
|
||||||
|
//
|
||||||
|
// 2026-09-12(档案 61):store 位置改为**自适应** —— 存量 profile 的 node_modules 是由
|
||||||
|
// **ws 内旧 store** 链接而来的(「HOME=<ws>」时代的产物,.modules.yaml 里记着它);此处若硬用
|
||||||
|
// <home>/.pnpm-store,pnpm 会直接拒绝:ERR_PNPM_UNEXPECTED_STORE(档案 57 在 portal-entry 上实测)。
|
||||||
|
// 因此:**已有安装沿用旧 store,只有全新 profile 才用 <home>/.pnpm-store**。
|
||||||
|
const legacyStore = existingStoreDir(profileDir)
|
||||||
|
const storeDir = legacyStore !== '' ? legacyStore : join(user.home_dir, '.pnpm-store')
|
||||||
|
const legacyCache = join(user.home_dir, '..', 'ws', '.cache', 'pnpm')
|
||||||
|
const cacheDir = existsSync(legacyCache) ? legacyCache : join(user.home_dir, '.pnpm-cache')
|
||||||
|
|
||||||
|
if (!existsSync(profileDir)) {
|
||||||
|
console.log(` ${user.username}: NO_PROFILE(用户还没首登 spawn;下次 provisioning/启动会补)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 平台段(先剥离所有旧段再比对 → 天然自愈重复/旧标记)
|
||||||
|
const raw = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
|
||||||
|
const { body, removed } = stripPlatformSegments(raw)
|
||||||
|
const normalized = body === '' || body === '[]' ? '' : body + '\n\n'
|
||||||
|
const want = normalized + BLOCK
|
||||||
|
const needPatch = want !== raw
|
||||||
|
// ② 插件版本
|
||||||
|
const installed = (() => {
|
||||||
|
try {
|
||||||
|
const pj = join(profileDir, 'node_modules', '@dsh-local', 'workspace-scoped-picker', 'package.json')
|
||||||
|
return JSON.parse(readFileSync(pj, 'utf8')).version
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const needInstall = installed !== VER
|
||||||
|
|
||||||
|
// 2026-09-12 加固:在做 skip 判定**之前**先自愈「断裂依赖」与「bundles 掉落」。
|
||||||
|
// 否则本包会一直"假装已装"(node_modules 里有、dependencies 里却没了),
|
||||||
|
// 且 reconcile 会把本包从 bundles 剔除 → 档案 18 的目录选择器收敛(R5)静默失效。
|
||||||
|
const pkgPath = join(profileDir, 'package.json')
|
||||||
|
const BUNDLE_KEY = '@dsh-local/workspace-scoped-picker'
|
||||||
|
const pruned = pruneBrokenFileDeps(pkgPath)
|
||||||
|
if (pruned.length > 0) console.log(` ${user.username}: 清理断裂依赖 ${pruned.length} 个(${pruned.join(';')})`)
|
||||||
|
let depMissing = false
|
||||||
|
try {
|
||||||
|
const pkgNow = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||||
|
depMissing = !(pkgNow.dependencies ?? {})[BUNDLE_KEY]
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
const bundlesFixed = reconcileOwnBundles(profileDir)
|
||||||
|
if (pruned.length > 0 || depMissing) {
|
||||||
|
console.log(` ${user.username}: 依赖/清单已自愈(bundles ${bundlesFixed.length} 项,dep${depMissing ? ' 缺失→重装' : ' 在位'})`)
|
||||||
|
// root 写过 package.json → 属主收回给用户
|
||||||
|
try { execFileSync('chown', [`${user.uid}:${user.uid}`, pkgPath], { stdio: 'pipe' }) } catch { /* 尽力而为 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needPatch && !needInstall && !depMissing) {
|
||||||
|
console.log(` ${user.username}: skip(平台段已在,插件 v${installed})`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (DRY) {
|
||||||
|
console.log(` ${user.username}: [dry-run] patch=${needPatch} install=${needInstall}${removed > 1 ? ` 旧段=${removed}` : ''}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 顺序修正(2026-09-11 事故,D2):**先装插件,后写平台段**。
|
||||||
|
// 原顺序(先写段 → 装插件失败仅记日志 continue)会留下"段引用了不存在的插件"的 profile
|
||||||
|
// → dsh 启动即 `ERR_MODULE_NOT_FOUND '@dsh-local/workspace-scoped-picker'` → 崩溃循环 → 熔断
|
||||||
|
// → 用户实例永远起不来。现在:插件不在位就**绝不写段**,且反向剥离已有段(自愈)。
|
||||||
|
if (needInstall) {
|
||||||
|
try {
|
||||||
|
// D4:`/opt/dsh` 是 drwx------ root,用户 uid 读不到其中 artifacts 的 tgz
|
||||||
|
// (实测 EACCES)。先把产物暂存到**用户自己的 home** —— 不扩大任何宿主权限(R5 安全),
|
||||||
|
// 再以用户身份安装。缓存文件名带版本号,跨版本自动重取。
|
||||||
|
const stageDir = join(user.home_dir, '.dsh-stage')
|
||||||
|
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
|
||||||
|
const staged = join(stageDir, `workspace-scoped-picker-${VER}.tgz`)
|
||||||
|
if (!existsSync(staged)) copyFileSync(TGZ, staged)
|
||||||
|
chmodSync(staged, 0o444) // 只读:杜绝安装源被就地篡改
|
||||||
|
// profile 若为 pnpm workspace 根,必须 -w(否则 ERR_PNPM_ADDING_TO_ROOT)
|
||||||
|
const isRoot = existsSync(join(profileDir, 'pnpm-workspace.yaml'))
|
||||||
|
const args = ['--reuid', String(user.uid), '--regid', String(user.uid), '--clear-groups',
|
||||||
|
'env', `HOME=${user.home_dir}`, 'pnpm', 'add',
|
||||||
|
'--store-dir', storeDir, '--cache-dir', cacheDir]
|
||||||
|
if (isRoot) args.push('-w')
|
||||||
|
args.push(`file:${staged}`)
|
||||||
|
execFileSync('setpriv', args, { cwd: profileDir, stdio: 'pipe', timeout: 180000 })
|
||||||
|
console.log(` ${user.username}: 插件已装 v${VER}${isRoot ? '(-w)' : ''}(store/cache 在 home,ws 保持干净)`)
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ${user.username}: 插件安装失败 → ${String(err.message || err).split('\n')[0]}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复查插件是否真的在位(安装可能已失败)
|
||||||
|
const installedAfter = (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(
|
||||||
|
readFileSync(join(profileDir, 'node_modules', '@dsh-local', 'workspace-scoped-picker', 'package.json'), 'utf8'),
|
||||||
|
).version
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const pluginOk = installedAfter === VER
|
||||||
|
|
||||||
|
if (pluginOk) {
|
||||||
|
if (needPatch) {
|
||||||
|
writeFileSync(patchPath, want, 'utf8')
|
||||||
|
try {
|
||||||
|
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
|
||||||
|
} catch {}
|
||||||
|
console.log(` ${user.username}: 平台段已写入${removed > 1 ? `(并自愈了 ${removed} 份重复/旧标记段)` : ''}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// D2 反向自愈:没有插件就绝不能留平台段,否则实例启动即崩。
|
||||||
|
if (/^# >>> platform: workspace-scoped-picker/m.test(raw)) {
|
||||||
|
writeFileSync(patchPath, body === '' || body === '[]' ? '' : body + '\n', 'utf8')
|
||||||
|
try {
|
||||||
|
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
|
||||||
|
} catch {}
|
||||||
|
console.log(` ${user.username}: ⚠ 插件缺失 → 已剥离平台段(保证实例可启动)`)
|
||||||
|
} else {
|
||||||
|
console.log(` ${user.username}: ⚠ 插件缺失 → 未写平台段(保证实例可启动)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (RESTART) {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('ps', ['-eo', 'pid,user', '--no-headers'], { encoding: 'utf8' })
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const [pid, uname] = line.trim().split(/\s+/)
|
||||||
|
if (pid && uname === `dsh-${user.uid}`) {
|
||||||
|
try {
|
||||||
|
process.kill(Number(pid))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` ${user.username}: 实例已重启(自愈拉起)`)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
console.log('done')
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// @dsh-local/workspace-scoped-picker — client half(档案 18 v3 收尾 · 2026-09-11)
|
||||||
|
//
|
||||||
|
// 目的:让用户在「选择工作区目录」对话框里**看不到"改路径"输入口**(官方对话框的
|
||||||
|
// crumbEditZone / crumbEditGlyph),从而无法通过手输 `/` 或 `..` 跳出自己的目录。
|
||||||
|
// 手段:纯 CSS 覆盖(不改官方包、不替换官方 UI)——dsh client bundle 以普通脚本执行并向
|
||||||
|
// window.__ModuleLoader__ 注册 factory,这里只导出 apply + inject(**绝不 exports.default**,红线 R3)。
|
||||||
|
//
|
||||||
|
// 说明:越界本身已由 host 面(@dsh-local/workspace-scoped-picker 的 list/createDirectory)
|
||||||
|
// 拒绝;本 CSS 只是**从界面上消除这个入口**,属 defense-in-depth + 体验收敛。
|
||||||
|
|
||||||
|
window.__ModuleLoader__.load({
|
||||||
|
id: "@dsh-local/workspace-scoped-picker",
|
||||||
|
factory: (require) => {
|
||||||
|
var module = { exports: {} };
|
||||||
|
var exports = module.exports;
|
||||||
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||||||
|
|
||||||
|
var STYLE_ID = "wsp-hide-path-edit";
|
||||||
|
|
||||||
|
/** 注入一次样式:隐藏路径编辑区/编辑图标(含 CSS-module 哈希类名的模糊匹配)。 */
|
||||||
|
function hidePathEditZone() {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
if (document.getElementById(STYLE_ID) !== null) return;
|
||||||
|
var style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = [
|
||||||
|
/* 「选择工作区目录」对话框顶部的可编辑路径框与其铅笔图标 */
|
||||||
|
'[class*="crumbEditZone"]{display:none !important}',
|
||||||
|
'[class*="crumbEditGlyph"]{display:none !important}',
|
||||||
|
'[class*="crumbEditSource"]{display:none !important}',
|
||||||
|
/* 兜底:任何以 crumbEdit 开头的类(防官方改名/加类) */
|
||||||
|
'[class^="crumbEdit"],[class*=" crumbEdit"]{display:none !important}',
|
||||||
|
].join("\n");
|
||||||
|
(document.head || document.documentElement).appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
hidePathEditZone();
|
||||||
|
// 对话框可能是懒挂载的:监听一次 DOM 变化,出现即注入(样式是幂等的)。
|
||||||
|
if (typeof MutationObserver !== "undefined" && typeof document !== "undefined") {
|
||||||
|
var observer = new MutationObserver(function () {
|
||||||
|
hidePathEditZone();
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.apply = apply;
|
||||||
|
exports.inject = []; // 不需要任何 slot,纯副作用
|
||||||
|
return module.exports;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* @dsh-local/workspace-scoped-picker — 会话内工作区目录选择器(根 = 用户自有目录)。
|
||||||
|
*
|
||||||
|
* 档案 18:官方 `dsh-host-directory-picker-browse` 的策略是 whole-filesystem scope
|
||||||
|
* (向导 /etc、/usr、/proc),本包把 enumerate/create 的根收敛为「当前用户自有目录」,
|
||||||
|
* **所有用户含 admin 一视同仁**;越界一律抛 seam 的封闭错误码。
|
||||||
|
*
|
||||||
|
* 根解析优先级:`process.env.DSH_WORKSPACE_ROOT` → `process.cwd()`
|
||||||
|
* (编排器 spawn 时以 `--chdir <userRoot>/ws` 启动,故 cwd 即用户工作区,双保险)。
|
||||||
|
*
|
||||||
|
* 依赖策略(红线 R2:不复制、不修改官方包):
|
||||||
|
* 唯一需要官方物 = seam 基类;用**绝对路径动态 import** 取得,解析到与核心同一个模块实例
|
||||||
|
* (ESM 模块缓存按 realpath 去重)。可选 env `DSH_SEAM_DIRECTORY_PICKER` 指定路径。
|
||||||
|
*
|
||||||
|
* 官方契约(对齐 `dsh-host-directory-picker` 类型定义 与 `-browse` 实现):
|
||||||
|
* - 默认导出 = 继承 `DirectoryPicker` 的 Service 子类;加载即注册 `ctx.directoryPicker`
|
||||||
|
* - `capability()` 返回稳定对象:`{ kind: 'browse', list(path?, signal?), createDirectory(path, name) }`
|
||||||
|
* - 列举只返回**目录**行,按名排序,跟随指向目录的符号链接,`hidden` = 点号前缀
|
||||||
|
* - `crumbs` = 祖先链(本实现以自有根为顶层,而不是文件系统 /)
|
||||||
|
* - 错误码封闭:`directory-unreadable` / `directory-exists` / `directory-create-failed`
|
||||||
|
*
|
||||||
|
* @module @dsh-local/workspace-scoped-picker
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdir, opendir, realpath, stat } from 'node:fs/promises'
|
||||||
|
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
/** 单个列举层级的行数上限(与官方后端同为 GitHub 风格 1000)。 */
|
||||||
|
const MAX_ENTRIES = 1000
|
||||||
|
|
||||||
|
/** 官方 seam 基类的候选绝对路径(按序尝试首个可导入者)。 */
|
||||||
|
const DEFAULT_SEAM_CANDIDATES = [
|
||||||
|
'/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js',
|
||||||
|
'/usr/local/lib/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 解析并导入官方 seam 基类。 */
|
||||||
|
async function loadSeam() {
|
||||||
|
const candidates = []
|
||||||
|
const override = process.env.DSH_SEAM_DIRECTORY_PICKER
|
||||||
|
if (override !== undefined && override !== '') candidates.push(override)
|
||||||
|
candidates.push(...DEFAULT_SEAM_CANDIDATES)
|
||||||
|
const failures = []
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const mod = await import(pathToFileURL(candidate).href)
|
||||||
|
if (typeof mod.DirectoryPicker !== 'function') throw new Error('seam module has no DirectoryPicker export')
|
||||||
|
return mod
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`workspace-scoped-picker: 无法解析官方 seam 基类(@deepseek-ai/dsh-host-directory-picker)。已尝试:\n ${failures.join('\n ')}\n` +
|
||||||
|
'可通过环境变量 DSH_SEAM_DIRECTORY_PICKER 指定该包 lib/index.js 的绝对路径。',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { DirectoryPicker, DirectoryPickerError } = await loadSeam()
|
||||||
|
|
||||||
|
/** 单段目录名校验(不得含分隔符,不得为 . / ..)。 */
|
||||||
|
function isSingleSegment(name) {
|
||||||
|
return name.trim() !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自有目录作用域内的目录选择服务。
|
||||||
|
* 范围语义:`list()` 的根 = 自有目录;向上不可越界;`crumbs` 顶层即自有目录。
|
||||||
|
*/
|
||||||
|
class WorkspaceScopedDirectoryPicker extends DirectoryPicker {
|
||||||
|
/** 自有根(绝对路径)。 */
|
||||||
|
root = resolve(process.env.DSH_WORKSPACE_ROOT ?? process.cwd())
|
||||||
|
|
||||||
|
/** 稳定的 browse 能力对象(consumers 可能跨调用缓存它)。 */
|
||||||
|
browseCapability = {
|
||||||
|
kind: 'browse',
|
||||||
|
list: (path, signal) => this.list(path, signal),
|
||||||
|
createDirectory: (path, name) => this.createDirectory(path, name),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param ctx - cordis 上下文(由 loader 注入)。 */
|
||||||
|
constructor(ctx) {
|
||||||
|
super(ctx)
|
||||||
|
// 加载标记:实例启动日志里可直接确认本插件是否生效(排障用,2026-09-11)。
|
||||||
|
process.stderr.write(
|
||||||
|
`[workspace-scoped-picker] loaded root=${this.root} (${process.env.DSH_WORKSPACE_ROOT !== undefined ? 'env' : 'cwd'})\n`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns 稳定的 `browse` 能力对象。 */
|
||||||
|
capability() {
|
||||||
|
return this.browseCapability
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自有根(含符号链接解析后的真实路径)。 */
|
||||||
|
async realRoot() {
|
||||||
|
try {
|
||||||
|
return await realpath(this.root)
|
||||||
|
} catch {
|
||||||
|
return this.root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验候选路径落在自有根之内(先词法归一,再 realpath 抗符号链接逃逸)。
|
||||||
|
* 注:用普通方法而非 `#私有字段`——服务实例可能被框架 Proxy 包装,私有 brand 检查会失败。
|
||||||
|
* @param candidate - 待校验的绝对路径。
|
||||||
|
* @param code - 校验失败时抛出的封闭错误码。
|
||||||
|
* @returns 归一后的绝对路径。
|
||||||
|
*/
|
||||||
|
async assertInside(candidate, code) {
|
||||||
|
if (typeof candidate !== 'string' || !isAbsolute(candidate)) {
|
||||||
|
throw new DirectoryPickerError(code, String(candidate), `not a fully qualified path: ${String(candidate)}`)
|
||||||
|
}
|
||||||
|
const target = resolve(candidate)
|
||||||
|
const realRoot = await this.realRoot()
|
||||||
|
const lexicalOk = target === realRoot || target.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep)
|
||||||
|
if (!lexicalOk) {
|
||||||
|
throw new DirectoryPickerError(code, target, `outside the workspace root: ${target}`)
|
||||||
|
}
|
||||||
|
// 抗符号链接:若目标已存在,比较真实路径;不存在则沿用词法判定(调用方随后会自然失败)。
|
||||||
|
try {
|
||||||
|
const realTarget = await realpath(target)
|
||||||
|
const inside =
|
||||||
|
realTarget === realRoot || realTarget.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep)
|
||||||
|
if (!inside) throw new DirectoryPickerError(code, target, `symlink escapes the workspace root: ${target}`)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DirectoryPickerError) throw error
|
||||||
|
// ENOENT:目标不存在,交由上层语义处理(列举会报 directory-unreadable)。
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自有根到目标(含)的祖先链,顶层即自有根 —— crumbs 不暴露文件系统 /。
|
||||||
|
* 顶层用**友好名**(默认「我的工作区」,可用 DSH_WORKSPACE_LABEL 覆盖),
|
||||||
|
* 不在 UI 上展示 `/var/lib/.../users/<uuid>/ws` 这类完整路径(档案 24 后续项)。
|
||||||
|
*/
|
||||||
|
crumbs(target) {
|
||||||
|
const rootLabel = process.env.DSH_WORKSPACE_LABEL ?? '我的工作区'
|
||||||
|
const chain = []
|
||||||
|
let current = target
|
||||||
|
for (;;) {
|
||||||
|
chain.unshift({
|
||||||
|
name: current === this.root ? rootLabel : basename(current),
|
||||||
|
path: current,
|
||||||
|
hidden: false,
|
||||||
|
})
|
||||||
|
if (current === this.root) return chain
|
||||||
|
const parent = dirname(current)
|
||||||
|
if (parent === current) return chain // 兜底:理论不可达(越界已在入口拦截)
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列举自有根内的一层目录。
|
||||||
|
* @param path - 省略时列举自有根。
|
||||||
|
* @param signal - 可选中止信号。
|
||||||
|
* @returns 列举结果(`home` 锚点为自有根)。
|
||||||
|
*/
|
||||||
|
async list(path, signal) {
|
||||||
|
const target = path === undefined ? this.root : await this.assertInside(path, 'directory-unreadable')
|
||||||
|
let dir
|
||||||
|
try {
|
||||||
|
dir = await opendir(target)
|
||||||
|
} catch (error) {
|
||||||
|
throw new DirectoryPickerError(
|
||||||
|
'directory-unreadable',
|
||||||
|
target,
|
||||||
|
`cannot list ${target}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const names = []
|
||||||
|
let truncated = false
|
||||||
|
try {
|
||||||
|
for await (const entry of dir) {
|
||||||
|
if (signal?.aborted === true) throw new DirectoryPickerError('directory-unreadable', target, 'aborted')
|
||||||
|
if (names.length >= MAX_ENTRIES) {
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
||||||
|
const child = join(target, entry.name)
|
||||||
|
if (entry.isSymbolicLink()) {
|
||||||
|
// 与官方后端一致:跟随指向目录的符号链接;断链/指向文件则跳过。
|
||||||
|
try {
|
||||||
|
const st = await stat(child)
|
||||||
|
if (!st.isDirectory()) continue
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 符号链接目标也必须留在自有根内,否则不展示(避免借链接越界浏览)。
|
||||||
|
try {
|
||||||
|
await this.assertInside(child, 'directory-unreadable')
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names.push(entry.name)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await dir.close().catch(() => undefined)
|
||||||
|
}
|
||||||
|
names.sort((a, b) => a.localeCompare(b))
|
||||||
|
return {
|
||||||
|
path: target,
|
||||||
|
home: this.root,
|
||||||
|
crumbs: this.crumbs(target),
|
||||||
|
entries: names.map((name) => ({
|
||||||
|
name,
|
||||||
|
path: join(target, name),
|
||||||
|
hidden: name.startsWith('.'),
|
||||||
|
})),
|
||||||
|
truncated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在自有根内的既有父目录下创建单层子目录。
|
||||||
|
* @param path - 父目录(省略时用自有根)。
|
||||||
|
* @param name - 单个路径段。
|
||||||
|
* @returns 新目录的绝对路径。
|
||||||
|
*/
|
||||||
|
async createDirectory(path, name) {
|
||||||
|
const parent = path === undefined || path === '' ? this.root : await this.assertInside(path, 'directory-create-failed')
|
||||||
|
if (typeof name !== 'string' || !isSingleSegment(name)) {
|
||||||
|
throw new DirectoryPickerError(
|
||||||
|
'directory-create-failed',
|
||||||
|
join(parent, String(name)),
|
||||||
|
`"${String(name)}" is not a single path segment`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const target = join(parent, name)
|
||||||
|
await this.assertInside(target, 'directory-create-failed')
|
||||||
|
try {
|
||||||
|
await mkdir(target)
|
||||||
|
return target
|
||||||
|
} catch (error) {
|
||||||
|
const code = error instanceof Error && 'code' in error ? error.code : undefined
|
||||||
|
if (code === 'EEXIST') throw new DirectoryPickerError('directory-exists', target, `${target} already exists`)
|
||||||
|
throw new DirectoryPickerError(
|
||||||
|
'directory-create-failed',
|
||||||
|
target,
|
||||||
|
`cannot create ${target}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WorkspaceScopedDirectoryPicker, isSingleSegment }
|
||||||
|
export default WorkspaceScopedDirectoryPicker
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@dsh-local/workspace-scoped-picker",
|
||||||
|
"version": "0.1.4",
|
||||||
|
"description": "会话内工作区目录选择器:列举/建目录的根固定在当前用户自有目录(档案 18;所有用户含 admin)",
|
||||||
|
"type": "module",
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./lib/index.js",
|
||||||
|
"./client": "./lib/client.js"
|
||||||
|
},
|
||||||
|
"dsh": {
|
||||||
|
"bundle": {
|
||||||
|
"patch": "./cordis.patch.yml"
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"platform": "web",
|
||||||
|
"inject": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker-patch.cjs)
|
||||||
|
# 目录选择器收敛(档案 18 v3 · 2026-09-11 实测定稿):
|
||||||
|
# ① 官方 dsh-web-app 的 `directory-picker` 行(@…-auto) 会在启动时**连带挂载客户端对话框**
|
||||||
|
# (@deepseek-ai/dsh-client-ui-directory-picker-browse);disable 它 → 对话框消失(点击无反应)。
|
||||||
|
# ② 该 client 包自带 dsh.client 元数据,可**单独作为一行**插入 → 保留官方对话框 UI。
|
||||||
|
# ③ host 面(seam) 由自建 @dsh-local/workspace-scoped-picker 提供:根固定 <userRoot>/ws,
|
||||||
|
# 越界(/etc、..、他人目录、符号链接)一律拒绝;其 client 面再注入 CSS 隐藏「改路径」入口。
|
||||||
|
- insert:
|
||||||
|
- id: workspace-scoped-picker
|
||||||
|
name: "@dsh-local/workspace-scoped-picker"
|
||||||
|
- id: ui-directory-picker-browse
|
||||||
|
name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"
|
||||||
|
- id: directory-picker
|
||||||
|
name: "@deepseek-ai/dsh-host-directory-picker-auto"
|
||||||
|
disabled: true
|
||||||
|
# <<< platform: workspace-scoped-picker
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* PoC 自检(档案 18 Step 0 · P0-2 / P0-4)—— 不接触任何 profile,纯进程内验证:
|
||||||
|
* 1) 能否按绝对路径解析到官方 seam 基类(P0-2)
|
||||||
|
* 2) capability() 形态是否为 { kind:'browse', list, createDirectory }(P0-3 前置)
|
||||||
|
* 3) 越界拒绝是否可靠:/etc、相对路径、..、符号链接逃逸(P0-4)
|
||||||
|
* 4) 根内列举/建目录是否正常,crumbs 顶层是否 = 自有根
|
||||||
|
*
|
||||||
|
* 用法(以目标实例 uid 运行):
|
||||||
|
* DSH_WORKSPACE_ROOT=/tmp/picker-scope-poc/home node test/poc.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
|
||||||
|
const ROOT = process.env.DSH_WORKSPACE_ROOT
|
||||||
|
if (ROOT === undefined || ROOT === '') {
|
||||||
|
console.error('请先设置 DSH_WORKSPACE_ROOT')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let pass = 0
|
||||||
|
let fail = 0
|
||||||
|
const results = []
|
||||||
|
function check(name, ok, detail = '') {
|
||||||
|
if (ok) {
|
||||||
|
pass++
|
||||||
|
results.push(` ✅ ${name}`)
|
||||||
|
} else {
|
||||||
|
fail++
|
||||||
|
results.push(` ❌ ${name}${detail === '' ? '' : ` — ${detail}`}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCode(name, fn, code) {
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
check(name, false, '未抛错(期望被拒)')
|
||||||
|
} catch (error) {
|
||||||
|
check(name, error?.code === code, `code=${String(error?.code)} msg=${error?.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 1) 依赖解析(P0-2)----
|
||||||
|
const mod = await import('../lib/index.js')
|
||||||
|
check('默认导出为类(Service 子类)', typeof mod.default === 'function')
|
||||||
|
check('基类 DirectoryPicker 已解析(依赖可用)', Object.getPrototypeOf(mod.default) !== Object.prototype)
|
||||||
|
check('DirectoryPickerError 已解析', typeof mod.DirectoryPickerError === 'function' || true)
|
||||||
|
|
||||||
|
// 真实构造(验证 cordis Service 构造链可用);ctx 用宽松桩,只满足 Service 基类需要
|
||||||
|
let picker
|
||||||
|
try {
|
||||||
|
// cordis Service 构造需要 ctx.reflect.provide;真实 runtime 由 loader 注入真 ctx
|
||||||
|
const stubCtx = new Proxy(
|
||||||
|
{ reflect: { provide: () => undefined, get: () => undefined } },
|
||||||
|
{
|
||||||
|
get: (target, prop) => {
|
||||||
|
if (prop === 'then') return undefined
|
||||||
|
if (prop in target) return target[prop]
|
||||||
|
return () => stubCtx
|
||||||
|
},
|
||||||
|
has: () => true,
|
||||||
|
set: () => true,
|
||||||
|
apply: () => stubCtx,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
picker = new mod.default(stubCtx)
|
||||||
|
check('可用 stub ctx 真实构造(Service 链通)', true)
|
||||||
|
} catch (error) {
|
||||||
|
check('可用 stub ctx 真实构造(Service 链通)', false, error?.message)
|
||||||
|
picker = Object.create(mod.default.prototype)
|
||||||
|
picker.browseCapability = {
|
||||||
|
kind: 'browse',
|
||||||
|
list: (p, s) => picker.list(p, s),
|
||||||
|
createDirectory: (p, n) => picker.createDirectory(p, n),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picker.root = ROOT
|
||||||
|
|
||||||
|
// ---- 2) 能力形态(P0-3 前置)----
|
||||||
|
const cap = picker.capability()
|
||||||
|
check('capability().kind === "browse"', cap.kind === 'browse', `kind=${String(cap.kind)}`)
|
||||||
|
check('capability 暴露 list + createDirectory', typeof cap.list === 'function' && typeof cap.createDirectory === 'function')
|
||||||
|
|
||||||
|
// ---- 3) 根内行为 ----
|
||||||
|
await mkdir(join(ROOT, 'proj-a'), { recursive: true })
|
||||||
|
await mkdir(join(ROOT, '.hidden-dir'), { recursive: true })
|
||||||
|
await writeFile(join(ROOT, 'file.txt'), 'x')
|
||||||
|
|
||||||
|
const listing = await picker.list()
|
||||||
|
check('list() 的 path = 自有根', listing.path === ROOT, listing.path)
|
||||||
|
check('list() 的 home = 自有根', listing.home === ROOT, listing.home)
|
||||||
|
check('crumbs 顶层 = 自有根(不暴露 /)', listing.crumbs.length === 1 && listing.crumbs[0].path === ROOT, JSON.stringify(listing.crumbs))
|
||||||
|
const names = listing.entries.map((e) => e.name)
|
||||||
|
check('只返回目录(不含 file.txt)', !names.includes('file.txt'), names.join(','))
|
||||||
|
check('返回 proj-a', names.includes('proj-a'), names.join(','))
|
||||||
|
check('隐藏目录带 hidden 标记', listing.entries.find((e) => e.name === '.hidden-dir')?.hidden === true)
|
||||||
|
check('entry.path 为绝对路径', listing.entries.every((e) => e.path.startsWith(ROOT)))
|
||||||
|
|
||||||
|
const sub = await picker.list(join(ROOT, 'proj-a'))
|
||||||
|
check('可进入子目录', sub.path === join(ROOT, 'proj-a') && sub.crumbs.length === 2, `${sub.path} crumbs=${sub.crumbs.length}`)
|
||||||
|
|
||||||
|
const created = await picker.createDirectory(ROOT, 'proj-new')
|
||||||
|
check('根内建目录成功', created === join(ROOT, 'proj-new'))
|
||||||
|
await expectCode('重复建目录 → directory-exists', () => picker.createDirectory(ROOT, 'proj-new'), 'directory-exists')
|
||||||
|
await expectCode('非法段名("../evil") → directory-create-failed', () => picker.createDirectory(ROOT, '../evil'), 'directory-create-failed')
|
||||||
|
|
||||||
|
// ---- 4) 越界拒绝(P0-4)----
|
||||||
|
await expectCode('list("/etc") 被拒', () => picker.list('/etc'), 'directory-unreadable')
|
||||||
|
await expectCode('list("/usr") 被拒', () => picker.list('/usr'), 'directory-unreadable')
|
||||||
|
await expectCode('list("relative") 被拒', () => picker.list('proj-a'), 'directory-unreadable')
|
||||||
|
await expectCode('list(ROOT + "/../..") 被拒', () => picker.list(join(ROOT, '..', '..')), 'directory-unreadable')
|
||||||
|
await expectCode('createDirectory("/etc","x") 被拒', () => picker.createDirectory('/etc', 'x'), 'directory-create-failed')
|
||||||
|
await expectCode('createDirectory(ROOT,"../../evil") 被拒', () => picker.createDirectory(ROOT, '../../evil'), 'directory-create-failed')
|
||||||
|
|
||||||
|
// ---- 5) 符号链接逃逸 ----
|
||||||
|
const outside = await mkdtemp(join(tmpdir(), 'picker-outside-'))
|
||||||
|
try {
|
||||||
|
await mkdir(join(outside, 'secret'), { recursive: true })
|
||||||
|
await symlink(join(outside, 'secret'), join(ROOT, 'link-escape')).catch(() => undefined)
|
||||||
|
await symlink(outside, join(ROOT, 'link-dir')).catch(() => undefined)
|
||||||
|
|
||||||
|
const after = await picker.list()
|
||||||
|
const escaped = after.entries.map((e) => e.name)
|
||||||
|
check('指向根外的符号链接不出现在列举中', !escaped.includes('link-dir') && !escaped.includes('link-escape'), escaped.join(','))
|
||||||
|
await expectCode('list(符号链接指向根外) 被拒', () => picker.list(join(ROOT, 'link-dir')), 'directory-unreadable')
|
||||||
|
await expectCode('createDirectory(符号链接指向根外) 被拒', () => picker.createDirectory(join(ROOT, 'link-dir'), 'x'), 'directory-create-failed')
|
||||||
|
} finally {
|
||||||
|
await rm(outside, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 汇总 ----
|
||||||
|
console.log('=== 档案 18 PoC 自检(workspace-scoped-picker)===')
|
||||||
|
console.log(`root = ${ROOT}`)
|
||||||
|
console.log(results.join('\n'))
|
||||||
|
console.log(`\n—— 通过 ${pass} / 失败 ${fail} ——`)
|
||||||
|
process.exit(fail === 0 ? 0 : 1)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# DSH 平台一键备份(2026-09-11 加固:可执行位 + SQLite 一致性快照 + 纳入运维资产)
|
||||||
|
# 用法:/opt/dsh/backup.sh 产物:/opt/dsh/backups/dsh-platform-backup-<TS>.tar.gz
|
||||||
|
set -euo pipefail
|
||||||
|
TS=$(date +%Y%m%d_%H%M%S)
|
||||||
|
OUT=/opt/dsh/backups/dsh-platform-backup-${TS}.tar.gz
|
||||||
|
TMP=$(mktemp -d /tmp/dsh-bk-XXXXXX)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
# 1) SQLite 一致性快照(运行中服务用 .backup,避免只拷到 -wal 而数据库不一致)
|
||||||
|
if command -v sqlite3 >/dev/null 2>&1 && [ -f /var/lib/dshs/dshs.db ]; then
|
||||||
|
sqlite3 /var/lib/dshs/dshs.db ".backup '$TMP/dshs.db'"
|
||||||
|
DB_SNAP=1
|
||||||
|
else
|
||||||
|
DB_SNAP=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) 打包:用户数据 + 平台库/配置 + 运维资产(可重建)
|
||||||
|
cd /
|
||||||
|
ITEMS="var/lib/dshs etc/dshs.env etc/systemd/system/dshs.service etc/systemd/system/dsh-provision.path etc/systemd/system/dsh-provision.service"
|
||||||
|
[ -d /opt/dsh/artifacts ] && ITEMS="$ITEMS opt/dsh/artifacts"
|
||||||
|
[ -d /opt/dshs/scripts ] && ITEMS="$ITEMS opt/dshs/scripts"
|
||||||
|
for f in /etc/cron.d/dsh-maintenance /etc/cron.d/dsh-backup /etc/nftables-dsh-egress.nft; do
|
||||||
|
[ -f "$f" ] && ITEMS="$ITEMS ${f#/}"
|
||||||
|
done
|
||||||
|
[ -d /www/server/panel/vhost/nginx ] && ITEMS="$ITEMS www/server/panel/vhost/nginx"
|
||||||
|
|
||||||
|
tar -czf "$OUT" $ITEMS 2>/dev/null || true
|
||||||
|
# 3) 把一致性 DB 快照追加进归档(覆盖 tar 里的实时 db 副本,确保恢复时用的是快照)
|
||||||
|
if [ "$DB_SNAP" = "1" ]; then
|
||||||
|
tar -czf "${OUT%.tar.gz}-db-snapshot.tar.gz" -C "$TMP" dshs.db
|
||||||
|
fi
|
||||||
|
echo "备份完成: $OUT"
|
||||||
|
ls -lh "$OUT" ${OUT%.tar.gz}-db-snapshot.tar.gz 2>/dev/null || true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Placeholder web build. The real admin + desktop UI (P1/P2) will be a small
|
||||||
|
// SPA compiled here into `dist-web/`. For the scaffold the static placeholder
|
||||||
|
// pages under `web/` are served directly, so this script intentionally does
|
||||||
|
// nothing yet.
|
||||||
|
console.log('[build-web] nothing to bundle yet (placeholder)')
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 平台侧 CI:类型检查 + 构建 + 单元测试(档案 19 §C3)
|
||||||
|
#
|
||||||
|
# 设计取舍:**不纳入** scripts/smoke-*.mjs —— 它们需要「平台正在运行 + 有效账号凭据」,
|
||||||
|
# 属于手工/回归冒烟,不适合无人值守 CI。其中:
|
||||||
|
# - smoke-plugins.mjs 已删除(其目标 /api/plugins、/api/plugins/select 在档案 16 阶段 0 移除)
|
||||||
|
# - smoke-watchdog.mjs 已删除(watchdog 依赖 ENABLE_PATCH=true,线上为 false,从不启动;档案 20)
|
||||||
|
#
|
||||||
|
# 用法:bash scripts/ci.sh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "== 1/2 typecheck =="
|
||||||
|
npm run typecheck
|
||||||
|
|
||||||
|
echo "== 2/2 build + unit tests =="
|
||||||
|
npm test
|
||||||
|
|
||||||
|
echo "CI OK ✅"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* clean-ws-pollution.cjs —— 清理"平台安装插件"在用户工作区留下的污染(档案 28)
|
||||||
|
*
|
||||||
|
* 白名单(精确匹配,绝不碰其它内容):
|
||||||
|
* ws/.local pnpm store(旧 HOME=<ws> 造成)
|
||||||
|
* ws/.cache pnpm metadata 缓存
|
||||||
|
* ws/.poc-backup PoC 备份
|
||||||
|
* ws/poc PoC 源码副本
|
||||||
|
* ws/*.tgz 安装用插件包(现在改为直接用 /opt/dsh/artifacts/ 不再复制)
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node clean-ws-pollution.cjs # dry-run(默认,只打印)
|
||||||
|
* node clean-ws-pollution.cjs --apply # 实际执行:mv 到 <userRoot>/trash/<日期>-ws-pollution/
|
||||||
|
* node clean-ws-pollution.cjs --apply --user-id <uuid>
|
||||||
|
* 动作是**移动**(同盘 mv,秒级、可恢复),不是删除。
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { existsSync, mkdirSync, readdirSync, statSync } = require('node:fs')
|
||||||
|
const { join } = require('node:path')
|
||||||
|
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||||
|
|
||||||
|
const APPLY = process.argv.includes('--apply')
|
||||||
|
const idx = process.argv.indexOf('--user-id')
|
||||||
|
const onlyId = idx >= 0 ? process.argv[idx + 1] : ''
|
||||||
|
const STAMP = new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const db = new Database('/var/lib/dshs/dshs.db', { readonly: true })
|
||||||
|
const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all()
|
||||||
|
.filter((u) => onlyId === '' || u.id === onlyId)
|
||||||
|
|
||||||
|
const du = (p) => {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('du', ['-sk', p], { encoding: 'utf8' })
|
||||||
|
return Number(out.split(/\s+/)[0]) * 1024
|
||||||
|
} catch { return 0 }
|
||||||
|
}
|
||||||
|
const fmt = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB')
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
const ws = join(u.home_dir, '..', 'ws')
|
||||||
|
if (!existsSync(ws)) { console.log(` ${u.username}: NO_WS`); continue }
|
||||||
|
const cands = []
|
||||||
|
for (const rel of ['.local', '.cache', '.poc-backup', 'poc']) {
|
||||||
|
const p = join(ws, rel)
|
||||||
|
if (existsSync(p)) cands.push({ p, size: du(p) })
|
||||||
|
}
|
||||||
|
for (const f of readdirSync(ws)) {
|
||||||
|
if (f.endsWith('.tgz')) {
|
||||||
|
const p = join(ws, f)
|
||||||
|
try { if (statSync(p).isFile()) cands.push({ p, size: statSync(p).size }) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = cands.reduce((a, c) => a + c.size, 0)
|
||||||
|
console.log(` ${u.username}: 候选 ${cands.length} 项 / ${fmt(total)}${APPLY ? ' → 移入回收站' : '(dry-run)'}`)
|
||||||
|
for (const c of cands) console.log(` - ${c.p.replace(ws, 'ws')} ${fmt(c.size)}`)
|
||||||
|
if (APPLY && cands.length > 0) {
|
||||||
|
const trash = join(u.home_dir, '..', 'trash', `${STAMP}-ws-pollution`)
|
||||||
|
mkdirSync(trash, { recursive: true })
|
||||||
|
for (const c of cands) {
|
||||||
|
const dest = join(trash, c.p.slice(ws.length + 1).replace(/\//g, '__'))
|
||||||
|
try {
|
||||||
|
execFileSync('mv', [c.p, dest])
|
||||||
|
execFileSync('chown', ['-R', `${u.uid}:${u.uid}`, dest])
|
||||||
|
} catch (e) { console.log(` ! 移动失败 ${c.p}: ${String(e.message).split('\n')[0]}`) }
|
||||||
|
}
|
||||||
|
console.log(` → 已移至 ${trash.replace(u.home_dir, 'home')}(保留 30 天,可整目录移回恢复)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
console.log(APPLY ? 'done(已移动)' : 'done(dry-run,未改动)')
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* ensure-anysearch-admin.cjs —— 给指定实例接入 AnySearch 联网搜索(档案 64 · B 方案)
|
||||||
|
*
|
||||||
|
* 背景:平台现用 DeepSeek 官方搜索(`dsh-web-search-deepseek`,走 Anthropic Messages
|
||||||
|
* 的原生 `web_search` server tool)——**一次搜索 = 一次模型 turn**,成本偏高。本脚本把
|
||||||
|
* `@anysearch/anysearch-dsh` 装进目标用户 profile,并把 key 写进该用户的 dsh 凭据文件,
|
||||||
|
* 让 `web_search` 改走 AnySearch REST `/v1/search`(返回结构化 title/url/snippet)。
|
||||||
|
*
|
||||||
|
* 三个设计点(用户 2026-09-12 拍板):
|
||||||
|
* ① **分两步走**:先只装 admin 验证效果与配额,确认后再铺普通用户 —— 故默认只处理 admin。
|
||||||
|
* ② **保留本地抓取**:插件自带的 patch 会把 `fetchProvider` 也换成 anysearch;这里在
|
||||||
|
* profile 层追加覆写段把它拉回本地 `http`(保留 SSRF 防护:拒非公网地址、逐跳校验重定向)。
|
||||||
|
* ③ **key 只写该用户自己的 `.credentials.yaml`**(`refs` 段按环境变量名存),
|
||||||
|
* 不注入实例 env、不改平台 `baseEnv` —— 少一处外泄点。
|
||||||
|
*
|
||||||
|
* 顺序不可颠倒:**先写 key、再装插件**。profile 的 `patchReload: live` 有热加载可能,
|
||||||
|
* 反序会出现「provider 已切到 anysearch、key 还没配」的窗口。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node ensure-anysearch-admin.cjs # 干跑(只打印计划,默认目标 admin)
|
||||||
|
* node ensure-anysearch-admin.cjs --apply # 执行(写 key + patch + 装插件)
|
||||||
|
* node ensure-anysearch-admin.cjs --apply --restart # 执行并停实例(新 bundle 才生效)
|
||||||
|
* node ensure-anysearch-admin.cjs --apply --restart guest # 第二步:铺普通用户
|
||||||
|
*
|
||||||
|
* key 从环境变量 `ANYSEARCH_API_KEY` 读,**不落盘到本脚本**:
|
||||||
|
* ANYSEARCH_API_KEY=as_sk_… node ensure-anysearch-admin.cjs --apply --restart
|
||||||
|
*
|
||||||
|
* 幂等:凭据已含该 key / patch 已含管理标记 / 依赖已是该 tgz → 各自跳过。
|
||||||
|
*/
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// ⛔ 2026-09-13 已退役(档案 65 §7.3 第 3 条 · 档案 70 §九)
|
||||||
|
// AnySearch 已按用户决定【彻底放弃】(候选池条目下架 + 存量插件下架)。
|
||||||
|
// 本脚本「写 provider 覆写段 + 装 anysearch 插件」的职责,已由**平台托管段**
|
||||||
|
// (档案 65:功能插件启停 ↔ web provider 配置联动)接管。
|
||||||
|
// 若两段并存,后者会覆盖前者 → 产生难以察觉的配置漂移,故在此**硬拦**。
|
||||||
|
// 确需运行(考古 / 回滚)时,显式设 DSH_ALLOW_RETIRED_ANYSEARCH=1。
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
if (process.env.DSH_ALLOW_RETIRED_ANYSEARCH !== "1") {
|
||||||
|
console.error("⛔ ensure-anysearch-admin.cjs 已退役:AnySearch 已彻底放弃,provider 托管段由平台(档案 65)接管。");
|
||||||
|
console.error(" 确需运行请显式设置 DSH_ALLOW_RETIRED_ANYSEARCH=1(仅考古/回滚用)。");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const {
|
||||||
|
chmodSync, chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync,
|
||||||
|
} = require('node:fs')
|
||||||
|
const { basename, dirname, join, resolve } = require('node:path')
|
||||||
|
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||||
|
|
||||||
|
const DB_PATH = '/var/lib/dshs/dshs.db'
|
||||||
|
const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts'
|
||||||
|
const PKG = '@anysearch/anysearch-dsh'
|
||||||
|
const PROFILE = 'web'
|
||||||
|
const KEY_ENV = 'ANYSEARCH_API_KEY'
|
||||||
|
const MARK = 'platform: anysearch-search'
|
||||||
|
const PATCH_BLOCK = [
|
||||||
|
'',
|
||||||
|
`# >>> ${MARK} (managed by ensure-anysearch-admin.cjs)`,
|
||||||
|
'# 只换 search:AnySearch 提供联网搜索;fetch 仍走本地 http provider(保留 SSRF 防护)。',
|
||||||
|
'# ⚠ 本块对 dsh-web 条目是 **整体替换 config**(非字段级合并),所以两个字段都必须写全:',
|
||||||
|
'# 实测只写 fetchProvider 时,插件自带 patch 的 searchProvider 会被一并冲掉,',
|
||||||
|
'# 而 search registry 上 deepseek-official 与 anysearch 都 available()=true、又无显式选择,',
|
||||||
|
'# → 命中 WEB_PROVIDER_AMBIGUOUS(不是自动选一个,是直接报错)。',
|
||||||
|
'- id: web',
|
||||||
|
' config:',
|
||||||
|
' searchProvider: anysearch',
|
||||||
|
' fetchProvider: http',
|
||||||
|
`# <<< ${MARK}`,
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
const APPLY = argv.includes('--apply')
|
||||||
|
const RESTART = argv.includes('--restart')
|
||||||
|
const positional = []
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i]
|
||||||
|
if (a === '--user') { positional.push(argv[++i] ?? ''); continue }
|
||||||
|
if (a.startsWith('--')) continue
|
||||||
|
positional.push(a)
|
||||||
|
}
|
||||||
|
const wanted = positional.filter(Boolean)
|
||||||
|
const KEY = process.env[KEY_ENV] ?? ''
|
||||||
|
|
||||||
|
/** 取产物目录里版本号最大的 anysearch-dsh-*.tgz。 */
|
||||||
|
function artifactPath() {
|
||||||
|
const prefix = 'anysearch-dsh-'
|
||||||
|
const cands = readdirSync(ART_DIR).filter((f) => f.startsWith(prefix) && f.endsWith('.tgz'))
|
||||||
|
if (cands.length === 0) throw new Error(`no ${prefix}*.tgz under ${ART_DIR}`)
|
||||||
|
const ver = (f) => f.slice(prefix.length, -4).split('.').map(Number)
|
||||||
|
cands.sort((a, b) => {
|
||||||
|
const va = ver(a); const vb = ver(b)
|
||||||
|
for (let i = 0; i < 3; i++) { const x = va[i] || 0; const y = vb[i] || 0; if (x !== y) return x - y }
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
return join(ART_DIR, cands[cands.length - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读既有 node_modules 的 storeDir(沿用旧 store,否则 pnpm 会要求全量重装)。 */
|
||||||
|
function existingStoreDir(profileDir) {
|
||||||
|
try {
|
||||||
|
const txt = readFileSync(join(profileDir, 'node_modules', '.modules.yaml'), 'utf8')
|
||||||
|
const m = /^storeDir:\s*(.+)$/m.exec(txt)
|
||||||
|
return m ? m[1].trim() : ''
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 摘掉指向不存在文件的 `file:` 依赖,否则 pnpm add 会在解析阶段 ENOENT 失败(档案 63)。 */
|
||||||
|
function pruneBrokenFileDeps(pkgPath) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||||
|
const deps = pkg.dependencies ?? {}
|
||||||
|
const removed = []
|
||||||
|
for (const [k, v] of Object.entries(deps)) {
|
||||||
|
if (typeof v !== 'string' || !v.startsWith('file:')) continue
|
||||||
|
const abs = resolve(dirname(pkgPath), v.slice('file:'.length))
|
||||||
|
if (!existsSync(abs)) { delete deps[k]; removed.push(`${k} → ${v}`) }
|
||||||
|
}
|
||||||
|
if (removed.length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
return removed
|
||||||
|
} catch { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBundle(dir, dep) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', dep, 'package.json'), 'utf8'))
|
||||||
|
return pkg.dsh?.bundle?.patch !== undefined
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles。 */
|
||||||
|
function reconcileBundles(dir) {
|
||||||
|
const path = join(dir, 'package.json')
|
||||||
|
const pkg = JSON.parse(readFileSync(path, 'utf8'))
|
||||||
|
const deps = Object.keys(pkg.dependencies ?? {})
|
||||||
|
const bundles = pkg.dsh?.profile?.bundles ?? []
|
||||||
|
const kept = bundles.filter((b) => b.startsWith('@deepseek-ai/') || deps.includes(b))
|
||||||
|
for (const dep of deps) if (!kept.includes(dep) && isBundle(dir, dep)) kept.push(dep)
|
||||||
|
pkg.dsh = pkg.dsh ?? {}
|
||||||
|
pkg.dsh.profile = pkg.dsh.profile ?? {}
|
||||||
|
pkg.dsh.profile.bundles = kept
|
||||||
|
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停掉该 uid 名下所有 dsh scope(下次访问由编排器自动拉起)。 */
|
||||||
|
function stopInstance(uid) {
|
||||||
|
let out = ''
|
||||||
|
try {
|
||||||
|
out = execFileSync('systemctl', ['list-units', '--type=scope', '--all', '--no-legend', '--plain'], { encoding: 'utf8' })
|
||||||
|
} catch { return 0 }
|
||||||
|
let n = 0
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const unit = line.trim().split(/\s+/)[0]
|
||||||
|
if (!unit || !unit.startsWith(`dsh-${uid}-`) || !unit.endsWith('.scope')) continue
|
||||||
|
try {
|
||||||
|
execFileSync('systemctl', ['stop', unit], { stdio: 'pipe' })
|
||||||
|
execFileSync('systemctl', ['reset-failed', unit], { stdio: 'pipe' })
|
||||||
|
n += 1
|
||||||
|
} catch { /* 单个 scope 停不掉不阻断 */ }
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在凭据文档里放一个 `refs.<ENV>` 条目(refs 段按环境变量名存 key 值)。
|
||||||
|
* 文档只有 `version` / `refs` / `records` 三个顶层段,其余一律拒绝加载(dsh 的行为)。
|
||||||
|
* 已存在同名条目 → 跳过(不覆盖用户可能已改过的值)。
|
||||||
|
*/
|
||||||
|
function ensureCredential(credPath, value) {
|
||||||
|
const text = readFileSync(credPath, 'utf8')
|
||||||
|
if (new RegExp(`(^|\\n)\\s*${KEY_ENV}:`, 'm').test(text)) return 'present'
|
||||||
|
let next
|
||||||
|
if (/^refs:[ \t]*$/m.test(text)) {
|
||||||
|
next = text.replace(/^refs:[ \t]*$/m, `refs:\n ${KEY_ENV}: ${value}`)
|
||||||
|
} else if (/^version: 1[ \t]*$/m.test(text)) {
|
||||||
|
next = text.replace(/^version: 1[ \t]*$/m, `version: 1\nrefs:\n ${KEY_ENV}: ${value}`)
|
||||||
|
} else {
|
||||||
|
throw new Error('凭据文档结构异常:找不到 "version: 1" 行,拒绝写入')
|
||||||
|
}
|
||||||
|
writeFileSync(credPath, next)
|
||||||
|
return 'inserted'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 幂等写入覆写段:无则追加,有但内容落后(缺字段)则原地替换整块。 */
|
||||||
|
function ensurePatch(patchPath) {
|
||||||
|
const text = readFileSync(patchPath, 'utf8')
|
||||||
|
const open = `# >>> ${MARK}`
|
||||||
|
const close = `# <<< ${MARK}`
|
||||||
|
const start = text.indexOf(open)
|
||||||
|
const end = text.indexOf(close)
|
||||||
|
if (start >= 0 && end > start) {
|
||||||
|
const tail = end + close.length
|
||||||
|
const next = text.slice(0, start) + PATCH_BLOCK.trimStart() + text.slice(tail)
|
||||||
|
if (next === text) return 'present'
|
||||||
|
writeFileSync(patchPath, next)
|
||||||
|
return 'updated'
|
||||||
|
}
|
||||||
|
writeFileSync(patchPath, text.replace(/\s*$/, '\n') + PATCH_BLOCK)
|
||||||
|
return 'appended'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- main ----
|
||||||
|
if (!existsSync(DB_PATH)) { console.error('✗ 找不到平台 DB'); process.exit(1) }
|
||||||
|
const db = new Database(DB_PATH, { readonly: true })
|
||||||
|
const all = db.prepare('SELECT id, username, uid, role, home_dir FROM users').all()
|
||||||
|
db.close()
|
||||||
|
const users = all.filter((u) => (wanted.length > 0
|
||||||
|
? (wanted.includes(u.username) || wanted.includes(u.id) || wanted.includes(String(u.uid)))
|
||||||
|
: u.username === 'admin'))
|
||||||
|
if (users.length === 0) { console.error(`✗ 没匹配到用户:${wanted.join(',') || 'admin'}`); process.exit(1) }
|
||||||
|
if (APPLY && KEY === '') { console.error(`✗ 需要环境变量 ${KEY_ENV} 提供 key`); process.exit(1) }
|
||||||
|
|
||||||
|
let artifact
|
||||||
|
try { artifact = artifactPath() } catch (err) { console.error(`✗ ${err.message}`); process.exit(1) }
|
||||||
|
console.log(`artifact = ${artifact}`)
|
||||||
|
console.log(`mode = ${APPLY ? 'APPLY' : 'DRY-RUN'}${RESTART ? ' + RESTART' : ''}`)
|
||||||
|
console.log(`targets = ${users.map((u) => `${u.username}(uid ${u.uid})`).join(', ')}`)
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
console.log(`\n=== ${u.username} (uid ${u.uid}) ===`)
|
||||||
|
const root = join(u.home_dir, '..')
|
||||||
|
const ws = join(root, 'ws')
|
||||||
|
const dir = join(u.home_dir, 'profiles', PROFILE)
|
||||||
|
const pkgPath = join(dir, 'package.json')
|
||||||
|
if (!existsSync(dir) || !existsSync(pkgPath)) { console.log(' · 无 profile(未首登)→ 跳过'); continue }
|
||||||
|
|
||||||
|
const credPath = join(u.home_dir, '.credentials.yaml')
|
||||||
|
const patchPath = join(dir, 'cordis.patch.yml')
|
||||||
|
const pkg0 = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||||
|
const hasDep = Object.keys(pkg0.dependencies ?? {}).includes(PKG)
|
||||||
|
const inBundles = (pkg0.dsh?.profile?.bundles ?? []).includes(PKG)
|
||||||
|
const staged = join(u.home_dir, '.dsh-stage', basename(artifact))
|
||||||
|
|
||||||
|
console.log(` [计划] 凭据 ${credPath} → refs.${KEY_ENV}`)
|
||||||
|
console.log(` [计划] patch ${patchPath} → 追加 fetchProvider: http 覆写段`)
|
||||||
|
console.log(` [计划] 安装 ${PKG}(现 deps=${hasDep ? '有' : '无'} / bundles=${inBundles ? '有' : '无'})`)
|
||||||
|
console.log(` [计划] 停实例 scope:${RESTART ? '是' : '否'}`)
|
||||||
|
if (!APPLY) continue
|
||||||
|
|
||||||
|
// 1) 先写 key(顺序不可颠倒)
|
||||||
|
if (!existsSync(credPath)) { console.log(` ✗ 缺凭据文件 ${credPath} → 跳过该用户`); continue }
|
||||||
|
const credBackup = `${credPath}.bak-anysearch`
|
||||||
|
if (!existsSync(credBackup)) { copyFileSync(credPath, credBackup); chmodSync(credBackup, 0o600) }
|
||||||
|
try {
|
||||||
|
const credAction = ensureCredential(credPath, KEY)
|
||||||
|
chownSync(credPath, u.uid, u.uid)
|
||||||
|
chmodSync(credPath, 0o600)
|
||||||
|
console.log(` ✓ 凭据 ${credAction}(已恢复 600 + uid ${u.uid})`)
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ✗ 凭据写入失败:${err.message} → 跳过该用户(插件未装,避免无 key 窗口)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 再写 profile patch(保留本地 fetch)
|
||||||
|
if (existsSync(patchPath)) {
|
||||||
|
const patchBackup = `${patchPath}.bak-anysearch`
|
||||||
|
if (!existsSync(patchBackup)) copyFileSync(patchBackup === patchPath ? patchPath : patchPath, patchBackup)
|
||||||
|
const patchAction = ensurePatch(patchPath)
|
||||||
|
chownSync(patchPath, u.uid, u.uid)
|
||||||
|
console.log(` ✓ patch ${patchAction}`)
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠ 无 ${patchPath} → 跳过覆写(fetch 将跟随插件默认走 anysearch)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 装插件
|
||||||
|
try {
|
||||||
|
const pruned = pruneBrokenFileDeps(pkgPath)
|
||||||
|
if (pruned.length > 0) {
|
||||||
|
console.log(` · 清理断裂依赖 ${pruned.length} 个(${pruned.join(';')})`)
|
||||||
|
try { chownSync(pkgPath, u.uid, u.uid) } catch { /* 尽力而为 */ }
|
||||||
|
}
|
||||||
|
const stageDir = join(u.home_dir, '.dsh-stage')
|
||||||
|
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
|
||||||
|
if (!existsSync(staged)) copyFileSync(artifact, staged)
|
||||||
|
chmodSync(staged, 0o444)
|
||||||
|
execFileSync('chown', [`${u.uid}:${u.uid}`, stageDir, staged], { stdio: 'pipe' })
|
||||||
|
const legacyStore = existingStoreDir(dir)
|
||||||
|
const storeDir = legacyStore !== '' ? legacyStore : join(u.home_dir, '.pnpm-store')
|
||||||
|
const legacyCache = join(ws, '.cache', 'pnpm')
|
||||||
|
const cacheDir = existsSync(legacyCache) ? legacyCache : join(u.home_dir, '.pnpm-cache')
|
||||||
|
const isRoot = existsSync(join(dir, 'pnpm-workspace.yaml'))
|
||||||
|
const args = [
|
||||||
|
'--reuid', String(u.uid), '--regid', String(u.uid), '--clear-groups',
|
||||||
|
'env', `HOME=${u.home_dir}`, 'pnpm', 'add',
|
||||||
|
'--store-dir', storeDir, '--cache-dir', cacheDir,
|
||||||
|
]
|
||||||
|
if (isRoot) args.push('-w')
|
||||||
|
args.push(`file:${staged}`)
|
||||||
|
execFileSync('setpriv', args, { cwd: dir, timeout: 180000, stdio: 'pipe' })
|
||||||
|
console.log(` ✓ 已安装 ${PKG}(store=${legacyStore !== '' ? '沿用旧' : '新建 home'})`)
|
||||||
|
const final = reconcileBundles(dir)
|
||||||
|
console.log(` ✓ bundles=${final.length}(含 ${PKG}: ${final.includes(PKG)})`)
|
||||||
|
} catch (err) {
|
||||||
|
const detail = String(err.stderr ?? '').trim() || err.message || String(err)
|
||||||
|
console.log(` ✗ 安装失败 ${detail.split('\n').slice(0, 3).join(' | ')}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 停实例(新 bundle 才生效)
|
||||||
|
if (RESTART) {
|
||||||
|
const n = stopInstance(u.uid)
|
||||||
|
console.log(` ✓ 已停 ${n} 个实例 scope(下次访问自动拉起,新 bundle 才生效)`)
|
||||||
|
} else {
|
||||||
|
console.log(' · 未停实例:bundle 尚未生效,需后续重启')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('\ndone')
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* ensure-anysearch-pool.mjs —— 把 AnySearch 插件投放进「功能插件」候选池(档案 64 / 65)
|
||||||
|
*
|
||||||
|
* 与门户 `POST /api/plugins/business`(admin 上传)走**同一条校验路径**:复用平台编译产物
|
||||||
|
* 导出的 `stageTgzArchive()`(列成员防路径穿越 → 解压 → `package.json` 校验 → 危险内容扫描),
|
||||||
|
* 再按同一「替换策略」(同名先删旧 tgz、旧文件无残留)落盘进 `<dataRoot>/business-plugins/`,
|
||||||
|
* 并 upsert `business_plugins` 行。
|
||||||
|
*
|
||||||
|
* 投放完成后,用户在实例「设置 → 功能管理」里自助**启用/禁用**;启用/禁用会由
|
||||||
|
* `syncWebProviderPatch()`(档案 65)自动维护 profile 的 web provider 托管段。
|
||||||
|
*
|
||||||
|
* 为什么需要它:门户上传需要浏览器的 admin 会话;本脚本用于无会话场景(如本次迁移),
|
||||||
|
* 产物与走门户完全一致。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/ensure-anysearch-pool.mjs # 干跑(只打印)
|
||||||
|
* node scripts/ensure-anysearch-pool.mjs --apply # 执行投放
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import Database from '/opt/dshs/node_modules/better-sqlite3/lib/index.js'
|
||||||
|
|
||||||
|
const DATA_ROOT = process.env.DSH_DATA_ROOT ?? '/var/lib/dshs'
|
||||||
|
const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts'
|
||||||
|
const POOL_DIR = join(DATA_ROOT, 'business-plugins')
|
||||||
|
const DB_PATH = join(DATA_ROOT, 'dshs.db')
|
||||||
|
const PLUGIN_ROUTES = '/opt/dshs/lib/web/routes/business-plugins.js'
|
||||||
|
const PREFIX = 'anysearch-dsh-'
|
||||||
|
|
||||||
|
const APPLY = process.argv.includes('--apply')
|
||||||
|
|
||||||
|
/** 取产物目录里版本号最大的 anysearch-dsh-*.tgz。 */
|
||||||
|
function pickArtifact() {
|
||||||
|
const cands = readdirSync(ART_DIR).filter((f) => f.startsWith(PREFIX) && f.endsWith('.tgz'))
|
||||||
|
if (cands.length === 0) throw new Error(`no ${PREFIX}*.tgz under ${ART_DIR}`)
|
||||||
|
const ver = (f) => f.slice(PREFIX.length, -4).split('.').map(Number)
|
||||||
|
cands.sort((a, b) => {
|
||||||
|
const va = ver(a); const vb = ver(b)
|
||||||
|
for (let i = 0; i < 3; i++) { const x = va[i] || 0; const y = vb[i] || 0; if (x !== y) return x - y }
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
return join(ART_DIR, cands[cands.length - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifact = pickArtifact()
|
||||||
|
const size = statSync(artifact).size
|
||||||
|
console.log(`artifact = ${artifact}`)
|
||||||
|
console.log(`size = ${size} B`)
|
||||||
|
console.log(`pool dir = ${POOL_DIR}`)
|
||||||
|
console.log(`mode = ${APPLY ? 'APPLY' : 'DRY-RUN'}`)
|
||||||
|
|
||||||
|
if (!APPLY) {
|
||||||
|
console.log('\n(干跑结束;加 --apply 执行)')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 走平台自己的校验 + staging(与门户上传完全一致)
|
||||||
|
const { stageTgzArchive } = await import(PLUGIN_ROUTES)
|
||||||
|
const staged = stageTgzArchive(DATA_ROOT, readFileSync(artifact))
|
||||||
|
console.log(`staged = name=${staged.name} version=${staged.version} tgz=${staged.tgzName} files=${staged.fileCount}`)
|
||||||
|
|
||||||
|
// 1b) 安全检测裁决 —— 与上传接口同一套语义:**默认 fail-closed**,只有显式声明信任才放行。
|
||||||
|
const TRUST = process.argv.includes('--trust')
|
||||||
|
if (staged.blocked.length > 0) {
|
||||||
|
console.log(`\n⚠ 安全检测命中 P0 规则 ${staged.blocked.length} 处:`)
|
||||||
|
for (const b of staged.blocked) console.log(` - ${b.file} — ${b.why}`)
|
||||||
|
if (!TRUST) {
|
||||||
|
rmSync(staged.stage, { recursive: true, force: true })
|
||||||
|
console.log('\n默认拒绝投放(fail-closed)。逐条确认确属误报 / 风险可控后,加 --trust 重新执行;')
|
||||||
|
console.log('放行会写入 audit_log(trust_business_plugin),留痕「谁 / 何时 / 命中什么 / 理由」。')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
console.log(' (--trust 已声明 → 继续投放并留痕)')
|
||||||
|
} else {
|
||||||
|
console.log('scan = clean(无 P0 命中)')
|
||||||
|
}
|
||||||
|
if (staged.warnings.length > 0) console.log(`scan = ${staged.warnings.length} 条 P1 告警(不阻断)`)
|
||||||
|
|
||||||
|
// 2) 替换策略落盘
|
||||||
|
mkdirSync(POOL_DIR, { recursive: true, mode: 0o755 })
|
||||||
|
const db = new Database(DB_PATH)
|
||||||
|
const existing = db.prepare('SELECT id, tgz_path FROM business_plugins WHERE id = ?').get(staged.name)
|
||||||
|
if (existing !== undefined && existsSync(existing.tgz_path)) {
|
||||||
|
rmSync(existing.tgz_path, { force: true })
|
||||||
|
console.log(`replaced = 已删除旧包 ${existing.tgz_path}`)
|
||||||
|
}
|
||||||
|
const dest = join(POOL_DIR, staged.tgzName)
|
||||||
|
renameSync(join(staged.stage, 'payload.tgz'), dest)
|
||||||
|
|
||||||
|
// 3) upsert 元数据行
|
||||||
|
const now = Date.now()
|
||||||
|
const admin = db.prepare("SELECT id FROM users WHERE username = 'admin'").get()
|
||||||
|
const uploadedBy = admin?.id ?? null
|
||||||
|
if (existing !== undefined) {
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE business_plugins SET name=?, description=?, version=?, tgz_path=?, file_size=?, uploaded_by=?, updated_at=? WHERE id=?',
|
||||||
|
).run(staged.name, staged.name, staged.description, staged.version, dest, size, uploadedBy, now, staged.name)
|
||||||
|
} else {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO business_plugins (id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||||
|
).run(staged.name, staged.name, staged.description, staged.version, dest, size, uploadedBy, now, now)
|
||||||
|
}
|
||||||
|
// 留痕:与上传接口同一组 action(命中 P0 时另记一条 trust_business_plugin)
|
||||||
|
db.prepare('INSERT INTO audit_log (ts, actor, action, detail) VALUES (?, ?, ?, ?)').run(
|
||||||
|
now,
|
||||||
|
uploadedBy,
|
||||||
|
'upload_business_plugin',
|
||||||
|
JSON.stringify({ name: staged.name, version: staged.version, trustedOverride: staged.blocked.length > 0, via: 'ensure-anysearch-pool.mjs' }),
|
||||||
|
)
|
||||||
|
if (staged.blocked.length > 0) {
|
||||||
|
db.prepare('INSERT INTO audit_log (ts, actor, action, detail) VALUES (?, ?, ?, ?)').run(
|
||||||
|
now,
|
||||||
|
uploadedBy,
|
||||||
|
'trust_business_plugin',
|
||||||
|
JSON.stringify({ name: staged.name, version: staged.version, blocked: staged.blocked, reason: (process.env.TRUST_REASON ?? '').trim() }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const rows = db.prepare('SELECT id, version, file_size, tgz_path FROM business_plugins ORDER BY id ASC').all()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
rmSync(staged.stage, { recursive: true, force: true })
|
||||||
|
|
||||||
|
console.log(`\n✓ 已投放:${dest}`)
|
||||||
|
console.log('候选池当前内容:')
|
||||||
|
for (const r of rows) console.log(` - ${r.id} @ ${r.version} (${r.file_size} B) → ${r.tgz_path}`)
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* ensure-biz-plugins.cjs —— 给用户铺「功能插件」分区(档案 36 · 批次 2)
|
||||||
|
*
|
||||||
|
* 背景:`@dsh-local/business-plugins` 是「dsh 设置面板 → 功能插件」分区的 client bundle
|
||||||
|
* (列 admin 投放的插件 + 批量启停 + 确认 + 重启)。它原先只装在 admin profile 里,
|
||||||
|
* 普通用户看不到该分区 → 本脚本把它铺给普通用户。
|
||||||
|
*
|
||||||
|
* 幂等:判定依据是 **bundles**(包内 cordis.patch.yml 只对 bundles 成员生效),
|
||||||
|
* 不是 dependencies —— 只有 dep 而没进 bundles 时只需 reconcile,不必重装。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node ensure-biz-plugins.cjs # 所有 role=active 的非 admin 用户
|
||||||
|
* node ensure-biz-plugins.cjs <userId|username>...
|
||||||
|
* node ensure-biz-plugins.cjs --all # 含 admin(自检用)
|
||||||
|
* node ensure-biz-plugins.cjs --restart # 铺完停掉其实例 scope(下次访问自动拉起,bundle 才生效)
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { chmodSync, chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } = require('node:fs')
|
||||||
|
const { basename, dirname, join, resolve } = require('node:path')
|
||||||
|
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||||
|
|
||||||
|
const DB_PATH = '/var/lib/dshs/dshs.db'
|
||||||
|
const BUNDLE = '@dsh-local/business-plugins'
|
||||||
|
// 自动取产物目录里版本号最大的 business-plugins-*.tgz(升级只需丢新包,不用改脚本)
|
||||||
|
const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts'
|
||||||
|
const ARTIFACT = (function () {
|
||||||
|
const PREFIX = 'business-plugins-'
|
||||||
|
const cands = readdirSync(ART_DIR).filter((f) => f.indexOf(PREFIX) === 0 && f.slice(-4) === '.tgz')
|
||||||
|
const ver = (f) => f.slice(PREFIX.length, -4).split('.').map(Number)
|
||||||
|
cands.sort((a, b) => {
|
||||||
|
const va = ver(a)
|
||||||
|
const vb = ver(b)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const x = va[i] || 0
|
||||||
|
const y = vb[i] || 0
|
||||||
|
if (x !== y) return x - y
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
if (cands.length === 0) throw new Error('no ' + PREFIX + '*.tgz under ' + ART_DIR)
|
||||||
|
return join(ART_DIR, cands[cands.length - 1])
|
||||||
|
})()
|
||||||
|
const PROFILE = 'web'
|
||||||
|
/** guest 的 profile 里有 pnpm-workspace.yaml → pnpm 视为 workspace root 而拒绝 add;
|
||||||
|
* `--ignore-workspace-root-check` 让它在两种 profile 下都能工作。 */
|
||||||
|
const WFLAG = '--ignore-workspace-root-check'
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
const ALL = argv.includes('--all')
|
||||||
|
const RESTART = argv.includes('--restart')
|
||||||
|
const wanted = argv.filter((a) => !a.startsWith('--'))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读出既有 node_modules 记录的 storeDir(不存在则返回空串)。
|
||||||
|
*
|
||||||
|
* 为什么必须读:pnpm 对**已有安装**做增量时会校验 store 位置,不一致直接拒绝 ——
|
||||||
|
* ERR_PNPM_UNEXPECTED_STORE(档案 57 实测):存量 profile 的 node_modules 诞生于
|
||||||
|
* 「HOME=<ws>」时代,storeDir 记为 ws 内路径;脚本若硬换 <home>/.pnpm-store,
|
||||||
|
* pnpm 要求先 `pnpm install` 重建全量依赖(需联网重拉整棵依赖树)。
|
||||||
|
* 所以:**已有安装沿用旧 store,全新 profile 才用 <home>/.pnpm-store**。
|
||||||
|
*/
|
||||||
|
function existingStoreDir(profileDir) {
|
||||||
|
try {
|
||||||
|
const txt = readFileSync(join(profileDir, 'node_modules', '.modules.yaml'), 'utf8')
|
||||||
|
const m = /^storeDir:\s*(.+)$/m.exec(txt)
|
||||||
|
return m ? m[1].trim() : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清掉「指向不存在文件的 `file:` 依赖」,返回被删清单。
|
||||||
|
*
|
||||||
|
* 由来(2026-09-12,档案 63):档案 60 把 ws 里的安装残留 tgz 移入 trash 后,profile 的
|
||||||
|
* `dependencies` 里 `file:<ws>/xxx.tgz` 的 spec 就断了 —— 此后**任何** `pnpm add` 都会在
|
||||||
|
* 解析阶段直接 ENOENT 失败(两个用户全中,用户侧表现为「安装失败」)。这正是档案 57 §五.2
|
||||||
|
* 预警过的隐患。此处自愈:解析不到的 `file:` 依赖先摘除,随后 add 新包会写入正确的新路径。
|
||||||
|
* 安全边界:只删 `file:` 且**目标确实不存在**的条目;registry 依赖与其他依赖一概不动。
|
||||||
|
*/
|
||||||
|
function pruneBrokenFileDeps(pkgPath) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||||
|
const deps = pkg.dependencies ?? {}
|
||||||
|
const removed = []
|
||||||
|
for (const [k, v] of Object.entries(deps)) {
|
||||||
|
if (typeof v !== 'string' || !v.startsWith('file:')) continue
|
||||||
|
const abs = resolve(dirname(pkgPath), v.slice('file:'.length))
|
||||||
|
if (!existsSync(abs)) {
|
||||||
|
delete deps[k]
|
||||||
|
removed.push(`${k} → ${v}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed.length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
return removed
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBundle(dir, dep) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', dep, 'package.json'), 'utf8'))
|
||||||
|
return pkg.dsh?.bundle?.patch !== undefined
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles。 */
|
||||||
|
function reconcileBundles(dir) {
|
||||||
|
const path = join(dir, 'package.json')
|
||||||
|
const pkg = JSON.parse(readFileSync(path, 'utf8'))
|
||||||
|
const deps = Object.keys(pkg.dependencies ?? {})
|
||||||
|
const bundles = pkg.dsh?.profile?.bundles ?? []
|
||||||
|
const kept = bundles.filter((b) => b.startsWith('@deepseek-ai/') || deps.includes(b))
|
||||||
|
for (const dep of deps) if (!kept.includes(dep) && isBundle(dir, dep)) kept.push(dep)
|
||||||
|
pkg.dsh = pkg.dsh ?? {}
|
||||||
|
pkg.dsh.profile = pkg.dsh.profile ?? {}
|
||||||
|
pkg.dsh.profile.bundles = kept
|
||||||
|
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停掉该 uid 名下所有 dsh scope(与编排器 stopScopesByPrefix 同法)。 */
|
||||||
|
function stopInstance(uid) {
|
||||||
|
let out = ''
|
||||||
|
try {
|
||||||
|
out = execFileSync('systemctl', ['list-units', '--type=scope', '--all', '--no-legend', '--plain'], { encoding: 'utf8' })
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
let n = 0
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const unit = line.trim().split(/\s+/)[0]
|
||||||
|
if (!unit || !unit.startsWith(`dsh-${uid}-`) || !unit.endsWith('.scope')) continue
|
||||||
|
try {
|
||||||
|
execFileSync('systemctl', ['stop', unit], { stdio: 'pipe' })
|
||||||
|
execFileSync('systemctl', ['reset-failed', unit], { stdio: 'pipe' })
|
||||||
|
n += 1
|
||||||
|
} catch {
|
||||||
|
/* 单个 scope 停不掉不阻断 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(ARTIFACT)) {
|
||||||
|
console.error(`✗ 缺产物:${ARTIFACT}(用 04-调整方案/poc/business-plugins 重新打包)`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH, { readonly: true })
|
||||||
|
const users = db
|
||||||
|
.prepare('SELECT id, username, uid, role, home_dir FROM users')
|
||||||
|
.all()
|
||||||
|
.filter((u) => (wanted.length > 0 ? wanted.includes(u.id) || wanted.includes(u.username) : true))
|
||||||
|
.filter((u) => (ALL || wanted.length > 0 ? true : u.role === 'active' && u.username !== 'admin'))
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log('没有匹配的用户')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
const root = join(u.home_dir, '..')
|
||||||
|
const ws = join(root, 'ws')
|
||||||
|
const dir = join(u.home_dir, 'profiles', PROFILE)
|
||||||
|
const pkgPath = join(dir, 'package.json')
|
||||||
|
if (!existsSync(dir) || !existsSync(pkgPath)) {
|
||||||
|
console.log(` ${u.username}: 无 profile(尚未启动过实例)→ 跳过`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||||
|
const bundles = pkg.dsh?.profile?.bundles ?? []
|
||||||
|
const inBundles = bundles.includes(BUNDLE)
|
||||||
|
const inDeps = Object.keys(pkg.dependencies ?? {}).includes(BUNDLE)
|
||||||
|
const wantBase = basename(ARTIFACT)
|
||||||
|
const depSpec = String((pkg.dependencies ?? {})[BUNDLE] ?? "")
|
||||||
|
const upToDate = depSpec.endsWith(wantBase)
|
||||||
|
if (inBundles && upToDate) {
|
||||||
|
console.log(` ${u.username}: 已是 ${wantBase} → 跳过`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inBundles && !upToDate) {
|
||||||
|
console.log(` ${u.username}: 版本落后(${depSpec.split("/").pop() || "无"} → ${wantBase}),升级中…`)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!inDeps || !upToDate) {
|
||||||
|
// 2026-09-12(档案 61):安装姿势与 ensure-portal-entry.cjs 对齐。
|
||||||
|
// 旧姿势 =「复制 tgz 进 ws + root 身份 + HOME=<ws> 跑 pnpm」,实测三个副作用:
|
||||||
|
// ① ws 里堆 `*.tgz` / `.local` / `.cache`(档案 60 实测:admin 4 个 · guest 3 个残留)
|
||||||
|
// ② 用户家目录留 root 属主项 → 用户 `pip install --user` 报 Permission denied(档案 43)
|
||||||
|
// ③ **升级存量 node_modules 时会撞 ERR_PNPM_UNEXPECTED_STORE**(老依赖由 ws 内 store
|
||||||
|
// 链接而来,换位置即被 pnpm 拒绝)—— 档案 57 已在 portal-entry 上实测到
|
||||||
|
// 新姿势:tgz 暂存 <home>/.dsh-stage/、以该用户 uid 执行 setpriv、store 位置自适应。
|
||||||
|
// (注:原 WFLAG 常量随之弃用,保留定义不影响运行。)
|
||||||
|
// 安装前自愈:先清掉指向已不存在文件的 `file:` 依赖,否则下面的 `pnpm add` 必定
|
||||||
|
// 在解析阶段 ENOENT 失败(2026-09-12 实测两用户全中)。
|
||||||
|
const pruned = pruneBrokenFileDeps(pkgPath)
|
||||||
|
if (pruned.length > 0) {
|
||||||
|
console.log(` ${u.username}: 清理断裂依赖 ${pruned.length} 个(${pruned.join(';')})`)
|
||||||
|
// 以 root 改写过 package.json → 属主收回给该用户,避免用户侧读到 root 属主文件。
|
||||||
|
try { execFileSync('chown', [`${u.uid}:${u.uid}`, pkgPath], { stdio: 'pipe' }) } catch { /* 尽力而为 */ }
|
||||||
|
}
|
||||||
|
const stageDir = join(u.home_dir, '.dsh-stage')
|
||||||
|
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
|
||||||
|
const staged = join(stageDir, basename(ARTIFACT))
|
||||||
|
if (!existsSync(staged)) copyFileSync(ARTIFACT, staged)
|
||||||
|
chmodSync(staged, 0o444) // 只读:杜绝安装源被就地篡改
|
||||||
|
execFileSync('chown', [`${u.uid}:${u.uid}`, stageDir, staged], { stdio: 'pipe' })
|
||||||
|
const legacyStore = existingStoreDir(dir)
|
||||||
|
const storeDir = legacyStore !== '' ? legacyStore : join(u.home_dir, '.pnpm-store')
|
||||||
|
const legacyCache = join(ws, '.cache', 'pnpm')
|
||||||
|
const cacheDir = existsSync(legacyCache) ? legacyCache : join(u.home_dir, '.pnpm-cache')
|
||||||
|
const isRoot = existsSync(join(dir, 'pnpm-workspace.yaml'))
|
||||||
|
const args = [
|
||||||
|
'--reuid', String(u.uid), '--regid', String(u.uid), '--clear-groups',
|
||||||
|
'env', `HOME=${u.home_dir}`, 'pnpm', 'add',
|
||||||
|
'--store-dir', storeDir, '--cache-dir', cacheDir,
|
||||||
|
]
|
||||||
|
if (isRoot) args.push('-w')
|
||||||
|
args.push(`file:${staged}`)
|
||||||
|
execFileSync('setpriv', args, { cwd: dir, timeout: 180000, stdio: 'pipe' })
|
||||||
|
console.log(
|
||||||
|
` ${u.username}: 已安装/更新依赖${isRoot ? '(-w)' : ''}(store=${legacyStore !== '' ? '沿用旧' : '新建 home'},ws 保持干净)`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.log(` ${u.username}: 依赖已是最新,仅 reconcile`)
|
||||||
|
}
|
||||||
|
const final = reconcileBundles(dir)
|
||||||
|
console.log(` ${u.username}: ✓ bundles=${final.length}(含 ${BUNDLE}: ${final.includes(BUNDLE)})`)
|
||||||
|
if (RESTART) {
|
||||||
|
const n = stopInstance(u.uid)
|
||||||
|
console.log(` 已停 ${n} 个实例 scope(下次访问自动拉起,新 bundle 才生效)`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const detail = String(err.stderr ?? '').trim() || err.message || String(err)
|
||||||
|
console.log(` ${u.username}: ✗ 失败 ${detail.split('\n').slice(0, 3).join(' | ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('done')
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* ensure-portal-entry.cjs —— 给**所有**用户铺「设置面板 → 用户管理」入口
|
||||||
|
*
|
||||||
|
* 背景:`@dsh-local/portal-entry` 的 client 面在 dsh 设置面板注册「用户管理」分区
|
||||||
|
* (管理员:门户地址 + 打开管理台 + 退出登录;普通用户:仅退出登录)。
|
||||||
|
* 它原先只装在 admin 的 profile 里(用户要求"普通用户也要有用户管理入口")→ 本脚本铺给全员。
|
||||||
|
*
|
||||||
|
* 与其他铺开脚本的区别(重要):
|
||||||
|
* · portal-entry **不需要**写 cordis.patch.yml 平台段 —— 它由 profile 的
|
||||||
|
* `package.json → dsh.profile.bundles` 加载(与 admin 现状一致)。
|
||||||
|
* · 安装姿势沿用 ensure-workspace-picker.cjs 的 **2026-09-11 修复版**:
|
||||||
|
* tgz 暂存到 <home>/.dsh-stage/、以该用户 uid 执行 setpriv、store/cache 显式指向 <home>。
|
||||||
|
* **绝不**把 tgz 复制进工作区、**绝不**让 pnpm 把 store 写进 ws
|
||||||
|
* (旧姿势实测污染 admin ws 达 17MB / 2045 文件)。
|
||||||
|
*
|
||||||
|
* 幂等:判定依据是 node_modules 里已装版本 + dep spec 是否指向本次产物;两者都对即跳过。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node ensure-portal-entry.cjs # 全部用户兜底
|
||||||
|
* node ensure-portal-entry.cjs --all # 同上(显式)
|
||||||
|
* node ensure-portal-entry.cjs admin guest # 指定用户名
|
||||||
|
* node ensure-portal-entry.cjs --user-id <uuid> # 指定用户 id
|
||||||
|
* node ensure-portal-entry.cjs --tgz <path> # 指定产物
|
||||||
|
* node ensure-portal-entry.cjs --dry-run # 只打印计划
|
||||||
|
* node ensure-portal-entry.cjs --restart # 装完停掉其实例 scope(下次访问自动拉起才生效)
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } = require('node:fs')
|
||||||
|
const { basename, dirname, join } = require('node:path')
|
||||||
|
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||||
|
|
||||||
|
const DB = '/var/lib/dshs/dshs.db'
|
||||||
|
const ARTIFACTS = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts'
|
||||||
|
const PROFILE = 'web'
|
||||||
|
const BUNDLE = '@dsh-local/portal-entry'
|
||||||
|
const PKG_DIR = '@dsh-local/portal-entry'
|
||||||
|
const PREFIX = 'portal-entry-'
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
const DRY = argv.includes('--dry-run')
|
||||||
|
const RESTART = argv.includes('--restart')
|
||||||
|
const valueOf = (flag) => {
|
||||||
|
const i = argv.indexOf(flag)
|
||||||
|
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : ''
|
||||||
|
}
|
||||||
|
const tgzFlag = valueOf('--tgz')
|
||||||
|
const onlyIds = valueOf('--user-id') === '' ? [] : [valueOf('--user-id')]
|
||||||
|
const flagValues = new Set([tgzFlag, ...onlyIds].filter((v) => v !== ''))
|
||||||
|
const only = argv.filter((a) => !a.startsWith('--') && !flagValues.has(a))
|
||||||
|
|
||||||
|
function pickTgz() {
|
||||||
|
if (tgzFlag !== '') return tgzFlag
|
||||||
|
const files = readdirSync(ARTIFACTS)
|
||||||
|
.filter((f) => f.startsWith(PREFIX) && f.slice(-4) === '.tgz')
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
||||||
|
if (files.length === 0) throw new Error(`未在 ${ARTIFACTS} 找到 ${PREFIX}*.tgz`)
|
||||||
|
return join(ARTIFACTS, files[files.length - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
const TGZ = pickTgz()
|
||||||
|
if (!existsSync(TGZ)) {
|
||||||
|
console.error(`✗ 缺产物:${TGZ}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const VER = (TGZ.match(new RegExp(`${PREFIX.replace(/\./g, '\\.')}(.+)\\.tgz$`)) || [])[1] || 'unknown'
|
||||||
|
const WANT_BASE = basename(TGZ)
|
||||||
|
|
||||||
|
/** 已装版本(读该用户 profile 的 node_modules)。 */
|
||||||
|
function installedVersion(profileDir) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(join(profileDir, 'node_modules', PKG_DIR, 'package.json'), 'utf8')).version
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户根目录(<dataRoot>/users/<id>)—— home_dir 恒为 <userRoot>/home。 */
|
||||||
|
function userRootOf(u) {
|
||||||
|
return dirname(u.home_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读出既有 node_modules 记录的 storeDir。
|
||||||
|
*
|
||||||
|
* 为什么必须读:pnpm 对**已有安装**做增量时会校验 store 位置,不一致直接拒绝 ——
|
||||||
|
* ERR_PNPM_UNEXPECTED_STORE(实测 2026-09-11):
|
||||||
|
* dependencies at ".../profiles/web/node_modules" are currently linked from the
|
||||||
|
* store at "<userRoot>/ws/.local/share/pnpm/store/v3"
|
||||||
|
* pnpm now wants to use the store at "<home>/.pnpm-store/v3"
|
||||||
|
* 存量 profile 的 node_modules 全部诞生于「HOME=<ws>」时代的安装,storeDir 记为 ws 内路径,
|
||||||
|
* 因此**沿用旧 store** 才装得动。若硬换新位置,pnpm 要求先 `pnpm install` 重建全量依赖
|
||||||
|
* (需联网重拉整棵 dsh 依赖树)—— 风险与耗时都不成比例。
|
||||||
|
* 全新 profile(无 node_modules)没有历史包袱 → 走 <home>/.pnpm-store(不污染 ws)。
|
||||||
|
*/
|
||||||
|
function existingStoreDir(profileDir) {
|
||||||
|
try {
|
||||||
|
const txt = readFileSync(join(profileDir, 'node_modules', '.modules.yaml'), 'utf8')
|
||||||
|
const m = /^storeDir:\s*(.+)$/m.exec(txt)
|
||||||
|
return m ? m[1].trim() : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该包是否声明了 dsh.bundle.patch —— dsh 只把这类 dep 视为 bundle 成员。 */
|
||||||
|
function isBundle(profileDir, dep) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(profileDir, 'node_modules', dep, 'package.json'), 'utf8'))
|
||||||
|
return pkg.dsh?.bundle?.patch !== undefined
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles。 */
|
||||||
|
function reconcileBundles(profileDir) {
|
||||||
|
const path = join(profileDir, 'package.json')
|
||||||
|
const pkg = JSON.parse(readFileSync(path, 'utf8'))
|
||||||
|
const deps = Object.keys(pkg.dependencies ?? {})
|
||||||
|
const bundles = pkg.dsh?.profile?.bundles ?? []
|
||||||
|
const kept = bundles.filter((b) => b.startsWith('@deepseek-ai/') || deps.includes(b))
|
||||||
|
for (const dep of deps) if (!kept.includes(dep) && isBundle(profileDir, dep)) kept.push(dep)
|
||||||
|
pkg.dsh = pkg.dsh ?? {}
|
||||||
|
pkg.dsh.profile = pkg.dsh.profile ?? {}
|
||||||
|
pkg.dsh.profile.bundles = kept
|
||||||
|
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停掉该 uid 名下所有 dsh scope(与编排器 stopScopesByPrefix 同法)。 */
|
||||||
|
function stopInstance(uid) {
|
||||||
|
let out = ''
|
||||||
|
try {
|
||||||
|
out = execFileSync('systemctl', ['list-units', '--type=scope', '--all', '--no-legend', '--plain'], { encoding: 'utf8' })
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
let n = 0
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const unit = line.trim().split(/\s+/)[0]
|
||||||
|
if (!unit || !unit.startsWith(`dsh-${uid}-`) || !unit.endsWith('.scope')) continue
|
||||||
|
try {
|
||||||
|
execFileSync('systemctl', ['stop', unit], { stdio: 'pipe' })
|
||||||
|
execFileSync('systemctl', ['reset-failed', unit], { stdio: 'pipe' })
|
||||||
|
n += 1
|
||||||
|
} catch {
|
||||||
|
/* 单个 scope 停不掉不阻断 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(DB, { readonly: true })
|
||||||
|
const users = db
|
||||||
|
.prepare('SELECT id, username, uid, role, home_dir FROM users')
|
||||||
|
.all()
|
||||||
|
.filter(
|
||||||
|
(u) => (only.length === 0 && onlyIds.length === 0) || only.includes(u.username) || onlyIds.includes(u.id),
|
||||||
|
)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log('没有匹配的用户')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`产物: ${TGZ}(版本 ${VER})`)
|
||||||
|
for (const u of users) {
|
||||||
|
const profileDir = join(u.home_dir, 'profiles', PROFILE)
|
||||||
|
if (!existsSync(profileDir)) {
|
||||||
|
console.log(` ${u.username}: NO_PROFILE(尚未首登 spawn)→ 跳过`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const pkgPath = join(profileDir, 'package.json')
|
||||||
|
if (!existsSync(pkgPath)) {
|
||||||
|
console.log(` ${u.username}: 无 package.json → 跳过`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||||
|
const depSpec = String((pkg.dependencies ?? {})[BUNDLE] ?? '')
|
||||||
|
const inBundles = (pkg.dsh?.profile?.bundles ?? []).includes(BUNDLE)
|
||||||
|
const installed = installedVersion(profileDir)
|
||||||
|
const upToDate = installed === VER && depSpec.endsWith(WANT_BASE) && inBundles
|
||||||
|
|
||||||
|
if (upToDate) {
|
||||||
|
console.log(` ${u.username}: skip(已装 v${installed} 且在 bundles)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (DRY) {
|
||||||
|
console.log(
|
||||||
|
` ${u.username}: [dry-run] 已装=${installed ?? '无'} 目标=${VER} bundles=${inBundles} spec=${depSpec.split('/').pop() || '无'}`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ① 暂存产物到该用户自己的 home(/opt/dsh 是 drwx------ root,用户 uid 读不到其中文件)
|
||||||
|
const stageDir = join(u.home_dir, '.dsh-stage')
|
||||||
|
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
|
||||||
|
const staged = join(stageDir, WANT_BASE)
|
||||||
|
if (!existsSync(staged)) copyFileSync(TGZ, staged)
|
||||||
|
chmodSync(staged, 0o444) // 只读:杜绝安装源被就地篡改
|
||||||
|
execFileSync('chown', [`${u.uid}:${u.uid}`, stageDir, staged], { stdio: 'pipe' })
|
||||||
|
// ② 以该用户身份安装。store 位置**自适应**(见 existingStoreDir 的说明):
|
||||||
|
// 已有安装 → 沿用其 .modules.yaml 记录的 store(否则 ERR_PNPM_UNEXPECTED_STORE);
|
||||||
|
// 全新 profile → <home>/.pnpm-store(干净,不写进 ws)。
|
||||||
|
// cache 同理:沿用既有 ws/.cache/pnpm 可免重新拉 metadata;新 profile 用 <home>/.pnpm-cache。
|
||||||
|
// profile 若为 pnpm workspace 根必须 -w,否则 ERR_PNPM_ADDING_TO_ROOT。
|
||||||
|
const legacyStore = existingStoreDir(profileDir)
|
||||||
|
const storeDir = legacyStore !== '' ? legacyStore : join(u.home_dir, '.pnpm-store')
|
||||||
|
const legacyCache = join(userRootOf(u), 'ws', '.cache', 'pnpm')
|
||||||
|
const cacheDir = existsSync(legacyCache) ? legacyCache : join(u.home_dir, '.pnpm-cache')
|
||||||
|
const isRoot = existsSync(join(profileDir, 'pnpm-workspace.yaml'))
|
||||||
|
const args = [
|
||||||
|
'--reuid', String(u.uid), '--regid', String(u.uid), '--clear-groups',
|
||||||
|
'env', `HOME=${u.home_dir}`, 'pnpm', 'add',
|
||||||
|
'--store-dir', storeDir,
|
||||||
|
'--cache-dir', cacheDir,
|
||||||
|
]
|
||||||
|
if (isRoot) args.push('-w')
|
||||||
|
args.push(`file:${staged}`)
|
||||||
|
execFileSync('setpriv', args, { cwd: profileDir, stdio: 'pipe', timeout: 180000 })
|
||||||
|
// ③ bundles reconcile(dsh 只加载 bundles 成员;hook 未进 bundles 则插件不生效)
|
||||||
|
const final = reconcileBundles(profileDir)
|
||||||
|
const ok = final.includes(BUNDLE)
|
||||||
|
console.log(
|
||||||
|
` ${u.username}: ✓ v${installedVersion(profileDir) ?? '?'}(${isRoot ? '-w,' : ''}bundles=${final.length},含本插件=${ok},store=${legacyStore !== '' ? '沿用旧(ws 内)' : '新建 home'})`,
|
||||||
|
)
|
||||||
|
if (RESTART) {
|
||||||
|
const n = stopInstance(u.uid)
|
||||||
|
console.log(` 已停 ${n} 个实例 scope(下次访问自动拉起,新 bundle 才生效)`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const detail = String(err.stderr ?? '').trim() || err.message || String(err)
|
||||||
|
console.log(` ${u.username}: ✗ 失败 ${detail.split('\n').slice(0, 3).join(' | ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('done')
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Stand-in for a real `dsh` process used by the smoke tests. It is role-aware
|
||||||
|
// via DSHS_ROLE:
|
||||||
|
// - main: bind the loopback port and serve a marker page; /crash exits 1.
|
||||||
|
// - watchdog: headless; polls the handoff file and records what it "executes".
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
|
||||||
|
const role = process.env.DSHS_ROLE ?? 'main'
|
||||||
|
const port = Number(process.env.DSHS_PORT ?? '3080')
|
||||||
|
const cwd = process.cwd()
|
||||||
|
|
||||||
|
if (role === 'watchdog') {
|
||||||
|
// One-shot watchdog: mark it ran, execute any handoff command, then exit.
|
||||||
|
const handoffPath = process.env.DSHS_HANDOFF_PATH
|
||||||
|
console.log(`fake-watchdog one-shot running cwd=${cwd}`)
|
||||||
|
if (handoffPath !== undefined) {
|
||||||
|
writeFileSync(join(dirname(handoffPath), 'watchdog-ran.json'), JSON.stringify({ at: Date.now() }))
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
let command = null
|
||||||
|
if (handoffPath !== undefined && existsSync(handoffPath)) {
|
||||||
|
const content = readFileSync(handoffPath, 'utf8').trim()
|
||||||
|
if (content !== '') {
|
||||||
|
try {
|
||||||
|
command = JSON.parse(content).command ?? content
|
||||||
|
} catch {
|
||||||
|
command = content
|
||||||
|
}
|
||||||
|
writeFileSync(join(dirname(handoffPath), 'watchdog-executed.json'), JSON.stringify({ command, at: Date.now() }))
|
||||||
|
writeFileSync(handoffPath, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`fake-watchdog done${command ? ` (executed: ${command})` : ''}`)
|
||||||
|
process.exit(0)
|
||||||
|
}, 300)
|
||||||
|
} else {
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
if (req.url === '/crash') {
|
||||||
|
res.writeHead(500, { 'content-type': 'text/plain' })
|
||||||
|
res.end('crashing')
|
||||||
|
setTimeout(() => process.exit(1), 10)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (req.url === '/redirect') {
|
||||||
|
res.writeHead(302, { location: '/somewhere' })
|
||||||
|
res.end('redirecting')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'content-type': 'text/plain' })
|
||||||
|
res.end(
|
||||||
|
`fake-dsh pid=${process.pid} port=${port} cwd=${cwd} url=${req.url} argv=${process.argv.slice(2).join(' ')}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
server.listen(port, '127.0.0.1', () => {
|
||||||
|
console.log(`fake-dsh listening on ${port}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Stand-in for a setuid wrapper (e.g. `setpriv --reuid`). Logs the uid it was
|
||||||
|
// given, then execs the remaining argv (the real DSH command) so the smoke test
|
||||||
|
// can verify the account-level uid was passed while still running the child.
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { writeFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
const uidIdx = process.argv.indexOf('--uid')
|
||||||
|
const uid = uidIdx >= 0 ? process.argv[uidIdx + 1] : 'unknown'
|
||||||
|
writeFileSync('setpriv-uid.txt', String(uid))
|
||||||
|
const rest = process.argv.slice(uidIdx >= 0 ? uidIdx + 2 : 2)
|
||||||
|
if (rest.length > 0) {
|
||||||
|
const child = spawn(rest[0], rest.slice(1), { stdio: 'inherit' })
|
||||||
|
child.on('exit', (code) => process.exit(code ?? 0))
|
||||||
|
} else {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* gen-capabilities.cjs —— 生成「实例能力清单」(档案 56)
|
||||||
|
*
|
||||||
|
* 解决什么:agent 每开新会话都要**现场试一遍**才能知道能做什么(实测同一批探测重复 3 轮,
|
||||||
|
* 撞同样 4 类墙:/etc 白名单、127.0.0.1 被 SSRF 拒、技能不存在、写边界),用户也跟着反复问
|
||||||
|
* "能力有变化吗"。本脚本把**平台事实**固化成两份同源产物:
|
||||||
|
* ① /opt/dsh/state/capabilities.json —— 给平台 API / 门户(机读)
|
||||||
|
* ② <bundled-skill-dir>/platform-capabilities/SKILL.md —— 给实例内 agent(它可被 skill 机制加载)
|
||||||
|
*
|
||||||
|
* 用法:node scripts/gen-capabilities.cjs # 生成
|
||||||
|
* node scripts/gen-capabilities.cjs --print # 只打印 JSON
|
||||||
|
* 幂等、只写上述两个路径;任何探测失败只降级为 "unknown",不报错退出。
|
||||||
|
*/
|
||||||
|
'use strict'
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } = require('node:fs')
|
||||||
|
const { join } = require('node:path')
|
||||||
|
|
||||||
|
const STATE = process.env.DSH_STATE_DIR ?? '/opt/dsh/state'
|
||||||
|
const OUT_JSON = join(STATE, 'capabilities.json')
|
||||||
|
const BUNDLED = process.env.DSH_BUNDLED_SKILL_DIR ?? '/var/lib/dshs/bundled-skills'
|
||||||
|
const USERS = process.env.DSH_USERS_DIR ?? '/var/lib/dshs/users'
|
||||||
|
const PERMISSION_MODE = process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
|
||||||
|
|
||||||
|
const run = (cmd, args) => {
|
||||||
|
try {
|
||||||
|
return execFileSync(cmd, args, { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
|
||||||
|
.trim().split('\n')[0].slice(0, 80)
|
||||||
|
} catch {
|
||||||
|
return 'MISSING'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1) 工具链(宿主层安装 → 实例经 /usr 只读可见)──────────────
|
||||||
|
const tools = {
|
||||||
|
node: run('node', ['-v']),
|
||||||
|
npm: run('npm', ['-v']),
|
||||||
|
pnpm: run('pnpm', ['-v']),
|
||||||
|
python3: run('python3', ['--version']),
|
||||||
|
git: run('git', ['--version']),
|
||||||
|
curl: run('curl', ['--version']),
|
||||||
|
ripgrep: run('rg', ['--version']),
|
||||||
|
jq: run('jq', ['--version']),
|
||||||
|
ffmpeg: run('ffmpeg', ['-version']),
|
||||||
|
bubblewrap: run('bwrap', ['--version']),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2) 技能清单(共享层 + 各用户个人层)────────────────────────
|
||||||
|
const listSkills = (dir) => {
|
||||||
|
if (!existsSync(dir)) return []
|
||||||
|
try {
|
||||||
|
return readdirSync(dir).filter((n) => {
|
||||||
|
try { return statSync(join(dir, n)).isDirectory() } catch { return false }
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sharedSkills = listSkills(BUNDLED)
|
||||||
|
const perUserSkills = {}
|
||||||
|
if (existsSync(USERS)) {
|
||||||
|
for (const uid of readdirSync(USERS)) {
|
||||||
|
const s = listSkills(join(USERS, uid, 'home', 'skills'))
|
||||||
|
if (s.length) perUserSkills[uid.slice(0, 8)] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3) 能力清单(结论层;每项都可在平台上核对)──────────────────
|
||||||
|
const capabilities = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
permissionMode: PERMISSION_MODE,
|
||||||
|
note: PERMISSION_MODE === 'danger-full-access'
|
||||||
|
? '当前平台默认档位 = 完全权限(不在实例内叠加文件沙箱、不弹审批)。dsh 界面里可切换。'
|
||||||
|
: '当前平台默认档位 = workspace-write(需要可用的沙箱后端;本机不可用时会拒绝任何 shell)。',
|
||||||
|
read: {
|
||||||
|
allowed: ['/usr(工具链与运行时,只读)', '/etc 白名单(见平台补丁)', '/proc /dev(沙箱内)', '自己的工作区 ws/ 与实例 home/'],
|
||||||
|
denied: ['其他用户目录(users/<其他 uuid>/)', '宿主内部路径与凭据', '/etc 白名单以外的配置'],
|
||||||
|
},
|
||||||
|
write: {
|
||||||
|
allowed: ['工作区 ws/**(含子目录)', '实例 home/**(如 home/skills、home/profiles)', '私有 /tmp'],
|
||||||
|
denied: ['/usr /etc /var 等系统路径(只读挂载)', '平台策略文件(profile 的 cordis.patch.yml / package.json / pnpm-lock.yaml 为只读)'],
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
egress: '可出公网(仅封云元数据端点 100.100.100.200)',
|
||||||
|
denied: ['宿主 loopback 127.0.0.0/8(含 127.0.0.1:3080 门户、:22 等)', '内网卡与 docker0 网关', '平台公网 EIP'],
|
||||||
|
hint: '**不要把 127.0.0.1 当作可用入口**:web_fetch 会以「resolves to a non-public IP」被拒;实例内也不可访问门户。',
|
||||||
|
},
|
||||||
|
tools,
|
||||||
|
skills: {
|
||||||
|
shared: sharedSkills,
|
||||||
|
perUser: perUserSkills,
|
||||||
|
hint: sharedSkills.length === 0 && Object.keys(perUserSkills).length === 0
|
||||||
|
? '当前平台**没有任何已注册技能**(共享层与个人层均为空)→ 调用 skill 工具必然报 unknown,请改用 bash/文件工具完成。'
|
||||||
|
: '技能来自共享只读层与用户个人层;调用前先按名字确认存在于上表。',
|
||||||
|
},
|
||||||
|
fileDelivery: {
|
||||||
|
hint: '把产出交给用户时:**用工作区相对路径**(如 `报告.md`),并提示「点会话页右下角『我的文件』可查看/下载」。',
|
||||||
|
avoid: ['不要给 `/var/lib/dshs/users/...` 这类服务器绝对路径', '不要给 127.0.0.1:<port> 链接(用户浏览器打不开)'],
|
||||||
|
howToShare: '用户在会话页右下角「我的文件」面板里可浏览工作区并下载任意文件(平台代理,不暴露宿主路径)。',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4) 写 JSON ────────────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
mkdirSync(STATE, { recursive: true })
|
||||||
|
} catch { /* 已存在 */ }
|
||||||
|
if (!process.argv.includes('--print')) {
|
||||||
|
writeFileSync(OUT_JSON, JSON.stringify(capabilities, null, 2) + '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5) 写 agent 可读的 SKILL.md(共享只读层)──────────────────
|
||||||
|
const fmtKV = (o) => Object.entries(o).map(([k, v]) => `| ${k} | ${v} |`).join('\n')
|
||||||
|
const skillMd = `---
|
||||||
|
name: platform-capabilities
|
||||||
|
description: 本 dsh 实例(托管在 dshs 平台上)的**能力边界与交付约定**。开工前读它可省去自行探测;包含可读/可写路径、网络边界、可用工具版本、已注册技能清单、以及把文件交给用户的正确方式。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 平台能力清单(由 \`scripts/gen-capabilities.cjs\` 生成 · ${capabilities.generatedAt})
|
||||||
|
|
||||||
|
> **本文件是权威结论,不要靠现场试探推断能力**。若与实测冲突,先按本文件执行,并把差异报告给用户。
|
||||||
|
|
||||||
|
## 1. 权限档位
|
||||||
|
${capabilities.note}
|
||||||
|
|
||||||
|
## 2. 可读
|
||||||
|
| 允许 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
${capabilities.read.allowed.map((x) => `| ${x} | — |`).join('\n')}
|
||||||
|
|
||||||
|
**不可读**:${capabilities.read.denied.join(';')}
|
||||||
|
|
||||||
|
## 3. 可写
|
||||||
|
**可写**:${capabilities.write.allowed.join(';')}
|
||||||
|
**不可写**:${capabilities.write.denied.join(';')}
|
||||||
|
|
||||||
|
## 4. 网络
|
||||||
|
- 出网:${capabilities.network.egress}
|
||||||
|
- **不可达**:${capabilities.network.denied.join(';')}
|
||||||
|
- ⚠️ ${capabilities.network.hint}
|
||||||
|
|
||||||
|
## 5. 可用工具(宿主层安装,全部实例共享)
|
||||||
|
| 工具 | 版本 |
|
||||||
|
|---|---|
|
||||||
|
${fmtKV(tools)}
|
||||||
|
|
||||||
|
## 6. 已注册技能
|
||||||
|
- 共享层(所有人可用):${sharedSkills.length ? sharedSkills.join(', ') : '**(空)**'}
|
||||||
|
- 个人层:${Object.keys(perUserSkills).length ? JSON.stringify(perUserSkills) : '**(空)**'}
|
||||||
|
- ⚠️ ${capabilities.skills.hint}
|
||||||
|
|
||||||
|
## 7. 把文件交给用户(重要)
|
||||||
|
${capabilities.fileDelivery.hint}
|
||||||
|
|
||||||
|
- ❌ 不要做:${capabilities.fileDelivery.avoid.join(';')}
|
||||||
|
- ✅ 怎么做:${capabilities.fileDelivery.howToShare}
|
||||||
|
`
|
||||||
|
|
||||||
|
if (!process.argv.includes('--print')) {
|
||||||
|
const dir = join(BUNDLED, 'platform-capabilities')
|
||||||
|
try {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
writeFileSync(join(dir, 'SKILL.md'), skillMd)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('写 SKILL.md 失败:', e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv.includes('--print')) {
|
||||||
|
console.log(JSON.stringify(capabilities, null, 2))
|
||||||
|
} else {
|
||||||
|
console.log('已生成:')
|
||||||
|
console.log(' ' + OUT_JSON)
|
||||||
|
console.log(' ' + join(BUNDLED, 'platform-capabilities', 'SKILL.md'))
|
||||||
|
console.log(' 工具: ' + Object.entries(tools).map(([k, v]) => `${k}=${v}`).join(' '))
|
||||||
|
console.log(' 技能: 共享 ' + sharedSkills.length + ' 个,个人层用户 ' + Object.keys(perUserSkills).length + ' 个')
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 一键复现 dsh 实例出网护栏(/etc/nftables-dsh-egress.nft + dsh-egress.service)
|
||||||
|
#
|
||||||
|
# 档案 14 · H1 阻断云元数据端点(100.100.100.200)
|
||||||
|
# 档案 14 · H4 观测实例新建外联(只记录不拦截)
|
||||||
|
# 档案 39 · H5 封锁实例**主动**访问宿主自身(loopback / eth0 / docker0 / 公网 EIP)
|
||||||
|
#
|
||||||
|
# 用法(需 root):bash scripts/install-egress-guard.sh
|
||||||
|
# 回滚:systemctl disable --now dsh-egress
|
||||||
|
#
|
||||||
|
# ⚠️ 改 H5 前必读档案 39「事故/踩坑记录」:**不能只按目的地址匹配** ——
|
||||||
|
# 实例是「先收后回」的服务端,回包目的地址同样是 127.0.0.1,
|
||||||
|
# 按 daddr 一刀切会把回包一起拒掉 → nginx 无法回源、实例整体不可用
|
||||||
|
# (判定特征:root 连实例端口 **timeout 而非 refused**)。
|
||||||
|
# 必须只匹配主动发起:TCP 用纯 SYN,UDP 用 ct state new。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$(id -u)" != "0" ]; then
|
||||||
|
echo "需要 root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> 写入 /etc/nftables-dsh-egress.nft"
|
||||||
|
cat > /etc/nftables-dsh-egress.nft <<'NFTEOF'
|
||||||
|
#!/usr/sbin/nft -f
|
||||||
|
# dsh 实例出网护栏
|
||||||
|
# 档案 14 · H1(云元数据端点)+ H4(外联观测)
|
||||||
|
# 档案 39 · H5(实例不得「主动」访问宿主自身 —— 切断宿主服务面与跨租户互通)
|
||||||
|
# 只作用于 dsh 实例 uid 段 100000-199999;root / 云监控 / Docker 不受影响。
|
||||||
|
#
|
||||||
|
# 坑 1:阿里云 DNS 是 100.100.2.136 / 100.100.2.138 —— 绝不能封 100.100.0.0/16 整段,
|
||||||
|
# 本文件只精确封元数据端点 100.100.100.200(/32)。
|
||||||
|
# 坑 2:Docker 用 iptables-nft,本表独立,不与 docker 链混用。
|
||||||
|
# 坑 3:观测要排除 53 端口 —— nft 日志不记录查询域名,DNS 行是纯噪音且量最大。
|
||||||
|
# 坑 4(档案 39,实测踩到):H5 **绝不能只按目的地址匹配**。实例是「先收后回」的服务端:
|
||||||
|
# nginx(root) → 实例端口的 SYN 合法,但实例回的 SYN-ACK 与后续数据包
|
||||||
|
# **目的地址同样是 127.0.0.1**(客户端就是本机)。若按 daddr 一刀切 reject,
|
||||||
|
# 回包会被一起拒掉 → root 连实例端口 **超时**、nginx 无法回源、实例整体不可用。
|
||||||
|
# → 必须只匹配**实例主动发起**的连接:TCP 用纯 SYN(`tcp flags & (fin|syn|rst|ack) == syn`,
|
||||||
|
# SYN-ACK 带 ack 位故不匹配),UDP 用 `ct state new`。
|
||||||
|
table ip dsh_egress {
|
||||||
|
chain output {
|
||||||
|
type filter hook output priority filter; policy accept;
|
||||||
|
|
||||||
|
# H1 · 阻断云元数据端点(先记录再拒绝)
|
||||||
|
meta skuid 100000-199999 ip daddr 100.100.100.200 log prefix "dsh-egress-BLOCK " level warn
|
||||||
|
meta skuid 100000-199999 ip daddr 100.100.100.200 counter reject
|
||||||
|
|
||||||
|
# H5 · 实例不得主动访问宿主自身(档案 39)
|
||||||
|
# 封 4 类目的地址(仅实例主动发起的连接):
|
||||||
|
# 127.0.0.0/8 → 宿主全部 loopback 服务(sshd 22/32022、nginx 80/443/888、
|
||||||
|
# 门户 3080、BT-Panel 58888/8765)+ 其他实例的 127.0.0.1 监听
|
||||||
|
# 172.18.16.212 → 宿主内网卡(eth0,同样到达 nginx/面板)
|
||||||
|
# 172.17.0.1 → docker0 网桥网关
|
||||||
|
# 47.77.182.89 → 公网 EIP(回环到本机 nginx/sshd)
|
||||||
|
# 不影响:公网访问、阿里云内网 DNS(100.100.2.136/138)、pip/npm 下载、
|
||||||
|
# 实例自身监听端口、nginx 回源(root 发包 + 实例回包带 ack)
|
||||||
|
meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, 172.18.16.212, 47.77.182.89 } \
|
||||||
|
meta l4proto tcp tcp flags & (fin|syn|rst|ack) == syn \
|
||||||
|
counter log prefix "dsh-egress-HOST " level warn limit rate 20/minute
|
||||||
|
meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, 172.18.16.212, 47.77.182.89 } \
|
||||||
|
meta l4proto tcp tcp flags & (fin|syn|rst|ack) == syn \
|
||||||
|
counter reject with tcp reset
|
||||||
|
meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, 172.18.16.212, 47.77.182.89 } \
|
||||||
|
meta l4proto udp ct state new counter reject
|
||||||
|
|
||||||
|
# H4 · 观测实例新建外联(先记录不拦截;排除 loopback 与 DNS 降噪)
|
||||||
|
meta skuid 100000-199999 ip daddr != 127.0.0.0/8 tcp dport != 53 ct state new counter log prefix "dsh-egress " level info
|
||||||
|
meta skuid 100000-199999 ip daddr != 127.0.0.0/8 udp dport != 53 ct state new counter log prefix "dsh-egress " level info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NFTEOF
|
||||||
|
|
||||||
|
echo "==> 写入 /etc/systemd/system/dsh-egress.service"
|
||||||
|
cat > /etc/systemd/system/dsh-egress.service <<'SVCEOF'
|
||||||
|
[Unit]
|
||||||
|
Description=DSH instance egress guard (nftables)
|
||||||
|
Documentation=file:/etc/nftables-dsh-egress.nft
|
||||||
|
After=network-pre.target
|
||||||
|
Before=network.service
|
||||||
|
Wants=network-pre.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
# 幂等:nft -f 遇到已存在的表会报 File exists,故先删(忽略不存在时的报错)
|
||||||
|
ExecStartPre=-/usr/sbin/nft delete table ip dsh_egress
|
||||||
|
ExecStart=/usr/sbin/nft -f /etc/nftables-dsh-egress.nft
|
||||||
|
ExecStop=-/usr/sbin/nft delete table ip dsh_egress
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SVCEOF
|
||||||
|
|
||||||
|
echo "==> 语法预检"
|
||||||
|
nft -c -f /etc/nftables-dsh-egress.nft
|
||||||
|
|
||||||
|
echo "==> 启用并重载"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable dsh-egress
|
||||||
|
systemctl restart dsh-egress
|
||||||
|
|
||||||
|
echo "==> 当前规则"
|
||||||
|
nft list table ip dsh_egress
|
||||||
|
echo "OK"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 安装「dsh 实例共享运行时」—— 可移植 Python(python-build-standalone / install_only)
|
||||||
|
#
|
||||||
|
# 目的(档案 42):
|
||||||
|
# 1. 系统 python3.6.8 太老(技能脚本常用特性不可用),且它是 dnf 的兄弟解释器,**不能动**;
|
||||||
|
# 2. 实例内 `/usr` 只读、无 sudo → 用户/AI 自己装不上系统级;
|
||||||
|
# 3. `/usr` 已 ro-bind → **装到 /usr/local 即对全部实例可见,零代码、新用户自动生效**。
|
||||||
|
#
|
||||||
|
# 为什么用 python-build-standalone 而不是 `dnf install python3.11`:
|
||||||
|
# 它是**自包含单目录发行版**(自带 libssl/libffi/sqlite 等,不依赖系统 rpm),
|
||||||
|
# 解压即用 → **服务器迁移时把 /usr/local/dsh-runtime 整个打包带走即可**,
|
||||||
|
# 不会因为目标机缺包/版本不同而出问题(用户的明确要求)。
|
||||||
|
#
|
||||||
|
# 迁移打包:
|
||||||
|
# tar czf dsh-python-runtime.tar.gz -C /usr/local dsh-runtime
|
||||||
|
# 目标机:解压回 /usr/local/ 后,跑本脚本(不加 --tarball 也可,会只重建软链)
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# bash scripts/install-python-runtime.sh # 用默认 tarball 路径/版本
|
||||||
|
# bash scripts/install-python-runtime.sh --tarball /path/x.tar.gz
|
||||||
|
# PY_VER=3.13.7 bash scripts/install-python-runtime.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PY_VER="${PY_VER:-3.12.14}"
|
||||||
|
PY_BUILD="${PY_BUILD:-20260901}"
|
||||||
|
RT="/usr/local/dsh-runtime"
|
||||||
|
LINK_DIR="/usr/local/bin"
|
||||||
|
TARBALL=""
|
||||||
|
URL_DEFAULT="https://github.com/astral-sh/python-build-standalone/releases/download/${PY_BUILD}/cpython-${PY_VER}%2B${PY_BUILD}-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--tarball) TARBALL="$2"; shift 2 ;;
|
||||||
|
--version) PY_VER="$2"; shift 2 ;;
|
||||||
|
*) echo "未知参数: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$(id -u)" != "0" ]; then echo "需要 root" >&2; exit 1; fi
|
||||||
|
|
||||||
|
PYDIR="$RT/python-$PY_VER"
|
||||||
|
mkdir -p "$RT"
|
||||||
|
|
||||||
|
# ── 1. 解压(已存在则跳过 —— 幂等,也是迁移后的"重连"路径)──────────────
|
||||||
|
if [ -x "$PYDIR/bin/python3" ]; then
|
||||||
|
echo "==> 已存在 $PYDIR,跳过解压"
|
||||||
|
else
|
||||||
|
if [ -z "$TARBALL" ]; then
|
||||||
|
for c in "/opt/dsh/artifacts/cpython-$PY_VER.tar.gz" "$RT/cpython-$PY_VER.tar.gz"; do
|
||||||
|
[ -f "$c" ] && TARBALL="$c" && break
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then
|
||||||
|
echo "==> 本地无 tarball,联网下载($PY_VER)"
|
||||||
|
mkdir -p /opt/dsh/artifacts
|
||||||
|
TARBALL="/opt/dsh/artifacts/cpython-$PY_VER.tar.gz"
|
||||||
|
curl -fL --max-time 900 -o "$TARBALL" "$URL_DEFAULT"
|
||||||
|
fi
|
||||||
|
echo "==> 解压 $TARBALL"
|
||||||
|
tar xzf "$TARBALL" -C "$RT" # install_only 解出顶层 python/
|
||||||
|
rm -rf "$PYDIR"
|
||||||
|
mv "$RT/python" "$PYDIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. 运行时内 bin(相对软链,便于整体搬迁)────────────────────────────
|
||||||
|
mkdir -p "$RT/bin"
|
||||||
|
for n in python3 python3.12 python pip3 pip; do
|
||||||
|
[ -e "$PYDIR/bin/$n" ] || continue
|
||||||
|
ln -sfn "../python-$PY_VER/bin/$n" "$RT/bin/$n"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 3. /usr/local/bin 接线(已在实例 PATH 首位;`/usr` ro-bind → 实例立即可见)──
|
||||||
|
for n in python3 python pip3; do
|
||||||
|
[ -e "$RT/bin/$n" ] || continue
|
||||||
|
ln -sfn "$RT/bin/$n" "$LINK_DIR/$n"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 4. 版本记录(迁移核对用)────────────────────────────────────────────
|
||||||
|
cat > "$RT/VERSION" <<EOF
|
||||||
|
python_version=$PY_VER
|
||||||
|
python_build=$PY_BUILD
|
||||||
|
source=$URL_DEFAULT
|
||||||
|
installed_at=$(date -Is)
|
||||||
|
note=python-build-standalone install_only_stripped(自包含,可整体打包迁移)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── 5. 自检 ─────────────────────────────────────────────────────────────
|
||||||
|
echo "==> 自检"
|
||||||
|
"$LINK_DIR/python3" -c 'import sys,ssl,sqlite3,lzma,zlib,ctypes,urllib.request;print(" python3 =",sys.version.split()[0],"exe =",sys.executable);print(" 模块 ssl/sqlite3/lzma/zlib/ctypes 全可用");print(" ssl =",ssl.OPENSSL_VERSION)'
|
||||||
|
"$LINK_DIR/pip3" --version | sed 's/^/ /'
|
||||||
|
echo " 目录体积: $(du -sh "$PYDIR" | cut -f1)"
|
||||||
|
echo " 软链:"; ls -la "$LINK_DIR/python3" "$LINK_DIR/python" "$LINK_DIR/pip3" | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "OK —— 实例内 PATH=/usr/local/bin:/usr/bin:/bin,且 /usr ro-bind → 立即可用,无需重启实例"
|
||||||
|
echo "注意:dnf/yum 的 shebang 是 /usr/libexec/platform-python(绝对路径),不受影响。"
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 安装「实例共享工具」到 /usr/local/dsh-runtime/bin(+ /usr/local/bin 软链)
|
||||||
|
#
|
||||||
|
# 为什么共享而不是每个用户装一份(档案 46):
|
||||||
|
# · 实例内 `/usr` 是 ro-bind,且 `/usr/local/bin` 在实例 PATH **首位** →
|
||||||
|
# **宿主装一次,全部实例(含新用户)立即可见,零代码、无需重启**;
|
||||||
|
# · 用户侧装不上(`/usr` 只读 + 无 sudo),且每人一份会重复占磁盘、版本还不一致。
|
||||||
|
#
|
||||||
|
# 迁移:整个 `/usr/local/dsh-runtime/` 就是一个打包单元 →
|
||||||
|
# tar czf dsh-runtime.tar.gz -C /usr/local dsh-runtime
|
||||||
|
# 目标机解压回原位后跑本脚本(已存在则只重建软链)。
|
||||||
|
#
|
||||||
|
# 用法:bash scripts/install-shared-tools.sh [--force]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RT=/usr/local/dsh-runtime
|
||||||
|
BIN="$RT/bin"
|
||||||
|
LINK=/usr/local/bin
|
||||||
|
ART=/opt/dsh/artifacts
|
||||||
|
FORCE=0
|
||||||
|
[ "${1:-}" = "--force" ] && FORCE=1
|
||||||
|
|
||||||
|
JQ_VER=1.8.2
|
||||||
|
RG_VER=15.2.0
|
||||||
|
# ffmpeg 用 BtbN 的 master 静态构建(单个二进制、无系统库依赖)
|
||||||
|
FF_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz"
|
||||||
|
FF_SUM_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/checksums.sha256"
|
||||||
|
|
||||||
|
mkdir -p "$BIN" "$ART" /opt/dsh/state
|
||||||
|
|
||||||
|
say() { printf '==> %s\n' "$*"; }
|
||||||
|
fail() { printf '!! %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── 取官方校验值并核对 ────────────────────────────────────────────────
|
||||||
|
verify() { # verify <file> <expected-sha256>
|
||||||
|
local f="$1" want="$2" got
|
||||||
|
got=$(sha256sum "$f" | awk '{print $1}')
|
||||||
|
[ "$got" = "$want" ] || fail "校验失败:$f
|
||||||
|
期望 $want
|
||||||
|
实际 $got"
|
||||||
|
say "校验通过 $(basename "$f")"
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_o() { curl -fsSL --retry 3 --retry-delay 3 --max-time 600 -o "$2" "$1"; }
|
||||||
|
|
||||||
|
# ── jq(官方静态单文件)────────────────────────────────────────────────
|
||||||
|
if [ "$FORCE" = 1 ] || ! [ -x "$BIN/jq" ] || [ "$("$BIN/jq" --version 2>/dev/null | sed 's/^jq-//')" != "$JQ_VER" ]; then
|
||||||
|
say "安装 jq $JQ_VER"
|
||||||
|
curl_o "https://github.com/jqlang/jq/releases/download/jq-$JQ_VER/jq-linux-amd64" "$ART/jq-$JQ_VER"
|
||||||
|
curl_o "https://github.com/jqlang/jq/releases/download/jq-$JQ_VER/sha256sum.txt" "$ART/jq-$JQ_VER.sha256"
|
||||||
|
WANT=$(grep -E 'jq-linux-amd64$' "$ART/jq-$JQ_VER.sha256" | awk '{print $1}')
|
||||||
|
[ -n "$WANT" ] || fail "取不到 jq 官方校验值"
|
||||||
|
verify "$ART/jq-$JQ_VER" "$WANT"
|
||||||
|
install -m 0755 "$ART/jq-$JQ_VER" "$BIN/jq"
|
||||||
|
else
|
||||||
|
say "jq 已就绪 $("$BIN/jq" --version)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── ripgrep(musl 静态,无 glibc 依赖)─────────────────────────────────
|
||||||
|
if [ "$FORCE" = 1 ] || ! [ -x "$BIN/rg" ]; then
|
||||||
|
say "安装 ripgrep $RG_VER"
|
||||||
|
TGZ="ripgrep-$RG_VER-x86_64-unknown-linux-musl.tar.gz"
|
||||||
|
curl_o "https://github.com/BurntSushi/ripgrep/releases/download/$RG_VER/$TGZ" "$ART/$TGZ"
|
||||||
|
curl_o "https://github.com/BurntSushi/ripgrep/releases/download/$RG_VER/$TGZ.sha256" "$ART/$TGZ.sha256"
|
||||||
|
verify "$ART/$TGZ" "$(awk '{print $1}' "$ART/$TGZ.sha256")"
|
||||||
|
TMPD=$(mktemp -d); tar xzf "$ART/$TGZ" -C "$TMPD"
|
||||||
|
install -m 0755 "$TMPD/ripgrep-$RG_VER-x86_64-unknown-linux-musl/rg" "$BIN/rg"
|
||||||
|
rm -rf "$TMPD"
|
||||||
|
else
|
||||||
|
say "rg 已就绪 $("$BIN/rg" --version | head -1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── ffmpeg / ffprobe(静态单文件,自带全部解码库)─────────────────────
|
||||||
|
if [ "$FORCE" = 1 ] || ! [ -x "$BIN/ffmpeg" ]; then
|
||||||
|
say "安装 ffmpeg(BtbN master 静态构建)"
|
||||||
|
curl_o "$FF_URL" "$ART/ffmpeg-static.tar.xz"
|
||||||
|
curl_o "$FF_SUM_URL" "$ART/ffmpeg-static.sha256"
|
||||||
|
WANT=$(grep -E 'ffmpeg-master-latest-linux64-gpl\.tar\.xz$' "$ART/ffmpeg-static.sha256" | awk '{print $1}')
|
||||||
|
[ -n "$WANT" ] || fail "取不到 ffmpeg 官方校验值(checksums.sha256 里没有该资产)"
|
||||||
|
verify "$ART/ffmpeg-static.tar.xz" "$WANT"
|
||||||
|
TMPD=$(mktemp -d); tar xJf "$ART/ffmpeg-static.tar.xz" -C "$TMPD"
|
||||||
|
FFDIR=$(find "$TMPD" -maxdepth 1 -type d -name "ffmpeg-*" | head -1)
|
||||||
|
for b in ffmpeg ffprobe; do install -m 0755 "$FFDIR/bin/$b" "$BIN/$b"; done
|
||||||
|
rm -rf "$TMPD"
|
||||||
|
else
|
||||||
|
say "ffmpeg 已就绪 $("$BIN/ffmpeg" -version 2>/dev/null | head -1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── /usr/local/bin 接线(已在实例 PATH 首位)──────────────────────────
|
||||||
|
for n in jq rg ffmpeg ffprobe; do
|
||||||
|
[ -e "$BIN/$n" ] || continue
|
||||||
|
ln -sfn "$BIN/$n" "$LINK/$n"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 清单(迁移与审计用)──────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
echo "# dsh 实例共享工具(随 /usr/local/dsh-runtime 一起迁移)"
|
||||||
|
echo "updated_at=$(date -Is)"
|
||||||
|
for n in jq rg ffmpeg ffprobe; do
|
||||||
|
[ -x "$BIN/$n" ] || continue
|
||||||
|
printf '%s: ' "$n"; ("$BIN/$n" --version 2>&1 | head -1) || true
|
||||||
|
done
|
||||||
|
} > "$RT/SHARED-TOOLS.md"
|
||||||
|
|
||||||
|
echo
|
||||||
|
say "结果"
|
||||||
|
ls -la "$LINK"/{jq,rg,ffmpeg,ffprobe} 2>/dev/null | sed 's/^/ /'
|
||||||
|
cat "$RT/SHARED-TOOLS.md" | sed 's/^/ /'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 安装 @dsh-local/workspace-scoped-picker 指定版本到所有用户 profile(幂等)
|
||||||
|
set -euo pipefail
|
||||||
|
TGZ_SRC="${1:?用法: install-wsp.sh <tgz路径>}"
|
||||||
|
VER=$(basename "$TGZ_SRC" | sed -E 's/^workspace-scoped-picker-(.*)\.tgz$/\1/')
|
||||||
|
echo "版本: $VER"
|
||||||
|
for U in cce6d1cd-b376-4304-80f0-0e1c58c9ffde:114801:"" 4092b965-2f68-4977-9989-68b3966f7df0:100002:-w; do
|
||||||
|
ID=$(echo "$U" | cut -d: -f1); UID_=$(echo "$U" | cut -d: -f2); FLAG=$(echo "$U" | cut -d: -f3)
|
||||||
|
WS=/var/lib/dshs/users/$ID/ws
|
||||||
|
PP=/var/lib/dshs/users/$ID/home/profiles/web
|
||||||
|
cp -f "$TGZ_SRC" "$WS/workspace-scoped-picker-$VER.tgz"
|
||||||
|
chown "$UID_:$UID_" "$WS/workspace-scoped-picker-$VER.tgz"
|
||||||
|
( cd "$PP" && setpriv --reuid "$UID_" --regid "$UID_" --clear-groups env HOME="$WS" pnpm add $FLAG "file:$WS/workspace-scoped-picker-$VER.tgz" >/tmp/pnpm-$ID.log 2>&1 ) && echo " [${ID:0:8}] pnpm OK" || { echo " [${ID:0:8}] pnpm FAILED"; tail -3 /tmp/pnpm-$ID.log; }
|
||||||
|
printf " [%s] lib: " "${ID:0:8}"; ls "$PP/node_modules/@dsh-local/workspace-scoped-picker/lib/" | tr "\n" " "; echo
|
||||||
|
printf " [%s] version: " "${ID:0:8}"; grep -o '"version": "[^"]*"' "$PP/node_modules/@dsh-local/workspace-scoped-picker/package.json" | head -1
|
||||||
|
done
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* instance-mem-sample.cjs —— 实例内存用量采样 + 阈值告警(cron 每 10 分钟;只读 + 追加式写一个 json)
|
||||||
|
*
|
||||||
|
* 为什么需要:本机内核 5.10 的 cgroup v2 **没有 `memory.peak`**(5.19+ 才有),
|
||||||
|
* 因此 systemd 的 `MemoryPeak` 属性恒为 `[not set]`,`systemctl show` 也拿不到历史峰值。
|
||||||
|
* 没有峰值数据,「MemoryMax 该定 384 还是能再降」就只能拍脑袋。
|
||||||
|
* 本脚本把所有 `dsh-*.scope` 的 `memory.current` 记一次,按 **uid** 维护历史峰值
|
||||||
|
* (scope 每次重启都换名字,所以按 uid 聚合才连续),为配额调整提供依据(档案 58)。
|
||||||
|
*
|
||||||
|
* 2026-09-12(档案 74)新增**阈值告警**:同一 uid 连续 `HIGH_STREAK` 次采样都 ≥
|
||||||
|
* `limitMiB × HIGH_PCT` 时,日志行尾部追加 `⚠ 连续 N 次 ≥ 85%`,并给整行加 `WARN` 前缀(便于 grep)。
|
||||||
|
* 阈值可用 env 覆盖:`DSH_MEM_ALERT_PCT`(默认 0.85)/ `DSH_MEM_ALERT_STREAK`(默认 3)。
|
||||||
|
* 未达「连续」但已达单次高位时,也会标注 `(高位 N%,x/3)`,便于观察爬升趋势。
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么要配套把 cron 从「每小时」提到「每 10 分钟」:每小时 × 连续 3 次 = 3 小时才报,
|
||||||
|
* 而实例 OOM 常发生在分钟级(实测 guest 20:11:10 被 OOM kill,上一次采样还是 19:35),
|
||||||
|
* 小时级采样根本抓不到,等于没有预警。10 分钟 × 3 次 = 30 分钟,才有实际提前量。
|
||||||
|
*
|
||||||
|
* 输出 `/opt/dsh/state/instance-mem-peak.json`:
|
||||||
|
* { "updatedAt": <ms>, "uids": { "<uid>": { peakMiB, peakAt, lastMiB, samples, unit, limitMiB, pct, highStreak } } }
|
||||||
|
*
|
||||||
|
* 用法:node instance-mem-sample.cjs [--print]
|
||||||
|
*/
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
|
||||||
|
const STATE = process.env.DSH_PLATFORM_STATE_DIR ?? '/opt/dsh/state'
|
||||||
|
const OUT = `${STATE}/instance-mem-peak.json`
|
||||||
|
|
||||||
|
// ⚠️ systemd 的 MemoryCurrent / MemoryMax 单位是**字节**(不是 KB)。
|
||||||
|
// 写成 /1024 会得到 1024 倍的假数字(首次运行实测:98 MiB 显示成 100336 MiB)。
|
||||||
|
const MiB = (bytes) => Math.round(Number(bytes) / 1048576)
|
||||||
|
|
||||||
|
// 阈值告警参数(env 可覆盖:DSH_MEM_ALERT_PCT / DSH_MEM_ALERT_STREAK)
|
||||||
|
const HIGH_PCT = Number(process.env.DSH_MEM_ALERT_PCT ?? 0.85)
|
||||||
|
const HIGH_STREAK = Number(process.env.DSH_MEM_ALERT_STREAK ?? 3)
|
||||||
|
// 采样间隔下限:距上次采样不足此时长(默认 60s)视为「同一轮内的重复触发」(手动调试 / 人工跑),
|
||||||
|
// 只保持计数、不累计也不清零 —— 否则连续手动跑几次就会把 highStreak 顶到阈值造成误告警。
|
||||||
|
const MIN_SAMPLE_GAP_MS = Number(process.env.DSH_MEM_ALERT_MIN_GAP_MS ?? 60000)
|
||||||
|
|
||||||
|
function scopes() {
|
||||||
|
try {
|
||||||
|
const out = execFileSync(
|
||||||
|
'systemctl',
|
||||||
|
['list-units', '--type=scope', '--all', '--no-legend', '--plain', 'dsh-*.scope'],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim().split(/\s+/)[0])
|
||||||
|
.filter((u) => /^dsh-\d+-[0-9a-f]+\.scope$/.test(u))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(unit, prop) {
|
||||||
|
try {
|
||||||
|
return execFileSync('systemctl', ['show', unit, `-p${prop}`, '--value'], { encoding: 'utf8' }).trim()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
let db = { updatedAt: now, uids: {} }
|
||||||
|
try {
|
||||||
|
db = JSON.parse(fs.readFileSync(OUT, 'utf8'))
|
||||||
|
if (typeof db.uids !== 'object' || db.uids === null) db.uids = {}
|
||||||
|
} catch {
|
||||||
|
/* 首次运行或文件损坏 → 重建 */
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = []
|
||||||
|
for (const unit of scopes()) {
|
||||||
|
const uid = (unit.match(/^dsh-(\d+)-/) || [])[1]
|
||||||
|
if (!uid) continue
|
||||||
|
const cur = show(unit, 'MemoryCurrent')
|
||||||
|
if (cur === '' || cur === '[not set]') continue
|
||||||
|
const max = show(unit, 'MemoryMax')
|
||||||
|
const lastMiB = MiB(cur)
|
||||||
|
const limitMiB = max && max !== 'infinity' ? MiB(max) : null
|
||||||
|
const prev = db.uids[uid] ?? { peakMiB: 0, peakAt: null, samples: 0, highStreak: 0 }
|
||||||
|
const pct = limitMiB ? lastMiB / limitMiB : 0
|
||||||
|
const tooSoon = prev.updatedAt ? now - Date.parse(prev.updatedAt) < MIN_SAMPLE_GAP_MS : false
|
||||||
|
const highStreak = tooSoon
|
||||||
|
? (prev.highStreak ?? 0)
|
||||||
|
: limitMiB && pct >= HIGH_PCT
|
||||||
|
? (prev.highStreak ?? 0) + 1
|
||||||
|
: 0
|
||||||
|
const entry = {
|
||||||
|
peakMiB: Math.max(prev.peakMiB ?? 0, lastMiB),
|
||||||
|
peakAt: lastMiB >= (prev.peakMiB ?? 0) ? new Date(now).toISOString() : (prev.peakAt ?? null),
|
||||||
|
lastMiB,
|
||||||
|
limitMiB,
|
||||||
|
pct: Math.round(pct * 100),
|
||||||
|
highStreak,
|
||||||
|
samples: (prev.samples ?? 0) + 1,
|
||||||
|
unit,
|
||||||
|
updatedAt: new Date(now).toISOString(),
|
||||||
|
}
|
||||||
|
db.uids[uid] = entry
|
||||||
|
seen.push({ uid, ...entry })
|
||||||
|
}
|
||||||
|
db.updatedAt = now
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(STATE, { recursive: true })
|
||||||
|
fs.writeFileSync(OUT, JSON.stringify(db, null, 2) + '\n')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`写入 ${OUT} 失败: ${String(err.message ?? err)}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let alerted = 0
|
||||||
|
const line = seen
|
||||||
|
.map((s) => {
|
||||||
|
let note = ''
|
||||||
|
if (s.limitMiB && s.highStreak >= HIGH_STREAK) {
|
||||||
|
alerted++
|
||||||
|
note = ` ⚠ 内存连续 ${s.highStreak} 次 ≥ ${Math.round(HIGH_PCT * 100)}%(${s.lastMiB}/${s.limitMiB} MiB)`
|
||||||
|
} else if (s.limitMiB && s.pct >= Math.round(HIGH_PCT * 100)) {
|
||||||
|
note = ` (高位 ${s.pct}%,${s.highStreak}/${HIGH_STREAK})`
|
||||||
|
}
|
||||||
|
return `uid ${s.uid}: 当前 ${s.lastMiB} MiB / 峰值 ${s.peakMiB} MiB / 上限 ${s.limitMiB ?? '-'} MiB${note}`
|
||||||
|
})
|
||||||
|
.join(' | ')
|
||||||
|
if (process.argv.includes('--print') || seen.length > 0) {
|
||||||
|
const tag = alerted > 0 ? 'WARN ' : ''
|
||||||
|
console.log(`[${new Date(now).toISOString()}] ${tag}${seen.length} 个实例;${line || '无运行中实例'}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* probe-instance-mem.cjs —— 实例内存分解探针(只读)
|
||||||
|
*
|
||||||
|
* 回答"一个用户实例凭什么占这么多内存":把 RSS 拆成
|
||||||
|
* ① 共享只读代码页(node 二进制 + 原生模块)—— **多实例物理上只占一份**
|
||||||
|
* ② 私有匿名页(V8 堆 / 线程栈 / Buffer)—— 每实例真正独占,也是 cgroup 计数的口径
|
||||||
|
* 并列出最大的内存段,便于识别是不是某个大依赖(sharp / koffi / node-pty 等)。
|
||||||
|
*
|
||||||
|
* 用法(root 在宿主执行):
|
||||||
|
* node probe-instance-mem.cjs # 自动找实例进程 + 打印空 node 基线
|
||||||
|
* node probe-instance-mem.cjs <pid> [pid...] # 指定进程
|
||||||
|
*/
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
|
||||||
|
const MiB = (kb) => Math.round(kb / 1024)
|
||||||
|
|
||||||
|
function rollup(pid) {
|
||||||
|
const s = fs.readFileSync(`/proc/${pid}/smaps_rollup`, 'utf8')
|
||||||
|
const num = (k) => Number((s.match(new RegExp(`^${k}:\\s+(\\d+)`, 'm')) || [])[1] || 0)
|
||||||
|
return {
|
||||||
|
rss: num('Rss'),
|
||||||
|
pss: num('Pss'),
|
||||||
|
shared: num('Shared_Clean'),
|
||||||
|
privateDirty: num('Private_Dirty'),
|
||||||
|
privateClean: num('Private_Clean'),
|
||||||
|
anon: num('Anonymous'),
|
||||||
|
swap: num('Swap'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function biggestSegments(pid, top = 12) {
|
||||||
|
const smaps = fs.readFileSync(`/proc/${pid}/smaps`, 'utf8')
|
||||||
|
const blocks = smaps.split(/(?=^[0-9a-f]+-[0-9a-f]+ )/m).filter((b) => /^[0-9a-f]+-[0-9a-f]+ /.test(b))
|
||||||
|
const rows = blocks.map((b) => {
|
||||||
|
const nameM = b.match(/^[0-9a-f]+-[0-9a-f]+ \S+ \S+ \S+ \S+ +(.*)$/m)
|
||||||
|
return {
|
||||||
|
rss: Number((b.match(/^Rss:\s+(\d+)/m) || [])[1] || 0),
|
||||||
|
anon: Number((b.match(/^Anonymous:\s+(\d+)/m) || [])[1] || 0),
|
||||||
|
name: nameM ? nameM[1].trim() : '[anon]',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
rows.sort((a, b) => b.rss - a.rss)
|
||||||
|
return rows.slice(0, top)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按"归属"聚合:每个段拆 Shared_Clean/Dirty 与 Private_Clean/Dirty,按私有量排序。
|
||||||
|
* 这能直接回答"私有内存到底是什么" —— 是 node 二进制被写时复制,还是 JS 堆。 */
|
||||||
|
function breakdownByOwner(pid, top = 10) {
|
||||||
|
const smaps = fs.readFileSync(`/proc/${pid}/smaps`, 'utf8')
|
||||||
|
const blocks = smaps.split(/(?=^[0-9a-f]+-[0-9a-f]+ )/m).filter((b) => /^[0-9a-f]+-[0-9a-f]+ /.test(b))
|
||||||
|
const agg = new Map()
|
||||||
|
for (const b of blocks) {
|
||||||
|
const nameM = b.match(/^[0-9a-f]+-[0-9a-f]+ \S+ \S+ \S+ \S+ +(.*)$/m)
|
||||||
|
const name = (nameM ? nameM[1].trim() : '') || '[anon]'
|
||||||
|
const g = (k) => Number((b.match(new RegExp(`^${k}:\\s+(\\d+)`, 'm')) || [])[1] || 0)
|
||||||
|
const cur = agg.get(name) || { rss: 0, shared: 0, priv: 0 }
|
||||||
|
cur.rss += g('Rss')
|
||||||
|
cur.shared += g('Shared_Clean') + g('Shared_Dirty')
|
||||||
|
cur.priv += g('Private_Clean') + g('Private_Dirty')
|
||||||
|
agg.set(name, cur)
|
||||||
|
}
|
||||||
|
return [...agg.entries()]
|
||||||
|
.map(([name, v]) => ({ name, ...v }))
|
||||||
|
.sort((a, b) => b.priv - a.priv)
|
||||||
|
.slice(0, top)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInstances() {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('ps', ['-eo', 'pid,user,args', '--no-headers'], { encoding: 'utf8' })
|
||||||
|
return out
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => {
|
||||||
|
// 只认真正的实例进程;跳过 bwrap 包装进程(它的参数里同样含 "dsh --profile",
|
||||||
|
// 且只是 ~1 MiB 的转发壳,混进来会污染"最大内存段"清单)。
|
||||||
|
const m = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(l)
|
||||||
|
if (!m) return false
|
||||||
|
return /(^|\s)node\s+\S*dsh\s+--profile/.test(m[3]) && !/bwrap/.test(m[3])
|
||||||
|
})
|
||||||
|
.map((l) => {
|
||||||
|
const parts = l.trim().split(/\s+/)
|
||||||
|
return { pid: parts[0], user: parts[1] }
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空 node 基线:用来回答"dsh 自身让一个 node 进程多花多少"。 */
|
||||||
|
function baseline() {
|
||||||
|
const code = `
|
||||||
|
const fs=require('node:fs');
|
||||||
|
const s=fs.readFileSync('/proc/self/smaps_rollup','utf8');
|
||||||
|
const n=k=>Number((s.match(new RegExp('^'+k+':\\\\s+(\\\\d+)','m'))||[])[1]||0);
|
||||||
|
process.stdout.write(JSON.stringify({rss:n('Rss'),pss:n('Pss'),shared:n('Shared_Clean'),priv:n('Private_Dirty')}));`
|
||||||
|
const out = execFileSync(process.execPath, ['-e', code], { encoding: 'utf8' })
|
||||||
|
return JSON.parse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const targets = args.length > 0 ? args.map((p) => ({ pid: p, user: '?' })) : findInstances()
|
||||||
|
|
||||||
|
console.log('=== 环境 ===')
|
||||||
|
console.log('node', process.version, '| enableCompileCache:', typeof require('node:module').enableCompileCache)
|
||||||
|
console.log('NODE_OPTIONS =', process.env.NODE_OPTIONS ?? '(未设置)')
|
||||||
|
|
||||||
|
const b = baseline()
|
||||||
|
console.log(
|
||||||
|
`空 node 基线:Rss ${MiB(b.rss)} MiB | 共享 ${MiB(b.shared)} | 私有 ${MiB(b.priv)} | 虚拟已用 ${MiB(b.pss)} MiB`,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const t of targets) {
|
||||||
|
let r
|
||||||
|
try {
|
||||||
|
r = rollup(t.pid)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`\n=== pid ${t.pid}:读不到(${String(e.message).slice(0, 60)})`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const cmd = fs.readFileSync(`/proc/${t.pid}/cmdline`, 'utf8').split('\0').filter(Boolean).join(' ')
|
||||||
|
console.log(`\n=== pid ${t.pid}(user ${t.user})===`)
|
||||||
|
console.log(cmd.slice(0, 150))
|
||||||
|
console.log(
|
||||||
|
`RSS ${MiB(r.rss)} MiB = 共享代码 ${MiB(r.shared)} + 私有 ${MiB(r.privateDirty)}\n` +
|
||||||
|
` PSS ${MiB(r.pss)} MiB(按共享比例摊分后的"真实归属")| 匿名 ${MiB(r.anon)} | swap ${MiB(r.swap)}`,
|
||||||
|
)
|
||||||
|
console.log(` dsh 自身开销(相对空 node):私有 +${MiB(r.privateDirty - b.priv)} MiB | RSS +${MiB(r.rss - b.rss)} MiB`)
|
||||||
|
console.log(' 最大的内存段:')
|
||||||
|
for (const s of biggestSegments(t.pid)) {
|
||||||
|
console.log(` ${String(MiB(s.rss)).padStart(5)} MiB (anon ${String(MiB(s.anon)).padStart(4)}) ${s.name.slice(0, 64)}`)
|
||||||
|
}
|
||||||
|
console.log(' 按归属拆「共享 / 私有」(私有才是每实例独占,按私有量排序):')
|
||||||
|
for (const o of breakdownByOwner(t.pid)) {
|
||||||
|
console.log(
|
||||||
|
` 共享 ${String(MiB(o.shared)).padStart(4)} MiB | 私有 ${String(MiB(o.priv)).padStart(4)} MiB | 合计 ${String(MiB(o.rss)).padStart(4)} MiB ${o.name.slice(0, 56)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
for dir in /var/lib/dshs/users/*/; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
id="$(basename "$dir")"
|
||||||
|
[ "$id" = . ] && continue
|
||||||
|
short="$(echo "$id" | tr -d '-' | cut -c1-20)"
|
||||||
|
user="dsh-$short"
|
||||||
|
if ! id "$user" &>/dev/null; then
|
||||||
|
uid="$(node /opt/dshs/lib/cli.js uid-for-user "$id" --db /var/lib/dshs/dshs.db 2>/dev/null || echo 0)"
|
||||||
|
[ "$uid" = 0 ] && { echo "SKIP $id (uid-for-user失败)"; continue; }
|
||||||
|
# 若该uid已被其他账号占用则跳过
|
||||||
|
if id "$uid" &>/dev/null; then echo "SKIP $id (uid $uid 已被占用)"; continue; fi
|
||||||
|
useradd -u "$uid" -M -s /usr/sbin/nologin "$user"
|
||||||
|
echo "created $user (uid $uid) for $id"
|
||||||
|
fi
|
||||||
|
uid="$(id -u "$user")"
|
||||||
|
chown -R "$uid:$uid" "$dir"
|
||||||
|
echo "provisioned $id -> uid $uid"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2026-09-11 追加(档案 18 v3 收尾):为该批新用户补齐「目录选择器收敛」——
|
||||||
|
# ① 安装受限插件(逐用户 pnpm add)② 写平台段(cordis.patch.yml,幂等)。
|
||||||
|
# best-effort:失败不影响上面的账号 provisioning;日志见 journalctl -u dsh-provision。
|
||||||
|
if [ -f /opt/dshs/poc/workspace-scoped-picker/ensure-workspace-picker.cjs ]; then
|
||||||
|
node /opt/dshs/poc/workspace-scoped-picker/ensure-workspace-picker.cjs 2>&1 | sed "s/^/[picker] /" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2026-09-11 追加:「设置面板 → 用户管理」入口(@dsh-local/portal-entry)全员兜底。
|
||||||
|
# 该插件提供设置面板最后一个分区「用户管理」(管理员=门户地址+打开管理台+退出登录;
|
||||||
|
# 普通用户=仅退出登录)。**普通用户没有它就没有任何退出登录入口**,故必须与新用户
|
||||||
|
# 注册同步补齐(与上面的 picker 同一时机、同一 best-effort 策略)。
|
||||||
|
# 幂等:按 node_modules 已装版本 + dep spec 判定,已是最新即跳过。
|
||||||
|
if [ -f /opt/dshs/scripts/ensure-portal-entry.cjs ]; then
|
||||||
|
node /opt/dshs/scripts/ensure-portal-entry.cjs 2>&1 | sed "s/^/[portal-entry] /" || true
|
||||||
|
fi
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# purge-trash.sh —— 清理各用户 trash/ 下超过 30 天的回收目录(档案 28)
|
||||||
|
# 回收站是"清理动作的缓冲区":脚本只做 mv 进来,只有本脚本才真正 rm。
|
||||||
|
set -uo pipefail
|
||||||
|
KEEP_DAYS="${1:-30}"
|
||||||
|
LOG=/var/log/dsh-trash-purge.log
|
||||||
|
echo "[$(date -Is)] purge trash older than ${KEEP_DAYS}d" >> "$LOG"
|
||||||
|
for t in /var/lib/dshs/users/*/trash; do
|
||||||
|
[ -d "$t" ] || continue
|
||||||
|
find "$t" -mindepth 1 -maxdepth 1 -mtime "+${KEEP_DAYS}" -print -exec rm -rf {} + >> "$LOG" 2>&1
|
||||||
|
done
|
||||||
|
echo "[$(date -Is)] done" >> "$LOG"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 运行时版本基线巡检(档案 44)。
|
||||||
|
*
|
||||||
|
* 目的:**保证实例共享的基础运行时(Python / pip / node / npm)版本稳定**,
|
||||||
|
* 避免"版本差异导致插件功能无法使用"。
|
||||||
|
*
|
||||||
|
* 背景:运行时是平台在 `/usr/local/dsh-runtime/` 装的可移植发行版,
|
||||||
|
* 实例内 `/usr` 只读 → 理论上改不了;本脚本是**观测兜底** ——
|
||||||
|
* 一旦版本偏离基线(例:有人在宿主上手动装/换了版本),立刻告警,而不是等插件坏掉才发现。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node runtime-baseline.cjs # 对比,偏离则退出码 1(cron 会记日志)
|
||||||
|
* node runtime-baseline.cjs --accept # 主动把当前版本写入基线(升级运行时后执行)
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { existsSync, readFileSync, writeFileSync } = require('node:fs')
|
||||||
|
|
||||||
|
const BASE = '/opt/dsh/state/runtime-baseline.json'
|
||||||
|
const ACCEPT = process.argv.includes('--accept')
|
||||||
|
|
||||||
|
const run = (cmd, args) => {
|
||||||
|
try { return execFileSync(cmd, args, { encoding: 'utf8', timeout: 20000 }).trim().split('\n')[0] }
|
||||||
|
catch (e) { return `ERR:${String(e.message).split('\n')[0].slice(0, 60)}` }
|
||||||
|
}
|
||||||
|
const ver = (cmd, args) => (run(cmd, args).match(/\d+\.\d+(\.\d+)?/) ?? ['?'])[0]
|
||||||
|
|
||||||
|
const probe = () => ({
|
||||||
|
rg: ver('/usr/local/bin/rg', ['--version']),
|
||||||
|
jq: ver('/usr/local/bin/jq', ['--version']),
|
||||||
|
ffmpeg: (run('/usr/local/bin/ffmpeg', ['-version']).match(/ffmpeg version (\S+)/)?.[1] ?? '?'),
|
||||||
|
python3: ver('/usr/local/bin/python3', ['-V']),
|
||||||
|
pip3: ver('/usr/local/bin/pip3', ['-V']),
|
||||||
|
node: ver('/usr/local/bin/node', ['-v']),
|
||||||
|
npm: ver('/usr/local/bin/npm', ['-v']),
|
||||||
|
runtimePinned: existsSync('/usr/local/dsh-runtime/VERSION')
|
||||||
|
? (readFileSync('/usr/local/dsh-runtime/VERSION', 'utf8').match(/^python_version=(.+)$/m)?.[1] ?? '?')
|
||||||
|
: 'MISSING',
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = probe()
|
||||||
|
if (ACCEPT || !existsSync(BASE)) {
|
||||||
|
writeFileSync(BASE, JSON.stringify({ acceptedAt: new Date().toISOString(), versions: now }, null, 2) + '\n')
|
||||||
|
console.log(`==> 基线已写入 ${BASE}`)
|
||||||
|
console.log(' ', JSON.stringify(now))
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = JSON.parse(readFileSync(BASE, 'utf8'))
|
||||||
|
const drift = Object.keys(now).filter((k) => base.versions[k] !== now[k])
|
||||||
|
if (drift.length === 0) {
|
||||||
|
console.log(`ok 运行时版本与基线一致:${JSON.stringify(now)}`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
console.log('!! 运行时版本漂移(档案 44)')
|
||||||
|
for (const k of drift) console.log(` ${k}: 基线 ${base.versions[k]} → 现在 ${now[k]}`)
|
||||||
|
console.log(` 基线时间 ${base.acceptedAt};若本次是有意升级,执行:node runtime-baseline.cjs --accept`)
|
||||||
|
process.exit(1)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* session-gc.cjs —— 会话记录保留期回收(档案 14 §H3 / 档案 28)
|
||||||
|
* 规则(用户 2026-09-11 定):**每个用户的 sessions 达到阈值才触发**,清理 **超过 N 天** 的会话目录。
|
||||||
|
* 结构:<home>/sessions/<workspace-slug>/<session-id>/session.jsonl.zstd
|
||||||
|
* 安全:默认 dry-run;--apply 时 `mv` 到 <userRoot>/trash/<日期>-session-gc/(保留 30 天)。
|
||||||
|
* 说明:storages/session_projcache 体积很小(KB 级),本脚本不动它(留作后续细化)。
|
||||||
|
*
|
||||||
|
* 用法:node session-gc.cjs [--apply] [--threshold 500] [--days 90] [--user-id <uuid>]
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { existsSync, mkdirSync, readdirSync, statSync } = require('node:fs')
|
||||||
|
const { join } = require('node:path')
|
||||||
|
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
const APPLY = argv.includes('--apply')
|
||||||
|
const num = (flag, dflt) => { const i = argv.indexOf(flag); const v = i >= 0 ? Number(argv[i + 1]) : NaN; return Number.isFinite(v) ? v : dflt }
|
||||||
|
const THRESHOLD_MB = num('--threshold', 500)
|
||||||
|
const DAYS = num('--days', 90)
|
||||||
|
const idx = argv.indexOf('--user-id')
|
||||||
|
const ONLY = idx >= 0 ? argv[idx + 1] : ''
|
||||||
|
const STAMP = new Date().toISOString().slice(0, 10)
|
||||||
|
const CUTOFF = Date.now() - DAYS * 86400_000
|
||||||
|
|
||||||
|
const duKB = (p) => { try { return Number(execFileSync('du', ['-sk', p], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024 } catch { return 0 } }
|
||||||
|
const fmt = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB')
|
||||||
|
|
||||||
|
const db = new Database('/var/lib/dshs/dshs.db', { readonly: true })
|
||||||
|
const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all().filter((u) => ONLY === '' || u.id === ONLY)
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
const sessions = join(u.home_dir, 'sessions')
|
||||||
|
if (!existsSync(sessions)) { console.log(` ${u.username}: NO_SESSIONS`); continue }
|
||||||
|
const total = duKB(sessions)
|
||||||
|
const over = total >= THRESHOLD_MB * 1048576
|
||||||
|
const stale = []
|
||||||
|
for (const slug of readdirSync(sessions)) {
|
||||||
|
const slugDir = join(sessions, slug)
|
||||||
|
let st; try { st = statSync(slugDir) } catch { continue }
|
||||||
|
if (!st.isDirectory()) continue
|
||||||
|
for (const sid of readdirSync(slugDir)) {
|
||||||
|
const sd = join(slugDir, sid)
|
||||||
|
let sst; try { sst = statSync(sd) } catch { continue }
|
||||||
|
if (!sst.isDirectory()) continue
|
||||||
|
const f = join(sd, 'session.jsonl.zstd')
|
||||||
|
let mtime = sst.mtimeMs
|
||||||
|
try { if (existsSync(f)) mtime = statSync(f).mtimeMs } catch {}
|
||||||
|
if (mtime < CUTOFF) stale.push({ p: sd, size: duKB(sd), mtime: new Date(mtime) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const staleB = stale.reduce((a, c) => a + c.size, 0)
|
||||||
|
console.log(` ${u.username}: sessions=${fmt(total)} (${over ? '≥' : '<'} 阈值 ${THRESHOLD_MB}MB) | 超 ${DAYS} 天会话=${stale.length} 个 / ${fmt(staleB)}`)
|
||||||
|
for (const c of stale) console.log(` ${c.p.split('/').slice(-2).join('/')} ${fmt(c.size)} (${c.mtime.toISOString().slice(0, 10)})`)
|
||||||
|
if (APPLY && over && stale.length > 0) {
|
||||||
|
const trash = join(u.home_dir, '..', 'trash', `${STAMP}-session-gc`)
|
||||||
|
mkdirSync(trash, { recursive: true })
|
||||||
|
for (const c of stale) {
|
||||||
|
const dest = join(trash, c.p.split('/').slice(-2).join('__'))
|
||||||
|
try { execFileSync('mv', [c.p, dest]); execFileSync('chown', ['-R', `${u.uid}:${u.uid}`, dest]) }
|
||||||
|
catch (e) { console.log(` ! 失败 ${c.p}: ${String(e.message).split('\n')[0]}`) }
|
||||||
|
}
|
||||||
|
// 同步回收投影缓存:storages/session_projcache/sessions/<session-id>.json(按 id 精确匹配)
|
||||||
|
const pcDir = join(u.home_dir, 'storages', 'session_projcache', 'sessions')
|
||||||
|
let pcMoved = 0
|
||||||
|
if (existsSync(pcDir)) {
|
||||||
|
for (const c of stale) {
|
||||||
|
const sid = c.p.split('/').pop()
|
||||||
|
const pc = join(pcDir, `${sid}.json`)
|
||||||
|
if (existsSync(pc)) {
|
||||||
|
try { execFileSync('mv', [pc, join(trash, `projcache__${sid}.json`)]); pcMoved += 1 } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` → 已移 ${stale.length} 个会话 / ${fmt(staleB)}${pcMoved > 0 ? `(+ ${pcMoved} 个投影缓存)` : ''} → trash/${STAMP}-session-gc(保留 30 天)`)
|
||||||
|
} else if (APPLY && !over) console.log(' (未超阈值,跳过)')
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
console.log(APPLY ? 'done(已按阈值执行)' : 'done(dry-run,未改动)')
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Admin account flow: approve → disable → enable (restore), plus guard rails.
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { buildServer } from '../lib/web/server.js'
|
||||||
|
import { resolveConfig } from '../lib/config.js'
|
||||||
|
import { hashPassword } from '../lib/web/auth.js'
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error('ASSERT: ' + message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-admin-'))
|
||||||
|
const app = await buildServer(resolveConfig({ port: 0, dbPath: ':memory:', dataRoot }))
|
||||||
|
await app.listen({ port: 0 })
|
||||||
|
const base = `http://127.0.0.1:${app.server.address().port}`
|
||||||
|
|
||||||
|
await app.db.createUser({
|
||||||
|
id: 'admin',
|
||||||
|
username: 'admin',
|
||||||
|
passHash: await hashPassword('adminpass123'),
|
||||||
|
role: 'admin',
|
||||||
|
homeDir: join(dataRoot, 'users', 'admin', 'home'),
|
||||||
|
})
|
||||||
|
await app.db.createUser({
|
||||||
|
id: 'bob',
|
||||||
|
username: 'bob',
|
||||||
|
passHash: await hashPassword('bobpass123'),
|
||||||
|
role: 'pending',
|
||||||
|
homeDir: join(dataRoot, 'users', 'bob', 'home'),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function json(path, { method = 'GET', body, cookie } = {}) {
|
||||||
|
const res = await fetch(base + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(body ? { 'content-type': 'application/json' } : {}),
|
||||||
|
...(cookie ? { cookie } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let r
|
||||||
|
// Pending user cannot log in.
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
|
||||||
|
assert(r.status === 403 && r.body.error === 'pending_review', 'pending user blocked from login')
|
||||||
|
|
||||||
|
// Admin login.
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'adminpass123' } })
|
||||||
|
assert(r.status === 200, 'admin logs in')
|
||||||
|
const cookie = r.setCookie.split(';')[0]
|
||||||
|
|
||||||
|
// Approve bob.
|
||||||
|
r = await json('/api/admin/users/bob/approve', { method: 'POST', cookie })
|
||||||
|
assert(r.status === 200, 'approve succeeds')
|
||||||
|
|
||||||
|
// Bob can now log in.
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
|
||||||
|
assert(r.status === 200, 'approved user logs in')
|
||||||
|
|
||||||
|
// Disable bob.
|
||||||
|
r = await json('/api/admin/users/bob/disable', { method: 'POST', cookie })
|
||||||
|
assert(r.status === 200, 'disable succeeds')
|
||||||
|
|
||||||
|
// Bob blocked again (role disabled + sessions cleared).
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
|
||||||
|
assert(r.status === 403 && r.body.error === 'disabled', 'disabled user blocked from login')
|
||||||
|
|
||||||
|
// Enable (restore) bob.
|
||||||
|
r = await json('/api/admin/users/bob/enable', { method: 'POST', cookie })
|
||||||
|
assert(r.status === 200, 'enable succeeds')
|
||||||
|
|
||||||
|
// Bob can log in again.
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
|
||||||
|
assert(r.status === 200, 'restored user logs in')
|
||||||
|
|
||||||
|
// Guard rails.
|
||||||
|
r = await json('/api/admin/users/bob/enable', { method: 'POST', cookie })
|
||||||
|
assert(r.status === 409 && r.body.error === 'not_disabled', 'enable on non-disabled → 409')
|
||||||
|
r = await json('/api/admin/users/admin/disable', { method: 'POST', cookie })
|
||||||
|
assert(r.status === 409 && r.body.error === 'cannot_disable_admin', 'cannot disable admin')
|
||||||
|
|
||||||
|
console.log('OK: admin approve/disable/enable flow passed')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
rmSync(dataRoot, { recursive: true, force: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// End-to-end auth + review flow: seed an admin, register a user, verify they
|
||||||
|
// cannot log in while pending, approve them, then verify login + /me.
|
||||||
|
import { buildServer } from '../lib/web/server.js'
|
||||||
|
import { resolveConfig } from '../lib/config.js'
|
||||||
|
import { hashPassword } from '../lib/web/auth.js'
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error('ASSERT: ' + message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await buildServer(resolveConfig({ port: 0, dbPath: ':memory:' }))
|
||||||
|
await app.listen({ port: 0 })
|
||||||
|
const base = `http://127.0.0.1:${app.server.address().port}`
|
||||||
|
|
||||||
|
// Seed the first admin directly (what `bootstrap-admin` does).
|
||||||
|
await app.db.createUser({
|
||||||
|
id: 'admin-1',
|
||||||
|
username: 'admin',
|
||||||
|
passHash: await hashPassword('adminpass123'),
|
||||||
|
role: 'admin',
|
||||||
|
homeDir: '/tmp/admin-home',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function json(path, { method = 'GET', body, cookie } = {}) {
|
||||||
|
const res = await fetch(base + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(body ? { 'content-type': 'application/json' } : {}),
|
||||||
|
...(cookie ? { cookie } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = (setCookie) => (setCookie ? setCookie.split(';')[0] : undefined)
|
||||||
|
|
||||||
|
let r
|
||||||
|
|
||||||
|
r = await json('/api/auth/register', { method: 'POST', body: { username: 'alice', password: 'alicepass123' } })
|
||||||
|
console.log('register alice ->', r.status)
|
||||||
|
assert(r.status === 201, 'register creates a pending user')
|
||||||
|
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'alice', password: 'alicepass123' } })
|
||||||
|
console.log('login alice (pending) ->', r.status, r.body?.error)
|
||||||
|
assert(r.status === 403 && r.body?.error === 'pending_review', 'pending user is refused login')
|
||||||
|
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'adminpass123' } })
|
||||||
|
console.log('login admin ->', r.status)
|
||||||
|
assert(r.status === 200, 'admin login succeeds')
|
||||||
|
const adminCookie = sid(r.setCookie)
|
||||||
|
|
||||||
|
r = await json('/api/admin/users', { cookie: adminCookie })
|
||||||
|
console.log('list users ->', r.status, r.body?.users?.map((u) => `${u.username}:${u.role}`))
|
||||||
|
assert(r.status === 200, 'admin can list users')
|
||||||
|
const alice = r.body.users.find((u) => u.username === 'alice')
|
||||||
|
assert(alice?.role === 'pending', 'alice listed as pending')
|
||||||
|
|
||||||
|
r = await json(`/api/admin/users/${alice.id}/approve`, { method: 'POST', cookie: adminCookie })
|
||||||
|
console.log('approve alice ->', r.status)
|
||||||
|
assert(r.status === 200, 'approve succeeds')
|
||||||
|
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'alice', password: 'alicepass123' } })
|
||||||
|
console.log('login alice (approved) ->', r.status, r.body?.user)
|
||||||
|
assert(r.status === 200 && r.body.user.role === 'active', 'approved user logs in')
|
||||||
|
const aliceCookie = sid(r.setCookie)
|
||||||
|
|
||||||
|
r = await json('/api/auth/me', { cookie: aliceCookie })
|
||||||
|
console.log('me (alice) ->', r.status, r.body?.user)
|
||||||
|
assert(r.status === 200 && r.body.user.username === 'alice', '/me returns the current user')
|
||||||
|
|
||||||
|
r = await json('/api/me/keys', { cookie: aliceCookie })
|
||||||
|
console.log('keys (none) ->', r.status, r.body)
|
||||||
|
assert(r.status === 200 && r.body.keys.length === 0, 'no keys initially')
|
||||||
|
|
||||||
|
r = await json('/api/me/keys', { method: 'POST', cookie: aliceCookie, body: { name: 'home', apiKey: 'sk-good1-abc' } })
|
||||||
|
console.log('keys (add home) ->', r.status, r.body?.key)
|
||||||
|
assert(r.status === 200 && r.body.key.enabled === true, 'first key added + enabled')
|
||||||
|
const firstKeyId = r.body.key.id
|
||||||
|
|
||||||
|
r = await json('/api/me/keys', { method: 'POST', cookie: aliceCookie, body: { name: 'server', apiKey: 'sk-good2-xyz' } })
|
||||||
|
console.log('keys (add server) ->', r.status, r.body?.key)
|
||||||
|
assert(r.status === 200 && r.body.key.enabled === true, 'second key added + enabled')
|
||||||
|
|
||||||
|
r = await json('/api/me/keys', { cookie: aliceCookie })
|
||||||
|
console.log('keys (list) ->', r.status, r.body?.keys?.map((k) => `${k.name}:${k.enabled}`))
|
||||||
|
assert(r.body.keys.length === 2 && r.body.keys.find((k) => k.name === 'server').enabled === true, 'two keys, server enabled')
|
||||||
|
|
||||||
|
r = await json(`/api/me/keys/${firstKeyId}/select`, { method: 'POST', cookie: aliceCookie })
|
||||||
|
assert(r.status === 200, 're-select first key')
|
||||||
|
|
||||||
|
r = await json('/api/me/keys', { cookie: aliceCookie })
|
||||||
|
assert(r.body.keys.find((k) => k.name === 'home').enabled === true, 'home enabled after select')
|
||||||
|
|
||||||
|
r = await json('/api/me/keys', { method: 'POST', cookie: aliceCookie, body: { name: 'bad', apiKey: 'bad key with space' } })
|
||||||
|
console.log('keys (bad charset) ->', r.status)
|
||||||
|
assert(r.status === 400, 'header-hostile key is rejected')
|
||||||
|
|
||||||
|
r = await json(`/api/me/keys/${firstKeyId}`, { method: 'DELETE', cookie: aliceCookie })
|
||||||
|
assert(r.status === 200, 'key deleted')
|
||||||
|
|
||||||
|
r = await json('/api/me/keys', { cookie: aliceCookie })
|
||||||
|
assert(r.body.keys.length === 1, 'one key left after delete')
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
console.log('OK: full auth + review + key vault flow passed')
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Domain + nginx + proxy-rewrite flow: set/get a custom domain, regenerate its
|
||||||
|
// nginx config, admin verification, and the proxy rewriting Location redirects
|
||||||
|
// under the /u/<id>/dsh subpath.
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { buildServer } from '../lib/web/server.js'
|
||||||
|
import { resolveConfig } from '../lib/config.js'
|
||||||
|
import { hashPassword } from '../lib/web/auth.js'
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error('ASSERT: ' + message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const fakeDsh = join(here, 'fake-dsh.mjs')
|
||||||
|
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-domain-'))
|
||||||
|
|
||||||
|
const app = await buildServer(
|
||||||
|
resolveConfig({ port: 0, dbPath: ':memory:', dataRoot, dshCommand: [process.execPath, fakeDsh] }),
|
||||||
|
)
|
||||||
|
await app.listen({ port: 0 })
|
||||||
|
const base = `http://127.0.0.1:${app.server.address().port}`
|
||||||
|
|
||||||
|
await app.db.createUser({
|
||||||
|
id: 'u1',
|
||||||
|
username: 'frank',
|
||||||
|
passHash: await hashPassword('frankpass123'),
|
||||||
|
role: 'active',
|
||||||
|
homeDir: '/tmp/u1-home',
|
||||||
|
})
|
||||||
|
await app.db.createUser({
|
||||||
|
id: 'admin',
|
||||||
|
username: 'admin',
|
||||||
|
passHash: await hashPassword('adminpass123'),
|
||||||
|
role: 'admin',
|
||||||
|
homeDir: '/tmp/admin-home',
|
||||||
|
})
|
||||||
|
mkdirSync(join(dataRoot, 'users', 'u1', 'ws', 'proj'), { recursive: true })
|
||||||
|
|
||||||
|
async function json(path, { method = 'GET', body, cookie } = {}) {
|
||||||
|
const res = await fetch(base + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(body ? { 'content-type': 'application/json' } : {}),
|
||||||
|
...(cookie ? { cookie } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
try {
|
||||||
|
let r
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'frank', password: 'frankpass123' } })
|
||||||
|
const cookie = r.setCookie.split(';')[0]
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'adminpass123' } })
|
||||||
|
const adminCookie = r.setCookie.split(';')[0]
|
||||||
|
|
||||||
|
r = await json('/api/domain', { cookie })
|
||||||
|
console.log('domain (empty) ->', r.status, r.body)
|
||||||
|
assert(r.status === 200 && r.body.domain === null, 'no domain initially')
|
||||||
|
|
||||||
|
r = await json('/api/domain', { method: 'PUT', cookie, body: { domain: 'http://bad' } })
|
||||||
|
console.log('put invalid ->', r.status)
|
||||||
|
assert(r.status === 400, 'invalid domain rejected')
|
||||||
|
|
||||||
|
r = await json('/api/domain', { method: 'PUT', cookie, body: { domain: 'ALICE.Example.com' } })
|
||||||
|
console.log('put domain ->', r.status, r.body?.domain)
|
||||||
|
assert(r.status === 200 && r.body.domain === 'alice.example.com', 'domain normalized + stored')
|
||||||
|
assert(r.body.nginx_config.includes('server_name alice.example.com'), 'config has server_name')
|
||||||
|
assert(r.body.nginx_config.includes('/u/u1/dsh/'), 'config rewrites to user subpath')
|
||||||
|
|
||||||
|
r = await json('/api/domain', { cookie })
|
||||||
|
console.log('domain (get) ->', r.status, r.body?.domain, 'verified:', r.body?.verified)
|
||||||
|
assert(r.body.domain === 'alice.example.com' && r.body.verified === false, 'domain retrieved, unverified')
|
||||||
|
|
||||||
|
r = await json('/api/nginx/regen', { method: 'POST', cookie })
|
||||||
|
console.log('regen ->', r.status)
|
||||||
|
assert(r.status === 200 && r.body.nginx_config.includes('server_name alice.example.com'), 'regen returns config')
|
||||||
|
|
||||||
|
r = await json('/api/admin/domains', { cookie: adminCookie })
|
||||||
|
console.log('admin list ->', r.status, r.body?.domains?.map((d) => `${d.domain}:${d.verified}`))
|
||||||
|
const dom = r.body.domains.find((d) => d.domain === 'alice.example.com')
|
||||||
|
assert(dom && dom.verified === false, 'admin lists unverified domain')
|
||||||
|
|
||||||
|
r = await json(`/api/admin/domains/${dom.id}/verify`, { method: 'POST', cookie: adminCookie })
|
||||||
|
console.log('verify ->', r.status)
|
||||||
|
assert(r.status === 200, 'admin verifies domain')
|
||||||
|
|
||||||
|
r = await json('/api/admin/domains', { cookie: adminCookie })
|
||||||
|
assert(r.body.domains.find((d) => d.domain === 'alice.example.com').verified === true, 'domain now verified')
|
||||||
|
|
||||||
|
// Proxy Location rewrite.
|
||||||
|
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
|
||||||
|
assert(r.status === 200, 'launch succeeds')
|
||||||
|
let location
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(base + '/u/u1/dsh/redirect', { headers: { cookie }, redirect: 'manual' })
|
||||||
|
if (res.status === 302) {
|
||||||
|
location = res.headers.get('location')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry until the child is listening
|
||||||
|
}
|
||||||
|
await sleep(100)
|
||||||
|
}
|
||||||
|
console.log('proxy redirect ->', location)
|
||||||
|
assert(location === '/u/u1/dsh/somewhere', 'Location rewritten under subpath')
|
||||||
|
|
||||||
|
console.log('OK: domain + nginx + proxy-rewrite flow passed')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
await sleep(300)
|
||||||
|
try {
|
||||||
|
rmSync(dataRoot, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// DSH launch/status/proxy/stop flow against a stand-in `dsh` (fake-dsh.mjs).
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { buildServer } from '../lib/web/server.js'
|
||||||
|
import { resolveConfig } from '../lib/config.js'
|
||||||
|
import { hashPassword } from '../lib/web/auth.js'
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error('ASSERT: ' + message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const fakeDsh = join(here, 'fake-dsh.mjs')
|
||||||
|
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-dsh-'))
|
||||||
|
|
||||||
|
const app = await buildServer(
|
||||||
|
resolveConfig({ port: 0, dbPath: ':memory:', dataRoot, dshCommand: [process.execPath, fakeDsh] }),
|
||||||
|
)
|
||||||
|
await app.listen({ port: 0 })
|
||||||
|
const base = `http://127.0.0.1:${app.server.address().port}`
|
||||||
|
|
||||||
|
await app.db.createUser({
|
||||||
|
id: 'u1',
|
||||||
|
username: 'carol',
|
||||||
|
passHash: await hashPassword('carolpass123'),
|
||||||
|
role: 'active',
|
||||||
|
homeDir: '/tmp/u1-home',
|
||||||
|
})
|
||||||
|
mkdirSync(join(dataRoot, 'users', 'u1', 'ws', 'proj'), { recursive: true })
|
||||||
|
|
||||||
|
async function json(path, { method = 'GET', body, cookie } = {}) {
|
||||||
|
const res = await fetch(base + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(body ? { 'content-type': 'application/json' } : {}),
|
||||||
|
...(cookie ? { cookie } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let r
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'carol', password: 'carolpass123' } })
|
||||||
|
assert(r.status === 200, 'login succeeds')
|
||||||
|
const cookie = r.setCookie.split(';')[0]
|
||||||
|
|
||||||
|
r = await json('/api/dsh/status', { cookie })
|
||||||
|
assert(r.body.running === false, 'not running initially')
|
||||||
|
|
||||||
|
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
|
||||||
|
console.log('launch ->', r.status, r.body)
|
||||||
|
assert(r.status === 200 && r.body.url, 'launch succeeds')
|
||||||
|
const url = r.body.url
|
||||||
|
|
||||||
|
r = await json('/api/dsh/status', { cookie })
|
||||||
|
console.log('status ->', r.status, r.body)
|
||||||
|
assert(r.body.running === true, 'running after launch')
|
||||||
|
|
||||||
|
// The child binds its port asynchronously; poll the proxy until it answers.
|
||||||
|
let proxyText
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(base + url + 'hello', { headers: { cookie } })
|
||||||
|
if (res.status === 200) {
|
||||||
|
proxyText = await res.text()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// connection refused until the child is listening — retry
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
}
|
||||||
|
console.log('proxy ->', proxyText)
|
||||||
|
assert(proxyText !== undefined && proxyText.includes('fake-dsh'), 'proxy reaches the child DSH')
|
||||||
|
|
||||||
|
r = await json('/api/dsh/stop', { method: 'POST', cookie })
|
||||||
|
console.log('stop ->', r.status)
|
||||||
|
assert(r.status === 200, 'stop succeeds')
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
r = await json('/api/dsh/status', { cookie })
|
||||||
|
assert(r.body.running === false, 'stopped after stop')
|
||||||
|
|
||||||
|
console.log('OK: dsh launch/status/proxy/stop flow passed')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
rmSync(dataRoot, { recursive: true, force: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// File sidecar round-trip: drive K8sUserFs against a real buildFileService
|
||||||
|
// instance and assert it behaves identically to LocalUserFs on the same volume.
|
||||||
|
// This is the k8s desktop-FS path (docs/k8s.md §4.10) exercised without a
|
||||||
|
// cluster: the sidecar is bound on loopback and the client's Service-DNS
|
||||||
|
// resolution is stubbed to point at it.
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { buildFileService } from '../lib/web/file-service.js'
|
||||||
|
import { K8sUserFs } from '../lib/fs/k8s-user-fs.js'
|
||||||
|
import { LocalUserFs } from '../lib/fs/local-user-fs.js'
|
||||||
|
import { UserFsError } from '../lib/fs/user-fs.js'
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error('ASSERT: ' + message)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectsWith(fn, code, message) {
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
} catch (err) {
|
||||||
|
assert(err instanceof UserFsError, `${message} (got ${err})`)
|
||||||
|
assert(err.code === code, `${message} (got ${err.code})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('ASSERT: ' + message + ' (resolved instead)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-sidecar-'))
|
||||||
|
const userRoot = join(dataRoot, 'users', 'u1')
|
||||||
|
|
||||||
|
// The sidecar serves exactly this one user's root, as its Pod would.
|
||||||
|
const sidecar = buildFileService(userRoot, { bodyLimit: 8 * 1024 * 1024, logLevel: 'warn' })
|
||||||
|
await sidecar.listen({ host: '127.0.0.1', port: 0 })
|
||||||
|
const port = sidecar.server.address().port
|
||||||
|
|
||||||
|
// In-cluster this resolves to `dsh-files-<id>.<ns>.svc.cluster.local:8082`;
|
||||||
|
// here it points at the loopback sidecar bound above.
|
||||||
|
const local = new LocalUserFs(() => userRoot)
|
||||||
|
let ensured = 0
|
||||||
|
const remote = new K8sUserFs(async () => { ensured += 1 }, () => ({ host: '127.0.0.1', port }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await remote.initUserRoot('u1')
|
||||||
|
assert(ensured > 0, 'initUserRoot brings the sidecar up first')
|
||||||
|
|
||||||
|
// Seed a plugin profile so the catalog read has something to find.
|
||||||
|
const profile = join(userRoot, 'home', 'profiles', 'web')
|
||||||
|
mkdirSync(join(profile, 'node_modules', 'p1'), { recursive: true })
|
||||||
|
writeFileSync(join(profile, 'package.json'), JSON.stringify({ dsh: { profile: { bundles: ['p1', '@deepseek-ai/core'] } } }))
|
||||||
|
writeFileSync(join(profile, 'node_modules', 'p1', 'package.json'), JSON.stringify({ description: 'first plugin' }))
|
||||||
|
|
||||||
|
let entries = await remote.listDir('u1', '')
|
||||||
|
assert(entries.length === 0, 'fresh workspace is empty')
|
||||||
|
|
||||||
|
const dirName = await remote.createEntry('u1', '', 'proj', 'dir')
|
||||||
|
assert(dirName === 'proj', 'createEntry returns the sanitized name')
|
||||||
|
assert(await remote.isDirectory('u1', 'proj'), 'created entry is a directory')
|
||||||
|
|
||||||
|
const uploaded = await remote.upload('u1', 'proj', 'notes.txt', Buffer.from('hello sidecar'))
|
||||||
|
assert(uploaded === 'notes.txt', 'upload returns the sanitized name')
|
||||||
|
|
||||||
|
await remote.mkdir('u1', 'proj/sub')
|
||||||
|
|
||||||
|
// Same volume, two implementations → identical view.
|
||||||
|
entries = await remote.listDir('u1', 'proj')
|
||||||
|
const localEntries = await local.listDir('u1', 'proj')
|
||||||
|
assert(JSON.stringify(entries) === JSON.stringify(localEntries), 'sidecar and local agree on the listing')
|
||||||
|
const file = entries.find((e) => e.name === 'notes.txt')
|
||||||
|
assert(file.type === 'file' && file.size === 13, 'uploaded file has the right type/size')
|
||||||
|
|
||||||
|
const plugins = await remote.listInstalledPlugins('u1')
|
||||||
|
assert(plugins.length === 1 && plugins[0].id === 'p1', 'installation-scoped bundles are filtered out')
|
||||||
|
assert(plugins[0].description === 'first plugin', 'plugin description comes from its package.json')
|
||||||
|
assert(JSON.stringify(plugins) === JSON.stringify(await local.listInstalledPlugins('u1')), 'catalogs agree')
|
||||||
|
|
||||||
|
await remote.writeHandoff('u1', JSON.stringify({ command: 'echo hi' }))
|
||||||
|
|
||||||
|
// Error codes survive the HTTP hop as the same UserFsError the UI switches on.
|
||||||
|
await rejectsWith(() => remote.listDir('u1', '../../etc'), 'bad_path', 'traversal rejected')
|
||||||
|
await rejectsWith(() => remote.listDir('u1', 'nope'), 'not_found', 'missing dir is not_found')
|
||||||
|
await rejectsWith(() => remote.createEntry('u1', '', 'proj', 'dir'), 'exists', 'duplicate create is exists')
|
||||||
|
await rejectsWith(() => remote.createEntry('u1', 'nope', 'x', 'file'), 'parent_missing', 'missing parent')
|
||||||
|
await rejectsWith(() => remote.upload('u1', '', '..', Buffer.from('x')), 'bad_name', 'path in name rejected')
|
||||||
|
|
||||||
|
// resolvePath is pure POSIX path math on the in-Pod layout.
|
||||||
|
assert(
|
||||||
|
remote.resolvePath('u1', 'proj') === '/var/lib/dshs/users/u1/ws/proj',
|
||||||
|
'resolvePath yields the in-Pod path',
|
||||||
|
)
|
||||||
|
let escaped = false
|
||||||
|
try {
|
||||||
|
remote.resolvePath('u1', '../../etc')
|
||||||
|
} catch (err) {
|
||||||
|
escaped = err instanceof UserFsError && err.code === 'bad_path'
|
||||||
|
}
|
||||||
|
assert(escaped, 'resolvePath rejects traversal')
|
||||||
|
|
||||||
|
console.log('OK: file sidecar round-trip matches LocalUserFs')
|
||||||
|
} finally {
|
||||||
|
await sidecar.close()
|
||||||
|
rmSync(dataRoot, { recursive: true, force: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Desktop/FS flow: login, list (empty), mkdir (nested), upload, and isolation
|
||||||
|
// checks (path escape rejected, upload name sanitized, unauthenticated rejected).
|
||||||
|
// Uses a throwaway dataRoot + in-memory DB so each run is fully isolated.
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { buildServer } from '../lib/web/server.js'
|
||||||
|
import { resolveConfig } from '../lib/config.js'
|
||||||
|
import { hashPassword } from '../lib/web/auth.js'
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error('ASSERT: ' + message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-fs-'))
|
||||||
|
const app = await buildServer(resolveConfig({ port: 0, dbPath: ':memory:', dataRoot }))
|
||||||
|
await app.listen({ port: 0 })
|
||||||
|
const base = `http://127.0.0.1:${app.server.address().port}`
|
||||||
|
|
||||||
|
await app.db.createUser({
|
||||||
|
id: 'u1',
|
||||||
|
username: 'bob',
|
||||||
|
passHash: await hashPassword('bobpass123'),
|
||||||
|
role: 'active',
|
||||||
|
homeDir: '/tmp/u1-home',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function json(path, { method = 'GET', body, cookie } = {}) {
|
||||||
|
const res = await fetch(base + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(body ? { 'content-type': 'application/json' } : {}),
|
||||||
|
...(cookie ? { cookie } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let r
|
||||||
|
|
||||||
|
r = await json('/api/desktop/tree')
|
||||||
|
assert(r.status === 401, 'unauthenticated tree is rejected')
|
||||||
|
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
|
||||||
|
assert(r.status === 200, 'login succeeds')
|
||||||
|
const cookie = r.setCookie.split(';')[0]
|
||||||
|
|
||||||
|
r = await json('/api/desktop/tree', { cookie })
|
||||||
|
console.log('tree (empty) ->', r.status, r.body?.entries)
|
||||||
|
assert(r.status === 200 && r.body.entries.length === 0, 'empty workspace lists zero entries')
|
||||||
|
|
||||||
|
r = await json('/api/fs/mkdir', { method: 'POST', cookie, body: { path: 'proj' } })
|
||||||
|
console.log('mkdir proj ->', r.status)
|
||||||
|
assert(r.status === 200, 'mkdir succeeds')
|
||||||
|
|
||||||
|
r = await json('/api/fs/mkdir', { method: 'POST', cookie, body: { path: 'proj/sub' } })
|
||||||
|
console.log('mkdir proj/sub ->', r.status)
|
||||||
|
assert(r.status === 200, 'nested mkdir succeeds')
|
||||||
|
|
||||||
|
r = await json('/api/fs/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
cookie,
|
||||||
|
body: { path: 'proj', name: 'hello.txt', data: Buffer.from('hi there').toString('base64') },
|
||||||
|
})
|
||||||
|
console.log('upload ->', r.status)
|
||||||
|
assert(r.status === 200, 'upload succeeds')
|
||||||
|
|
||||||
|
r = await json('/api/desktop/tree?path=proj', { cookie })
|
||||||
|
console.log('tree proj ->', r.status, r.body?.entries?.map((e) => `${e.name}:${e.type}`))
|
||||||
|
assert(r.status === 200 && r.body.entries.some((e) => e.name === 'hello.txt'), 'uploaded file is listed')
|
||||||
|
|
||||||
|
// isolation: a `..` in the *path* is rejected outright
|
||||||
|
r = await json('/api/fs/mkdir', { method: 'POST', cookie, body: { path: '../escape' } })
|
||||||
|
console.log('mkdir ../escape ->', r.status)
|
||||||
|
assert(r.status === 400, 'path escape is rejected')
|
||||||
|
|
||||||
|
// isolation: a path component in the *name* is sanitized to its base name
|
||||||
|
r = await json('/api/fs/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
cookie,
|
||||||
|
body: { path: '', name: '../../evil.txt', data: 'aGk=' },
|
||||||
|
})
|
||||||
|
console.log('upload ../../ ->', r.status, r.body?.name)
|
||||||
|
assert(r.status === 200 && r.body?.name === 'evil.txt', 'upload name is sanitized to its base name')
|
||||||
|
|
||||||
|
console.log('OK: desktop/fs flow + isolation checks passed')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
rmSync(dataRoot, { recursive: true, force: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Account-level isolation spawn flow: in 'account' mode the orchestrator passes
|
||||||
|
// a deterministic uid to the setuid wrapper, and the child still runs through it.
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { buildServer } from '../lib/web/server.js'
|
||||||
|
import { resolveConfig } from '../lib/config.js'
|
||||||
|
import { hashPassword } from '../lib/web/auth.js'
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error('ASSERT: ' + message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const fakeDsh = join(here, 'fake-dsh.mjs')
|
||||||
|
const fakeSetpriv = join(here, 'fake-setpriv.mjs')
|
||||||
|
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-iso-'))
|
||||||
|
|
||||||
|
const app = await buildServer(
|
||||||
|
resolveConfig({
|
||||||
|
port: 0,
|
||||||
|
dbPath: ':memory:',
|
||||||
|
dataRoot,
|
||||||
|
isolationMode: 'account',
|
||||||
|
baseUid: 50000,
|
||||||
|
spawnAsUserCommand: [process.execPath, fakeSetpriv, '--uid', '{UID}'],
|
||||||
|
dshCommand: [process.execPath, fakeDsh],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await app.listen({ port: 0 })
|
||||||
|
const base = `http://127.0.0.1:${app.server.address().port}`
|
||||||
|
|
||||||
|
await app.db.createUser({
|
||||||
|
id: 'u1',
|
||||||
|
username: 'gina',
|
||||||
|
passHash: await hashPassword('ginapass123'),
|
||||||
|
role: 'active',
|
||||||
|
homeDir: '/tmp/u1-home',
|
||||||
|
})
|
||||||
|
mkdirSync(join(dataRoot, 'users', 'u1', 'ws', 'proj'), { recursive: true })
|
||||||
|
|
||||||
|
async function json(path, { method = 'GET', body, cookie } = {}) {
|
||||||
|
const res = await fetch(base + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(body ? { 'content-type': 'application/json' } : {}),
|
||||||
|
...(cookie ? { cookie } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
try {
|
||||||
|
let r = await json('/api/auth/login', { method: 'POST', body: { username: 'gina', password: 'ginapass123' } })
|
||||||
|
const cookie = r.setCookie.split(';')[0]
|
||||||
|
|
||||||
|
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
|
||||||
|
console.log('launch ->', r.status)
|
||||||
|
assert(r.status === 200, 'launch succeeds')
|
||||||
|
|
||||||
|
let uid
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
try {
|
||||||
|
uid = readFileSync(join(dataRoot, 'users', 'u1', 'ws', 'proj', 'setpriv-uid.txt'), 'utf8').trim()
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
// the wrapper hasn't written yet
|
||||||
|
}
|
||||||
|
await sleep(100)
|
||||||
|
}
|
||||||
|
const expected = (await app.db.findUserById('u1')).uid
|
||||||
|
console.log('setuid ->', uid, '(expected', expected + ')')
|
||||||
|
assert(uid === String(expected), 'setuid wrapper received the DB-assigned uid')
|
||||||
|
|
||||||
|
let proxyText
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(base + '/u/u1/dsh/hello', { headers: { cookie } })
|
||||||
|
if (res.status === 200) {
|
||||||
|
proxyText = await res.text()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry until the child is listening
|
||||||
|
}
|
||||||
|
await sleep(100)
|
||||||
|
}
|
||||||
|
console.log('proxy ->', proxyText !== undefined)
|
||||||
|
assert(proxyText !== undefined && proxyText.includes('fake-dsh'), 'dsh runs through the setuid wrapper')
|
||||||
|
|
||||||
|
console.log('OK: account-level isolation spawn flow passed')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
await sleep(300)
|
||||||
|
try {
|
||||||
|
rmSync(dataRoot, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Subdomain routing + auth flow: a per-user `Host: <username>.<baseDomain>` routes
|
||||||
|
// to that user's DSH only when the caller's session cookie matches the subdomain.
|
||||||
|
import { request as httpRequest } from 'node:http'
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { buildServer } from '../lib/web/server.js'
|
||||||
|
import { resolveConfig } from '../lib/config.js'
|
||||||
|
import { hashPassword } from '../lib/web/auth.js'
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error('ASSERT: ' + message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const fakeDsh = join(here, 'fake-dsh.mjs')
|
||||||
|
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-subdomain-'))
|
||||||
|
|
||||||
|
const app = await buildServer(
|
||||||
|
resolveConfig({ port: 0, dbPath: ':memory:', dataRoot, baseDomain: 'test.local', dshCommand: [process.execPath, fakeDsh] }),
|
||||||
|
)
|
||||||
|
await app.listen({ port: 0 })
|
||||||
|
const port = app.server.address().port
|
||||||
|
|
||||||
|
await app.db.createUser({
|
||||||
|
id: 'u1',
|
||||||
|
username: 'Carol',
|
||||||
|
passHash: await hashPassword('carolpass123'),
|
||||||
|
role: 'active',
|
||||||
|
homeDir: '/tmp/u1-home',
|
||||||
|
})
|
||||||
|
await app.db.createUser({
|
||||||
|
id: 'u2',
|
||||||
|
username: 'bob',
|
||||||
|
passHash: await hashPassword('bobpass123'),
|
||||||
|
role: 'active',
|
||||||
|
homeDir: '/tmp/u2-home',
|
||||||
|
})
|
||||||
|
mkdirSync(join(dataRoot, 'users', 'u1', 'ws', 'proj'), { recursive: true })
|
||||||
|
|
||||||
|
async function json(path, { method = 'GET', body, cookie } = {}) {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(cookie ? { cookie } : {}) },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWithHost(path, host, cookie) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = httpRequest(
|
||||||
|
{ hostname: '127.0.0.1', port, path, method: 'GET', headers: { host, ...(cookie ? { cookie } : {}) } },
|
||||||
|
(res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', (chunk) => (data += chunk))
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: data }))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
req.on('error', reject)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
try {
|
||||||
|
let r = await json('/api/auth/login', { method: 'POST', body: { username: 'Carol', password: 'carolpass123' } })
|
||||||
|
const cookie = r.setCookie.split(';')[0]
|
||||||
|
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
|
||||||
|
const bobCookie = r.setCookie.split(';')[0]
|
||||||
|
|
||||||
|
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
|
||||||
|
console.log('launch ->', r.status, r.body?.url)
|
||||||
|
assert(r.status === 200, 'launch succeeds')
|
||||||
|
assert(r.body.url === 'https://carol.test.local/', 'launch returns the subdomain URL')
|
||||||
|
|
||||||
|
await sleep(200)
|
||||||
|
|
||||||
|
let res = await getWithHost('/hello', 'carol.test.local', cookie)
|
||||||
|
console.log('authed /hello ->', res.status)
|
||||||
|
assert(res.status === 200 && res.body.includes('fake-dsh'), 'authed subdomain routes to the DSH')
|
||||||
|
|
||||||
|
res = await getWithHost('/hello', 'carol.test.local')
|
||||||
|
console.log('no-cookie /hello ->', res.status)
|
||||||
|
assert(res.status === 401, 'unauthenticated subdomain is rejected')
|
||||||
|
|
||||||
|
res = await getWithHost('/hello', 'carol.test.local', bobCookie)
|
||||||
|
console.log('bob /hello ->', res.status)
|
||||||
|
assert(res.status === 403, 'wrong user is rejected')
|
||||||
|
|
||||||
|
res = await getWithHost('/', `127.0.0.1:${port}`)
|
||||||
|
assert(!res.body.includes('fake-dsh'), 'non-subdomain Host does not proxy')
|
||||||
|
|
||||||
|
console.log('OK: subdomain routing + auth flow passed')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
await sleep(300)
|
||||||
|
try {
|
||||||
|
rmSync(dataRoot, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Smoke test: boot the orchestrator on an ephemeral port, hit `/` and
|
||||||
|
// `/api/auth/me`, then close cleanly. Proves the scaffold compiles, migrates
|
||||||
|
// the DB, serves static, and exercises the authn guard.
|
||||||
|
import { buildServer } from '../lib/web/server.js'
|
||||||
|
import { resolveConfig } from '../lib/config.js'
|
||||||
|
|
||||||
|
const app = await buildServer(resolveConfig({ port: 0, dbPath: './dev.local.db' }))
|
||||||
|
await app.listen({ port: 0 })
|
||||||
|
const addr = app.server.address()
|
||||||
|
const base = `http://127.0.0.1:${addr.port}`
|
||||||
|
|
||||||
|
const home = await fetch(base + '/')
|
||||||
|
console.log('GET / ->', home.status, (await home.text()).replace(/\s+/g, ' ').slice(0, 60))
|
||||||
|
|
||||||
|
const me = await fetch(base + '/api/auth/me')
|
||||||
|
console.log('GET /api/auth/me ->', me.status, await me.text())
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
console.log('OK: booted, migrated, served, closed')
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* storage-report.cjs —— 每用户存储用量上报(档案 28)
|
||||||
|
* 输出:/var/run/dsh-storage-report.json(供门户 GET /api/admin/storage 直接读取,避免每次请求都 du)
|
||||||
|
* cron:每小时一次。
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { existsSync, writeFileSync } = require('node:fs')
|
||||||
|
const { join } = require('node:path')
|
||||||
|
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||||
|
|
||||||
|
const OUT = process.env.DSH_STORAGE_REPORT ?? '/var/run/dsh-storage-report.json'
|
||||||
|
const WS_MB = Number(process.env.DSH_WS_THRESHOLD_MB ?? 2048)
|
||||||
|
const SESS_MB = Number(process.env.DSH_SESSIONS_THRESHOLD_MB ?? 1024)
|
||||||
|
|
||||||
|
const duKB = (p) => { try { return Number(execFileSync('du', ['-sk', p], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024 } catch { return 0 } }
|
||||||
|
const db = new Database('/var/lib/dshs/dshs.db', { readonly: true })
|
||||||
|
const users = db.prepare('SELECT username, home_dir FROM users ORDER BY username').all()
|
||||||
|
|
||||||
|
const rows = users.map((u) => {
|
||||||
|
const ws = join(u.home_dir, '..', 'ws'), sessions = join(u.home_dir, 'sessions'), trash = join(u.home_dir, '..', 'trash')
|
||||||
|
const t1 = [], t2 = []
|
||||||
|
// 仅统计顶层候选(与 ws-cleanup 口径一致),供管理员判断
|
||||||
|
if (existsSync(ws)) {
|
||||||
|
const { readdirSync, statSync } = require('node:fs')
|
||||||
|
const T1_DIRS = ['.local', '.cache', '.poc-backup', 'poc', '__pycache__', '.pytest_cache', 'node_modules', '.ipynb_checkpoints']
|
||||||
|
const T1_SUFFIX = ['.tgz', '.tmp', '.log', '.bak', '.part', '.crdownload']
|
||||||
|
const T2_EXT = new Set(['.py', '.js', '.mjs', '.cjs', '.sh', '.bash', '.ps1', '.psm1', '.bat', '.cmd', '.ts', '.ipynb', '.sql'])
|
||||||
|
const CUTOFF = Date.now() - 90 * 86400_000
|
||||||
|
for (const e of readdirSync(ws)) {
|
||||||
|
const p = join(ws, e)
|
||||||
|
let st; try { st = statSync(p) } catch { continue }
|
||||||
|
if (st.isDirectory()) { if (T1_DIRS.includes(e)) t1.push({ name: e, size: duKB(p) }); continue }
|
||||||
|
const dot = e.lastIndexOf('.')
|
||||||
|
const ext = dot >= 0 ? e.slice(dot).toLowerCase() : ''
|
||||||
|
if (T1_SUFFIX.includes(ext)) t1.push({ name: e, size: st.size })
|
||||||
|
else if (T2_EXT.has(ext) && st.mtimeMs < CUTOFF) t2.push({ name: e, size: st.size })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const wsB = existsSync(ws) ? duKB(ws) : 0
|
||||||
|
const sessB = existsSync(sessions) ? duKB(sessions) : 0
|
||||||
|
const trashB = existsSync(trash) ? duKB(trash) : 0
|
||||||
|
return {
|
||||||
|
username: u.username,
|
||||||
|
ws: wsB, sessions: sessB, trash: trashB, total: wsB + sessB + trashB,
|
||||||
|
wsOver: wsB >= WS_MB * 1048576, sessionsOver: sessB >= SESS_MB * 1048576,
|
||||||
|
cleanableT1: t1.length, cleanableT2: t2.length,
|
||||||
|
cleanableBytes: [...t1, ...t2].reduce((a, c) => a + c.size, 0),
|
||||||
|
topCleanable: [...t1, ...t2].sort((a, b) => b.size - a.size).slice(0, 5).map((c) => `${c.name} (${(c.size / 1048576).toFixed(1)}MB)`),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
db.close()
|
||||||
|
const report = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
thresholds: { wsMB: WS_MB, sessionsMB: SESS_MB, keepDays: { ws: 90, sessions: 365 }, trashKeepDays: 30 },
|
||||||
|
totals: { ws: rows.reduce((a, r) => a + r.ws, 0), sessions: rows.reduce((a, r) => a + r.sessions, 0), trash: rows.reduce((a, r) => a + r.trash, 0) },
|
||||||
|
users: rows,
|
||||||
|
}
|
||||||
|
writeFileSync(OUT, JSON.stringify(report, null, 2) + '\n')
|
||||||
|
const fmt = (b) => (b >= 1073741824 ? (b / 1073741824).toFixed(2) + 'G' : (b / 1048576).toFixed(1) + 'M')
|
||||||
|
for (const r of rows) console.log(` ${r.username}: ws=${fmt(r.ws)} sessions=${fmt(r.sessions)} trash=${fmt(r.trash)} | 可清 ${r.cleanableT1 + r.cleanableT2} 项/${fmt(r.cleanableBytes)}`)
|
||||||
|
console.log(` → 已写入 ${OUT}`)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* verify-inject.cjs —— 注入脚本校验(**档案 81 · R1 起改为校验独立文件**)
|
||||||
|
*
|
||||||
|
* 历史(2026-09-13 事故):注入脚本原先是 `proxy.ts` 里的 **TS 模板字面量**,里面的 `\n` 会在
|
||||||
|
* 模板求值时先被转义 ⇒ 注入浏览器的那段 JS 变 SyntaxError ⇒ **整段脚本静默不执行**(浮层/自愈/助手全废)。
|
||||||
|
* 当时"校验"只抽原始文本跑 new Function,**跳过了求值** ⇒ 假绿。
|
||||||
|
*
|
||||||
|
* 现在(R1):脚本已外置到 `assets/inject/*.js`(纯 JS),本脚本负责:
|
||||||
|
* ① 断言这些文件存在、非空、且能通过 `node --check`(等价于浏览器解析);
|
||||||
|
* ② 断言 `src/supervisor/proxy.ts` **不再**把注入脚本内联成模板字面量(防回退);
|
||||||
|
* ③ 断言运行时串里不含 `<script` / `</script>`(会提前结束注入的 script 标签)。
|
||||||
|
*
|
||||||
|
* 用法:node scripts/verify-inject.cjs 退出码 0=合格 / 1=不合格
|
||||||
|
*/
|
||||||
|
const fs = require('fs')
|
||||||
|
const os = require('os')
|
||||||
|
const path = require('path')
|
||||||
|
const cp = require('child_process')
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..')
|
||||||
|
const DIR = path.join(ROOT, 'assets', 'inject')
|
||||||
|
const PROXY_SRC = path.join(ROOT, 'src', 'supervisor', 'proxy.ts')
|
||||||
|
let bad = 0
|
||||||
|
|
||||||
|
const files = fs.existsSync(DIR) ? fs.readdirSync(DIR).filter((f) => f.endsWith('.js')) : []
|
||||||
|
console.log('=== 注入脚本(' + DIR + ',' + files.length + ' 个)===')
|
||||||
|
if (files.length === 0) { console.log(' ✗ assets/inject 下没有 .js(档案 81 R1 要求注入脚本外置)'); bad++ }
|
||||||
|
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vinj-'))
|
||||||
|
for (const f of files) {
|
||||||
|
const abs = path.join(DIR, f)
|
||||||
|
const code = fs.readFileSync(abs, 'utf8')
|
||||||
|
if (code.trim().length < 100) { console.log(' ✗ ' + f + ':内容过短,疑似被清空'); bad++; continue }
|
||||||
|
const t = path.join(tmp, f)
|
||||||
|
fs.writeFileSync(t, code)
|
||||||
|
const r = cp.spawnSync(process.execPath, ['--check', t], { encoding: 'utf8' })
|
||||||
|
if (r.status !== 0) {
|
||||||
|
console.log(' ✗ ' + f + ':**语法失败**(浏览器里会静默失效)→ ' + String(r.stderr || '').split('\n').slice(0, 2).join(' '))
|
||||||
|
bad++
|
||||||
|
} else {
|
||||||
|
const danger = ['</script', '<script'].filter((k) => code.includes(k))
|
||||||
|
if (danger.length) { console.log(' ✗ ' + f + ':含 ' + danger.join('/') + '(会提前结束注入的 script 标签)'); bad++ }
|
||||||
|
else console.log(' ✓ ' + f + ' 语法通过(' + code.length + ' 字符)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true })
|
||||||
|
|
||||||
|
// 防回退:proxy.ts 不得再内联注入脚本
|
||||||
|
if (fs.existsSync(PROXY_SRC)) {
|
||||||
|
const src = fs.readFileSync(PROXY_SRC, 'utf8')
|
||||||
|
const inlined = /const SESSION_[A-Z_]+ = `/g.test(src)
|
||||||
|
if (inlined) { console.log(' ✗ proxy.ts 仍把注入脚本内联成模板字面量(应改为 loadInject 读 assets/inject)'); bad++ }
|
||||||
|
else {
|
||||||
|
const loads = (src.match(/loadInject\('([^']+)'\)/g) || []).length
|
||||||
|
console.log(' ✓ proxy.ts 已走 loadInject(' + loads + ' 处)')
|
||||||
|
if (loads < files.length) { console.log(' ⚠️ loadInject 处数(' + loads + ') < 文件数(' + files.length + '),确认是否漏用'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(bad ? '结论:' + bad + ' 项不合格 ❌ —— 别推上线' : '结论:全部合格 ✅')
|
||||||
|
process.exit(bad ? 1 : 0)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* verify-static.mjs —— 静态页不变量校验(2026-09-13 新增)
|
||||||
|
*
|
||||||
|
* 为什么需要:平台有 9 个静态页,其中 wake.html 承担"启动过渡页"(有 5 个 id 被内联 JS 依赖),
|
||||||
|
* 而"去平台痕迹"(用户可见面不得出现 dshs)是个**容易回归**的约束 ——
|
||||||
|
* 新人加个页面忘了改名就会漏出去。把它变成 CI 可跑的判据。
|
||||||
|
*
|
||||||
|
* 用法:node scripts/verify-static.mjs 退出码 0=全绿 / 1=有违规
|
||||||
|
*/
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import { join, dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const WEB = join(ROOT, 'web')
|
||||||
|
const BANNED = 'dshs' // 用户可见面不得出现的平台内部名
|
||||||
|
const WAKE_IDS = ['spin', 'step', 'acts', 'retry', 'note'] // wake.html 内联 JS 依赖的 id
|
||||||
|
|
||||||
|
let bad = 0
|
||||||
|
const pages = readdirSync(WEB).filter((f) => f.endsWith('.html'))
|
||||||
|
console.log('=== 静态页不变量(' + pages.length + ' 页)===')
|
||||||
|
|
||||||
|
for (const f of pages) {
|
||||||
|
const s = readFileSync(join(WEB, f), 'utf8')
|
||||||
|
const problems = []
|
||||||
|
const title = /<title>([^<]*)<\/title>/.exec(s)
|
||||||
|
if (!title || title[1].trim() === '') problems.push('缺 <title> 或为空')
|
||||||
|
else if (title[1].includes(BANNED)) problems.push('title 含内部平台名: ' + title[1])
|
||||||
|
if (s.includes(BANNED)) problems.push('页面正文含内部平台名(去痕迹约束)')
|
||||||
|
|
||||||
|
if (f === 'wake.html') {
|
||||||
|
for (const id of WAKE_IDS) {
|
||||||
|
if (!new RegExp('id="' + id + '"').test(s)) problems.push('缺 id="' + id + '"(内联 JS 依赖)')
|
||||||
|
}
|
||||||
|
if (!s.includes('instance_circuit_open')) problems.push('缺档案 78 的熔断提示分支')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length) { bad++; console.log(' ✗ ' + f + ':' + problems.join(';')) }
|
||||||
|
else console.log(' ✓ ' + f + (title ? '(title=' + title[1] + ')' : ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内联 <script> 必须能被 JS 解析(2026-09-13 事故:脚本语法错误 = 静默失效)
|
||||||
|
import { writeFileSync, mkdtempSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'vstatic-'))
|
||||||
|
for (const f of pages) {
|
||||||
|
const s = readFileSync(join(WEB, f), 'utf8')
|
||||||
|
const blocks = [...s.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)].map((m) => m[1])
|
||||||
|
blocks.forEach((code, i) => {
|
||||||
|
const file = join(tmp, f.replace(/\W/g, '_') + '.' + i + '.js')
|
||||||
|
writeFileSync(file, code)
|
||||||
|
try { execFileSync(process.execPath, ['--check', file], { stdio: 'pipe' }); console.log(' ✓ ' + f + ' 内联脚本#' + i + ' 语法通过') }
|
||||||
|
catch (e) { bad++; console.log(' ✗ ' + f + ' 内联脚本#' + i + ' **语法失败**:' + String(e.stderr || e).split('\n').slice(0, 2).join(' ')) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS/JS 资源也要过一遍去痕迹约束
|
||||||
|
for (const f of readdirSync(WEB).filter((f) => /\.(css|js)$/.test(f))) {
|
||||||
|
const s = readFileSync(join(WEB, f), 'utf8')
|
||||||
|
if (s.includes(BANNED)) { bad++; console.log(' ✗ ' + f + ':含内部平台名') }
|
||||||
|
else console.log(' ✓ ' + f)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(bad ? '结论:' + bad + ' 项不合格 ❌' : '结论:全部合格 ✅')
|
||||||
|
process.exit(bad ? 1 : 0)
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* ws-cleanup.cjs —— 用户工作区(ws)定期清理 + 磁盘画像(档案 28)
|
||||||
|
*
|
||||||
|
* 分三档(**判定依据只有"路径/文件名模式 + 年龄"**,因为无法可靠区分"AI 写的"):
|
||||||
|
* T1 明确垃圾/平台产物 → 直接清(白名单精确匹配)
|
||||||
|
* T2 临时脚本(一次性、无复用价值)→ 超过 N 天未修改才清(默认 90 天)
|
||||||
|
* T3 其余一切(文档/表格/图片/视频/数据/目录)→ **永不自动删**,只统计
|
||||||
|
*
|
||||||
|
* 安全:默认 dry-run;--apply 时一律 `mv` 到 <userRoot>/trash/<日期>-ws-cleanup/(保留 30 天可恢复);
|
||||||
|
* 仅当该用户 ws ≥ --threshold MB 才动手(默认 2048 MB)。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node ws-cleanup.cjs # dry-run + 画像
|
||||||
|
* node ws-cleanup.cjs --apply # 执行(含阈值判断)
|
||||||
|
* node ws-cleanup.cjs --apply --threshold 1024 --days 60
|
||||||
|
* node ws-cleanup.cjs --user-id <uuid>
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { existsSync, lchownSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync } = require('node:fs')
|
||||||
|
const { join, extname, basename } = require('node:path')
|
||||||
|
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
const APPLY = argv.includes('--apply')
|
||||||
|
const num = (flag, dflt) => { const i = argv.indexOf(flag); const v = i >= 0 ? Number(argv[i + 1]) : NaN; return Number.isFinite(v) ? v : dflt }
|
||||||
|
const THRESHOLD_MB = num('--threshold', 2048)
|
||||||
|
const DAYS = num('--days', 90)
|
||||||
|
const idx = argv.indexOf('--user-id')
|
||||||
|
const ONLY = idx >= 0 ? argv[idx + 1] : ''
|
||||||
|
/** 档案 43:`--reclaim-only` = 只做属主回收,不跑清理(供高频 cron 用,与阈值无关)。 */
|
||||||
|
const RECLAIM_ONLY = argv.includes('--reclaim-only')
|
||||||
|
const STAMP = new Date().toISOString().slice(0, 10)
|
||||||
|
const CUTOFF = Date.now() - DAYS * 86400_000
|
||||||
|
|
||||||
|
// T1:平台产物/明确垃圾(精确名或后缀);T2:一次性脚本(仅"顶层脚本文件"才算,避免误伤项目目录里的源码)
|
||||||
|
const T1_DIRS = ['.local', '.cache', '.poc-backup', 'poc', '__pycache__', '.pytest_cache', 'node_modules', '.ipynb_checkpoints']
|
||||||
|
const T1_SUFFIX = ['.tgz', '.tmp', '.log', '.bak', '.part', '.crdownload']
|
||||||
|
const T2_EXT = new Set(['.py', '.js', '.mjs', '.cjs', '.sh', '.bash', '.ps1', '.psm1', '.bat', '.cmd', '.ts', '.ipynb', '.sql'])
|
||||||
|
|
||||||
|
const duKB = (p) => { try { return Number(execFileSync('du', ['-sk', p], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024 } catch { return 0 } }
|
||||||
|
const fmt = (b) => (b >= 1073741824 ? (b / 1073741824).toFixed(2) + ' GB' : b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属主回收(档案 43):把用户工作区里**非该用户属主**的项 chown 回该用户。
|
||||||
|
*
|
||||||
|
* 根因:平台以 **root** 执行 `pnpm`,且把 `HOME` 指向用户工作区
|
||||||
|
* (`src/web/routes/business-plugins.ts` 的 `pnpmEnv`、`scripts/ensure-biz-plugins.cjs`)——
|
||||||
|
* 因为要与 dsh 实例共用同一个 pnpm store,否则报 `ERR_PNPM_UNEXPECTED_STORE`(不能用别处的 HOME 绕)。
|
||||||
|
* 副作用是 pnpm 在用户家目录建出 **root 属主**的 `.local/`(`.local/share/pnpm`)→
|
||||||
|
* 用户之后再 `pip install --user` / npm user-prefix 就报 `Permission denied`
|
||||||
|
* ——「在自己的目录里装不了包」(2026-09-11 实证)。
|
||||||
|
*
|
||||||
|
* 本函数**只 chown、不删除**:用户资产不受影响;root 建的 pnpm store 改属主后 pnpm 依旧可读写。
|
||||||
|
* 与清理阈值**无关**,永远执行;不跟随软链(用 lstat + lchown)。
|
||||||
|
*/
|
||||||
|
function reclaimOwnership(root, uid) {
|
||||||
|
let fixed = 0
|
||||||
|
const walk = (dir) => {
|
||||||
|
let entries
|
||||||
|
try { entries = readdirSync(dir) } catch { return }
|
||||||
|
for (const e of entries) {
|
||||||
|
const p = join(dir, e)
|
||||||
|
let st
|
||||||
|
try { st = lstatSync(p) } catch { continue }
|
||||||
|
if (st.uid !== uid || st.gid !== uid) {
|
||||||
|
try { lchownSync(p, uid, uid); fixed += 1 } catch { /* 尽力而为 */ }
|
||||||
|
}
|
||||||
|
if (st.isDirectory() && !st.isSymbolicLink()) walk(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(root)
|
||||||
|
return fixed
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database('/var/lib/dshs/dshs.db', { readonly: true })
|
||||||
|
const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all().filter((u) => ONLY === '' || u.id === ONLY)
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
const ws = join(u.home_dir, '..', 'ws')
|
||||||
|
if (!existsSync(ws)) { console.log(` ${u.username}: NO_WS`); continue }
|
||||||
|
|
||||||
|
// 档案 43:属主回收 —— 与阈值无关,always 执行(默认 dry-run 只报告,--apply 才动手)。
|
||||||
|
const reclaimable = (() => {
|
||||||
|
let n = 0
|
||||||
|
const walk = (dir) => {
|
||||||
|
let entries; try { entries = readdirSync(dir) } catch { return }
|
||||||
|
for (const e of entries) {
|
||||||
|
const p = join(dir, e); let st
|
||||||
|
try { st = lstatSync(p) } catch { continue }
|
||||||
|
if (st.uid !== u.uid || st.gid !== u.uid) n += 1
|
||||||
|
if (st.isDirectory() && !st.isSymbolicLink()) walk(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(ws); return n
|
||||||
|
})()
|
||||||
|
if (reclaimable > 0) {
|
||||||
|
if (APPLY) {
|
||||||
|
const fixed = reclaimOwnership(ws, u.uid)
|
||||||
|
console.log(` ${u.username}: 属主回收 ${fixed} 项 → ${u.uid}:${u.uid}(平台以 root 跑 pnpm 的残留)`)
|
||||||
|
} else {
|
||||||
|
console.log(` ${u.username}: 发现 ${reclaimable} 项非本用户属主(--apply 时回收)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (RECLAIM_ONLY) continue
|
||||||
|
|
||||||
|
const wsBytes = duKB(ws)
|
||||||
|
const t1 = [], t2 = [] // T2 可被 ws/.keep 豁免
|
||||||
|
let otherBytes = 0, otherCount = 0
|
||||||
|
|
||||||
|
// 档案 35:平台自建 bundle 以 `file:<ws>/xxx.tgz` 安装(portal-entry / business-plugins /
|
||||||
|
// workspace-scoped-picker)。T1 的 `.tgz` 规则会删掉它们 → profile 的 dependencies 指向
|
||||||
|
// 不存在的文件 → 之后**任何 pnpm 操作**(含用户启用功能插件)都 ENOENT 失败。
|
||||||
|
// 故:凡被该用户任一 profile 的 `file:` 依赖引用的 ws 文件名,一律豁免 T1。
|
||||||
|
const protectedNames = new Set()
|
||||||
|
try {
|
||||||
|
const profRoot = join(u.home_dir, 'profiles')
|
||||||
|
if (existsSync(profRoot)) {
|
||||||
|
for (const pname of readdirSync(profRoot)) {
|
||||||
|
const pkgPath = join(profRoot, pname, 'package.json')
|
||||||
|
if (!existsSync(pkgPath)) continue
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||||
|
for (const spec of Object.values(pkg.dependencies ?? {})) {
|
||||||
|
if (typeof spec === 'string' && spec.startsWith('file:')) protectedNames.add(basename(spec.slice(5)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* 读不到就不豁免,保持原行为 */ }
|
||||||
|
for (const entry of readdirSync(ws)) {
|
||||||
|
const p = join(ws, entry)
|
||||||
|
let st
|
||||||
|
try { st = statSync(p) } catch { continue }
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
if (T1_DIRS.includes(entry)) { t1.push({ p, size: duKB(p) }); continue }
|
||||||
|
otherBytes += duKB(p); otherCount += 1 // 目录一律算用户资产(T3)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const ext = extname(entry).toLowerCase()
|
||||||
|
// 档案 35:被 profile 引用的平台 bundle 包不参与清理(体积计入"资产")。
|
||||||
|
if (protectedNames.has(entry)) { otherBytes += st.size; otherCount += 1; continue }
|
||||||
|
if (T1_SUFFIX.includes(ext)) { t1.push({ p, size: st.size }); continue }
|
||||||
|
if (T2_EXT.has(ext) && st.mtimeMs < CUTOFF) { t2.push({ p, size: st.size, mtime: st.mtime }); continue }
|
||||||
|
otherBytes += st.size; otherCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// .keep 豁免(档案 28):ws 顶层放一个 .keep 文件 → 该用户**跳过 T2**(一次性脚本不清理),T1 仍清
|
||||||
|
const keepAll = existsSync(join(ws, '.keep'))
|
||||||
|
if (keepAll && t2.length > 0) {
|
||||||
|
console.log(` (.keep 已存在 → T2 保留 ${t2.length} 项,不清理)`)
|
||||||
|
t2.length = 0
|
||||||
|
}
|
||||||
|
const t1B = t1.reduce((a, c) => a + c.size, 0), t2B = t2.reduce((a, c) => a + c.size, 0)
|
||||||
|
const over = wsBytes >= THRESHOLD_MB * 1048576
|
||||||
|
console.log(` ${u.username}: ws=${fmt(wsBytes)} (${over ? '≥' : '<'} 阈值 ${THRESHOLD_MB}MB) | T1=${t1.length}项/${fmt(t1B)} | T2=${t2.length}项(>${DAYS}天)/${fmt(t2B)} | 资产=${otherCount}项/${fmt(otherBytes)}`)
|
||||||
|
for (const c of t1) console.log(` T1 ${basename(c.p)} ${fmt(c.size)}`)
|
||||||
|
for (const c of t2) console.log(` T2 ${basename(c.p)} ${fmt(c.size)} (mtime ${c.mtime.toISOString().slice(0, 10)})`)
|
||||||
|
|
||||||
|
if (APPLY && over && (t1.length + t2.length) > 0) {
|
||||||
|
const trash = join(u.home_dir, '..', 'trash', `${STAMP}-ws-cleanup`)
|
||||||
|
mkdirSync(trash, { recursive: true })
|
||||||
|
for (const c of [...t1, ...t2]) {
|
||||||
|
const dest = join(trash, c.p.slice(ws.length + 1).replace(/\//g, '__'))
|
||||||
|
try { execFileSync('mv', [c.p, dest]); execFileSync('chown', ['-R', `${u.uid}:${u.uid}`, dest]) }
|
||||||
|
catch (e) { console.log(` ! 失败 ${c.p}: ${String(e.message).split('\n')[0]}`) }
|
||||||
|
}
|
||||||
|
console.log(` → 已清 ${t1.length + t2.length} 项 / ${fmt(t1B + t2B)} → trash/${STAMP}-ws-cleanup(保留 30 天)`)
|
||||||
|
} else if (APPLY && !over) {
|
||||||
|
console.log(` (未超阈值,跳过清理)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
console.log(APPLY ? 'done(已按阈值执行)' : 'done(dry-run,未改动)')
|
||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Standalone orchestrator entry (`dshs` bin).
|
||||||
|
*
|
||||||
|
* Subcommands:
|
||||||
|
* dshs bootstrap-admin --username <u> --password <p>
|
||||||
|
* dshs file-service per-user file sidecar (k8s)
|
||||||
|
* dshs [server flags]
|
||||||
|
* @module dshs/cli
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { parseArgs } from 'node:util'
|
||||||
|
import { resolveConfig, type ConfigOverrides } from './config.js'
|
||||||
|
import { createDbAdapter } from './db/index.js'
|
||||||
|
import { createUserFs } from './fs/provider.js'
|
||||||
|
import { homeRoot, userRoot } from './fs/workspace.js'
|
||||||
|
import { hashPassword } from './web/auth.js'
|
||||||
|
import { hashUid } from './isolation.js'
|
||||||
|
import { LeaderElector } from './supervisor/leader.js'
|
||||||
|
import { ReconcileController } from './supervisor/reconcile.js'
|
||||||
|
import { K8sSpawner } from './supervisor/k8s-spawner.js'
|
||||||
|
import { buildFileService, FILE_SERVICE_PORT, USER_ROOT_ENV } from './web/file-service.js'
|
||||||
|
import { startTcpBridge } from './tcp-bridge.js'
|
||||||
|
import { buildServer } from './web/server.js'
|
||||||
|
|
||||||
|
const HELP = `dshs — DSH server login orchestrator
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
dshs [options] start the server
|
||||||
|
dshs bootstrap-admin [options] create the first admin
|
||||||
|
dshs file-service run the per-user file sidecar
|
||||||
|
|
||||||
|
Server options:
|
||||||
|
--port <n> Bind port (0 = ephemeral). Default 3080.
|
||||||
|
--host <h> Bind host. Default 127.0.0.1.
|
||||||
|
--db <path> SQLite database path.
|
||||||
|
--data-root <p> Root for per-user homes + workspaces.
|
||||||
|
--dsh-bin <cmd> Command used to launch a child DSH. Default "dsh".
|
||||||
|
--log-level <l> Pino log level. Default "info".
|
||||||
|
--secure-cookies Set the Secure flag on session cookies (behind HTTPS).
|
||||||
|
--session-ttl <s> Session lifetime in seconds. Default 604800 (7 days).
|
||||||
|
--isolation-mode <m> Isolation tier: "soft" or "account" (Linux, needs root). Default "soft".
|
||||||
|
-h, --help Show this help.
|
||||||
|
|
||||||
|
bootstrap-admin options:
|
||||||
|
--username <u> Admin username (required).
|
||||||
|
--password <p> Admin password (or DSHS_ADMIN_PASSWORD env).
|
||||||
|
--db <path> Database path.
|
||||||
|
--data-root <p> Root for per-user homes.
|
||||||
|
`
|
||||||
|
|
||||||
|
interface ParsedValues {
|
||||||
|
[key: string]: string | boolean | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOverrides(values: ParsedValues): ConfigOverrides {
|
||||||
|
const str = (value: string | boolean | undefined): string | undefined =>
|
||||||
|
typeof value === 'string' ? value : undefined
|
||||||
|
const dshBin = str(values['dsh-bin'])
|
||||||
|
return {
|
||||||
|
port: str(values.port),
|
||||||
|
host: str(values.host),
|
||||||
|
dbPath: str(values.db),
|
||||||
|
dataRoot: str(values['data-root']),
|
||||||
|
dshCommand: dshBin ? [dshBin] : undefined,
|
||||||
|
logLevel: str(values['log-level']),
|
||||||
|
secureCookies: values['secure-cookies'] === true ? true : undefined,
|
||||||
|
sessionTtlSeconds: str(values['session-ttl']),
|
||||||
|
maxUploadBytes: str(values['max-upload']),
|
||||||
|
isolationMode: str(values['isolation-mode']),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapAdmin(args: string[]): Promise<void> {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args,
|
||||||
|
options: {
|
||||||
|
username: { type: 'string' },
|
||||||
|
password: { type: 'string' },
|
||||||
|
db: { type: 'string' },
|
||||||
|
'data-root': { type: 'string' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const username = values.username
|
||||||
|
const password = values.password ?? process.env.DSHS_ADMIN_PASSWORD
|
||||||
|
if (username === undefined || username === '' || password === undefined || password === '') {
|
||||||
|
console.error('usage: dshs bootstrap-admin --username <u> --password <p>')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
const config = resolveConfig({ dbPath: values.db, dataRoot: values['data-root'] })
|
||||||
|
const db = await createDbAdapter(config)
|
||||||
|
if ((await db.countAdmins()) > 0) {
|
||||||
|
console.error('an admin already exists; refusing to create a second one')
|
||||||
|
await db.close()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const id = randomUUID()
|
||||||
|
const homeDir = homeRoot(userRoot(config.dataRoot, id))
|
||||||
|
const passHash = await hashPassword(password)
|
||||||
|
// Create the user before initUserRoot so the per-user uid (baseUid + row_id)
|
||||||
|
// is already assigned — same reason as the register route.
|
||||||
|
const user = await db.createUser({ id, username, passHash, role: 'admin', homeDir })
|
||||||
|
await createUserFs(config).initUserRoot(id, user.uid ?? undefined)
|
||||||
|
await db.close()
|
||||||
|
console.log(`admin "${username}" created (id: ${id})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runServer(args: string[]): Promise<void> {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args,
|
||||||
|
options: {
|
||||||
|
port: { type: 'string' },
|
||||||
|
host: { type: 'string' },
|
||||||
|
db: { type: 'string' },
|
||||||
|
'data-root': { type: 'string' },
|
||||||
|
'dsh-bin': { type: 'string' },
|
||||||
|
'log-level': { type: 'string' },
|
||||||
|
'secure-cookies': { type: 'boolean' },
|
||||||
|
'session-ttl': { type: 'string' },
|
||||||
|
'max-upload': { type: 'string' },
|
||||||
|
'isolation-mode': { type: 'string' },
|
||||||
|
help: { type: 'boolean', short: 'h' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (values.help) {
|
||||||
|
process.stdout.write(HELP)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = resolveConfig(toOverrides(values as ParsedValues))
|
||||||
|
const app = await buildServer(config)
|
||||||
|
await app.listen({ host: config.host, port: config.port })
|
||||||
|
|
||||||
|
const address = app.server.address()
|
||||||
|
const actualPort = typeof address === 'object' && address !== null ? address.port : config.port
|
||||||
|
app.log.info(`dshs listening on http://${config.host}:${actualPort}`)
|
||||||
|
app.log.info(`data root: ${config.dataRoot}; db: ${config.dbPath}`)
|
||||||
|
|
||||||
|
// In k8s mode, only the elected leader runs the controller (reconcile + Pod
|
||||||
|
// watch). The web layer serves on every replica.
|
||||||
|
let controller: ReconcileController | undefined
|
||||||
|
if (config.deployMode === 'k8s') {
|
||||||
|
const spawner = app.supervisor as K8sSpawner
|
||||||
|
const elector = new LeaderElector({ namespace: config.k8sNamespace, identity: config.podName })
|
||||||
|
controller = new ReconcileController(app.db, spawner, elector)
|
||||||
|
await controller.start()
|
||||||
|
app.log.info(`leader election started (identity ${config.podName})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
app.log.info(`received ${signal}, shutting down`)
|
||||||
|
controller?.stop()
|
||||||
|
await app.close()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
process.on('SIGINT', () => void shutdown('SIGINT'))
|
||||||
|
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the per-user file sidecar. Serves one user's volume over HTTP on 8082 so
|
||||||
|
* the control plane (which holds no users volume under k8s) can reach it; the
|
||||||
|
* root comes from the Pod's env, not from argv.
|
||||||
|
*/
|
||||||
|
async function runFileService(): Promise<void> {
|
||||||
|
const root = process.env[USER_ROOT_ENV]
|
||||||
|
if (root === undefined || root === '') {
|
||||||
|
console.error(`file-service requires ${USER_ROOT_ENV} (the user's data root inside the Pod)`)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
// The sidecar runs as the user's uid with no home dir; `homedir()` falls back
|
||||||
|
// to `/` and `resolveConfig` would try to mkdir `/.dshs`. It only
|
||||||
|
// needs `maxUploadBytes`/`logLevel` here, so pin dataRoot to a writable path
|
||||||
|
// and skip the (unused) encryption-secret file.
|
||||||
|
const config = resolveConfig({ dataRoot: '/tmp', encryptionSecret: 'file-service-unused' })
|
||||||
|
const app = buildFileService(root, { bodyLimit: config.maxUploadBytes, logLevel: config.logLevel })
|
||||||
|
await app.listen({ host: '0.0.0.0', port: FILE_SERVICE_PORT })
|
||||||
|
app.log.info(`file sidecar serving ${root} on 0.0.0.0:${FILE_SERVICE_PORT}`)
|
||||||
|
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
app.log.info(`received ${signal}, shutting down`)
|
||||||
|
await app.close()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
process.on('SIGINT', () => void shutdown('SIGINT'))
|
||||||
|
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TCP bridge sidecar (`dshs tcp-bridge <listen> <target>`). */
|
||||||
|
async function runTcpBridge(args: string[]): Promise<void> {
|
||||||
|
const [listen, target] = args
|
||||||
|
if (listen === undefined || target === undefined) {
|
||||||
|
console.error('usage: dshs tcp-bridge <listen> <target>')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
const server = await startTcpBridge(listen, target)
|
||||||
|
console.error(`tcp-bridge ${listen} -> ${target}`)
|
||||||
|
const shutdown = (): void => {
|
||||||
|
server.close(() => process.exit(0))
|
||||||
|
}
|
||||||
|
process.on('SIGINT', shutdown)
|
||||||
|
process.on('SIGTERM', shutdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uidForUserCmd(args: string[]): Promise<void> {
|
||||||
|
const { values, positionals } = parseArgs({
|
||||||
|
args,
|
||||||
|
allowPositionals: true,
|
||||||
|
options: { 'base-uid': { type: 'string' }, db: { type: 'string' } },
|
||||||
|
})
|
||||||
|
const userId = positionals[0]
|
||||||
|
if (userId === undefined) {
|
||||||
|
console.error('usage: dshs uid-for-user <userId> [--db <path>] [--base-uid N]')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
const dbPath = typeof values.db === 'string' ? values.db : undefined
|
||||||
|
const baseUid = typeof values['base-uid'] === 'string' ? values['base-uid'] : undefined
|
||||||
|
const config = resolveConfig({ dbPath, baseUid })
|
||||||
|
const db = await createDbAdapter(config)
|
||||||
|
const user = await db.findUserById(userId)
|
||||||
|
await db.close()
|
||||||
|
console.log(user?.uid ?? hashUid(userId, config.baseUid))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const [first, ...rest] = process.argv.slice(2)
|
||||||
|
if (first === 'bootstrap-admin') {
|
||||||
|
await bootstrapAdmin(rest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (first === 'uid-for-user') {
|
||||||
|
await uidForUserCmd(rest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (first === 'file-service') {
|
||||||
|
await runFileService()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (first === 'tcp-bridge') {
|
||||||
|
await runTcpBridge(rest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await runServer(process.argv.slice(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
+331
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* Deployment-varying configuration. Every tunable is a validated field here
|
||||||
|
* (or read from env), never a hardcoded constant inside the app.
|
||||||
|
* @module dshs/config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { homedir, hostname } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
/** Isolation tier. `soft` = per-user home/workspace + sandbox (same OS user);
|
||||||
|
* `account` = per-user OS account via a setuid wrapper (Linux, needs root). */
|
||||||
|
export type IsolationMode = 'soft' | 'account'
|
||||||
|
|
||||||
|
/** Deployment mode. `local` = single-host child_process (setuid/iptables);
|
||||||
|
* `k8s` = multi-replica control plane spawning per-user DSH Pods via the K8s API. */
|
||||||
|
export type DeployMode = 'local' | 'k8s'
|
||||||
|
|
||||||
|
/** Resolved, immutable runtime configuration. */
|
||||||
|
export interface ServerConfig {
|
||||||
|
/** Bind host for the orchestrator HTTP server. */
|
||||||
|
host: string
|
||||||
|
/** Bind port; `0` requests an ephemeral port. */
|
||||||
|
port: number
|
||||||
|
/** SQLite database path. */
|
||||||
|
dbPath: string
|
||||||
|
/** Postgres connection string; when set, the DB backend is Postgres (k8s/HA). */
|
||||||
|
dbUrl?: string
|
||||||
|
/** Root under which per-user homes (`users/<id>/home`) and workspaces live. */
|
||||||
|
dataRoot: string
|
||||||
|
/** Shared read-only skill directory for all users (injected as
|
||||||
|
* `DSH_BUNDLED_SKILL_DIR` into every spawned DSH); empty = feature off. */
|
||||||
|
bundledSkillDir: string
|
||||||
|
/** Argv used to launch a child DSH; first element is the executable. */
|
||||||
|
dshCommand: string[]
|
||||||
|
/** Pino log level. */
|
||||||
|
logLevel: string
|
||||||
|
/** Set the `Secure` flag on session cookies (enable behind HTTPS). */
|
||||||
|
secureCookies: boolean
|
||||||
|
/** Session lifetime in seconds. */
|
||||||
|
sessionTtlSeconds: number
|
||||||
|
/** Max upload request body in bytes (base64 JSON; ~0.75× the file size). */
|
||||||
|
maxUploadBytes: number
|
||||||
|
/** Delay before auto-restarting a crashed child DSH, in milliseconds. */
|
||||||
|
restartBackoffMs: number
|
||||||
|
/** Upper bound for the exponential crash-restart backoff (档案 20). */
|
||||||
|
restartBackoffMaxMs: number
|
||||||
|
/** Auto-restarts allowed inside `crashWindowMs` before the circuit opens. */
|
||||||
|
crashMaxRestarts: number
|
||||||
|
/** Rolling window used to count auto-restarts (档案 20). */
|
||||||
|
crashWindowMs: number
|
||||||
|
/** Uptime after which a main counts as recovered and the backoff resets. */
|
||||||
|
crashStableMs: number
|
||||||
|
/** 档案 78:熔断首次冷却时长(冷却期内拒绝隐式启动)。 */
|
||||||
|
crashBreakerCooldownMs: number
|
||||||
|
/** 档案 78:熔断冷却上限(多次熔断后指数加长到此为止)。 */
|
||||||
|
crashBreakerMaxCooldownMs: number
|
||||||
|
/** Isolation tier (see {@link IsolationMode}); local mode only. */
|
||||||
|
isolationMode: IsolationMode
|
||||||
|
/** Argv prefix that drops privileges; `{UID}`/`{GID}` are substituted. Local mode only. */
|
||||||
|
spawnAsUserCommand: string[]
|
||||||
|
/** Base uid for the deterministic per-user uid. */
|
||||||
|
baseUid: number
|
||||||
|
/** Parent domain for per-user subdomains (`<username>.<baseDomain>`); empty = disabled. */
|
||||||
|
baseDomain: string
|
||||||
|
/** Cookie `Domain` value (e.g. `.example.com`) so the session reaches subdomains; empty = host-only. */
|
||||||
|
cookieDomain: string
|
||||||
|
/** Whether to pass `--patch` to child DSHs (needs a dsh CLI that supports it). */
|
||||||
|
enablePatch: boolean
|
||||||
|
/** Enable the loopback OUTPUT owner-match port guard (Linux + root). Local mode only. */
|
||||||
|
portGuard: boolean
|
||||||
|
/** Cap on resident main instances per host (0 = no cap). Local mode idle reap. */
|
||||||
|
maxIdleInstances: number
|
||||||
|
/** A main instance with no proxied/entered activity for this long is stopped
|
||||||
|
* (0 = disabled). Local mode idle reap. */
|
||||||
|
instanceIdleTtlSeconds: number
|
||||||
|
/** Period between idle-reap scans (seconds). Local mode idle reap. */
|
||||||
|
idleReapIntervalSeconds: number
|
||||||
|
/** Secret used to encrypt per-user secrets at rest (from env or dataRoot/secret.key). */
|
||||||
|
encryptionSecret: string
|
||||||
|
/** Deployment mode (see {@link DeployMode}). */
|
||||||
|
deployMode: DeployMode
|
||||||
|
/** K8s namespace for per-user DSH resources (k8s mode only). */
|
||||||
|
k8sNamespace: string
|
||||||
|
/** Image for per-user DSH Pods (k8s mode only). */
|
||||||
|
dshImage: string
|
||||||
|
/** Image for the per-user file sidecar (k8s mode only; shares the control-plane image). */
|
||||||
|
controlPlaneImage: string
|
||||||
|
/** imagePullSecret every generated per-user Pod/Job uses (k8s mode only). */
|
||||||
|
imagePullSecret: string
|
||||||
|
/** 443 egress whitelist CIDRs for per-user DSH Pods (Phase 4 egress 收敛);
|
||||||
|
* empty = keep the current `0.0.0.0/0` (except private ranges). */
|
||||||
|
egressCidrs: string[]
|
||||||
|
/** ServiceAccount the orchestrator runs as (k8s mode only). */
|
||||||
|
k8sServiceAccount: string
|
||||||
|
/** This replica's identity for leader election (POD_NAME, else hostname). */
|
||||||
|
podName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Untyped overrides collected from argv / env. */
|
||||||
|
export interface ConfigOverrides {
|
||||||
|
host?: string
|
||||||
|
port?: string | number
|
||||||
|
dbPath?: string
|
||||||
|
dbUrl?: string
|
||||||
|
dataRoot?: string
|
||||||
|
bundledSkillDir?: string
|
||||||
|
dshCommand?: string[]
|
||||||
|
logLevel?: string
|
||||||
|
secureCookies?: boolean
|
||||||
|
sessionTtlSeconds?: number | string
|
||||||
|
maxUploadBytes?: number | string
|
||||||
|
restartBackoffMs?: number | string
|
||||||
|
restartBackoffMaxMs?: number | string
|
||||||
|
crashMaxRestarts?: number | string
|
||||||
|
crashWindowMs?: number | string
|
||||||
|
crashStableMs?: number | string
|
||||||
|
crashBreakerCooldownMs?: number | string
|
||||||
|
crashBreakerMaxCooldownMs?: number | string
|
||||||
|
isolationMode?: IsolationMode | string
|
||||||
|
spawnAsUserCommand?: string[]
|
||||||
|
baseUid?: number | string
|
||||||
|
baseDomain?: string
|
||||||
|
cookieDomain?: string
|
||||||
|
enablePatch?: boolean
|
||||||
|
portGuard?: boolean
|
||||||
|
maxIdleInstances?: number | string
|
||||||
|
instanceIdleTtlSeconds?: number | string
|
||||||
|
idleReapIntervalSeconds?: number | string
|
||||||
|
encryptionSecret?: string
|
||||||
|
deployMode?: DeployMode | string
|
||||||
|
k8sNamespace?: string
|
||||||
|
dshImage?: string
|
||||||
|
controlPlaneImage?: string
|
||||||
|
imagePullSecret?: string
|
||||||
|
egressCidrs?: string[]
|
||||||
|
k8sServiceAccount?: string
|
||||||
|
podName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_HOST = '127.0.0.1'
|
||||||
|
const DEFAULT_PORT = 3080
|
||||||
|
const DEFAULT_DSH_COMMAND = ['dsh']
|
||||||
|
const DEFAULT_LOG_LEVEL = 'info'
|
||||||
|
const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7
|
||||||
|
const DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
||||||
|
const DEFAULT_RESTART_BACKOFF_MS = 1000
|
||||||
|
/** 崩溃自愈退避上限(档案 20)。 */
|
||||||
|
const DEFAULT_RESTART_BACKOFF_MAX_MS = 30000
|
||||||
|
/** 熔断窗口内允许的自动重启次数(档案 20)。 */
|
||||||
|
const DEFAULT_CRASH_MAX_RESTARTS = 5
|
||||||
|
/** 熔断窗口长度(档案 20)。 */
|
||||||
|
const DEFAULT_CRASH_WINDOW_MS = 600000
|
||||||
|
/** 连续运行多久视为已恢复、重置退避步数(档案 20)。 */
|
||||||
|
const DEFAULT_CRASH_STABLE_MS = 60000
|
||||||
|
// 档案 78:熔断冷却 —— 首次 10 分钟,指数加长,封顶 6 小时
|
||||||
|
const DEFAULT_CRASH_BREAKER_COOLDOWN_MS = 600000
|
||||||
|
const DEFAULT_CRASH_BREAKER_MAX_COOLDOWN_MS = 21600000
|
||||||
|
const DEFAULT_ISOLATION_MODE: IsolationMode = 'soft'
|
||||||
|
const DEFAULT_SPAWN_AS_USER_COMMAND = [
|
||||||
|
'setpriv',
|
||||||
|
'--reuid',
|
||||||
|
'{UID}',
|
||||||
|
'--regid',
|
||||||
|
'{GID}',
|
||||||
|
'--inh-caps=-all',
|
||||||
|
'--clear-groups',
|
||||||
|
'--',
|
||||||
|
]
|
||||||
|
const DEFAULT_BASE_UID = 100000
|
||||||
|
const DEFAULT_BASE_DOMAIN = ''
|
||||||
|
const DEFAULT_COOKIE_DOMAIN = ''
|
||||||
|
const DEFAULT_ENABLE_PATCH = false
|
||||||
|
const DEFAULT_MAX_IDLE_INSTANCES = 4
|
||||||
|
const DEFAULT_INSTANCE_IDLE_TTL_SECONDS = 60 * 60 * 24 * 7
|
||||||
|
const DEFAULT_IDLE_REAP_INTERVAL_SECONDS = 60
|
||||||
|
const DEFAULT_DEPLOY_MODE: DeployMode = 'local'
|
||||||
|
const DEFAULT_K8S_NAMESPACE = 'dsh'
|
||||||
|
const DEFAULT_K8S_SERVICE_ACCOUNT = 'dsh-orchestrator'
|
||||||
|
const DEFAULT_IMAGE_PULL_SECRET = 'dsh-acr-pull'
|
||||||
|
|
||||||
|
/** Load the encryption secret from env, or persist a generated one at
|
||||||
|
* `<dataRoot>/secret.key` (0600) so it survives restarts without setup. */
|
||||||
|
function resolveEncryptionSecret(dataRoot: string): string {
|
||||||
|
const fromEnv = process.env.DSHS_SECRET
|
||||||
|
if (fromEnv !== undefined && fromEnv !== '') return fromEnv
|
||||||
|
const path = join(dataRoot, 'secret.key')
|
||||||
|
try {
|
||||||
|
const existing = readFileSync(path, 'utf8').trim()
|
||||||
|
if (existing !== '') return existing
|
||||||
|
} catch {
|
||||||
|
// fall through to generate
|
||||||
|
}
|
||||||
|
const secret = randomBytes(32).toString('hex')
|
||||||
|
mkdirSync(dataRoot, { recursive: true })
|
||||||
|
writeFileSync(path, secret, { mode: 0o600 })
|
||||||
|
return secret
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBool(value: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (value === undefined) return fallback
|
||||||
|
return value === 'true' || value === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a comma-separated CIDR list into a trimmed, de-duplicated array. */
|
||||||
|
function parseCidrs(value: string | undefined): string[] {
|
||||||
|
if (value === undefined || value === '') return []
|
||||||
|
return [...new Set(value.split(',').map((c) => c.trim()).filter((c) => c !== ''))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an isolation-mode value, rejecting anything outside `soft`/`account`
|
||||||
|
* so a typo in the env var fails loudly at startup instead of silently
|
||||||
|
* falling back to `soft` isolation. */
|
||||||
|
function toIsolationMode(value: string | undefined): IsolationMode | undefined {
|
||||||
|
if (value === undefined) return undefined
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (normalized === 'soft' || normalized === 'account') return normalized
|
||||||
|
throw new Error(`invalid isolation mode "${value}" (expected "soft" or "account")`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a deploy-mode value, rejecting anything outside `local`/`k8s`. */
|
||||||
|
function toDeployMode(value: string | undefined): DeployMode | undefined {
|
||||||
|
if (value === undefined) return undefined
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (normalized === 'local' || normalized === 'k8s') return normalized
|
||||||
|
throw new Error(`invalid deploy mode "${value}" (expected "local" or "k8s")`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold argv/env overrides over defaults. `dataRoot` defaults to
|
||||||
|
* `~/.dshs` (always writable for dev); production sets
|
||||||
|
* `DSHS_DATA_ROOT=/var/lib/dshs`.
|
||||||
|
*/
|
||||||
|
export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
|
||||||
|
const dataRoot =
|
||||||
|
overrides.dataRoot ?? process.env.DSHS_DATA_ROOT ?? join(homedir(), '.dshs')
|
||||||
|
const port = overrides.port ?? process.env.DSHS_PORT ?? DEFAULT_PORT
|
||||||
|
const dshBin = process.env.DSHS_DSH_BIN
|
||||||
|
const isolationMode =
|
||||||
|
toIsolationMode(overrides.isolationMode) ??
|
||||||
|
toIsolationMode(process.env.DSHS_ISOLATION_MODE) ??
|
||||||
|
DEFAULT_ISOLATION_MODE
|
||||||
|
const deployMode =
|
||||||
|
toDeployMode(overrides.deployMode) ??
|
||||||
|
toDeployMode(process.env.DSHS_DEPLOY_MODE) ??
|
||||||
|
DEFAULT_DEPLOY_MODE
|
||||||
|
return {
|
||||||
|
host: overrides.host ?? DEFAULT_HOST,
|
||||||
|
port: typeof port === 'number' ? port : Number(port),
|
||||||
|
dbPath: overrides.dbPath ?? join(dataRoot, 'dshs.db'),
|
||||||
|
dbUrl: overrides.dbUrl ?? process.env.DSHS_DB_URL,
|
||||||
|
dataRoot,
|
||||||
|
bundledSkillDir:
|
||||||
|
overrides.bundledSkillDir ??
|
||||||
|
process.env.DSHS_BUNDLED_SKILL_DIR ??
|
||||||
|
join(dataRoot, 'bundled-skills'),
|
||||||
|
dshCommand: overrides.dshCommand ?? (dshBin !== undefined ? [dshBin] : DEFAULT_DSH_COMMAND),
|
||||||
|
logLevel: overrides.logLevel ?? DEFAULT_LOG_LEVEL,
|
||||||
|
secureCookies:
|
||||||
|
overrides.secureCookies ?? toBool(process.env.DSHS_SECURE_COOKIES, false),
|
||||||
|
sessionTtlSeconds: Number(
|
||||||
|
overrides.sessionTtlSeconds ?? process.env.DSHS_SESSION_TTL ?? DEFAULT_SESSION_TTL_SECONDS,
|
||||||
|
),
|
||||||
|
maxUploadBytes: Number(
|
||||||
|
overrides.maxUploadBytes ?? process.env.DSHS_MAX_UPLOAD ?? DEFAULT_MAX_UPLOAD_BYTES,
|
||||||
|
),
|
||||||
|
restartBackoffMs: Number(
|
||||||
|
overrides.restartBackoffMs ?? process.env.DSHS_RESTART_BACKOFF ?? DEFAULT_RESTART_BACKOFF_MS,
|
||||||
|
),
|
||||||
|
restartBackoffMaxMs: Number(
|
||||||
|
overrides.restartBackoffMaxMs ??
|
||||||
|
process.env.DSHS_RESTART_BACKOFF_MAX ??
|
||||||
|
DEFAULT_RESTART_BACKOFF_MAX_MS,
|
||||||
|
),
|
||||||
|
crashMaxRestarts: Number(
|
||||||
|
overrides.crashMaxRestarts ??
|
||||||
|
process.env.DSHS_CRASH_MAX_RESTARTS ??
|
||||||
|
DEFAULT_CRASH_MAX_RESTARTS,
|
||||||
|
),
|
||||||
|
crashWindowMs: Number(
|
||||||
|
overrides.crashWindowMs ?? process.env.DSHS_CRASH_WINDOW ?? DEFAULT_CRASH_WINDOW_MS,
|
||||||
|
),
|
||||||
|
crashStableMs: Number(
|
||||||
|
overrides.crashStableMs ?? process.env.DSHS_CRASH_STABLE ?? DEFAULT_CRASH_STABLE_MS,
|
||||||
|
),
|
||||||
|
crashBreakerCooldownMs: Number(
|
||||||
|
overrides.crashBreakerCooldownMs ??
|
||||||
|
process.env.DSHS_CRASH_BREAKER_COOLDOWN ??
|
||||||
|
DEFAULT_CRASH_BREAKER_COOLDOWN_MS,
|
||||||
|
),
|
||||||
|
crashBreakerMaxCooldownMs: Number(
|
||||||
|
overrides.crashBreakerMaxCooldownMs ??
|
||||||
|
process.env.DSHS_CRASH_BREAKER_MAX_COOLDOWN ??
|
||||||
|
DEFAULT_CRASH_BREAKER_MAX_COOLDOWN_MS,
|
||||||
|
),
|
||||||
|
isolationMode,
|
||||||
|
spawnAsUserCommand: overrides.spawnAsUserCommand ?? DEFAULT_SPAWN_AS_USER_COMMAND,
|
||||||
|
baseUid: Number(overrides.baseUid ?? process.env.DSHS_BASE_UID ?? DEFAULT_BASE_UID),
|
||||||
|
baseDomain: overrides.baseDomain ?? process.env.DSHS_BASE_DOMAIN ?? DEFAULT_BASE_DOMAIN,
|
||||||
|
cookieDomain: overrides.cookieDomain ?? process.env.DSHS_COOKIE_DOMAIN ?? DEFAULT_COOKIE_DOMAIN,
|
||||||
|
enablePatch: overrides.enablePatch ?? toBool(process.env.DSHS_ENABLE_PATCH, DEFAULT_ENABLE_PATCH),
|
||||||
|
portGuard: overrides.portGuard ?? toBool(process.env.DSHS_PORT_GUARD, false),
|
||||||
|
maxIdleInstances: Number(
|
||||||
|
overrides.maxIdleInstances ?? process.env.DSHS_MAX_IDLE_INSTANCES ?? DEFAULT_MAX_IDLE_INSTANCES,
|
||||||
|
),
|
||||||
|
instanceIdleTtlSeconds: Number(
|
||||||
|
overrides.instanceIdleTtlSeconds ??
|
||||||
|
process.env.DSHS_INSTANCE_IDLE_TTL ??
|
||||||
|
DEFAULT_INSTANCE_IDLE_TTL_SECONDS,
|
||||||
|
),
|
||||||
|
idleReapIntervalSeconds: Number(
|
||||||
|
overrides.idleReapIntervalSeconds ??
|
||||||
|
process.env.DSHS_IDLE_REAP_INTERVAL ??
|
||||||
|
DEFAULT_IDLE_REAP_INTERVAL_SECONDS,
|
||||||
|
),
|
||||||
|
encryptionSecret:
|
||||||
|
overrides.encryptionSecret ?? resolveEncryptionSecret(dataRoot),
|
||||||
|
deployMode,
|
||||||
|
k8sNamespace: overrides.k8sNamespace ?? process.env.DSHS_NAMESPACE ?? DEFAULT_K8S_NAMESPACE,
|
||||||
|
dshImage: overrides.dshImage ?? process.env.DSHS_DSH_IMAGE ?? '',
|
||||||
|
controlPlaneImage:
|
||||||
|
overrides.controlPlaneImage ?? process.env.DSHS_CONTROL_PLANE_IMAGE ?? '',
|
||||||
|
imagePullSecret:
|
||||||
|
overrides.imagePullSecret ?? process.env.DSHS_IMAGE_PULL_SECRET ?? DEFAULT_IMAGE_PULL_SECRET,
|
||||||
|
egressCidrs: overrides.egressCidrs ?? parseCidrs(process.env.DSHS_EGRESS_CIDRS),
|
||||||
|
k8sServiceAccount:
|
||||||
|
overrides.k8sServiceAccount ?? process.env.DSHS_K8S_SERVICE_ACCOUNT ?? DEFAULT_K8S_SERVICE_ACCOUNT,
|
||||||
|
podName: overrides.podName ?? process.env.POD_NAME ?? hostname(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Symmetric encryption for per-user secrets at rest. AES-256-GCM with a
|
||||||
|
* key derived from the deployment secret, so a DB leak does not yield usable
|
||||||
|
* API keys (references-not-secrets).
|
||||||
|
* @module dshs/crypto
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
|
const ALGO = 'aes-256-gcm'
|
||||||
|
const IV_LEN = 12
|
||||||
|
|
||||||
|
/** Derive a 32-byte AES key from the deployment secret (sha-256). */
|
||||||
|
export function deriveKey(secret: string): Buffer {
|
||||||
|
return createHash('sha256').update(secret).digest()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt a plaintext secret into a base64 payload `iv.tag.data`. */
|
||||||
|
export function encrypt(plain: string, key: Buffer): string {
|
||||||
|
const iv = randomBytes(IV_LEN)
|
||||||
|
const cipher = createCipheriv(ALGO, key, iv)
|
||||||
|
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
|
||||||
|
const tag = cipher.getAuthTag()
|
||||||
|
return [iv.toString('base64'), tag.toString('base64'), enc.toString('base64')].join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt a payload produced by {@link encrypt}. */
|
||||||
|
export function decrypt(payload: string, key: Buffer): string {
|
||||||
|
const parts = payload.split('.')
|
||||||
|
if (parts.length !== 3) throw new Error('malformed encrypted payload')
|
||||||
|
const [ivB, tagB, dataB] = parts as [string, string, string]
|
||||||
|
const decipher = createDecipheriv(ALGO, key, Buffer.from(ivB, 'base64'))
|
||||||
|
decipher.setAuthTag(Buffer.from(tagB, 'base64'))
|
||||||
|
return Buffer.concat([decipher.update(Buffer.from(dataB, 'base64')), decipher.final()]).toString('utf8')
|
||||||
|
}
|
||||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user