commit 43976fea6ae8bcdc5040342a8be6eeaf879f3c12 Author: maogeigei Date: Sun Sep 13 16:18:10 2026 +0800 初始提交:DSH 多租户平台(dshs) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ee722cf --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..669821f --- /dev/null +++ b/.github/workflows/build.yml @@ -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/trivy-action@v0.36.0 + with: + image-ref: dshs:ci + severity: HIGH,CRITICAL + exit-code: 1 + # 只阻断「可修复」的高危;收紧为 false 则连不可修复的也拦。 + ignore-unfixed: true + + - name: Trivy — dsh + uses: aquasecurity/trivy-action@v0.36.0 + 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4cedd0f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0c13d51 --- /dev/null +++ b/Dockerfile @@ -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: . +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"] diff --git a/Dockerfile.dsh b/Dockerfile.dsh new file mode 100644 index 0000000..0800330 --- /dev/null +++ b/Dockerfile.dsh @@ -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// resolves it. DSH_HOME is +# /var/lib/dshs/users//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/dsh@0.1.1-rc.2 +# 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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cc81ed --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# dshs + + +> 📌 **本部署的两处文档别混(档案 19 §C10)** +> - `docs/`(本仓库内)= **仓库自带 7 篇**(blueprint / deployment / k8s-deploy …)——**不要往这里写我们的改造记录**; +> - **本项目的改造文档库**在另一仓库:`git@work.alotbuy.com: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//dsh/* 路由 + └─ 反向代理 → 各用户的 DSH 子进程(只绑 127.0.0.1 动态端口,不出公网) +``` + +流程:管理员审核注册用户 → 每个用户落到自己的文件桌面 → 按文件夹启动 DSH → 通过域名访问,彼此文件隔离。 + +## 核心能力 + +| 能力 | 说明 | +|---|---| +| **登录与审核** | `bootstrap-admin` 创建首个管理员;注册后需审核通过才能登录;禁用用户会同时删除其会话并停止运行中的 DSH | +| **每用户隔离 DSH** | 主 DSH 常驻对外服务;崩溃时**按需拉起一次守护 DSH** 修复并自动重启主实例 | +| **网页桌面** | 文件浏览 / 新建 / 上传;按文件夹启动 DSH;所有路径经「词法包含 + 符号链接分量」双重围栏校验 | +| **每文件夹插件** | 自动检测该用户 profile 已安装的插件,按文件夹勾选启用,持久化并注入 cordis patch | +| **多形态访问** | 默认子路径 `/u//dsh/`;每用户子域名 `<用户名>.`(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` | 自动生成 | 每用户密钥库的加密主密钥;未设置时生成并持久化到 `/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 或 `/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 天内回复**,确认有效的问题会尽快修复并发布更新。 +感谢你的支持!🌟 diff --git a/STANDARD.md b/STANDARD.md new file mode 100644 index 0000000..c38e155 --- /dev/null +++ b/STANDARD.md @@ -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/` | +| 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/`(排除 .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` | 验证工具与版本(如 `dsh-plugin-verify@0.1.2`) | 开放数据层条目 | +| `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` 实现一一对应;改动判定逻辑时须同步更新本表。* diff --git a/assets/inject/assist.js b/assets/inject/assist.js new file mode 100644 index 0000000..c11bbb4 --- /dev/null +++ b/assets/inject/assist.js @@ -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() +})() diff --git a/assets/inject/recovery.js b/assets/inject/recovery.js new file mode 100644 index 0000000..9637b63 --- /dev/null +++ b/assets/inject/recovery.js @@ -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(); +})(); diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..34593d4 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: dshs + name: dshs diff --git a/deploy/00-namespace.yaml b/deploy/00-namespace.yaml new file mode 100644 index 0000000..1a7c988 --- /dev/null +++ b/deploy/00-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: dsh diff --git a/deploy/01-dsh-pg.yaml b/deploy/01-dsh-pg.yaml new file mode 100644 index 0000000..5406d23 --- /dev/null +++ b/deploy/01-dsh-pg.yaml @@ -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 diff --git a/deploy/02-control-plane.yaml b/deploy/02-control-plane.yaml new file mode 100644 index 0000000..f778b5d --- /dev/null +++ b/deploy/02-control-plane.yaml @@ -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 diff --git a/deploy/03-rbac.yaml b/deploy/03-rbac.yaml new file mode 100644 index 0000000..8c01bd6 --- /dev/null +++ b/deploy/03-rbac.yaml @@ -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 diff --git a/deploy/04-pvc.yaml b/deploy/04-pvc.yaml new file mode 100644 index 0000000..a81ba09 --- /dev/null +++ b/deploy/04-pvc.yaml @@ -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 diff --git a/deploy/05-storage.yaml b/deploy/05-storage.yaml new file mode 100644 index 0000000..d297db7 --- /dev/null +++ b/deploy/05-storage.yaml @@ -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" diff --git a/deploy/06-quota.yaml b/deploy/06-quota.yaml new file mode 100644 index 0000000..c2788f2 --- /dev/null +++ b/deploy/06-quota.yaml @@ -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" diff --git a/deploy/07-psa.yaml b/deploy/07-psa.yaml new file mode 100644 index 0000000..63af049 --- /dev/null +++ b/deploy/07-psa.yaml @@ -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 diff --git a/deploy/08-bootstrap.yaml b/deploy/08-bootstrap.yaml new file mode 100644 index 0000000..856cc2e --- /dev/null +++ b/deploy/08-bootstrap.yaml @@ -0,0 +1,37 @@ +# 一次性权限提升 Job:把共享 RWX PVC 根 chmod 1777(world-writable + sticky), +# 让后续各用户的非 root initContainer 能在根下建自己的 / 目录 +# (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 diff --git a/deploy/09-control-plane-np.yaml b/deploy/09-control-plane-np.yaml new file mode 100644 index 0000000..1afbc7b --- /dev/null +++ b/deploy/09-control-plane-np.yaml @@ -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 diff --git a/deploy/10-sunrpc.yaml b/deploy/10-sunrpc.yaml new file mode 100644 index 0000000..368e063 --- /dev/null +++ b/deploy/10-sunrpc.yaml @@ -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 diff --git a/docs/blueprint.md b/docs/blueprint.md new file mode 100644 index 0000000..c781c1f --- /dev/null +++ b/docs/blueprint.md @@ -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)。 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..b17d3cb --- /dev/null +++ b/docs/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 用的必须是**同一个数据库**。 +> 本教程统一用 `/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/`,不编译的话改动不会生效。 diff --git a/docs/domain-config.md b/docs/domain-config.md new file mode 100644 index 0000000..dd514f7 --- /dev/null +++ b/docs/domain-config.md @@ -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//dsh/* ──> 127.0.0.1:<动态端口> (每用户 DSH) +``` + +- 每用户 DSH 只绑定**回环端口**(`127.0.0.1:<动态端口>`),不直接暴露公网;端口由编排服务在启动时分配。 +- nginx 只做边缘 TLS 与转发,把 `/u/*` 原样透传给编排服务即可——**每用户 DSH 的端口映射由编排服务自己维护**,nginx 无需在每次 DSH 重启时 reload。 + +## 2. 默认域名(所有用户共享一个域名) + +用子路径区分用户:`https://dsh.example.com/u//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//dsh/` 的转发到哪台每用户 DSH,由编排服务内部决定,nginx 不关心。 + +## 3. 自定义域名(每用户专属域名) + +设计目标:每个用户可用自己的域名直达自己的 DSH,例如 `https://alice.example.com` → alice 的 DSH。 + +### 3.1 手动映射(当前可手写生效) + +由于编排服务已经支持 `/u//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//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//dsh/`, +> 于是 `https://alice.example.com/foo` → `http://127.0.0.1:3080/u//dsh/foo`。 +> 编排服务再剥掉 `/u//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` 给普通用户随意映射他人路径。 diff --git a/docs/hard-isolation.md b/docs/hard-isolation.md new file mode 100644 index 0000000..9880974 --- /dev/null +++ b/docs/hard-isolation.md @@ -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 --regid --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 就是数据库里 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 从哪拿**:管理员登录后访问 `/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 ` 也行。**但别忘了**——漏一个 = 那个用户还在 root 下跑,等于没隔离。 + +> **推荐**:方案 A 最省心、自动、幂等,一次配好就不用管。 + +--- + +## 4. 验证硬隔离真的生效 + +**第 1 步:看 DSH 进程的 uid。** + +先在桌面启动一个用户的 DSH,然后: + +```sh +# 找到 DSH 进程 +ps aux | grep dsh | grep -v grep + +# 假设拿到了 pid,看它的真实 uid +ps -o uid,user,cmd -p +``` + +**应该看到**:`uid` 等于 `dshs uid-for-user <那个userId>`(是一个 >100000 的账号),**不是** 0(root)。如果显示 0,说明没降权,回头检查第 2、3 步。 + +**第 2 步:越权读测试(最关键)。** + +```sh +# 1) 在用户 A 的 home 里放个文件 +echo "secret" > /var/lib/dshs/users//home/s.txt +chmod 600 /var/lib/dshs/users//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` 强制覆盖。 | + +--- diff --git a/docs/k8s-deploy.md b/docs/k8s-deploy.md new file mode 100644 index 0000000..e9ed942 --- /dev/null +++ b/docs/k8s-deploy.md @@ -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 --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:`。 + +### 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 '

'` +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 \ + --env PGHOST=-rw --env PGUSER=dsh --env PGPASSWORD= --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 前缀)→ 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.` 登录后跳 `.dsh.`,`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(``)时,`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()` 替换,再短连接重试一次 | diff --git a/docs/k8s-deployment.md b/docs/k8s-deployment.md new file mode 100644 index 0000000..a2e41f2 --- /dev/null +++ b/docs/k8s-deployment.md @@ -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 /dshs:0.2.0 . + docker build -t /dsh:0.1.1-rc.2 -f Dockerfile.dsh . + docker push /dshs:0.2.0 + docker push /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:@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='' --docker-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:...@dsh-pg-rw.dsh: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://.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。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..20802b3 --- /dev/null +++ b/docs/troubleshooting.md @@ -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//dsh/*` 下,这些绝对路径会打到域名根 → 404;连 `/api` 也会打到编排服务自己的 API。**子路径方案与这个 SPA 从根上不兼容**。 +- **修法**:改**每用户子域名** `<用户名>.`,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 警告:`` / `` / `viewport` 缺失 + +- **现象**:Lighthouse 报这三条。 +- **根因**:来自 **DSH 自己的聊天界面 SPA**(harness 前端),不是本插件页面(本插件的 login/desktop/admin 都写了这些)。 +- **处理**:无害、不影响功能。要修需改 harness 前端或由 runtime 插件 `tapIndex` 注入,暂缓。 diff --git a/ensure-role-profile-patch.cjs b/ensure-role-profile-patch.cjs new file mode 100644 index 0000000..8522e51 --- /dev/null +++ b/ensure-role-profile-patch.cjs @@ -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() diff --git a/mksess.cjs b/mksess.cjs new file mode 100644 index 0000000..3487876 --- /dev/null +++ b/mksess.cjs @@ -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(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..db0f532 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2299 @@ +{ + "name": "dshs", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dshs", + "version": "0.1.0", + "license": "MIT", + "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" + }, + "bin": { + "dshs": "lib/cli.js" + }, + "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" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", + "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz", + "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.3.tgz", + "integrity": "sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^2.0.1", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@fastify/static/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@kubernetes/client-node": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-2.0.0.tgz", + "integrity": "sha512-Jx2cRxEVb4XkDNiR/cg8SX9dH6ZHdMhKmZdQcfhn2vdBWHdb2ldwM0P3I0dK4msYhFmC6TCODsNHH144IpA06w==", + "license": "Apache-2.0", + "dependencies": { + "@types/js-yaml": "^4.0.1", + "@types/node": "^26.0.0", + "@types/stream-buffers": "^3.0.3", + "form-data": "^4.0.0", + "hpagent": "^1.2.0", + "isomorphic-ws": "^5.0.0", + "js-yaml": "^5.1.0", + "jsonpath-plus": "^10.3.0", + "openid-client": "^6.1.3", + "rfc4648": "^1.3.0", + "socks": "^2.8.4", + "socks-proxy-agent": "^10.0.0", + "stream-buffers": "^3.0.2", + "tar-fs": "^3.0.9", + "undici": "^8.7.0", + "ws": "^8.18.2" + } + }, + "node_modules/@kubernetes/client-node/node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@kubernetes/client-node/node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/@kubernetes/client-node/node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/@kubernetes/client-node/node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/stream-buffers": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.8.tgz", + "integrity": "sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.0.tgz", + "integrity": "sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.2.tgz", + "integrity": "sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", + "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.0.tgz", + "integrity": "sha512-A3RNEaDIHWaxFW8n8rNJaW1wQ+XAXuoU71llfUQJjuh5WaYLmKfRhhanaJBOx8m2EBPQkR6sDBovYdHNe9F6rA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-my-way": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.8.0.tgz", + "integrity": "sha512-JtyUgATO7qxRp2zKhrmWof74Mqxc1ikbwpwMY97p8ipuTj2QtreA4gK2JNAF6SOqqHnYYkwMUvsgQVi2AJxIyw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz", + "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz", + "integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openid-client": { + "version": "6.8.7", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.7.tgz", + "integrity": "sha512-gtKthNu7evSBvTdrrlHb4F3Fi9dcwlb5QaITlCs+9mfpvuOi0Q3qtBf5+iY4sEP8hy1qCoAdxBNPDcmZeVSDzQ==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.8", + "oauth4webapi": "^3.8.7" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfc4648": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", + "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz", + "integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-buffers": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", + "integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9996a45 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/poc/01-longhorn-subpath/bootstrap-job.yaml b/poc/01-longhorn-subpath/bootstrap-job.yaml new file mode 100644 index 0000000..2875ae0 --- /dev/null +++ b/poc/01-longhorn-subpath/bootstrap-job.yaml @@ -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 diff --git a/poc/01-longhorn-subpath/init-job.yaml b/poc/01-longhorn-subpath/init-job.yaml new file mode 100644 index 0000000..d483dcf --- /dev/null +++ b/poc/01-longhorn-subpath/init-job.yaml @@ -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 diff --git a/poc/01-longhorn-subpath/pvc.yaml b/poc/01-longhorn-subpath/pvc.yaml new file mode 100644 index 0000000..74089e1 --- /dev/null +++ b/poc/01-longhorn-subpath/pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: dsh-users + namespace: dsh-poc +spec: + accessModes: [ReadWriteMany] + storageClassName: longhorn + resources: + requests: + storage: 1Gi diff --git a/poc/01-longhorn-subpath/run.sh b/poc/01-longhorn-subpath/run.sh new file mode 100644 index 0000000..a164f12 --- /dev/null +++ b/poc/01-longhorn-subpath/run.sh @@ -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" diff --git a/poc/01-longhorn-subpath/test-pod.yaml b/poc/01-longhorn-subpath/test-pod.yaml new file mode 100644 index 0000000..2b5396a --- /dev/null +++ b/poc/01-longhorn-subpath/test-pod.yaml @@ -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 diff --git a/poc/02-socat-ws/fake-dsh.mjs b/poc/02-socat-ws/fake-dsh.mjs new file mode 100644 index 0000000..b62ddd4 --- /dev/null +++ b/poc/02-socat-ws/fake-dsh.mjs @@ -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')) diff --git a/poc/02-socat-ws/pod.yaml b/poc/02-socat-ws/pod.yaml new file mode 100644 index 0000000..8fa927f --- /dev/null +++ b/poc/02-socat-ws/pod.yaml @@ -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 diff --git a/poc/02-socat-ws/run.sh b/poc/02-socat-ws/run.sh new file mode 100644 index 0000000..c3ba908 --- /dev/null +++ b/poc/02-socat-ws/run.sh @@ -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" diff --git a/poc/02-socat-ws/ws-client.mjs b/poc/02-socat-ws/ws-client.mjs new file mode 100644 index 0000000..21469f0 --- /dev/null +++ b/poc/02-socat-ws/ws-client.mjs @@ -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) diff --git a/poc/03-networkpolicy/allow-controlplane.yaml b/poc/03-networkpolicy/allow-controlplane.yaml new file mode 100644 index 0000000..9f28cb8 --- /dev/null +++ b/poc/03-networkpolicy/allow-controlplane.yaml @@ -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 diff --git a/poc/03-networkpolicy/default-deny.yaml b/poc/03-networkpolicy/default-deny.yaml new file mode 100644 index 0000000..07716ee --- /dev/null +++ b/poc/03-networkpolicy/default-deny.yaml @@ -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 = 拒绝一切入站 diff --git a/poc/03-networkpolicy/pods.yaml b/poc/03-networkpolicy/pods.yaml new file mode 100644 index 0000000..deee370 --- /dev/null +++ b/poc/03-networkpolicy/pods.yaml @@ -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"] diff --git a/poc/03-networkpolicy/run.sh b/poc/03-networkpolicy/run.sh new file mode 100644 index 0000000..0a849e3 --- /dev/null +++ b/poc/03-networkpolicy/run.sh @@ -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" diff --git a/poc/03-networkpolicy/target.mjs b/poc/03-networkpolicy/target.mjs new file mode 100644 index 0000000..686d4b3 --- /dev/null +++ b/poc/03-networkpolicy/target.mjs @@ -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')) diff --git a/poc/04-cnpg-pgbench/cluster.yaml b/poc/04-cnpg-pgbench/cluster.yaml new file mode 100644 index 0000000..b10edfc --- /dev/null +++ b/poc/04-cnpg-pgbench/cluster.yaml @@ -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 diff --git a/poc/04-cnpg-pgbench/run.sh b/poc/04-cnpg-pgbench/run.sh new file mode 100644 index 0000000..c8a52c1 --- /dev/null +++ b/poc/04-cnpg-pgbench/run.sh @@ -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 是否可接受)" diff --git a/poc/README.md b/poc/README.md new file mode 100644 index 0000000..6bfd3f5 --- /dev/null +++ b/poc/README.md @@ -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 回退方案调整后重跑。 diff --git a/poc/business-plugins/.npmignore b/poc/business-plugins/.npmignore new file mode 100644 index 0000000..f159290 --- /dev/null +++ b/poc/business-plugins/.npmignore @@ -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 diff --git a/poc/business-plugins/cordis.patch.yml b/poc/business-plugins/cordis.patch.yml new file mode 100644 index 0000000..ed54dd2 --- /dev/null +++ b/poc/business-plugins/cordis.patch.yml @@ -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' diff --git a/poc/business-plugins/lib/client.js b/poc/business-plugins/lib/client.js new file mode 100644 index 0000000..9df27f0 --- /dev/null +++ b/poc/business-plugins/lib/client.js @@ -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; + } +}); diff --git a/poc/business-plugins/lib/index.js b/poc/business-plugins/lib/index.js new file mode 100644 index 0000000..e6e44ec --- /dev/null +++ b/poc/business-plugins/lib/index.js @@ -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}`); +} diff --git a/poc/business-plugins/package.json b/poc/business-plugins/package.json new file mode 100644 index 0000000..4c144a8 --- /dev/null +++ b/poc/business-plugins/package.json @@ -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" +} \ No newline at end of file diff --git a/poc/workspace-scoped-picker/README.md b/poc/workspace-scoped-picker/README.md new file mode 100644 index 0000000..7900bb9 --- /dev/null +++ b/poc/workspace-scoped-picker/README.md @@ -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。 diff --git a/poc/workspace-scoped-picker/cordis.patch.yml b/poc/workspace-scoped-picker/cordis.patch.yml new file mode 100644 index 0000000..6f0638d --- /dev/null +++ b/poc/workspace-scoped-picker/cordis.patch.yml @@ -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 保持空,避免双写。 +[] diff --git a/poc/workspace-scoped-picker/ensure-workspace-picker-patch.cjs b/poc/workspace-scoped-picker/ensure-workspace-picker-patch.cjs new file mode 100644 index 0000000..70b5874 --- /dev/null +++ b/poc/workspace-scoped-picker/ensure-workspace-picker-patch.cjs @@ -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') diff --git a/poc/workspace-scoped-picker/ensure-workspace-picker.cjs b/poc/workspace-scoped-picker/ensure-workspace-picker.cjs new file mode 100644 index 0000000..d6a424b --- /dev/null +++ b/poc/workspace-scoped-picker/ensure-workspace-picker.cjs @@ -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') diff --git a/poc/workspace-scoped-picker/lib/client.js b/poc/workspace-scoped-picker/lib/client.js new file mode 100644 index 0000000..d1da229 --- /dev/null +++ b/poc/workspace-scoped-picker/lib/client.js @@ -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; + }, +}); diff --git a/poc/workspace-scoped-picker/lib/index.js b/poc/workspace-scoped-picker/lib/index.js new file mode 100644 index 0000000..ac208fd --- /dev/null +++ b/poc/workspace-scoped-picker/lib/index.js @@ -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 diff --git a/poc/workspace-scoped-picker/package.json b/poc/workspace-scoped-picker/package.json new file mode 100644 index 0000000..c31939d --- /dev/null +++ b/poc/workspace-scoped-picker/package.json @@ -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" +} diff --git a/poc/workspace-scoped-picker/profile-patch.snippet.yml b/poc/workspace-scoped-picker/profile-patch.snippet.yml new file mode 100644 index 0000000..ec1a591 --- /dev/null +++ b/poc/workspace-scoped-picker/profile-patch.snippet.yml @@ -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 diff --git a/poc/workspace-scoped-picker/test/poc.mjs b/poc/workspace-scoped-picker/test/poc.mjs new file mode 100644 index 0000000..931e9ea --- /dev/null +++ b/poc/workspace-scoped-picker/test/poc.mjs @@ -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) diff --git a/scripts/backup-platform.sh b/scripts/backup-platform.sh new file mode 100644 index 0000000..0b7a998 --- /dev/null +++ b/scripts/backup-platform.sh @@ -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 diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs new file mode 100644 index 0000000..cc3244e --- /dev/null +++ b/scripts/build-web.mjs @@ -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)') diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100644 index 0000000..899e32c --- /dev/null +++ b/scripts/ci.sh @@ -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 ✅" diff --git a/scripts/clean-ws-pollution.cjs b/scripts/clean-ws-pollution.cjs new file mode 100644 index 0000000..1487e8b --- /dev/null +++ b/scripts/clean-ws-pollution.cjs @@ -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,未改动)') diff --git a/scripts/ensure-anysearch-admin.cjs b/scripts/ensure-anysearch-admin.cjs new file mode 100644 index 0000000..dd073ee --- /dev/null +++ b/scripts/ensure-anysearch-admin.cjs @@ -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') diff --git a/scripts/ensure-anysearch-pool.mjs b/scripts/ensure-anysearch-pool.mjs new file mode 100644 index 0000000..94cd4ff --- /dev/null +++ b/scripts/ensure-anysearch-pool.mjs @@ -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}`) diff --git a/scripts/ensure-biz-plugins.cjs b/scripts/ensure-biz-plugins.cjs new file mode 100644 index 0000000..1cb1752 --- /dev/null +++ b/scripts/ensure-biz-plugins.cjs @@ -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') diff --git a/scripts/ensure-portal-entry.cjs b/scripts/ensure-portal-entry.cjs new file mode 100644 index 0000000..fc652c7 --- /dev/null +++ b/scripts/ensure-portal-entry.cjs @@ -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') diff --git a/scripts/fake-dsh.mjs b/scripts/fake-dsh.mjs new file mode 100644 index 0000000..2ddb3d0 --- /dev/null +++ b/scripts/fake-dsh.mjs @@ -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}`) + }) +} diff --git a/scripts/fake-setpriv.mjs b/scripts/fake-setpriv.mjs new file mode 100644 index 0000000..45b9df4 --- /dev/null +++ b/scripts/fake-setpriv.mjs @@ -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) +} diff --git a/scripts/gen-capabilities.cjs b/scripts/gen-capabilities.cjs new file mode 100644 index 0000000..e07044e --- /dev/null +++ b/scripts/gen-capabilities.cjs @@ -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 + ' 个') +} diff --git a/scripts/install-egress-guard.sh b/scripts/install-egress-guard.sh new file mode 100644 index 0000000..f50cf47 --- /dev/null +++ b/scripts/install-egress-guard.sh @@ -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" diff --git a/scripts/install-python-runtime.sh b/scripts/install-python-runtime.sh new file mode 100644 index 0000000..7e7e905 --- /dev/null +++ b/scripts/install-python-runtime.sh @@ -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(绝对路径),不受影响。" diff --git a/scripts/install-shared-tools.sh b/scripts/install-shared-tools.sh new file mode 100644 index 0000000..d5b3e1d --- /dev/null +++ b/scripts/install-shared-tools.sh @@ -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/^/ /' diff --git a/scripts/install-workspace-picker.sh b/scripts/install-workspace-picker.sh new file mode 100644 index 0000000..aa70b5e --- /dev/null +++ b/scripts/install-workspace-picker.sh @@ -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 diff --git a/scripts/instance-mem-sample.cjs b/scripts/instance-mem-sample.cjs new file mode 100644 index 0000000..afd8369 --- /dev/null +++ b/scripts/instance-mem-sample.cjs @@ -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 || '无运行中实例'}`) +} diff --git a/scripts/probe-instance-mem.cjs b/scripts/probe-instance-mem.cjs new file mode 100644 index 0000000..0553328 --- /dev/null +++ b/scripts/probe-instance-mem.cjs @@ -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)}`, + ) + } +} diff --git a/scripts/provision-new-users.sh b/scripts/provision-new-users.sh new file mode 100644 index 0000000..c583113 --- /dev/null +++ b/scripts/provision-new-users.sh @@ -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 diff --git a/scripts/purge-trash.sh b/scripts/purge-trash.sh new file mode 100644 index 0000000..bd748db --- /dev/null +++ b/scripts/purge-trash.sh @@ -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" diff --git a/scripts/runtime-baseline.cjs b/scripts/runtime-baseline.cjs new file mode 100644 index 0000000..90b53ae --- /dev/null +++ b/scripts/runtime-baseline.cjs @@ -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) diff --git a/scripts/session-gc.cjs b/scripts/session-gc.cjs new file mode 100644 index 0000000..d9ba7cb --- /dev/null +++ b/scripts/session-gc.cjs @@ -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,未改动)') diff --git a/scripts/smoke-admin.mjs b/scripts/smoke-admin.mjs new file mode 100644 index 0000000..94347f3 --- /dev/null +++ b/scripts/smoke-admin.mjs @@ -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 }) +} diff --git a/scripts/smoke-auth.mjs b/scripts/smoke-auth.mjs new file mode 100644 index 0000000..c2dc611 --- /dev/null +++ b/scripts/smoke-auth.mjs @@ -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') diff --git a/scripts/smoke-domain.mjs b/scripts/smoke-domain.mjs new file mode 100644 index 0000000..50b4284 --- /dev/null +++ b/scripts/smoke-domain.mjs @@ -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 + } +} diff --git a/scripts/smoke-dsh.mjs b/scripts/smoke-dsh.mjs new file mode 100644 index 0000000..bb6bb0b --- /dev/null +++ b/scripts/smoke-dsh.mjs @@ -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 }) +} diff --git a/scripts/smoke-file-service.mjs b/scripts/smoke-file-service.mjs new file mode 100644 index 0000000..dcd7b1b --- /dev/null +++ b/scripts/smoke-file-service.mjs @@ -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 }) +} diff --git a/scripts/smoke-fs.mjs b/scripts/smoke-fs.mjs new file mode 100644 index 0000000..522c0a1 --- /dev/null +++ b/scripts/smoke-fs.mjs @@ -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 }) +} diff --git a/scripts/smoke-isolation.mjs b/scripts/smoke-isolation.mjs new file mode 100644 index 0000000..d617650 --- /dev/null +++ b/scripts/smoke-isolation.mjs @@ -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 + } +} diff --git a/scripts/smoke-subdomain.mjs b/scripts/smoke-subdomain.mjs new file mode 100644 index 0000000..8cb38c1 --- /dev/null +++ b/scripts/smoke-subdomain.mjs @@ -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 + } +} diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..9a89454 --- /dev/null +++ b/scripts/smoke.mjs @@ -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') diff --git a/scripts/storage-report.cjs b/scripts/storage-report.cjs new file mode 100644 index 0000000..d1b3c5e --- /dev/null +++ b/scripts/storage-report.cjs @@ -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}`) diff --git a/scripts/verify-inject.cjs b/scripts/verify-inject.cjs new file mode 100644 index 0000000..8cccb5d --- /dev/null +++ b/scripts/verify-inject.cjs @@ -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) diff --git a/scripts/verify-static.mjs b/scripts/verify-static.mjs new file mode 100644 index 0000000..640a410 --- /dev/null +++ b/scripts/verify-static.mjs @@ -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) diff --git a/scripts/ws-cleanup.cjs b/scripts/ws-cleanup.cjs new file mode 100644 index 0000000..a42bf4c --- /dev/null +++ b/scripts/ws-cleanup.cjs @@ -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,未改动)') diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..c460a5a --- /dev/null +++ b/src/cli.ts @@ -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) +}) diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..72e52f6 --- /dev/null +++ b/src/config.ts @@ -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(), + } +} diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..b1f489f --- /dev/null +++ b/src/crypto.ts @@ -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') +} diff --git a/src/db/adapter.ts b/src/db/adapter.ts new file mode 100644 index 0000000..f6f6098 --- /dev/null +++ b/src/db/adapter.ts @@ -0,0 +1,93 @@ +/** + * The unified async DB interface. Routes depend only on this — never on + * `better-sqlite3` or `pg` directly — so the backend can switch between SQLite + * (`deployMode=local`) and Postgres (`deployMode=k8s`) without touching callers. + * @module dshs/db/adapter + */ + +import type { + BusinessPlugin, + CredentialKey, + CreateSessionInput, + CreateUserInput, + Domain, + DshInstance, + DshInstanceRole, + DshInstanceStatus, + PublicUser, + SessionRow, + SessionUser, + UpsertBusinessPluginInput, + UpsertDshInstanceInput, + User, + UserRole, + Workspace, +} from './types.js' + +export interface DbAdapter { + // users + createUser(input: CreateUserInput): Promise<User> + findUserByUsername(username: string): Promise<User | undefined> + findUserBySlug(slug: string): Promise<User | undefined> + findUserById(id: string): Promise<User | undefined> + listPublicUsers(): Promise<PublicUser[]> + countAdmins(): Promise<number> + setUserRole(id: string, role: UserRole, approvedBy?: string): Promise<boolean> + /** Assign a Linux uid to a user (used by the legacy backfill). */ + setUserUid(userId: string, uid: number): Promise<void> + /** Ids of users whose uid column is still null (legacy rows awaiting backfill). */ + listUsersWithoutUid(): Promise<string[]> + /** + * Permanently delete a user and every owned row (credential_vault / domains / + * sessions / dsh_instances / audit_log / folder_plugins / workspaces), inside + * one transaction. Returns false when no such user exists. + */ + deleteUser(userId: string): Promise<boolean> + // sessions + createSession(input: CreateSessionInput): Promise<void> + findSession(tokenHash: string): Promise<SessionRow | undefined> + deleteSession(tokenHash: string): Promise<void> + deleteUserSessions(userId: string): Promise<void> + findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> + /** Whether the user has any session that has not yet expired (idle reap). */ + hasActiveSession(userId: string): Promise<boolean> + // audit + audit(actor: string | null, action: string, detail?: string | null): Promise<void> + // workspaces / plugins + findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> + getOrCreateWorkspace(userId: string, relPath: string): Promise<Workspace> + setFolderPlugins(workspaceId: string, selections: ReadonlyArray<{ id: string; enabled: boolean }>): Promise<void> + getEnabledPluginIds(workspaceId: string): Promise<string[]> + // business plugins (系统外插件候选池) + listBusinessPlugins(): Promise<BusinessPlugin[]> + findBusinessPlugin(id: string): Promise<BusinessPlugin | undefined> + upsertBusinessPlugin(input: UpsertBusinessPluginInput): Promise<BusinessPlugin> + deleteBusinessPlugin(id: string): Promise<boolean> + // domains + findDomainByUser(userId: string): Promise<Domain | undefined> + findDomainById(id: string): Promise<Domain | undefined> + listDomains(): Promise<Domain[]> + upsertDomain(userId: string, domain: string, nginxConfig: string): Promise<Domain> + setDomainVerified(id: string, verified: boolean): Promise<boolean> + // credential vault + listCredentialKeys(userId: string): Promise<CredentialKey[]> + getEnabledCredentialKeyRef(userId: string): Promise<string | null> + setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> + selectCredentialKey(userId: string, id: string): Promise<boolean> + deleteCredentialKey(userId: string, id: string): Promise<boolean> + // instances (desired state the k8s controller reconciles against — docs/k8s.md §5.7) + upsertInstance(input: UpsertDshInstanceInput): Promise<void> + findInstance(id: string): Promise<DshInstance | undefined> + findUserInstance(userId: string, role: DshInstanceRole): Promise<DshInstance | undefined> + listInstancesByRole(role: DshInstanceRole): Promise<DshInstance[]> + /** Record a state transition; `exitCode`/`lastError` also stamp `last_exit`. */ + setInstanceStatus( + id: string, + status: DshInstanceStatus, + outcome?: { exitCode?: number; lastError?: string }, + ): Promise<boolean> + deleteInstance(id: string): Promise<boolean> + deleteUserInstances(userId: string): Promise<void> + // lifecycle + close(): Promise<void> +} diff --git a/src/db/connection.ts b/src/db/connection.ts new file mode 100644 index 0000000..adf21d3 --- /dev/null +++ b/src/db/connection.ts @@ -0,0 +1,28 @@ +/** + * SQLite connection lifecycle: open, WAL, foreign keys, migration. + * @module dshs/db/connection + */ + +import Database from 'better-sqlite3' +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { runSqliteMigrations } from './schema.js' + +/** The better-sqlite3 instance type. */ +export type Database = Database.Database + +/** + * Open (creating parent directories as needed), enable WAL + foreign keys, + * then run migrations. + * @param path - database file path, or `:memory:`. + */ +export function openDatabase(path: string): Database { + if (path !== ':memory:') { + mkdirSync(dirname(path), { recursive: true }) + } + const db: Database = new Database(path) + db.pragma('journal_mode = WAL') + db.pragma('foreign_keys = ON') + runSqliteMigrations(db) + return db +} diff --git a/src/db/errors.ts b/src/db/errors.ts new file mode 100644 index 0000000..6b180f4 --- /dev/null +++ b/src/db/errors.ts @@ -0,0 +1,26 @@ +/** + * Driver-agnostic DB error hierarchy. Each adapter maps its driver's error + * codes onto these classes, so the route layer catches a single exception type + * regardless of backend (SQLite or Postgres). + * @module dshs/db/errors + */ + +export class DbError extends Error {} +export class UniqueViolationError extends DbError {} +export class ForeignKeyViolationError extends DbError {} + +/** Map a better-sqlite3 constraint error onto the shared hierarchy. */ +export function mapSqliteError(e: unknown): never { + const code = (e as { code?: string }).code + if (code === 'SQLITE_CONSTRAINT_UNIQUE') throw new UniqueViolationError() + if (code === 'SQLITE_CONSTRAINT_FOREIGNKEY') throw new ForeignKeyViolationError() + throw e +} + +/** Map a node-postgres error onto the shared hierarchy. */ +export function mapPgError(e: unknown): never { + const code = (e as { code?: string }).code + if (code === '23505') throw new UniqueViolationError() + if (code === '23503') throw new ForeignKeyViolationError() + throw e +} diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..ef51187 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,31 @@ +/** + * DB adapter factory. Picks the backend from config: Postgres when a + * `DSHS_DB_URL` is set (k8s / shared HA), else local SQLite. + * Also backfills legacy users' uids once (see docs/k8s.md §5.8). + * @module dshs/db + */ + +import type { ServerConfig } from '../config.js' +import { hashUid } from '../isolation.js' +import type { DbAdapter } from './adapter.js' +import { SqliteAdapter } from './sqlite.js' +import { openPgAdapter } from './pg.js' + +export type { DbAdapter } from './adapter.js' +export * from './types.js' +export { DbError, UniqueViolationError, ForeignKeyViolationError } from './errors.js' + +/** Create the configured backend. SQLite is synchronous-open; Postgres is async. */ +export async function createDbAdapter(config: ServerConfig): Promise<DbAdapter> { + const adapter = + config.dbUrl !== undefined + ? await openPgAdapter(config.dbUrl, config.baseUid) + : new SqliteAdapter(config.dbPath, config.baseUid) + // Backfill rows created before the uid column with their stable hash uid so + // existing workspace file ownership is preserved. New users get `baseUid + + // row_id` (set inside createUser), which needs no backfill. + for (const userId of await adapter.listUsersWithoutUid()) { + await adapter.setUserUid(userId, hashUid(userId, config.baseUid)) + } + return adapter +} diff --git a/src/db/pg.ts b/src/db/pg.ts new file mode 100644 index 0000000..78767ee --- /dev/null +++ b/src/db/pg.ts @@ -0,0 +1,508 @@ +/** + * Postgres backend for {@link DbAdapter} via node-postgres (`pg`). Used when + * `deployMode=k8s` (a `DSHS_DB_URL` is set). All methods are + * genuinely async; transactions use {@link withTx}. + * @module dshs/db/pg + */ + +import { randomUUID } from 'node:crypto' +import { Pool, types, type PoolClient } from 'pg' +import type { DbAdapter } from './adapter.js' +import { mapPgError } from './errors.js' +import { runPgMigrations } from './schema.js' +import { + toBusinessPlugin, + toDomain, + toDshInstance, + toPublicUser, + toSession, + toUser, + toWorkspace, + type BusinessPlugin, + type CredentialKey, + type CreateSessionInput, + type CreateUserInput, + type Domain, + type DshInstance, + type DshInstanceRole, + type DshInstanceStatus, + type PublicUser, + type SessionRow, + type SessionUser, + type UpsertBusinessPluginInput, + type UpsertDshInstanceInput, + type User, + type UserRole, + type Workspace, +} from './types.js' + +// Postgres returns int8 (BIGINT) as a string to avoid JS 53-bit precision loss. +// Our only BIGINT columns are epoch-*milliseconds*, which stay well below 2^53, +// so parse them back to numbers — the shared row mappers then read numbers in +// both backends. This is process-global and idempotent. +types.setTypeParser(20, (value: string) => Number(value)) + +const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid' +const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at' +const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at' +const INSTANCE_COLS = + 'id, user_id, workspace_id, role, pid, port, status, started_at, last_exit, exit_code, last_error, folder, patch' + +/** Run `fn` on a dedicated client inside a BEGIN/COMMIT/ROLLBACK transaction. */ +export async function withTx<T>(pool: Pool, fn: (client: PoolClient) => Promise<T>): Promise<T> { + const client = await pool.connect() + try { + await client.query('BEGIN') + const result = await fn(client) + await client.query('COMMIT') + return result + } catch (e) { + await client.query('ROLLBACK') + throw e + } finally { + client.release() + } +} + +export class PgAdapter implements DbAdapter { + constructor(private readonly pool: Pool, private readonly baseUid: number) {} + + async createUser(input: CreateUserInput): Promise<User> { + const createdAt = Date.now() + try { + return await withTx(this.pool, async (client) => { + const { rows } = await client.query( + 'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING row_id', + [input.id, input.username, input.passHash, input.role, input.homeDir, createdAt], + ) + const uid = this.baseUid + Number((rows[0] as { row_id: number }).row_id) + await client.query('UPDATE users SET uid = $1 WHERE id = $2', [uid, input.id]) + return { + id: input.id, + username: input.username, + pass_hash: input.passHash, + role: input.role, + home_dir: input.homeDir, + api_key_ref: null, + created_at: createdAt, + approved_by: null, + uid, + } + }) + } catch (e) { + mapPgError(e) + } + } + + async findUserByUsername(username: string): Promise<User | undefined> { + const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE username = $1`, [username]) + return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined + } + + async findUserBySlug(slug: string): Promise<User | undefined> { + const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE LOWER(username) = $1`, [ + slug.toLowerCase(), + ]) + return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined + } + + async findUserById(id: string): Promise<User | undefined> { + const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE id = $1`, [id]) + return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined + } + + async listPublicUsers(): Promise<PublicUser[]> { + const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users ORDER BY created_at ASC`) + return rows.map((row) => toPublicUser(toUser(row as Record<string, unknown>))) + } + + async countAdmins(): Promise<number> { + const { rows } = await this.pool.query(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`) + return (rows[0] as { n: number }).n + } + + async setUserRole(id: string, role: UserRole, approvedBy?: string): Promise<boolean> { + const result = + approvedBy === undefined + ? await this.pool.query('UPDATE users SET role = $1 WHERE id = $2', [role, id]) + : await this.pool.query('UPDATE users SET role = $1, approved_by = $2 WHERE id = $3', [role, approvedBy, id]) + return (result.rowCount ?? 0) > 0 + } + + async setUserUid(userId: string, uid: number): Promise<void> { + await this.pool.query('UPDATE users SET uid = $1 WHERE id = $2', [uid, userId]) + } + + async listUsersWithoutUid(): Promise<string[]> { + const { rows } = await this.pool.query('SELECT id FROM users WHERE uid IS NULL') + return (rows as Array<{ id: string }>).map((row) => row.id) + } + + async createSession(input: CreateSessionInput): Promise<void> { + try { + await this.pool.query( + 'INSERT INTO sessions (token_hash, user_id, created_at, expires_at, ip, user_agent) VALUES ($1, $2, $3, $4, $5, $6)', + [input.tokenHash, input.userId, Date.now(), input.expiresAt, input.ip ?? null, input.userAgent ?? null], + ) + } catch (e) { + mapPgError(e) + } + } + + async findSession(tokenHash: string): Promise<SessionRow | undefined> { + const { rows } = await this.pool.query( + 'SELECT token_hash, user_id, created_at, expires_at, ip, user_agent FROM sessions WHERE token_hash = $1', + [tokenHash], + ) + return rows.length > 0 ? toSession(rows[0] as Record<string, unknown>) : undefined + } + + async deleteSession(tokenHash: string): Promise<void> { + await this.pool.query('DELETE FROM sessions WHERE token_hash = $1', [tokenHash]) + } + + async deleteUserSessions(userId: string): Promise<void> { + await this.pool.query('DELETE FROM sessions WHERE user_id = $1', [userId]) + } + + async hasActiveSession(userId: string): Promise<boolean> { + const { rows } = await this.pool.query( + 'SELECT 1 FROM sessions WHERE user_id = $1 AND expires_at > $2 LIMIT 1', + [userId, Date.now()], + ) + return rows.length > 0 + } + + async findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> { + const { rows } = await this.pool.query( + `SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid, + s.expires_at + FROM sessions s JOIN users u ON s.user_id = u.id + WHERE s.token_hash = $1`, + [tokenHash], + ) + if (rows.length === 0) return undefined + const row = rows[0] as Record<string, unknown> + return { expiresAt: row.expires_at as number, user: toUser(row) } + } + + async audit(actor: string | null, action: string, detail?: string | null): Promise<void> { + await this.pool.query('INSERT INTO audit_log (ts, actor, action, detail) VALUES ($1, $2, $3, $4)', [ + Date.now(), + actor, + action, + detail ?? null, + ]) + } + + async findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> { + const { rows } = await this.pool.query( + 'SELECT id, user_id, name, rel_path, created_at FROM workspaces WHERE user_id = $1 AND rel_path = $2', + [userId, relPath], + ) + return rows.length > 0 ? toWorkspace(rows[0] as Record<string, unknown>) : undefined + } + + async getOrCreateWorkspace(userId: string, relPath: string): Promise<Workspace> { + const existing = await this.findWorkspaceByPath(userId, relPath) + if (existing !== undefined) return existing + const id = randomUUID() + const segments = relPath.split('/').filter(Boolean) + const name = segments.at(-1) ?? 'root' + try { + await this.pool.query( + 'INSERT INTO workspaces (id, user_id, name, rel_path, created_at) VALUES ($1, $2, $3, $4, $5)', + [id, userId, name, relPath, Date.now()], + ) + } catch (e) { + mapPgError(e) + } + return { id, userId, name, relPath, createdAt: Date.now() } + } + + async setFolderPlugins( + workspaceId: string, + selections: ReadonlyArray<{ id: string; enabled: boolean }>, + ): Promise<void> { + try { + await withTx(this.pool, async (client) => { + await client.query('DELETE FROM folder_plugins WHERE workspace_id = $1', [workspaceId]) + for (const selection of selections) { + await client.query( + 'INSERT INTO folder_plugins (workspace_id, plugin_id, enabled, updated_at) VALUES ($1, $2, $3, $4)', + [workspaceId, selection.id, selection.enabled ? 1 : 0, Date.now()], + ) + } + }) + } catch (e) { + mapPgError(e) + } + } + + async getEnabledPluginIds(workspaceId: string): Promise<string[]> { + const { rows } = await this.pool.query( + 'SELECT plugin_id FROM folder_plugins WHERE workspace_id = $1 AND enabled = 1', + [workspaceId], + ) + return (rows as Array<{ plugin_id: string }>).map((row) => row.plugin_id) + } + + async listBusinessPlugins(): Promise<BusinessPlugin[]> { + const { rows } = await this.pool.query(`SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins ORDER BY name ASC`) + return rows.map((row) => toBusinessPlugin(row as Record<string, unknown>)) + } + + async findBusinessPlugin(id: string): Promise<BusinessPlugin | undefined> { + const { rows } = await this.pool.query(`SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins WHERE id = $1`, [id]) + return rows.length > 0 ? toBusinessPlugin(rows[0] as Record<string, unknown>) : undefined + } + + async upsertBusinessPlugin(input: UpsertBusinessPluginInput): Promise<BusinessPlugin> { + await this.pool.query( + ` + INSERT INTO business_plugins (id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + version = excluded.version, + tgz_path = excluded.tgz_path, + file_size = excluded.file_size, + uploaded_by = excluded.uploaded_by, + updated_at = excluded.updated_at + `, + [ + input.id, + input.name, + input.description ?? null, + input.version ?? null, + input.tgzPath, + input.fileSize, + input.uploadedBy, + Date.now(), + ], + ) + return (await this.findBusinessPlugin(input.id))! + } + + async deleteBusinessPlugin(id: string): Promise<boolean> { + const result = await this.pool.query('DELETE FROM business_plugins WHERE id = $1', [id]) + return (result.rowCount ?? 0) > 0 + } + + async findDomainByUser(userId: string): Promise<Domain | undefined> { + const { rows } = await this.pool.query(`SELECT ${DOMAIN_COLS} FROM domains WHERE user_id = $1`, [userId]) + return rows.length > 0 ? toDomain(rows[0] as Record<string, unknown>) : undefined + } + + async findDomainById(id: string): Promise<Domain | undefined> { + const { rows } = await this.pool.query(`SELECT ${DOMAIN_COLS} FROM domains WHERE id = $1`, [id]) + return rows.length > 0 ? toDomain(rows[0] as Record<string, unknown>) : undefined + } + + async listDomains(): Promise<Domain[]> { + const { rows } = await this.pool.query(`SELECT ${DOMAIN_COLS} FROM domains ORDER BY updated_at DESC`) + return rows.map((row) => toDomain(row as Record<string, unknown>)) + } + + async upsertDomain(userId: string, domain: string, nginxConfig: string): Promise<Domain> { + try { + await this.pool.query( + ` + INSERT INTO domains (id, user_id, domain, verified, nginx_config, updated_at) + VALUES ($1, $2, $3, 0, $4, $5) + ON CONFLICT(user_id) DO UPDATE SET + domain = excluded.domain, + verified = 0, + nginx_config = excluded.nginx_config, + updated_at = excluded.updated_at + `, + [randomUUID(), userId, domain, nginxConfig, Date.now()], + ) + } catch (e) { + mapPgError(e) + } + return (await this.findDomainByUser(userId))! + } + + async setDomainVerified(id: string, verified: boolean): Promise<boolean> { + const result = await this.pool.query('UPDATE domains SET verified = $1, updated_at = $2 WHERE id = $3', [ + verified ? 1 : 0, + Date.now(), + id, + ]) + return (result.rowCount ?? 0) > 0 + } + + async listCredentialKeys(userId: string): Promise<CredentialKey[]> { + const { rows } = await this.pool.query( + 'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = $1 ORDER BY updated_at DESC', + [userId], + ) + return (rows as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>).map((r) => ({ + id: r.id, + name: r.key_name, + enabled: r.enabled === 1, + updatedAt: r.updated_at, + })) + } + + async getEnabledCredentialKeyRef(userId: string): Promise<string | null> { + const { rows } = await this.pool.query( + 'SELECT secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1', + [userId], + ) + const row = rows[0] as { secret_ref: string } | undefined + return row?.secret_ref ?? null + } + + async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> { + try { + return await withTx(this.pool, async (client) => { + await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId]) + const existing = await client.query( + 'SELECT id FROM credential_vault WHERE user_id = $1 AND key_name = $2', + [userId, name], + ) + let id: string + if (existing.rows.length > 0) { + id = (existing.rows[0] as { id: string }).id + await client.query('UPDATE credential_vault SET secret_ref = $1, enabled = 1, updated_at = $2 WHERE id = $3', [ + encryptedRef, + Date.now(), + id, + ]) + } else { + id = randomUUID() + await client.query( + 'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES ($1, $2, $3, $4, 1, $5)', + [id, userId, name, encryptedRef, Date.now()], + ) + } + return { id, name, enabled: true, updatedAt: Date.now() } + }) + } catch (e) { + mapPgError(e) + } + } + + async selectCredentialKey(userId: string, id: string): Promise<boolean> { + return await withTx(this.pool, async (client) => { + await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId]) + const result = await client.query('UPDATE credential_vault SET enabled = 1 WHERE id = $1 AND user_id = $2', [ + id, + userId, + ]) + return (result.rowCount ?? 0) > 0 + }) + } + + async deleteCredentialKey(userId: string, id: string): Promise<boolean> { + const result = await this.pool.query('DELETE FROM credential_vault WHERE id = $1 AND user_id = $2', [id, userId]) + return (result.rowCount ?? 0) > 0 + } + + async deleteUser(userId: string): Promise<boolean> { + return await withTx(this.pool, async (client) => { + await client.query('DELETE FROM credential_vault WHERE user_id = $1', [userId]) + await client.query('DELETE FROM domains WHERE user_id = $1', [userId]) + await client.query('DELETE FROM sessions WHERE user_id = $1', [userId]) + await client.query('DELETE FROM dsh_instances WHERE user_id = $1', [userId]) + await client.query('DELETE FROM audit_log WHERE actor = $1', [userId]) + await client.query( + 'DELETE FROM folder_plugins WHERE workspace_id IN (SELECT id FROM workspaces WHERE user_id = $1)', + [userId], + ) + await client.query('DELETE FROM workspaces WHERE user_id = $1', [userId]) + const result = await client.query('DELETE FROM users WHERE id = $1', [userId]) + return (result.rowCount ?? 0) > 0 + }) + } + + async upsertInstance(input: UpsertDshInstanceInput): Promise<void> { + try { + await this.pool.query( + `INSERT INTO dsh_instances (id, user_id, workspace_id, role, pid, port, status, started_at, folder, patch) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT(id) DO UPDATE SET + workspace_id = excluded.workspace_id, + pid = excluded.pid, + port = excluded.port, + status = excluded.status, + started_at = excluded.started_at, + folder = excluded.folder, + patch = excluded.patch`, + [ + input.id, + input.userId, + input.workspaceId ?? null, + input.role, + input.pid ?? null, + input.port ?? null, + input.status, + Date.now(), + input.folder ?? null, + input.patch ?? null, + ], + ) + } catch (e) { + mapPgError(e) + } + } + + async findInstance(id: string): Promise<DshInstance | undefined> { + const { rows } = await this.pool.query(`SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE id = $1`, [id]) + return rows.length > 0 ? toDshInstance(rows[0] as Record<string, unknown>) : undefined + } + + async findUserInstance(userId: string, role: DshInstanceRole): Promise<DshInstance | undefined> { + const { rows } = await this.pool.query( + `SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE user_id = $1 AND role = $2`, + [userId, role], + ) + return rows.length > 0 ? toDshInstance(rows[0] as Record<string, unknown>) : undefined + } + + async listInstancesByRole(role: DshInstanceRole): Promise<DshInstance[]> { + const { rows } = await this.pool.query( + `SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE role = $1 ORDER BY started_at ASC`, + [role], + ) + return rows.map((row) => toDshInstance(row as Record<string, unknown>)) + } + + async setInstanceStatus( + id: string, + status: DshInstanceStatus, + outcome?: { exitCode?: number; lastError?: string }, + ): Promise<boolean> { + const result = + outcome === undefined + ? await this.pool.query('UPDATE dsh_instances SET status = $1 WHERE id = $2', [status, id]) + : await this.pool.query( + 'UPDATE dsh_instances SET status = $1, last_exit = $2, exit_code = $3, last_error = $4 WHERE id = $5', + [status, Date.now(), outcome.exitCode ?? null, outcome.lastError ?? null, id], + ) + return (result.rowCount ?? 0) > 0 + } + + async deleteInstance(id: string): Promise<boolean> { + const result = await this.pool.query('DELETE FROM dsh_instances WHERE id = $1', [id]) + return (result.rowCount ?? 0) > 0 + } + + async deleteUserInstances(userId: string): Promise<void> { + await this.pool.query('DELETE FROM dsh_instances WHERE user_id = $1', [userId]) + } + + async close(): Promise<void> { + await this.pool.end() + } +} + +/** Open a Postgres adapter: connect, run migrations, then wrap the pool. */ +export async function openPgAdapter(connectionString: string, baseUid: number): Promise<PgAdapter> { + const pool = new Pool({ connectionString }) + await runPgMigrations(pool) + return new PgAdapter(pool, baseUid) +} diff --git a/src/db/prepared.ts b/src/db/prepared.ts new file mode 100644 index 0000000..208d30c --- /dev/null +++ b/src/db/prepared.ts @@ -0,0 +1,29 @@ +/** + * Prepared-statement cache. better-sqlite3 does not cache `db.prepare`, so + * re-preparing the same SQL on every call re-parses it. This memoizes prepared + * statements per connection (WeakMap), which matters for hot paths such as + * authentication and the reverse proxy. + * @module dshs/db/prepared + */ + +import type Database from 'better-sqlite3' +import type { Database as Db } from './connection.js' + +type Statement = Database.Statement<unknown[], unknown> + +const caches = new WeakMap<Db, Map<string, Statement>>() + +/** Prepare (and cache) a statement for a connection. */ +export function prepare(db: Db, sql: string): Statement { + let cache = caches.get(db) + if (cache === undefined) { + cache = new Map() + caches.set(db, cache) + } + let statement = cache.get(sql) + if (statement === undefined) { + statement = db.prepare(sql) + cache.set(sql, statement) + } + return statement +} diff --git a/src/db/repo.ts b/src/db/repo.ts new file mode 100644 index 0000000..de862c3 --- /dev/null +++ b/src/db/repo.ts @@ -0,0 +1,451 @@ +/** + * Synchronous SQLite data access. This is the raw layer behind + * {@link SqliteAdapter}; the route layer must never import these functions + * directly — it goes through {@link DbAdapter} so Postgres can be substituted. + * + * All access is parameterized (prepared statements). Functions take the + * connection explicitly so they stay free of Fastify/app state and testable. + * @module dshs/db/repo + */ + +import { randomUUID } from 'node:crypto' +import type { Database } from './connection.js' +import { prepare } from './prepared.js' +import { + toBusinessPlugin, + toDomain, + toDshInstance, + toPublicUser, + toSession, + toUser, + toWorkspace, + type BusinessPlugin, + type CredentialKey, + type CreateSessionInput, + type CreateUserInput, + type Domain, + type DshInstance, + type DshInstanceRole, + type DshInstanceStatus, + type PublicUser, + type SessionRow, + type SessionUser, + type UpsertBusinessPluginInput, + type UpsertDshInstanceInput, + type User, + type UserRole, + type Workspace, +} from './types.js' + +const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid' +const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at' +const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at' +const INSTANCE_COLS = + 'id, user_id, workspace_id, role, pid, port, status, started_at, last_exit, exit_code, last_error, folder, patch' + +export function createUser(db: Database, input: CreateUserInput, baseUid: number): User { + const createdAt = Date.now() + return db.transaction((): User => { + const info = prepare(db, + 'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES (?, ?, ?, ?, ?, ?)', + ).run(input.id, input.username, input.passHash, input.role, input.homeDir, createdAt) + // SQLite's implicit rowid is the per-user incrementing integer; uid = baseUid + it. + const uid = baseUid + Number(info.lastInsertRowid) + prepare(db, 'UPDATE users SET uid = ? WHERE id = ?').run(uid, input.id) + return { + id: input.id, + username: input.username, + pass_hash: input.passHash, + role: input.role, + home_dir: input.homeDir, + api_key_ref: null, + created_at: createdAt, + approved_by: null, + uid, + } + })() +} + +export function findUserByUsername(db: Database, username: string): User | undefined { + const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE username = ?`).get(username) + return row ? toUser(row as Record<string, unknown>) : undefined +} + +/** Case-insensitive username lookup (for subdomain routing). */ +export function findUserBySlug(db: Database, slug: string): User | undefined { + const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE LOWER(username) = ?`).get(slug.toLowerCase()) + return row ? toUser(row as Record<string, unknown>) : undefined +} + +export function findUserById(db: Database, id: string): User | undefined { + const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE id = ?`).get(id) + return row ? toUser(row as Record<string, unknown>) : undefined +} + +export function listPublicUsers(db: Database): PublicUser[] { + const rows = prepare(db, `SELECT ${USER_COLS} FROM users ORDER BY created_at ASC`).all() as Array< + Record<string, unknown> + > + return rows.map((row) => toPublicUser(toUser(row))) +} + +export function countAdmins(db: Database): number { + const row = prepare(db, `SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`).get() as { n: number } + return row.n +} + +export function setUserRole(db: Database, id: string, role: UserRole, approvedBy?: string): boolean { + const info = + approvedBy === undefined + ? prepare(db, 'UPDATE users SET role = ? WHERE id = ?').run(role, id) + : prepare(db, 'UPDATE users SET role = ?, approved_by = ? WHERE id = ?').run(role, approvedBy, id) + return info.changes > 0 +} + +export function createSession(db: Database, input: CreateSessionInput): void { + prepare(db, + 'INSERT INTO sessions (token_hash, user_id, created_at, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)', + ).run(input.tokenHash, input.userId, Date.now(), input.expiresAt, input.ip ?? null, input.userAgent ?? null) +} + +export function findSession(db: Database, tokenHash: string): SessionRow | undefined { + const row = prepare(db, 'SELECT token_hash, user_id, created_at, expires_at, ip, user_agent FROM sessions WHERE token_hash = ?') + .get(tokenHash) + return row ? toSession(row as Record<string, unknown>) : undefined +} + +export function deleteSession(db: Database, tokenHash: string): void { + prepare(db, 'DELETE FROM sessions WHERE token_hash = ?').run(tokenHash) +} + +export function deleteUserSessions(db: Database, userId: string): void { + prepare(db, 'DELETE FROM sessions WHERE user_id = ?').run(userId) +} + +/** Append an audit entry. `actor` is a user id or `'system'`. */ +export function audit(db: Database, actor: string | null, action: string, detail?: string | null): void { + prepare(db, 'INSERT INTO audit_log (ts, actor, action, detail) VALUES (?, ?, ?, ?)').run( + Date.now(), + actor, + action, + detail ?? null, + ) +} + +export function findWorkspaceByPath(db: Database, userId: string, relPath: string): Workspace | undefined { + const row = prepare(db, 'SELECT id, user_id, name, rel_path, created_at FROM workspaces WHERE user_id = ? AND rel_path = ?') + .get(userId, relPath) + return row ? toWorkspace(row as Record<string, unknown>) : undefined +} + +/** Upsert a workspace row by (user, relPath); create with a derived name. */ +export function getOrCreateWorkspace(db: Database, userId: string, relPath: string): Workspace { + const existing = findWorkspaceByPath(db, userId, relPath) + if (existing !== undefined) return existing + const id = randomUUID() + const segments = relPath.split('/').filter(Boolean) + const name = segments.at(-1) ?? 'root' + prepare(db, 'INSERT INTO workspaces (id, user_id, name, rel_path, created_at) VALUES (?, ?, ?, ?, ?)').run( + id, + userId, + name, + relPath, + Date.now(), + ) + return { id, userId, name, relPath, createdAt: Date.now() } +} + +/** Replace a workspace's plugin selection (insert/delete in one transaction). */ +export function setFolderPlugins( + db: Database, + workspaceId: string, + selections: ReadonlyArray<{ id: string; enabled: boolean }>, +): void { + const tx = db.transaction(() => { + prepare(db, 'DELETE FROM folder_plugins WHERE workspace_id = ?').run(workspaceId) + const insert = prepare(db, + 'INSERT INTO folder_plugins (workspace_id, plugin_id, enabled, updated_at) VALUES (?, ?, ?, ?)', + ) + for (const selection of selections) { + insert.run(workspaceId, selection.id, selection.enabled ? 1 : 0, Date.now()) + } + }) + tx() +} + +/** Enabled plugin ids for a workspace. */ +export function getEnabledPluginIds(db: Database, workspaceId: string): string[] { + const rows = prepare(db, 'SELECT plugin_id FROM folder_plugins WHERE workspace_id = ? AND enabled = 1') + .all(workspaceId) as Array<{ plugin_id: string }> + return rows.map((row) => row.plugin_id) +} + +export function findDomainByUser(db: Database, userId: string): Domain | undefined { + const row = prepare(db, `SELECT ${DOMAIN_COLS} FROM domains WHERE user_id = ?`).get(userId) + return row ? toDomain(row as Record<string, unknown>) : undefined +} + +export function findDomainById(db: Database, id: string): Domain | undefined { + const row = prepare(db, `SELECT ${DOMAIN_COLS} FROM domains WHERE id = ?`).get(id) + return row ? toDomain(row as Record<string, unknown>) : undefined +} + +export function listDomains(db: Database): Domain[] { + const rows = prepare(db, `SELECT ${DOMAIN_COLS} FROM domains ORDER BY updated_at DESC`).all() as Array< + Record<string, unknown> + > + return rows.map((row) => toDomain(row)) +} + +/** Upsert a user's custom domain (resetting `verified` to 0). */ +export function upsertDomain(db: Database, userId: string, domain: string, nginxConfig: string): Domain { + prepare(db, ` + INSERT INTO domains (id, user_id, domain, verified, nginx_config, updated_at) + VALUES (?, ?, ?, 0, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + domain = excluded.domain, + verified = 0, + nginx_config = excluded.nginx_config, + updated_at = excluded.updated_at + `).run(randomUUID(), userId, domain, nginxConfig, Date.now()) + return findDomainByUser(db, userId)! +} + +/** List a user's named credential keys (metadata only). */ +export function listCredentialKeys(db: Database, userId: string): CredentialKey[] { + const rows = prepare( + db, + 'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC', + ).all(userId) as Array<{ id: string; key_name: string; enabled: number; updated_at: number }> + return rows.map((r) => ({ id: r.id, name: r.key_name, enabled: r.enabled === 1, updatedAt: r.updated_at })) +} + +/** The enabled key's encrypted ref for a user (decrypt with the deployment secret). */ +export function getEnabledCredentialKeyRef(db: Database, userId: string): string | null { + const row = prepare(db, 'SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1').get(userId) as + | { secret_ref: string } + | undefined + return row?.secret_ref ?? null +} + +/** Upsert a named key, disable the others, and enable this one. */ +export function setCredentialKey(db: Database, userId: string, name: string, encryptedRef: string): CredentialKey { + const id = db.transaction(() => { + prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId) + const existing = prepare(db, 'SELECT id FROM credential_vault WHERE user_id = ? AND key_name = ?').get( + userId, + name, + ) as { id: string } | undefined + if (existing !== undefined) { + prepare(db, 'UPDATE credential_vault SET secret_ref = ?, enabled = 1, updated_at = ? WHERE id = ?').run( + encryptedRef, + Date.now(), + existing.id, + ) + return existing.id + } + const id = randomUUID() + prepare( + db, + 'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES (?, ?, ?, ?, 1, ?)', + ).run(id, userId, name, encryptedRef, Date.now()) + return id + })() + return { id, name, enabled: true, updatedAt: Date.now() } +} + +/** Enable one of a user's named keys (disabling the others). */ +export function selectCredentialKey(db: Database, userId: string, id: string): boolean { + const ok = db.transaction(() => { + prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId) + const info = prepare(db, 'UPDATE credential_vault SET enabled = 1 WHERE id = ? AND user_id = ?').run(id, userId) + return info.changes > 0 + })() + return ok as boolean +} + +/** Delete a named key (by id, scoped to the user). */ +export function deleteCredentialKey(db: Database, userId: string, id: string): boolean { + const info = prepare(db, 'DELETE FROM credential_vault WHERE id = ? AND user_id = ?').run(id, userId) + return info.changes > 0 +} + +/** + * Permanently delete a user and all owned rows. Child tables are removed + * explicitly (in dependency order) rather than relying on FK cascade, so the + * cleanup works even when `PRAGMA foreign_keys` is off. Returns false when the + * user does not exist. + */ +export function deleteUser(db: Database, userId: string): boolean { + const deleted = db.transaction(() => { + prepare(db, 'DELETE FROM credential_vault WHERE user_id = ?').run(userId) + prepare(db, 'DELETE FROM domains WHERE user_id = ?').run(userId) + prepare(db, 'DELETE FROM sessions WHERE user_id = ?').run(userId) + prepare(db, 'DELETE FROM dsh_instances WHERE user_id = ?').run(userId) + prepare(db, 'DELETE FROM audit_log WHERE actor = ?').run(userId) + prepare( + db, + 'DELETE FROM folder_plugins WHERE workspace_id IN (SELECT id FROM workspaces WHERE user_id = ?)', + ).run(userId) + prepare(db, 'DELETE FROM workspaces WHERE user_id = ?').run(userId) + const info = prepare(db, 'DELETE FROM users WHERE id = ?').run(userId) + return info.changes > 0 + })() + return deleted as boolean +} + +/** Set the verified flag on a domain. */ +export function setDomainVerified(db: Database, id: string, verified: boolean): boolean { + const info = prepare(db, 'UPDATE domains SET verified = ?, updated_at = ? WHERE id = ?') + .run(verified ? 1 : 0, Date.now(), id) + return info.changes > 0 +} + +/** Whether any of a user's sessions is still unexpired. */ +export function hasActiveSession(db: Database, userId: string): boolean { + const row = prepare(db, 'SELECT 1 FROM sessions WHERE user_id = ? AND expires_at > ? LIMIT 1') + .get(userId, Date.now()) as { 1: number } | undefined + return row !== undefined +} + +/** Look up a session and its user in a single join. */ +export function findSessionWithUser(db: Database, tokenHash: string): SessionUser | undefined { + const row = prepare( + db, + `SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid, + s.expires_at + FROM sessions s JOIN users u ON s.user_id = u.id + WHERE s.token_hash = ?`, + ).get(tokenHash) as Record<string, unknown> | undefined + if (row === undefined) return undefined + return { expiresAt: row.expires_at as number, user: toUser(row) } +} + +/** Assign a Linux uid to a user (legacy backfill). */ +export function setUserUid(db: Database, userId: string, uid: number): void { + prepare(db, 'UPDATE users SET uid = ? WHERE id = ?').run(uid, userId) +} + +/** Ids of users whose uid is still null (legacy rows awaiting backfill). */ +export function listUsersWithoutUid(db: Database): string[] { + const rows = prepare(db, 'SELECT id FROM users WHERE uid IS NULL').all() as Array<{ id: string }> + return rows.map((row) => row.id) +} + +/** Record (or re-record) an instance's desired state. Keyed on the caller's + * deterministic id, so a relaunch overwrites rather than duplicating. */ +export function upsertInstance(db: Database, input: UpsertDshInstanceInput): void { + prepare(db, ` + INSERT INTO dsh_instances (id, user_id, workspace_id, role, pid, port, status, started_at, folder, patch) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + workspace_id = excluded.workspace_id, + pid = excluded.pid, + port = excluded.port, + status = excluded.status, + started_at = excluded.started_at, + folder = excluded.folder, + patch = excluded.patch + `).run( + input.id, + input.userId, + input.workspaceId ?? null, + input.role, + input.pid ?? null, + input.port ?? null, + input.status, + Date.now(), + input.folder ?? null, + input.patch ?? null, + ) +} + +export function findInstance(db: Database, id: string): DshInstance | undefined { + const row = prepare(db, `SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE id = ?`).get(id) + return row ? toDshInstance(row as Record<string, unknown>) : undefined +} + +export function findUserInstance(db: Database, userId: string, role: DshInstanceRole): DshInstance | undefined { + const row = prepare(db, `SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE user_id = ? AND role = ?`).get(userId, role) + return row ? toDshInstance(row as Record<string, unknown>) : undefined +} + +export function listInstancesByRole(db: Database, role: DshInstanceRole): DshInstance[] { + const rows = prepare(db, `SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE role = ? ORDER BY started_at ASC`) + .all(role) as Array<Record<string, unknown>> + return rows.map((row) => toDshInstance(row)) +} + +/** Record a state transition; an `outcome` also stamps `last_exit`. */ +export function setInstanceStatus( + db: Database, + id: string, + status: DshInstanceStatus, + outcome?: { exitCode?: number; lastError?: string }, +): boolean { + const info = + outcome === undefined + ? prepare(db, 'UPDATE dsh_instances SET status = ? WHERE id = ?').run(status, id) + : prepare(db, 'UPDATE dsh_instances SET status = ?, last_exit = ?, exit_code = ?, last_error = ? WHERE id = ?') + .run(status, Date.now(), outcome.exitCode ?? null, outcome.lastError ?? null, id) + return info.changes > 0 +} + +export function deleteInstance(db: Database, id: string): boolean { + const info = prepare(db, 'DELETE FROM dsh_instances WHERE id = ?').run(id) + return info.changes > 0 +} + +export function deleteUserInstances(db: Database, userId: string): void { + prepare(db, 'DELETE FROM dsh_instances WHERE user_id = ?').run(userId) +} + +// ── business plugins (系统外插件候选池) ──────────────────────────────── + +export function listBusinessPlugins(db: Database): BusinessPlugin[] { + const rows = prepare(db, `SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins ORDER BY name ASC`).all() as Array< + Record<string, unknown> + > + return rows.map((row) => toBusinessPlugin(row)) +} + +export function findBusinessPlugin(db: Database, id: string): BusinessPlugin | undefined { + const row = prepare(db, `SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins WHERE id = ?`).get(id) + return row ? toBusinessPlugin(row as Record<string, unknown>) : undefined +} + +/** + * Upsert a candidate-pool row keyed on the bundle package name (`id`). Same-name + * upload REPLACES the row — the caller removes the previous tgz file first so no + * stale artifact survives. + */ +export function upsertBusinessPlugin(db: Database, input: UpsertBusinessPluginInput): BusinessPlugin { + prepare(db, ` + INSERT INTO business_plugins (id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + version = excluded.version, + tgz_path = excluded.tgz_path, + file_size = excluded.file_size, + uploaded_by = excluded.uploaded_by, + updated_at = excluded.updated_at + `).run( + input.id, + input.name, + input.description ?? null, + input.version ?? null, + input.tgzPath, + input.fileSize, + input.uploadedBy, + Date.now(), + Date.now(), + ) + return findBusinessPlugin(db, input.id)! +} + +export function deleteBusinessPlugin(db: Database, id: string): boolean { + const info = prepare(db, 'DELETE FROM business_plugins WHERE id = ?').run(id) + return info.changes > 0 +} diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..c85f2f8 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,328 @@ +/** + * Schema migrations, dual-dialect. Each migration carries SQLite and Postgres + * DDL; the active adapter runs only its own dialect. `schema_migrations` is + * shared (same table shape), so a SQLite↔Postgres dump/restore round-trips the + * applied-version marker too. + * + * Dialect notes (kept out of the route layer): + * - timestamps are epoch **milliseconds** (Date.now()), which exceeds 32-bit + * `INTEGER`; SQLite `INTEGER` is 64-bit, Postgres uses `BIGINT`. + * - `enabled`/`verified` are `INTEGER 0/1` in *both* dialects so the row mappers + * stay byte-identical across backends (no boolean/0/1 branch). + * - `audit_log.id` uses SQLite `AUTOINCREMENT` vs Postgres `IDENTITY`. + * @module dshs/db/schema + */ + +import type { Database } from './connection.js' +import type { Pool } from 'pg' + +const SQLITE_V1 = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + pass_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'pending' + CHECK (role IN ('admin','pending','active','disabled')), + home_dir TEXT NOT NULL, + api_key_ref TEXT, + created_at INTEGER NOT NULL, + approved_by TEXT REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + ip TEXT, + user_agent TEXT +); + +-- 【2026-09-11 档案 19 §C5 便宜版】该表仅被 enablePatch 分支使用 +-- (src/web/routes/dsh.ts 的 findWorkspaceByPath),而 DEFAULT_ENABLE_PATCH=false 且生产 env 未覆盖 +-- → 本部署不可达。保留以兼容 k8s 路径;请勿在此表上新增功能。 +CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + rel_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (user_id, rel_path) +); + +-- 【2026-09-11 档案 19 §C5 便宜版】该表在本部署已废弃:folder_plugins 无任何业务/路由调用 +-- (grep 实证:仅 src/db/* 自引用;档案 16 已宣布 folder 级插件废弃)。 +-- 保留表结构仅为兼容 k8s/PG 未验证路径(档案 19 §C8)——请勿在此表上新增功能。 +CREATE TABLE IF NOT EXISTS folder_plugins ( + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + plugin_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + description TEXT, + updated_at INTEGER NOT NULL, + PRIMARY KEY (workspace_id, plugin_id) +); + +CREATE TABLE IF NOT EXISTS dsh_instances ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id), + role TEXT NOT NULL CHECK (role IN ('main','watchdog')), + pid INTEGER, + port INTEGER, + status TEXT NOT NULL + CHECK (status IN ('starting','running','crashed','repairing','stopped')), + started_at INTEGER, + last_exit INTEGER, + exit_code INTEGER, + last_error TEXT +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + actor TEXT, + action TEXT NOT NULL, + detail TEXT +); + +CREATE TABLE IF NOT EXISTS domains ( + id TEXT PRIMARY KEY, + user_id TEXT UNIQUE REFERENCES users(id), + domain TEXT NOT NULL, + verified INTEGER NOT NULL DEFAULT 0, + nginx_config TEXT, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS credential_vault ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + key_name TEXT NOT NULL, + secret_ref TEXT NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (user_id, key_name) +); +` + +const SQLITE_V2 = ` +ALTER TABLE credential_vault ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0; +` + +const PG_V1 = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + pass_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'pending' + CHECK (role IN ('admin','pending','active','disabled')), + home_dir TEXT NOT NULL, + api_key_ref TEXT, + created_at BIGINT NOT NULL, + approved_by TEXT REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + ip TEXT, + user_agent TEXT +); + +-- 【2026-09-11 档案 19 §C5 便宜版】该表仅被 enablePatch 分支使用 +-- (src/web/routes/dsh.ts 的 findWorkspaceByPath),而 DEFAULT_ENABLE_PATCH=false 且生产 env 未覆盖 +-- → 本部署不可达。保留以兼容 k8s 路径;请勿在此表上新增功能。 +CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + rel_path TEXT NOT NULL, + created_at BIGINT NOT NULL, + UNIQUE (user_id, rel_path) +); + +-- 【2026-09-11 档案 19 §C5 便宜版】该表在本部署已废弃:folder_plugins 无任何业务/路由调用 +-- (grep 实证:仅 src/db/* 自引用;档案 16 已宣布 folder 级插件废弃)。 +-- 保留表结构仅为兼容 k8s/PG 未验证路径(档案 19 §C8)——请勿在此表上新增功能。 +CREATE TABLE IF NOT EXISTS folder_plugins ( + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + plugin_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + description TEXT, + updated_at BIGINT NOT NULL, + PRIMARY KEY (workspace_id, plugin_id) +); + +CREATE TABLE IF NOT EXISTS dsh_instances ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id), + role TEXT NOT NULL CHECK (role IN ('main','watchdog')), + pid INTEGER, + port INTEGER, + status TEXT NOT NULL + CHECK (status IN ('starting','running','crashed','repairing','stopped')), + started_at BIGINT, + last_exit BIGINT, + exit_code INTEGER, + last_error TEXT +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + ts BIGINT NOT NULL, + actor TEXT, + action TEXT NOT NULL, + detail TEXT +); + +CREATE TABLE IF NOT EXISTS domains ( + id TEXT PRIMARY KEY, + user_id TEXT UNIQUE REFERENCES users(id), + domain TEXT NOT NULL, + verified INTEGER NOT NULL DEFAULT 0, + nginx_config TEXT, + updated_at BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS credential_vault ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + key_name TEXT NOT NULL, + secret_ref TEXT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE (user_id, key_name) +); +` + +const PG_V2 = ` +ALTER TABLE credential_vault ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0; +` + +// v3: per-user Linux uid (non-colliding for new users via an identity column). +// SQLite reuses its implicit `rowid` for the incrementing integer, so only the +// `uid` column is added here; Postgres adds an explicit identity `row_id`. +const SQLITE_V3 = ` +ALTER TABLE users ADD COLUMN uid INTEGER; +` + +const PG_V3 = ` +ALTER TABLE users ADD COLUMN row_id BIGINT GENERATED ALWAYS AS IDENTITY; +ALTER TABLE users ADD COLUMN uid BIGINT; +` + +// v4: desired-state columns on `dsh_instances`. The table has existed since v1 +// but was never read or written; the k8s controller uses it as the reconcile +// target (docs/k8s.md §5.7), which needs the launch folder and the rendered +// Cordis patch to rebuild a Pod that went missing. +const SQLITE_V4 = ` +ALTER TABLE dsh_instances ADD COLUMN folder TEXT; +ALTER TABLE dsh_instances ADD COLUMN patch TEXT; +` + +const PG_V4 = ` +ALTER TABLE dsh_instances ADD COLUMN folder TEXT; +ALTER TABLE dsh_instances ADD COLUMN patch TEXT; +` + +// v5: business-plugin candidate pool (系统外插件 = 功能插件). Admin uploads a +// tgz bundle into the pool; users enable it per-instance from the dsh settings +// section (档案 16). Same-name upload REPLACES the row (not overwrite — the old +// tgz file is removed first, so deleted files cannot survive). +const SQLITE_V5 = ` +CREATE TABLE IF NOT EXISTS business_plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + version TEXT, + tgz_path TEXT NOT NULL, + file_size INTEGER NOT NULL, + uploaded_by TEXT REFERENCES users(id), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +` + +const PG_V5 = ` +CREATE TABLE IF NOT EXISTS business_plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + version TEXT, + tgz_path TEXT NOT NULL, + file_size BIGINT NOT NULL, + uploaded_by TEXT REFERENCES users(id), + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +` + +interface Migration { + version: number + name: string + sqlite: string + pg: string +} + +const MIGRATIONS: readonly Migration[] = [ + { version: 1, name: 'initial schema', sqlite: SQLITE_V1, pg: PG_V1 }, + { version: 2, name: 'credential vault enabled flag', sqlite: SQLITE_V2, pg: PG_V2 }, + { version: 3, name: 'per-user uid', sqlite: SQLITE_V3, pg: PG_V3 }, + { version: 4, name: 'instance desired state', sqlite: SQLITE_V4, pg: PG_V4 }, + { version: 5, name: 'business plugin candidate pool', sqlite: SQLITE_V5, pg: PG_V5 }, +] + +/** Apply unapplied SQLite migrations inside a single transaction. */ +export function runSqliteMigrations(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + ); + `) + const rows = db.prepare('SELECT version FROM schema_migrations').all() as Array<{ version: number }> + const applied = new Set(rows.map((row) => row.version)) + + const apply = db.transaction(() => { + for (const migration of MIGRATIONS) { + if (applied.has(migration.version)) continue + db.exec(migration.sqlite) + db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)').run( + migration.version, + Date.now(), + ) + } + }) + apply() +} + +/** Apply unapplied Postgres migrations inside a single transaction. */ +export async function runPgMigrations(pool: Pool): Promise<void> { + const client = await pool.connect() + try { + await client.query('BEGIN') + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at BIGINT NOT NULL + ); + `) + const { rows } = await client.query('SELECT version FROM schema_migrations') + const applied = new Set(rows.map((row) => row.version as number)) + for (const migration of MIGRATIONS) { + if (applied.has(migration.version)) continue + await client.query(migration.pg) + await client.query('INSERT INTO schema_migrations (version, applied_at) VALUES ($1, $2)', [ + migration.version, + Date.now(), + ]) + } + await client.query('COMMIT') + } catch (e) { + await client.query('ROLLBACK') + throw e + } finally { + client.release() + } +} diff --git a/src/db/sqlite.ts b/src/db/sqlite.ts new file mode 100644 index 0000000..152b894 --- /dev/null +++ b/src/db/sqlite.ts @@ -0,0 +1,295 @@ +/** + * SQLite backend for {@link DbAdapter}. Wraps the synchronous better-sqlite3 + * repo in async methods and maps constraint errors onto the shared hierarchy. + * Used when `deployMode=local` (no `DSHS_DB_URL`). + * + * NOTE: better-sqlite3 is synchronous; the `async` here only matches the + * interface — the underlying calls still block the event loop. Fine for the + * small single-machine local mode this backend targets (see docs/k8s.md §5.1). + * @module dshs/db/sqlite + */ + +import type { DbAdapter } from './adapter.js' +import { openDatabase, type Database } from './connection.js' +import { mapSqliteError } from './errors.js' +import { + audit as auditSync, + countAdmins as countAdminsSync, + createSession as createSessionSync, + createUser as createUserSync, + deleteBusinessPlugin as deleteBusinessPluginSync, + deleteCredentialKey as deleteCredentialKeySync, + deleteInstance as deleteInstanceSync, + deleteSession as deleteSessionSync, + deleteUser as deleteUserSync, + deleteUserInstances as deleteUserInstancesSync, + deleteUserSessions as deleteUserSessionsSync, + findBusinessPlugin as findBusinessPluginSync, + findDomainById as findDomainByIdSync, + findDomainByUser as findDomainByUserSync, + findInstance as findInstanceSync, + findSession as findSessionSync, + findSessionWithUser as findSessionWithUserSync, + hasActiveSession as hasActiveSessionSync, + findUserById as findUserByIdSync, + findUserBySlug as findUserBySlugSync, + findUserByUsername as findUserByUsernameSync, + findUserInstance as findUserInstanceSync, + findWorkspaceByPath as findWorkspaceByPathSync, + getEnabledCredentialKeyRef as getEnabledCredentialKeyRefSync, + getEnabledPluginIds as getEnabledPluginIdsSync, + getOrCreateWorkspace as getOrCreateWorkspaceSync, + listBusinessPlugins as listBusinessPluginsSync, + listCredentialKeys as listCredentialKeysSync, + listDomains as listDomainsSync, + listInstancesByRole as listInstancesByRoleSync, + listPublicUsers as listPublicUsersSync, + listUsersWithoutUid as listUsersWithoutUidSync, + selectCredentialKey as selectCredentialKeySync, + setCredentialKey as setCredentialKeySync, + setDomainVerified as setDomainVerifiedSync, + setFolderPlugins as setFolderPluginsSync, + setInstanceStatus as setInstanceStatusSync, + setUserRole as setUserRoleSync, + setUserUid as setUserUidSync, + upsertBusinessPlugin as upsertBusinessPluginSync, + upsertDomain as upsertDomainSync, + upsertInstance as upsertInstanceSync, +} from './repo.js' +import type { + BusinessPlugin, + CredentialKey, + CreateSessionInput, + CreateUserInput, + Domain, + DshInstance, + DshInstanceRole, + DshInstanceStatus, + PublicUser, + SessionRow, + SessionUser, + UpsertBusinessPluginInput, + UpsertDshInstanceInput, + User, + UserRole, + Workspace, +} from './types.js' + +export class SqliteAdapter implements DbAdapter { + private readonly db: Database + + constructor(path: string, private readonly baseUid: number) { + this.db = openDatabase(path) + } + + async createUser(input: CreateUserInput): Promise<User> { + try { + return createUserSync(this.db, input, this.baseUid) + } catch (e) { + mapSqliteError(e) + } + } + + async findUserByUsername(username: string): Promise<User | undefined> { + return findUserByUsernameSync(this.db, username) + } + + async findUserBySlug(slug: string): Promise<User | undefined> { + return findUserBySlugSync(this.db, slug) + } + + async findUserById(id: string): Promise<User | undefined> { + return findUserByIdSync(this.db, id) + } + + async listPublicUsers(): Promise<PublicUser[]> { + return listPublicUsersSync(this.db) + } + + async countAdmins(): Promise<number> { + return countAdminsSync(this.db) + } + + async setUserRole(id: string, role: UserRole, approvedBy?: string): Promise<boolean> { + return setUserRoleSync(this.db, id, role, approvedBy) + } + + async setUserUid(userId: string, uid: number): Promise<void> { + setUserUidSync(this.db, userId, uid) + } + + async listUsersWithoutUid(): Promise<string[]> { + return listUsersWithoutUidSync(this.db) + } + + async createSession(input: CreateSessionInput): Promise<void> { + try { + createSessionSync(this.db, input) + } catch (e) { + mapSqliteError(e) + } + } + + async findSession(tokenHash: string): Promise<SessionRow | undefined> { + return findSessionSync(this.db, tokenHash) + } + + async deleteSession(tokenHash: string): Promise<void> { + deleteSessionSync(this.db, tokenHash) + } + + async deleteUserSessions(userId: string): Promise<void> { + deleteUserSessionsSync(this.db, userId) + } + + async findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> { + return findSessionWithUserSync(this.db, tokenHash) + } + + async hasActiveSession(userId: string): Promise<boolean> { + return hasActiveSessionSync(this.db, userId) + } + + async audit(actor: string | null, action: string, detail?: string | null): Promise<void> { + auditSync(this.db, actor, action, detail) + } + + async findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> { + return findWorkspaceByPathSync(this.db, userId, relPath) + } + + async getOrCreateWorkspace(userId: string, relPath: string): Promise<Workspace> { + try { + return getOrCreateWorkspaceSync(this.db, userId, relPath) + } catch (e) { + mapSqliteError(e) + } + } + + async setFolderPlugins( + workspaceId: string, + selections: ReadonlyArray<{ id: string; enabled: boolean }>, + ): Promise<void> { + try { + setFolderPluginsSync(this.db, workspaceId, selections) + } catch (e) { + mapSqliteError(e) + } + } + + async getEnabledPluginIds(workspaceId: string): Promise<string[]> { + return getEnabledPluginIdsSync(this.db, workspaceId) + } + + async listBusinessPlugins(): Promise<BusinessPlugin[]> { + return listBusinessPluginsSync(this.db) + } + + async findBusinessPlugin(id: string): Promise<BusinessPlugin | undefined> { + return findBusinessPluginSync(this.db, id) + } + + async upsertBusinessPlugin(input: UpsertBusinessPluginInput): Promise<BusinessPlugin> { + try { + return upsertBusinessPluginSync(this.db, input) + } catch (e) { + mapSqliteError(e) + } + } + + async deleteBusinessPlugin(id: string): Promise<boolean> { + return deleteBusinessPluginSync(this.db, id) + } + + async findDomainByUser(userId: string): Promise<Domain | undefined> { + return findDomainByUserSync(this.db, userId) + } + + async findDomainById(id: string): Promise<Domain | undefined> { + return findDomainByIdSync(this.db, id) + } + + async listDomains(): Promise<Domain[]> { + return listDomainsSync(this.db) + } + + async upsertDomain(userId: string, domain: string, nginxConfig: string): Promise<Domain> { + try { + return upsertDomainSync(this.db, userId, domain, nginxConfig) + } catch (e) { + mapSqliteError(e) + } + } + + async setDomainVerified(id: string, verified: boolean): Promise<boolean> { + return setDomainVerifiedSync(this.db, id, verified) + } + + async listCredentialKeys(userId: string): Promise<CredentialKey[]> { + return listCredentialKeysSync(this.db, userId) + } + + async getEnabledCredentialKeyRef(userId: string): Promise<string | null> { + return getEnabledCredentialKeyRefSync(this.db, userId) + } + + async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> { + try { + return setCredentialKeySync(this.db, userId, name, encryptedRef) + } catch (e) { + mapSqliteError(e) + } + } + + async selectCredentialKey(userId: string, id: string): Promise<boolean> { + return selectCredentialKeySync(this.db, userId, id) + } + + async deleteCredentialKey(userId: string, id: string): Promise<boolean> { + return deleteCredentialKeySync(this.db, userId, id) + } + + async deleteUser(userId: string): Promise<boolean> { + return deleteUserSync(this.db, userId) + } + + async upsertInstance(input: UpsertDshInstanceInput): Promise<void> { + try { + upsertInstanceSync(this.db, input) + } catch (e) { + mapSqliteError(e) + } + } + + async findInstance(id: string): Promise<DshInstance | undefined> { + return findInstanceSync(this.db, id) + } + + async findUserInstance(userId: string, role: DshInstanceRole): Promise<DshInstance | undefined> { + return findUserInstanceSync(this.db, userId, role) + } + + async listInstancesByRole(role: DshInstanceRole): Promise<DshInstance[]> { + return listInstancesByRoleSync(this.db, role) + } + + async setInstanceStatus( + id: string, + status: DshInstanceStatus, + outcome?: { exitCode?: number; lastError?: string }, + ): Promise<boolean> { + return setInstanceStatusSync(this.db, id, status, outcome) + } + + async deleteInstance(id: string): Promise<boolean> { + return deleteInstanceSync(this.db, id) + } + + async deleteUserInstances(userId: string): Promise<void> { + deleteUserInstancesSync(this.db, userId) + } + + async close(): Promise<void> { + this.db.close() + } +} diff --git a/src/db/types.ts b/src/db/types.ts new file mode 100644 index 0000000..151d8c9 --- /dev/null +++ b/src/db/types.ts @@ -0,0 +1,239 @@ +/** + * Domain types shared by both DB backends (SQLite and Postgres). Kept free of + * any driver so the `DbAdapter` implementations and the route layer depend only + * on these shapes, never on `better-sqlite3` or `pg`. + * @module dshs/db/types + */ + +export type UserRole = 'admin' | 'pending' | 'active' | 'disabled' + +/** A full user row, including secrets (never serialized to clients). */ +export interface User { + id: string + username: string + pass_hash: string + role: UserRole + home_dir: string + api_key_ref: string | null + created_at: number + approved_by: string | null + /** Assigned Linux uid; null until backfilled/assigned (legacy rows). */ + uid: number | null +} + +/** The user shape safe to return over the wire. */ +export interface PublicUser { + id: string + username: string + role: UserRole + createdAt: number +} + +/** A persisted login session (token stored only as its hash). */ +export interface SessionRow { + token_hash: string + user_id: string + created_at: number + expires_at: number + ip: string | null + user_agent: string | null +} + +/** A session joined with its user, for the authn hot path (one query). */ +export interface SessionUser { + expiresAt: number + user: User +} + +/** A per-user project folder (workspace) row. */ +export interface Workspace { + id: string + userId: string + name: string + relPath: string + createdAt: number +} + +/** A custom-domain row. */ +export interface Domain { + id: string + userId: string + domain: string + verified: number + nginxConfig: string | null + updatedAt: number +} + +/** Which half of a user's DSH pair a `dsh_instances` row describes. */ +export type DshInstanceRole = 'main' | 'watchdog' + +/** Lifecycle state of a `dsh_instances` row (mirrors the column's CHECK). */ +export type DshInstanceStatus = 'starting' | 'running' | 'crashed' | 'repairing' | 'stopped' + +/** + * A persisted DSH instance — the **desired** state the k8s controller reconciles + * the cluster against (docs/k8s.md §5.7). `folder` and `patch` are what a + * relaunch needs; `pid`/`port` are local-mode only and stay null under k8s. + */ +export interface DshInstance { + id: string + userId: string + workspaceId: string | null + role: DshInstanceRole + pid: number | null + port: number | null + status: DshInstanceStatus + startedAt: number | null + lastExit: number | null + exitCode: number | null + lastError: string | null + folder: string | null + /** Rendered Cordis patch content (not a path — the control plane holds no user volume). */ + patch: string | null +} + +/** A named per-user credential key (secret never exposed). */ +export interface CredentialKey { + id: string + name: string + enabled: boolean + updatedAt: number +} + +/** A business-plugin (系统外插件) candidate-pool row. `id` = bundle package name. */ +export interface BusinessPlugin { + id: string + name: string + description: string | null + version: string | null + tgzPath: string + fileSize: number + uploadedBy: string | null + createdAt: number + updatedAt: number +} + +export interface UpsertBusinessPluginInput { + id: string + name: string + description?: string | null + version?: string | null + tgzPath: string + fileSize: number + uploadedBy: string | null +} + +export interface CreateUserInput { + id: string + username: string + passHash: string + role: UserRole + homeDir: string +} + +export interface CreateSessionInput { + tokenHash: string + userId: string + expiresAt: number + ip?: string + userAgent?: string +} + +/** Upsert payload for `dsh_instances`; `id` is the deterministic resource name. */ +export interface UpsertDshInstanceInput { + id: string + userId: string + role: DshInstanceRole + status: DshInstanceStatus + folder?: string + patch?: string + workspaceId?: string + pid?: number + port?: number +} + +export function toPublicUser(user: User): PublicUser { + return { id: user.id, username: user.username, role: user.role, createdAt: user.created_at } +} + +// Row mappers. Shared by both adapters — they read the same column names, so the +// only dialect difference (SQLite 64-bit INTEGER vs Postgres BIGINT→number) is +// resolved by the Postgres int8 parser before these run. + +export function toUser(row: Record<string, unknown>): User { + return { + id: row.id as string, + username: row.username as string, + pass_hash: row.pass_hash as string, + role: row.role as UserRole, + home_dir: row.home_dir as string, + api_key_ref: (row.api_key_ref as string | null) ?? null, + created_at: row.created_at as number, + approved_by: (row.approved_by as string | null) ?? null, + uid: (row.uid as number | null) ?? null, + } +} + +export function toSession(row: Record<string, unknown>): SessionRow { + return { + token_hash: row.token_hash as string, + user_id: row.user_id as string, + created_at: row.created_at as number, + expires_at: row.expires_at as number, + ip: (row.ip as string | null) ?? null, + user_agent: (row.user_agent as string | null) ?? null, + } +} + +export function toWorkspace(row: Record<string, unknown>): Workspace { + return { + id: row.id as string, + userId: row.user_id as string, + name: row.name as string, + relPath: row.rel_path as string, + createdAt: row.created_at as number, + } +} + +export function toDshInstance(row: Record<string, unknown>): DshInstance { + return { + id: row.id as string, + userId: row.user_id as string, + workspaceId: (row.workspace_id as string | null) ?? null, + role: row.role as DshInstanceRole, + pid: (row.pid as number | null) ?? null, + port: (row.port as number | null) ?? null, + status: row.status as DshInstanceStatus, + startedAt: (row.started_at as number | null) ?? null, + lastExit: (row.last_exit as number | null) ?? null, + exitCode: (row.exit_code as number | null) ?? null, + lastError: (row.last_error as string | null) ?? null, + folder: (row.folder as string | null) ?? null, + patch: (row.patch as string | null) ?? null, + } +} + +export function toDomain(row: Record<string, unknown>): Domain { + return { + id: row.id as string, + userId: row.user_id as string, + domain: row.domain as string, + verified: row.verified as number, + nginxConfig: (row.nginx_config as string | null) ?? null, + updatedAt: row.updated_at as number, + } +} + +export function toBusinessPlugin(row: Record<string, unknown>): BusinessPlugin { + return { + id: row.id as string, + name: row.name as string, + description: (row.description as string | null) ?? null, + version: (row.version as string | null) ?? null, + tgzPath: row.tgz_path as string, + fileSize: row.file_size as number, + uploadedBy: (row.uploaded_by as string | null) ?? null, + createdAt: row.created_at as number, + updatedAt: row.updated_at as number, + } +} diff --git a/src/fs/k8s-user-fs.ts b/src/fs/k8s-user-fs.ts new file mode 100644 index 0000000..5d7b173 --- /dev/null +++ b/src/fs/k8s-user-fs.ts @@ -0,0 +1,167 @@ +/** + * HTTP {@link UserFs}: every file operation is delegated to the user's own file + * sidecar (docs/k8s.md §4.10), because the control plane runs as uid 65532 with + * no users volume and could not touch a `0700` user directory even if it did. + * + * The sidecar is addressed through its per-user Headless Service. That DNS A + * record has a ~30s TTL, so a Pod that was just rebuilt can still resolve to + * its old IP — connection-level failures therefore drop the keep-alive pool and + * retry once, mirroring what the DSH proxy does (docs/k8s.md §5.4). + * @module dshs/fs/k8s-user-fs + */ + +import { Agent, request as httpRequest } from 'node:http' +import type { Endpoint } from '../supervisor/spawner.js' +import { POSIX, PathEscapeError, resolveWithinRoot } from '../web/middleware/fs-guard.js' +import type { PluginInfo } from './plugins.js' +import { isUserFsErrorCode, UserFsError, type UserFs } from './user-fs.js' +import { HOME_DIR, USERS_DIR, WORKSPACE_DIR, type FsEntry } from './workspace.js' + +/** Data root inside every per-user Pod (mirrors `k8s-spawner`'s POD_DATA_ROOT). */ +const POD_DATA_ROOT = '/var/lib/dshs' + +/** Errors that mean "the connection never got anywhere" — worth one retry + * against a freshly resolved address. */ +const RETRYABLE = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ETIMEDOUT']) + +/** Ensure the user's file sidecar exists and is ready; supplied by the spawner. */ +export type EnsureFileService = (userId: string) => Promise<void> + +interface Reply { + status: number + body: unknown +} + +export class K8sUserFs implements UserFs { + private agent = new Agent({ keepAlive: true, maxSockets: 16 }) + + /** + * @param ensureFileService - brings the sidecar up before the first call. + * @param endpointFor - the sidecar's address; the k8s backend supplies the + * per-user Headless Service DNS, tests supply a loopback bind. + */ + constructor( + private readonly ensureFileService: EnsureFileService, + private readonly endpointFor: (userId: string) => Endpoint, + ) {} + + async initUserRoot(userId: string): Promise<void> { + // Creating the sidecar Pod *is* the initialization: its init container + // builds `<pvc>/<userId>/{ws,home}` as the user's own uid (docs/k8s.md §4.9). + await this.ensureFileService(userId) + await this.call(userId, 'POST', '/fs/init') + } + + resolvePath(userId: string, relPath: string): string { + const root = `${POD_DATA_ROOT}/${USERS_DIR}/${userId}/${WORKSPACE_DIR}` + try { + return resolveWithinRoot(root, relPath, POSIX) + } catch (err) { + if (err instanceof PathEscapeError) throw new UserFsError('bad_path') + throw err + } + } + + /** The user's DSH state directory inside their Pod. */ + homePath(userId: string): string { + return `${POD_DATA_ROOT}/${USERS_DIR}/${userId}/${HOME_DIR}` + } + + async listDir(userId: string, relPath: string): Promise<FsEntry[]> { + const body = await this.call(userId, 'GET', `/fs/tree?path=${encodeURIComponent(relPath)}`) + return (body as { entries: FsEntry[] }).entries + } + + async mkdir(userId: string, relPath: string): Promise<void> { + await this.call(userId, 'POST', '/fs/mkdir', { path: relPath }) + } + + async createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string> { + const body = await this.call(userId, 'POST', '/fs/create', { path: relPath, name, type }) + return (body as { name: string }).name + } + + async upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string> { + const body = await this.call(userId, 'POST', '/fs/upload', { + path: relPath, + name, + data: data.toString('base64'), + }) + return (body as { name: string }).name + } + + /** 档案 56:k8s 路径未验证 —— sidecar 尚无 read 端点,明确报 `unsupported` 而非静默失败。 */ + async readFile(): Promise<{ name: string; data: Buffer }> { + throw new UserFsError('unsupported') + } + + async isDirectory(userId: string, relPath: string): Promise<boolean> { + const body = await this.call(userId, 'GET', `/fs/stat?path=${encodeURIComponent(relPath)}`) + return (body as { isDirectory: boolean }).isDirectory + } + + async listInstalledPlugins(userId: string): Promise<PluginInfo[]> { + const body = await this.call(userId, 'GET', '/fs/plugins') + return (body as { plugins: PluginInfo[] }).plugins + } + + async writeHandoff(userId: string, content: string): Promise<void> { + await this.call(userId, 'POST', '/fs/handoff', { content }) + } + + /** One sidecar call: ensure the Pod, send, retry once on a dead connection, + * then translate a non-2xx `{error}` back into a {@link UserFsError}. */ + private async call(userId: string, method: string, path: string, payload?: unknown): Promise<unknown> { + await this.ensureFileService(userId) + let reply: Reply + try { + reply = await this.send(userId, method, path, payload) + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code === undefined || !RETRYABLE.has(code)) throw err + // The keep-alive pool may hold sockets to a Pod IP that no longer exists; + // drop them so the retry re-resolves the Headless Service. + this.agent.destroy() + this.agent = new Agent({ keepAlive: true, maxSockets: 16 }) + reply = await this.send(userId, method, path, payload) + } + if (reply.status >= 200 && reply.status < 300) return reply.body + const error = (reply.body as { error?: unknown }).error + if (typeof error === 'string' && isUserFsErrorCode(error)) throw new UserFsError(error) + throw new Error(`file sidecar for ${userId} returned ${reply.status}`) + } + + private send(userId: string, method: string, path: string, payload?: unknown): Promise<Reply> { + const body = payload === undefined ? undefined : JSON.stringify(payload) + const endpoint = this.endpointFor(userId) + return new Promise<Reply>((resolve, reject) => { + const req = httpRequest( + { + host: endpoint.host, + port: endpoint.port, + path, + method, + agent: this.agent, + headers: body === undefined + ? {} + : { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }, + }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + try { + resolve({ status: res.statusCode ?? 502, body: text === '' ? {} : JSON.parse(text) }) + } catch { + reject(new Error(`file sidecar for ${userId} returned non-JSON (${res.statusCode})`)) + } + }) + }, + ) + req.on('error', reject) + if (body !== undefined) req.write(body) + req.end() + }) + } +} diff --git a/src/fs/local-user-fs.ts b/src/fs/local-user-fs.ts new file mode 100644 index 0000000..15b4fa5 --- /dev/null +++ b/src/fs/local-user-fs.ts @@ -0,0 +1,179 @@ +/** + * Direct-filesystem {@link UserFs}: the control plane owns the users volume and + * touches it in-process. This is the `deployMode=local` implementation, and it + * is also what the per-user file sidecar runs behind its HTTP surface — the + * sidecar is just a `LocalUserFs` pinned to one user's root. + * @module dshs/fs/local-user-fs + */ + +import { chownSync, chmodSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { stat } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { PathEscapeError, resolveWithinRoot, safeFilename } from '../web/middleware/fs-guard.js' +import { listInstalledPlugins, type PluginInfo } from './plugins.js' +import { UserFsError, type UserFs } from './user-fs.js' +import { ensureDir, handoffPath, homeRoot, listDir, rejectSymlinkEscape, workspaceRoot, type FsEntry } from './workspace.js' + +/** Resolve a user id to that user's data root (`<dataRoot>/users/<id>`, or a + * fixed directory when the sidecar serves exactly one user). */ +export type UserRootResolver = (userId: string) => string + +/** Map a Node errno onto the wire code the desktop already handles. */ +function fsError(err: unknown, onMissing: 'not_found' | 'parent_missing'): never { + const code = (err as NodeJS.ErrnoException).code + if (code === 'EEXIST') throw new UserFsError('exists') + if (code === 'ENOENT' || code === 'ENOTDIR') throw new UserFsError(onMissing) + throw err +} + +export class LocalUserFs implements UserFs { + constructor(private readonly rootFor: UserRootResolver) {} + + async initUserRoot(userId: string, uid?: number): Promise<void> { + const root = this.rootFor(userId) + ensureDir(homeRoot(root)) + ensureDir(workspaceRoot(root)) + // The roots were created by the (possibly root) control plane; the DSH child + // runs as the user's own uid and must be able to traverse + write them. + // chown the user root + home/ws so the child can mkdir profiles/, etc. + if (uid !== undefined && typeof process.getuid === 'function' && process.getuid() === 0) { + chownSync(root, uid, uid) + chownSync(homeRoot(root), uid, uid) + chownSync(workspaceRoot(root), uid, uid) + // 0700 + sticky-ish owner only; keep group/other off. + chmodSync(homeRoot(root), 0o700) + chmodSync(workspaceRoot(root), 0o700) + } + } + + resolvePath(userId: string, relPath: string): string { + return this.resolve(userId, relPath) + } + + async listDir(userId: string, relPath: string): Promise<FsEntry[]> { + const abs = this.resolve(userId, relPath) + await this.assertNoLinks(userId, abs) + try { + return await listDir(abs) + } catch (err) { + fsError(err, 'not_found') + } + } + + async mkdir(userId: string, relPath: string): Promise<void> { + const abs = this.resolve(userId, relPath) + await this.assertNoLinks(userId, abs) + try { + await mkdir(abs) + } catch (err) { + fsError(err, 'parent_missing') + } + } + + async createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string> { + const dirAbs = this.resolve(userId, relPath) + const filename = this.sanitize(name) + const target = join(dirAbs, filename) + await this.assertNoLinks(userId, target) + try { + if (type === 'dir') await mkdir(target) + else await writeFile(target, '') + } catch (err) { + fsError(err, 'parent_missing') + } + return filename + } + + async upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string> { + const dirAbs = this.resolve(userId, relPath) + const filename = this.sanitize(name) + const target = join(dirAbs, filename) + await this.assertNoLinks(userId, target) + try { + await writeFile(target, data) + } catch (err) { + fsError(err, 'parent_missing') + } + return filename + } + + /** 档案 56:读取工作区内的**文件**(门户/实例页「我的文件」下载用)。 */ + async readFile( + userId: string, + relPath: string, + maxBytes = 32 * 1024 * 1024, + ): Promise<{ name: string; data: Buffer }> { + const abs = this.resolve(userId, relPath) + await this.assertNoLinks(userId, abs) + let st + try { + st = await stat(abs) + } catch (err) { + fsError(err, 'not_found') + } + if (st.isDirectory()) throw new UserFsError('not_a_file') + if (st.size > maxBytes) throw new UserFsError('too_large') + try { + return { name: basename(abs), data: await readFile(abs) } + } catch (err) { + fsError(err, 'not_found') + } + } + + async isDirectory(userId: string, relPath: string): Promise<boolean> { + const abs = this.resolve(userId, relPath) + await this.assertNoLinks(userId, abs) + try { + return (await stat(abs)).isDirectory() + } catch (err) { + fsError(err, 'not_found') + } + } + + async listInstalledPlugins(userId: string): Promise<PluginInfo[]> { + return listInstalledPlugins(this.rootFor(userId)) + } + + async writeHandoff(userId: string, content: string): Promise<void> { + const root = this.rootFor(userId) + ensureDir(root) + await writeFile(handoffPath(root), content) + } + + /** Resolve a workspace-relative path, self-healing the root the way the + * pre-seam routes did (every one of them called `ensureWorkspaceRoot` first). */ + private resolve(userId: string, relPath: string): string { + const ws = workspaceRoot(this.rootFor(userId)) + ensureDir(ws) + try { + return resolveWithinRoot(ws, relPath) + } catch (err) { + if (err instanceof PathEscapeError) throw new UserFsError('bad_path') + throw err + } + } + + /** The lexical guard above is prefix-only and blind to links planted inside + * the workspace, so every operation re-walks its final absolute path (the + * write target for upload/createEntry) and rejects any symlink component — + * `writeFile`/`mkdir` would otherwise follow it outside the root with this + * process's privileges. Surfaces as the usual `bad_path` wire code. */ + private async assertNoLinks(userId: string, abs: string): Promise<void> { + try { + await rejectSymlinkEscape(workspaceRoot(this.rootFor(userId)), abs) + } catch (err) { + if (err instanceof PathEscapeError) throw new UserFsError('bad_path') + throw err + } + } + + private sanitize(name: string): string { + try { + return safeFilename(name) + } catch (err) { + if (err instanceof PathEscapeError) throw new UserFsError('bad_name') + throw err + } + } +} diff --git a/src/fs/plugins.ts b/src/fs/plugins.ts new file mode 100644 index 0000000..483bf0a --- /dev/null +++ b/src/fs/plugins.ts @@ -0,0 +1,66 @@ +/** + * Per-user plugin discovery: read the resident DSH profile's `dsh.profile.bundles` + * and surface user-installed bundles, filtering out installation-owned + * `@deepseek-ai/*` bundles. Name/description come from each bundle's own + * package.json. + * @module dshs/fs/plugins + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { homeRoot } from './workspace.js' + +/** A plugin the user may enable per folder (`id` doubles as the package name). */ +export interface PluginInfo { + id: string + name: string + description: string +} + +/** Profile name the resident main DSH is launched with (see supervisor). */ +export const MAIN_PROFILE = 'web' + +/** Bundles under this scope come from the dsh installation, not the user. */ +const INSTALLATION_SCOPE = '@deepseek-ai/' + +/** Absolute profile directory within a user root (`<root>/home/profiles/web`). */ +function profileDir(root: string): string { + return join(homeRoot(root), 'profiles', MAIN_PROFILE) +} + +/** Read a package.json `description`, tolerating a missing/empty field or file. */ +function readDescription(manifestPath: string): string { + try { + const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as { description?: unknown } + return typeof parsed.description === 'string' ? parsed.description : '' + } catch { + return '' + } +} + +/** + * List a user's installed plugins from their profile's `dsh.profile.bundles`, + * minus installation-owned bundles. Returns `[]` when the profile (or a listed + * package) has not been installed yet. Order follows the bundle layer order. + * @param root - the user's data root (see {@link userRoot}). + */ +export function listInstalledPlugins(root: string): PluginInfo[] { + const dir = profileDir(root) + let manifest: { dsh?: { profile?: { bundles?: string[] } } } + try { + manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) + } catch { + return [] + } + const bundles = manifest.dsh?.profile?.bundles ?? [] + const plugins: PluginInfo[] = [] + for (const packageName of bundles) { + if (packageName.startsWith(INSTALLATION_SCOPE)) continue + plugins.push({ + id: packageName, + name: packageName, + description: readDescription(join(dir, 'node_modules', packageName, 'package.json')), + }) + } + return plugins +} diff --git a/src/fs/provider.ts b/src/fs/provider.ts new file mode 100644 index 0000000..6381792 --- /dev/null +++ b/src/fs/provider.ts @@ -0,0 +1,37 @@ +/** + * {@link UserFs} factory. Picks the implementation from `deployMode`, the same + * way {@link createDbAdapter} picks a DB backend and `buildServer` picks a + * {@link Spawner}. + * @module dshs/fs/provider + */ + +import type { ServerConfig } from '../config.js' +import type { Endpoint } from '../supervisor/spawner.js' +import { FILE_SERVICE_PORT } from '../web/file-service.js' +import { K8sUserFs, type EnsureFileService } from './k8s-user-fs.js' +import { LocalUserFs } from './local-user-fs.js' +import type { UserFs } from './user-fs.js' +import { userRoot } from './workspace.js' + +/** Headless Service fronting a user's file sidecar (docs/k8s.md §4.10). */ +export function fileServiceName(userId: string): string { + return `dsh-files-${userId}` +} + +/** In-cluster address of a user's file sidecar. */ +export function fileServiceEndpoint(namespace: string, userId: string): Endpoint { + return { host: `${fileServiceName(userId)}.${namespace}.svc.cluster.local`, port: FILE_SERVICE_PORT } +} + +/** + * Build the configured per-user filesystem. + * + * `local` touches the users volume in-process. `k8s` delegates to each user's + * file sidecar, which the spawner brings up on demand via `ensureFileService`. + */ +export function createUserFs(config: ServerConfig, ensureFileService?: EnsureFileService): UserFs { + if (config.deployMode === 'k8s' && ensureFileService !== undefined) { + return new K8sUserFs(ensureFileService, (userId) => fileServiceEndpoint(config.k8sNamespace, userId)) + } + return new LocalUserFs((userId) => userRoot(config.dataRoot, userId)) +} diff --git a/src/fs/user-fs.ts b/src/fs/user-fs.ts new file mode 100644 index 0000000..197c4d1 --- /dev/null +++ b/src/fs/user-fs.ts @@ -0,0 +1,91 @@ +/** + * The per-user filesystem seam (docs/k8s.md §4.10 / §6.0-1). + * + * Under `local` the control plane owns the users volume and touches it directly + * ({@link LocalUserFs}). Under `k8s` it must not: it runs as uid 65532 while + * each user's directory is `0700` owned by that user's uid, so every file + * operation is delegated over HTTP to a per-user file sidecar + * ({@link K8sUserFs}). Routes depend only on this interface. + * + * All paths crossing this interface are **workspace-relative**; each + * implementation resolves them against its own root via `resolveWithinRoot`. + * @module dshs/fs/user-fs + */ + +import type { PluginInfo } from './plugins.js' +import type { FsEntry } from './workspace.js' + +/** Wire-level failure codes. These are the exact `{error}` values the desktop + * UI already switches on, so they survive the control-plane ↔ sidecar hop. */ +export type UserFsErrorCode = + | 'bad_path' + | 'bad_name' + | 'not_found' + | 'exists' + | 'parent_missing' + | 'not_a_folder' + // 档案 56:下载/查看文件 + | 'not_a_file' + | 'too_large' + // 该实现不支持(如 k8s sidecar 尚无 read 端点,档案 19 §C8 未验证路径) + | 'unsupported' + +/** HTTP status each code maps to (unchanged from the pre-seam routes). */ +const STATUS: Record<UserFsErrorCode, number> = { + bad_path: 400, + bad_name: 400, + not_found: 404, + exists: 409, + parent_missing: 404, + not_a_folder: 400, + not_a_file: 400, + too_large: 413, + unsupported: 501, +} + +/** + * A filesystem failure already reduced to its wire form. Routes rethrow it as + * `reply.code(err.status).send({ error: err.code })` without inspecting errno, + * which is what lets the k8s implementation rebuild it from a sidecar response. + */ +export class UserFsError extends Error { + readonly status: number + + constructor(readonly code: UserFsErrorCode) { + super(code) + this.name = 'UserFsError' + this.status = STATUS[code] + } +} + +/** True when `code` is one this seam knows how to represent. */ +export function isUserFsErrorCode(code: string): code is UserFsErrorCode { + return code in STATUS +} + +/** Per-user filesystem operations, as the route layer needs them. */ +export interface UserFs { + /** Create the user's home/workspace roots (`0700`). Idempotent. + * `uid` (when provided) makes local mode chown the roots to the user's Linux + * uid — the DSH child runs as that uid and would otherwise hit EACCES writing + * `home/` (directories are created by the root control plane). */ + initUserRoot(userId: string, uid?: number): Promise<void> + /** Absolute path of `relPath` **as the user's DSH process sees it** (pure path + * math — the in-Pod mount path under k8s, the host path under local). */ + resolvePath(userId: string, relPath: string): string + listDir(userId: string, relPath: string): Promise<FsEntry[]> + mkdir(userId: string, relPath: string): Promise<void> + /** Create a file or directory under `relPath`; returns the sanitized name. */ + createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string> + /** Write `data` as `name` under `relPath`; returns the sanitized name. */ + upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string> + /** Whether `relPath` is a directory; throws `not_found` when absent. */ + isDirectory(userId: string, relPath: string): Promise<boolean> + /** Read a workspace-relative **file** (档案 56:供门户/实例页「我的文件」下载)。 + * 目录 → `not_a_file`;超过 `maxBytes` → `too_large`。 + * 注:k8s 实现目前抛 `unsupported`(sidecar 尚无 read 端点,属档案 19 §C8 未验证路径)。 */ + readFile(userId: string, relPath: string, maxBytes?: number): Promise<{ name: string; data: Buffer }> + listInstalledPlugins(userId: string): Promise<PluginInfo[]> + /** Write the post-restart command handoff the watchdog reads. */ + writeHandoff(userId: string, content: string): Promise<void> +} diff --git a/src/fs/workspace.ts b/src/fs/workspace.ts new file mode 100644 index 0000000..17d0f4b --- /dev/null +++ b/src/fs/workspace.ts @@ -0,0 +1,105 @@ +/** + * Per-user filesystem layout + low-level helpers. This module is the single + * place that knows the `users/<id>/{home,ws}` shape — the control plane, the + * local spawner, the k8s Pod spec, and the file sidecar all derive their paths + * from here so the four never drift apart. + * + * Isolation is enforced by the caller (see fs-guard). + * @module dshs/fs/workspace + */ + +import { mkdirSync } from 'node:fs' +import { lstat, readdir, stat } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' +import { PathEscapeError } from '../web/middleware/fs-guard.js' + +// Layout segment names. Exported so the k8s Pod spec can build the same layout +// with POSIX separators (`join` would emit backslashes when the control plane +// is developed/tested on Windows) without duplicating the literals. +export const USERS_DIR = 'users' +export const WORKSPACE_DIR = 'ws' +export const HOME_DIR = 'home' +export const HANDOFF_FILE = 'handoff.json' + +/** A user's own data root (`<dataRoot>/users/<id>`). */ +export function userRoot(dataRoot: string, userId: string): string { + return join(dataRoot, USERS_DIR, userId) +} + +/** The workspace (user-visible files) within a user root. */ +export function workspaceRoot(root: string): string { + return join(root, WORKSPACE_DIR) +} + +/** DSH's own state (profiles/sessions/credentials) within a user root. */ +export function homeRoot(root: string): string { + return join(root, HOME_DIR) +} + +/** The watchdog command handoff within a user root. */ +export function handoffPath(root: string): string { + return join(root, HANDOFF_FILE) +} + +/** Create a directory (0700) if it does not exist. */ +export function ensureDir(path: string): void { + mkdirSync(path, { recursive: true, mode: 0o700 }) +} + +/** + * Reject `abs` when any path component between `root` and `abs` (inclusive) is + * a symbolic link. The lexical guard (`resolveWithinRoot`) cannot see links + * planted INSIDE the workspace: an upload through `ws/link -> /etc` passes the + * prefix check and lands outside the user's root with the caller's privileges. + * The desktop surface never needs symlinks, so they are forbidden here; the + * user's DSH process keeps its normal kernel view of links. + * + * Only components strictly below `root` are inspected — the data root itself + * may legitimately sit behind a link. A missing (or not-a-directory) component + * ends the walk: nothing can exist below it, and the caller's own open/stat + * will surface that as its usual errno. + */ +export async function rejectSymlinkEscape(root: string, abs: string): Promise<void> { + const rel = relative(root, abs) + if (rel === '') return // abs IS the root + if (rel.startsWith('..')) throw new PathEscapeError(abs, root) + let current = root + for (const segment of rel.split(sep)) { + if (segment === '' || segment === '.') continue + current = join(current, segment) + try { + const stats = await lstat(current) + if (stats.isSymbolicLink()) throw new PathEscapeError(abs, root) + } catch (err) { + if (err instanceof PathEscapeError) throw err + const code = (err as NodeJS.ErrnoException).code + // Missing/not-a-directory component: nothing below it can exist either. + if (code === 'ENOENT' || code === 'ENOTDIR') return + throw err + } + } +} + +/** One filesystem entry as returned to the desktop. */ +export interface FsEntry { + name: string + type: 'file' | 'dir' + size: number + mtimeMs: number +} + +/** List the immediate children of a directory (async, non-blocking). */ +export async function listDir(absPath: string): Promise<FsEntry[]> { + const entries = await readdir(absPath, { withFileTypes: true }) + const result: FsEntry[] = [] + for (const entry of entries) { + const st = await stat(join(absPath, entry.name)) + result.push({ + name: entry.name, + type: st.isDirectory() ? 'dir' : 'file', + size: st.isFile() ? st.size : 0, + mtimeMs: st.mtimeMs, + }) + } + return result +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..953685b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,26 @@ +/** + * Cordis entry used only for marketplace recognition. + * + * The real product is the standalone `dshs` binary (see + * `./cli.js`), which runs its own Fastify server and spawns per-user DSH + * processes. This `apply` is a guarded no-op: loading the bundle into any + * profile must not start a server. + * @module dshs + */ + +export const name = 'dshs' + +/** Options read by the Cordis loader. `serve` is reserved, not implemented. */ +export interface Config { + serve?: boolean +} + +/** + * Guarded no-op shim. Kept side-effect free so marketplace installs and + * ordinary profiles never boot the orchestrator by accident. + * @param _ctx - the Cordis context (unused). + * @param _config - loader-supplied config (unused). + */ +export function apply(_ctx?: unknown, _config?: Config): void { + // Intentionally empty. +} diff --git a/src/isolation.ts b/src/isolation.ts new file mode 100644 index 0000000..b1fd2c1 --- /dev/null +++ b/src/isolation.ts @@ -0,0 +1,21 @@ +/** + * Account-level isolation helpers. Linux-only: each user maps to a deterministic + * OS uid so the orchestrator can spawn its DSH as that account (via setuid) and + * per-user 0700 directories become a real boundary. + * + * `hashUid` is the *legacy* deterministic hash (stable across restarts and + * hosts); new users get `baseUid + row_id` from the DB instead (collision-free, + * see `src/db`). `hashUid` remains as the backfill value for pre-existing rows + * and the fallback when a uid is not yet assigned. + * @module dshs/isolation + */ + +/** + * Deterministic OS uid for a user id (stable across restarts and hosts). + * `baseUid` should sit above the distro's system uid range (typically < 1000). + */ +export function hashUid(userId: string, baseUid: number): number { + let hash = 0 + for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) >>> 0 + return baseUid + (hash % 100000) +} diff --git a/src/nginx/generate.ts b/src/nginx/generate.ts new file mode 100644 index 0000000..514b216 --- /dev/null +++ b/src/nginx/generate.ts @@ -0,0 +1,45 @@ +/** + * nginx `server {}` rendering and domain validation. (P6) + * + * `renderServerBlock` produces a per-custom-domain block that rewrites the + * domain root onto the orchestrator's `/u/<userId>/dsh/` subpath (see + * docs/domain-config.md §3.1). + * @module dshs/nginx/generate + */ + +/** Render an nginx server block mapping a custom domain to a user's DSH. */ +export function renderServerBlock(domain: string, userId: string, upstreamPort: number): string { + return [ + `# ${domain} -> user ${userId}`, + 'server {', + ' listen 443 ssl;', + ` server_name ${domain};`, + '', + ` ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;`, + ` ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;`, + '', + ' location / {', + ` proxy_pass http://127.0.0.1:${upstreamPort}/u/${userId}/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;', + ' }', + '}', + ].join('\n') +} + +/** + * Validate an ASCII hostname: dot-separated labels of alnum/hyphen, no leading + * or trailing hyphen, no scheme/path/port. + */ +export function isValidDomain(domain: string): boolean { + if (domain.length === 0 || domain.length > 253) return false + return domain.split('.').every((label) => { + if (label.length === 0 || label.length > 63) return false + return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(label) + }) +} diff --git a/src/supervisor/crash-policy.ts b/src/supervisor/crash-policy.ts new file mode 100644 index 0000000..e220c03 --- /dev/null +++ b/src/supervisor/crash-policy.ts @@ -0,0 +1,112 @@ +/** + * Crash-restart policy for a resident main DSH (档案 20 · 方案 A). + * + * Pure decision logic, deliberately separated from `LocalSpawner` so it can be + * unit-tested without spawning anything: given the recent restart timestamps + * and the current consecutive-attempt count, decide either "restart after + * `delayMs` (exponential backoff capped at `maxDelayMs`)" or "circuit open" + * (too many restarts inside the window — stop auto-restarting so a + * persistently failing instance cannot spin spawns forever). + * + * @module dshs/supervisor/crash-policy + */ + +/** Tunables (see {@link ServerConfig} — all env-overridable). */ +export interface CrashPolicyConfig { + /** Delay for the first restart (consecutive attempt 0). */ + baseDelayMs: number + /** Upper bound for the exponential backoff. */ + maxDelayMs: number + /** Rolling window over which restarts are counted. */ + windowMs: number + /** Restarts allowed inside the window before the circuit opens. */ + maxRestartsInWindow: number + /** A main that stays up this long is considered recovered: streak resets. */ + stableResetMs: number +} + +/** Outcome of one crash decision. */ +export type CrashDecision = + | { action: 'restart'; delayMs: number; attempt: number; windowRestarts: number } + | { action: 'circuit-open'; windowRestarts: number } + +/** Drop timestamps that fell out of the rolling window. */ +export function pruneHistory(history: readonly number[], now: number, windowMs: number): number[] { + return history.filter((at) => now - at < windowMs) +} + +/** Exponential backoff for a given consecutive attempt (0-based), capped. */ +export function backoffDelayMs(consecutive: number, cfg: Pick<CrashPolicyConfig, 'baseDelayMs' | 'maxDelayMs'>): number { + const safe = Math.max(0, Math.min(consecutive, 20)) // guard against overflow + return Math.min(cfg.baseDelayMs * 2 ** safe, cfg.maxDelayMs) +} + +/** + * Decide what to do after a main crashed. + * @param history - restart timestamps (ms epoch) already recorded for this user. + * @param consecutive - consecutive attempts since the last stable run. + * @param now - current time (ms epoch). + * @param cfg - policy tunables. + */ +export function decideCrashAction( + history: readonly number[], + consecutive: number, + now: number, + cfg: CrashPolicyConfig, +): CrashDecision { + const inWindow = pruneHistory(history, now, cfg.windowMs) + if (inWindow.length >= cfg.maxRestartsInWindow) { + return { action: 'circuit-open', windowRestarts: inWindow.length } + } + return { + action: 'restart', + delayMs: backoffDelayMs(consecutive, cfg), + attempt: consecutive + 1, + windowRestarts: inWindow.length + 1, + } +} + +/* ───────────────────────────────────────────────────────────────────────────── + * 档案 78:熔断冷却(防「崩溃循环可无限重来」) + * + * 原始缺陷:`circuit-open` 分支里 `mains.delete` + `resetCrashState()` 会把窗口 + * 历史一并清空,于是**下一次 enter/launch 又是满额预算** —— 只要有人(用户 F5、 + * 注入脚本自愈、脚本直铺)不断重试,崩溃循环就能无限重复,且只留一行 stderr。 + * + * 修法:每次熔断记一个**跨轮存活**的冷却窗(`opens` 递增 → 冷却指数加长), + * 冷却期内拒绝隐式/自动启动;冷却过后只给**一次**干净预算。纯函数,便于单测。 + * ──────────────────────────────────────────────────────────────────────────── */ + +/** 熔断状态:`opens` = 该用户累计熔断次数(跨轮不清零)。 */ +export interface BreakerState { + openedAt: number + opens: number +} + +/** 冷却策略(见 ServerConfig,均可 env 覆盖)。 */ +export interface BreakerPolicy { + baseCooldownMs: number + maxCooldownMs: number +} + +/** 第 `opens` 次熔断的冷却时长:`base × 2^(opens-1)`,上限 `maxCooldownMs`。 */ +export function breakerCooldownMs(opens: number, cfg: BreakerPolicy): number { + const safe = Math.max(1, Math.min(opens, 20)) + return Math.min(cfg.baseCooldownMs * 2 ** (safe - 1), cfg.maxCooldownMs) +} + +/** 冷却是否仍在生效(`now < openedAt + cooldown`)。 */ +export function breakerActive(b: BreakerState | undefined, now: number, cfg: BreakerPolicy): boolean { + if (b === undefined) return false + return now < b.openedAt + breakerCooldownMs(b.opens, cfg) +} + +/** 冷却结束时刻(无熔断时返回 0)。 */ +export function breakerUntil(b: BreakerState | undefined, cfg: BreakerPolicy): number { + return b === undefined ? 0 : b.openedAt + breakerCooldownMs(b.opens, cfg) +} + +/** 再次熔断:`opens` 递增,`openedAt` 取本次时刻。 */ +export function openBreaker(prev: BreakerState | undefined, now: number): BreakerState { + return { openedAt: now, opens: (prev?.opens ?? 0) + 1 } +} diff --git a/src/supervisor/firewall.ts b/src/supervisor/firewall.ts new file mode 100644 index 0000000..ee3f089 --- /dev/null +++ b/src/supervisor/firewall.ts @@ -0,0 +1,77 @@ +/** + * Loopback port guard: an iptables OUTPUT owner-match rule that lets only the + * orchestrator (root) open a connection to a per-user DSH's loopback RPC port. + * Because every user DSH binds the shared 127.0.0.1 loopback on a dynamic port, + * a co-tenant local user could otherwise `curl` another user's DSH directly and + * bypass the orchestrator's session authentication. The owner match filters on + * the *client* uid, which is only observable on the OUTPUT chain for a + * same-host loopback connection (an INPUT rule would see the receiving DSH's + * uid and block nothing selectively). + * + * The REJECT is inserted at the *top* of OUTPUT (`-I OUTPUT 1`), not appended: + * iptables stops at the first match, so an earlier ACCEPT (ufw or a conntrack + * `ESTABLISHED,RELATED -j ACCEPT`) would otherwise swallow the packet before an + * appended `-A` REJECT is ever evaluated. + * + * Linux + root only; opt in via `config.portGuard` and fail loud when enabled + * on a host that cannot apply it. + * @module dshs/supervisor/firewall + */ + +import { execFileSync } from 'node:child_process' + +/** One guarded loopback port with idempotent install/remove. */ +export interface PortGuard { + /** Add the OUTPUT owner-match REJECT rule (throws on failure). */ + install(port: number): void + /** Remove the rule best-effort (a missing rule is not an error). */ + remove(port: number): void +} + +/** iptables arguments (excluding the executable) for one insert/delete. + * Install inserts at position 1 (`-I OUTPUT 1`) to stay ahead of any earlier + * ACCEPT (ufw / conntrack ESTABLISHED); delete matches by rule spec (`-D + * OUTPUT`), which works regardless of position. */ +function ruleArgs(port: number, action: '-I' | '-D'): string[] { + const insert = action === '-I' ? ['1'] : [] + return ['-t', 'filter', action, 'OUTPUT', ...insert, '-p', 'tcp', '--dport', String(port), '-m', 'owner', '!', '--uid-owner', '0', '-j', 'REJECT'] +} + +/** Whether this process can manage the loopback OUTPUT guard. */ +function canGuard(): boolean { + return process.platform === 'linux' && typeof process.getuid === 'function' && process.getuid() === 0 +} + +/** + * Create the port guard when enabled, or undefined. Fails loud when enabled on + * a host that cannot apply it (non-Linux or non-root): a silently absent guard + * would leave co-tenants able to reach each other's DSH. + * @param enabled - deployment flag (`config.portGuard`). + */ +export function createPortGuard(enabled: boolean): PortGuard | undefined { + if (!enabled) return undefined + if (!canGuard()) { + throw new Error('portGuard requires a Linux host running as root (iptables OUTPUT owner-match)') + } + const guarded = new Set<number>() + return { + install(port) { + if (guarded.has(port)) return + // Insert at position 1 so an earlier ACCEPT (ufw / conntrack) can't + // swallow the packet before the REJECT is evaluated. + execFileSync('iptables', ruleArgs(port, '-I')) + guarded.add(port) + }, + remove(port) { + if (!guarded.has(port)) return + try { + execFileSync('iptables', ruleArgs(port, '-D')) + } catch { + // Best-effort teardown: a stale rule leaves the port closed to + // co-tenants, which is fail-safe, and a dropped rule is already gone. + } finally { + guarded.delete(port) + } + }, + } +} diff --git a/src/supervisor/k8s-spawner.ts b/src/supervisor/k8s-spawner.ts new file mode 100644 index 0000000..4aa4780 --- /dev/null +++ b/src/supervisor/k8s-spawner.ts @@ -0,0 +1,687 @@ +/** + * K8s backend for per-user DSH lifecycle (docs/k8s.md §5.2/§4.3/§4.4/§4.8). + * + * Each user gets a `dsh-<userId>` Pod (dsh + socat sidecar), a Headless Service, + * a NetworkPolicy, and a `dsh-key-<userId>` Secret for the API key. A crash + * pulls up a one-shot `dsh-<userId>-watchdog` Job. The control plane runs + * in-cluster and talks to the K8s API via @kubernetes/client-node. + * @module dshs/supervisor/k8s-spawner + */ + +import * as k8s from '@kubernetes/client-node' +import type { ServerConfig } from '../config.js' +import type { DbAdapter } from '../db/adapter.js' +import { HANDOFF_FILE, HOME_DIR, USERS_DIR, WORKSPACE_DIR } from '../fs/workspace.js' +import { FILE_SERVICE_PORT, USER_ROOT_ENV } from '../web/file-service.js' +import type { Fencing } from './leader.js' +import { AlreadyRunningError, type Endpoint, type Instance, type LivePod, type Spawner, type UserStatus } from './spawner.js' + +/** Loopback port the dsh container binds; the socat sidecar bridges 8081 → 8080. */ +const DSH_LOOPBACK_PORT = 8080 +/** Sidecar bridge port the per-user Service targets (80 → 8081). */ +const SOCAT_PORT = 8081 +/** Task the one-shot headless watchdog runs (executes the handoff command). */ +const WATCHDOG_TASK = 'Read DSHS_HANDOFF_PATH. If it contains a JSON {"command": ...}, run that command. Then exit.' +/** Shared RWX PVC every user's Pod mounts via subPath. */ +const USERS_PVC = 'dsh-users' + +/** Per-user resource names (deterministic — idempotent create). */ +function names(userId: string) { + return { + pod: `dsh-${userId}`, + service: `dsh-${userId}`, + networkPolicy: `dsh-${userId}`, + secret: `dsh-key-${userId}`, + job: `dsh-${userId}-watchdog`, + patch: `dsh-${userId}-patch`, + filesPod: `dsh-files-${userId}`, + filesService: `dsh-files-${userId}`, + filesNetworkPolicy: `dsh-files-${userId}`, + } +} + +/** The data root inside every per-user Pod, independent of the control plane's + * own `dataRoot` (which under k8s points at a volume the Pod never sees). */ +const POD_DATA_ROOT = '/var/lib/dshs' + +/** Home/workspace paths inside the DSH Pod (docs/k8s.md §4.3). Built with + * POSIX separators — the control plane may be developed/tested on Windows. */ +function userPaths(userId: string): { home: string; ws: string; mount: string } { + const mount = `${POD_DATA_ROOT}/${USERS_DIR}/${userId}` + return { home: `${mount}/${HOME_DIR}`, ws: `${mount}/${WORKSPACE_DIR}`, mount } +} + +/** Pod-safe labels shared by the DSH Pod/Service/NetworkPolicy/Job. */ +function podLabels(userId: string): { app: string; user: string } { + return { app: 'dsh', user: userId } +} + +/** Labels for the per-user file sidecar (a distinct `app`, so its own + * Service/NetworkPolicy select it without touching the DSH Pod). */ +function filesLabels(userId: string): { app: string; user: string } { + return { app: 'dsh-files', user: userId } +} + +/** API-key env entry, omitted when the user has no key. */ +function apiKeyEnv(userId: string, apiKey: string | null): k8s.V1EnvVar[] { + if (apiKey === null) return [] + return [{ name: 'DEEPSEEK_API_KEY', valueFrom: { secretKeyRef: { name: names(userId).secret, key: 'key' } } }] +} + +/** Common per-user container security context (non-root + drop ALL + seccomp + + * read-only rootfs — /tmp comes from an emptyDir, docs/k8s.md Phase 4). */ +function containerSecurity(uid: number): k8s.V1SecurityContext { + return { + runAsNonRoot: true, + runAsUser: uid, + allowPrivilegeEscalation: false, + capabilities: { drop: ['ALL'] }, + seccompProfile: { type: 'RuntimeDefault' }, + readOnlyRootFilesystem: true, + } +} + +/** Writable scratch volume + mount for containers whose rootfs is read-only. */ +function tmpVolume(): k8s.V1Volume[] { + return [{ name: 'tmp', emptyDir: {} }] +} +function tmpMount(): k8s.V1VolumeMount { + return { name: 'tmp', mountPath: '/tmp' } +} + +/** The shared RWX volume (subPath mounts per-user at use time). */ +function dataVolume(): k8s.V1Volume[] { + return [{ name: 'data', persistentVolumeClaim: { claimName: USERS_PVC } }] +} + +/** The shared RWX volume mounted at its **root** (no subPath) so an init + * container can create/chown `<pvc>/<userId>` as the user's own uid. */ +function dataRootVolume(): k8s.V1Volume[] { + return [{ name: 'data-root', persistentVolumeClaim: { claimName: USERS_PVC } }] +} + +/** Every per-user Pod/Job references ACR private images, so each must carry the + * cluster's pull secret (the control-plane Deployment has it in its manifest, + * but generated Pods don't inherit it). */ +function pullSecrets(name: string): k8s.V1LocalObjectReference[] { + return name === '' ? [] : [{ name }] +} + +/** Fencing labels stamped onto resources created by the leader, so a successor + * can identify work a dead leader left in flight (docs/k8s.md §5.3). */ +function stampFencing(labels: Record<string, string>, fencing: Fencing | undefined): void { + if (fencing === undefined) return + labels['dsh.io/holder'] = fencing.holder + labels['dsh.io/operation-id'] = String(fencing.operationId) +} + +/** + * K8s backend implementing {@link Spawner}. State lives in the cluster; every + * method is a K8s API call (or a read). + */ +export class K8sSpawner implements Spawner { + private readonly core: k8s.CoreV1Api + private readonly networking: k8s.NetworkingV1Api + private readonly batch: k8s.BatchV1Api + private readonly namespace: string + private readonly kc: k8s.KubeConfig + private fencing: Fencing | undefined + + constructor( + private readonly config: ServerConfig, + private readonly db: DbAdapter, + private readonly resolveApiKey: (userId: string) => Promise<string | null>, + private readonly resolveUid: (userId: string) => Promise<number>, + clients?: { core: k8s.CoreV1Api; networking: k8s.NetworkingV1Api; batch: k8s.BatchV1Api }, + ) { + // Injecting clients short-circuits cluster auth (used by tests). Otherwise + // build them from the in-cluster config, which needs a mounted SA token. + const kc = new k8s.KubeConfig() + if (clients === undefined) { + kc.loadFromCluster() + clients = { + core: kc.makeApiClient(k8s.CoreV1Api), + networking: kc.makeApiClient(k8s.NetworkingV1Api), + batch: kc.makeApiClient(k8s.BatchV1Api), + } + } + this.kc = kc + this.core = clients.core + this.networking = clients.networking + this.batch = clients.batch + this.namespace = config.k8sNamespace + } + + async launch(userId: string, folder: string, patch?: string): Promise<Instance> { + const n = names(userId) + if (await this.podExists(n.pod)) throw new AlreadyRunningError(userId) + await this.ensureFileService(userId) // the DSH Pod's subPath must already exist + const apiKey = await this.resolveApiKey(userId) + const uid = await this.resolveUid(userId) + const hasPatch = this.config.enablePatch && patch !== undefined + if (hasPatch) await this.ensurePatchConfigMap(n.patch, patch) + await this.ensureSecret(n.secret, apiKey) + await this.ensurePod(n.pod, userId, uid, apiKey, hasPatch ? n.patch : undefined) + await this.ensureService(n.service, userId) + await this.ensureNetworkPolicy(n.networkPolicy, userId) + try { + await this.db.upsertInstance({ id: n.pod, userId, role: 'main', status: 'starting', folder, patch }) + } catch (err) { + this.logError(err) // reconcile needs this row; don't fail the launch over it + } + return { id: n.pod, userId, role: 'main', folder, status: 'starting', patch } + } + + async restartMain(userId: string): Promise<Instance | undefined> { + const desired = await this.db.findUserInstance(userId, 'main') + if (desired === undefined) return undefined + await this.stop(userId) + return await this.launch(userId, desired.folder ?? '', desired.patch ?? undefined) + } + + async restartAllMains(): Promise<void> { + const rows = await this.db.listInstancesByRole('main') + for (const row of rows) { + try { + await this.restartMain(row.userId) + } catch (err) { + this.logError(err) // keep broadcasting — one pod failure must not abort the rest + } + } + } + + async spawnWatchdog(userId: string): Promise<Instance | undefined> { + const n = names(userId) + const apiKey = await this.resolveApiKey(userId) + const uid = await this.resolveUid(userId) + const { home, ws, mount } = userPaths(userId) + const jobLabels = podLabels(userId) + const podTemplateLabels = podLabels(userId) + stampFencing(jobLabels, this.fencing) + stampFencing(podTemplateLabels, this.fencing) + await this.batch.createNamespacedJob({ namespace: this.namespace, body: { + apiVersion: 'batch/v1', + kind: 'Job', + metadata: { name: n.job, namespace: this.namespace, labels: jobLabels }, + spec: { + ttlSecondsAfterFinished: 300, + template: { + metadata: { labels: podTemplateLabels }, + spec: { + automountServiceAccountToken: false, + imagePullSecrets: pullSecrets(this.config.imagePullSecret), + restartPolicy: 'Never', + securityContext: { + runAsNonRoot: true, + runAsUser: uid, + fsGroup: uid, + seccompProfile: { type: 'RuntimeDefault' }, + }, + containers: [ + { + name: 'dsh', + image: this.config.dshImage, + args: ['--profile', 'headless', WATCHDOG_TASK], + env: [ + { name: 'HOME', value: ws }, + { name: 'DSH_HOME', value: home }, + { name: 'DSHS_ROLE', value: 'watchdog' }, + { name: 'DSHS_HANDOFF_PATH', value: `${mount}/${HANDOFF_FILE}` }, + ...apiKeyEnv(userId, apiKey), + ], + volumeMounts: [{ name: 'data', mountPath: mount, subPath: userId }, tmpMount()], + securityContext: containerSecurity(uid), + }, + ], + volumes: [...dataVolume(), ...tmpVolume()], + }, + }, + }, + } }) + return { id: n.job, userId, role: 'watchdog', folder: ws, status: 'starting' } + } + + async status(userId: string): Promise<UserStatus> { + return { main: await this.readInstance(names(userId).pod, userId, 'main') } + } + + async endpointFor(userId: string): Promise<Endpoint | undefined> { + let pod: k8s.V1Pod + try { + pod = await this.core.readNamespacedPod({ name: names(userId).pod, namespace: this.namespace }) + } catch (err) { + if (this.isNotFound(err)) return undefined + throw err // 403/500 are real failures, not "not running" + } + if (pod.status?.phase !== 'Running') return undefined + // Dial the Pod IP directly (not the Headless Service DNS): re-reading the Pod + // on every request gives the *current* IP immediately after a restart, so a + // rebuilt Pod never leaves the proxy pointing at a stale IP for the ~30s DNS + // TTL. Port is the sidecar's 8081 (no kube-proxy DNAT on Headless Services). + const ip = pod.status?.podIP + if (ip === undefined || ip === '') return undefined + return { host: ip, port: SOCAT_PORT } + } + + // k8s 模式无 launch token 概念(Pod 就绪由 endpointFor 的 phase 判断),no-op。 + async waitForLaunchTokenForUser(_userId: string, _timeoutMs?: number): Promise<void> {} + + /** + * k8s spawner 未实现探活(本部署走本地 spawner)。返回 ok:true 保持与旧行为一致: + * 不做回滚,交由 k8s 自身 readiness/liveness 处理。 + */ + async restartAndProbe(_userId: string, _settleMs?: number): Promise<{ ok: boolean; reason: string }> { + return { ok: true, reason: "k8s spawner: 探活未实现" } + } + + async stop(userId: string): Promise<void> { + const n = names(userId) + await this.ignoreNotFound(() => this.core.deleteNamespacedPod({ name: n.pod, namespace: this.namespace })) + await this.ignoreNotFound(() => this.core.deleteNamespacedService({ name: n.service, namespace: this.namespace })) + await this.ignoreNotFound(() => this.networking.deleteNamespacedNetworkPolicy({ name: n.networkPolicy, namespace: this.namespace })) + await this.ignoreNotFound(() => this.batch.deleteNamespacedJob({ name: n.job, namespace: this.namespace })) + await this.ignoreNotFound(() => this.core.deleteNamespacedConfigMap({ name: n.patch, namespace: this.namespace })) + try { + await this.db.deleteInstance(n.pod) // desired state is gone once stopped + } catch (err) { + this.logError(err) + } + } + + /** No-op: per-user Pods outlive any single control-plane replica (reconcile/leader manages them). */ + async teardown(): Promise<void> {} + + /** Activity signal is unused under k8s: the reconcile loop idles-reaps by + * session presence (reconcile.ts Phase 4), not by proxied-traffic recency. */ + touch(_userId: string): void {} + + /** Bring up the user's file sidecar (docs/k8s.md §4.10). The sidecar is a + * distinct always-on Pod so the desktop is usable *before* the on-demand DSH + * launches; its init container creates the user's subPath directory. */ + async ensureFileService(userId: string): Promise<void> { + if (this.config.controlPlaneImage === '') { + throw new Error('DSHS_CONTROL_PLANE_IMAGE is required in k8s mode (file sidecar image)') + } + const n = names(userId) + const uid = await this.resolveUid(userId) + await this.ensureFilesPod(n.filesPod, userId, uid) + await this.ensureFilesService(n.filesService, userId) + await this.ensureFilesNetworkPolicy(n.filesNetworkPolicy, userId) + // The Headless Service only publishes an A record once the Pod is Ready; + // the caller resolves it immediately, so block until it comes up. + await this.waitForPodReady(n.filesPod) + } + + // --- reconcile / watch support (leader-only callers) --- + + /** Stamp the current leader's fencing token onto resources created from now on. */ + setFencing(fencing: Fencing | undefined): void { + this.fencing = fencing + } + + /** Main DSH Pods (`app=dsh`), mapped to the shape the controller needs. */ + async listUserPods(): Promise<LivePod[]> { + const res = await this.core.listNamespacedPod({ namespace: this.namespace, labelSelector: 'app=dsh' }) + return (res.items ?? []).map((pod) => { + const phase = pod.status?.phase ?? '' + return { + name: pod.metadata?.name ?? '', + userId: pod.metadata?.labels?.user ?? '', + running: phase === 'Running', + crashed: phase === 'Failed' || phase === 'Succeeded', + } + }) + } + + /** Recreate a lost Service/NetworkPolicy for a user whose main Pod exists. */ + async ensureUserResources(userId: string): Promise<void> { + await this.ensureService(names(userId).service, userId) + await this.ensureNetworkPolicy(names(userId).networkPolicy, userId) + } + + /** Watch main DSH Pods; `callback` fires on add/update/delete with their state. */ + watchMainPods(callback: (pod: LivePod) => void): k8s.Informer<k8s.V1Pod> & k8s.ObjectCache<k8s.V1Pod> { + const informer = k8s.makeInformer<k8s.V1Pod>( + this.kc, + `/api/v1/namespaces/${this.namespace}/pods`, + () => this.core.listNamespacedPod({ namespace: this.namespace, labelSelector: 'app=dsh' }), + 'app=dsh', + ) + const emit = (obj: k8s.V1Pod | undefined): void => { + const phase = obj?.status?.phase ?? '' + callback({ + name: obj?.metadata?.name ?? '', + userId: obj?.metadata?.labels?.user ?? '', + running: phase === 'Running', + crashed: phase === 'Failed' || phase === 'Succeeded', + }) + } + informer.on('add', (obj) => emit(obj)) + informer.on('update', (obj) => emit(obj)) + informer.on('delete', (obj) => emit(obj)) + return informer + } + + /** Best-effort error surface for the controller's tick loop. */ + logError(err: unknown): void { + console.error('[dsh-reconcile]', err) + } + + // --- helpers --- + + private async podExists(name: string): Promise<boolean> { + try { + await this.core.readNamespacedPod({ name, namespace: this.namespace }) + return true + } catch (err) { + if (this.isNotFound(err)) return false + throw err // 403/500 are real failures, not "no Pod" + } + } + + private async readInstance(name: string, userId: string, role: 'main' | 'watchdog'): Promise<Instance | undefined> { + try { + const pod = await this.core.readNamespacedPod({ name, namespace: this.namespace }) + const status = pod.status?.phase === 'Running' ? 'running' : pod.status?.phase === 'Failed' ? 'crashed' : 'starting' + return { id: name, userId, role, folder: '', status } + } catch (err) { + if (this.isNotFound(err)) return undefined + throw err + } + } + + /** Create-or-replace, so a relaunch after `stop()` (which deletes these) can + * never 409 on a stale Secret/ConfigMap from a prior launch. */ + private async ensureSecret(name: string, apiKey: string | null): Promise<void> { + if (apiKey === null) return + await this.replace({ + read: () => this.core.readNamespacedSecret({ name, namespace: this.namespace }), + create: () => this.core.createNamespacedSecret({ namespace: this.namespace, body: { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name, namespace: this.namespace }, + type: 'Opaque', + stringData: { key: apiKey }, + } }), + del: () => this.core.deleteNamespacedSecret({ name, namespace: this.namespace }), + }) + } + + /** Create-or-replace a generated resource. Reads first for the cheap path, + * then treats a 409 race by deleting and recreating (we own every resource + * this helper touches, so replacement is safe). */ + private async replace(ops: { + read: () => Promise<unknown> + create: () => Promise<unknown> + del: () => Promise<unknown> + }): Promise<void> { + try { + await ops.read() + } catch (err) { + if (!this.isNotFound(err)) throw err + try { + await ops.create() + } catch (createErr) { + if (!this.isConflict(createErr)) throw createErr + await this.ignoreNotFound(ops.del) + await ops.create() + } + } + } + + private async ensurePod(name: string, userId: string, uid: number, apiKey: string | null, patchConfigMapName?: string): Promise<void> { + const { home, ws, mount } = userPaths(userId) + const args = ['--profile', 'web', '--host', '127.0.0.1', '--port', String(DSH_LOOPBACK_PORT)] + // The runtime plugin (dshs/runtime) is baked into the dsh image + // but loaded via --patch; the rendered patch is mounted at /etc/dsh/patch.yml. + if (patchConfigMapName !== undefined) args.splice(1, 0, '--patch', '/etc/dsh/patch.yml') + const labels = podLabels(userId) + stampFencing(labels, this.fencing) + await this.core.createNamespacedPod({ namespace: this.namespace, body: { + apiVersion: 'v1', + kind: 'Pod', + metadata: { name, namespace: this.namespace, labels }, + spec: { + automountServiceAccountToken: false, + imagePullSecrets: pullSecrets(this.config.imagePullSecret), + hostNetwork: false, + hostPID: false, + securityContext: { + runAsNonRoot: true, + runAsUser: uid, + fsGroup: uid, + seccompProfile: { type: 'RuntimeDefault' }, + }, + containers: [ + { + name: 'dsh', + image: this.config.dshImage, + args, + // DSH binds loopback only, so a tcpSocket probe (which hits the Pod IP) + // would never succeed; probe 127.0.0.1:8080 from inside the container. + readinessProbe: { + exec: { command: ['node', '-e', 'require("http").get("http://127.0.0.1:8080", r => process.exit(r.statusCode < 500 ? 0 : 1)).on("error", () => process.exit(1))'] }, + initialDelaySeconds: 5, + periodSeconds: 3, + }, + env: [ + { name: 'HOME', value: ws }, + { name: 'DSH_HOME', value: home }, + ...apiKeyEnv(userId, apiKey), + ], + volumeMounts: [ + { name: 'data', mountPath: mount, subPath: userId }, + ...(patchConfigMapName !== undefined ? [{ name: 'patch', mountPath: '/etc/dsh', readOnly: true }] : []), + tmpMount(), + ], + securityContext: containerSecurity(uid), + resources: { requests: { cpu: '500m', memory: '1Gi' }, limits: { cpu: '2', memory: '4Gi' } }, + }, + { + name: 'sidecar', + image: this.config.controlPlaneImage, + args: ['tcp-bridge', `0.0.0.0:${SOCAT_PORT}`, `127.0.0.1:${DSH_LOOPBACK_PORT}`], + ports: [{ containerPort: SOCAT_PORT }], + securityContext: containerSecurity(uid), + resources: { requests: { cpu: '10m', memory: '32Mi' }, limits: { cpu: '100m', memory: '128Mi' } }, + }, + ], + volumes: [ + ...dataVolume(), + ...(patchConfigMapName !== undefined ? [{ name: 'patch', configMap: { name: patchConfigMapName } }] : []), + ...tmpVolume(), + ], + }, + } }) + } + + private async ensurePatchConfigMap(name: string, patch: string): Promise<void> { + await this.replace({ + read: () => this.core.readNamespacedConfigMap({ name, namespace: this.namespace }), + create: () => this.core.createNamespacedConfigMap({ namespace: this.namespace, body: { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name, namespace: this.namespace }, + data: { 'patch.yml': patch }, + } }), + del: () => this.core.deleteNamespacedConfigMap({ name, namespace: this.namespace }), + }) + } + + private async ensureService(name: string, userId: string): Promise<void> { + await this.replace({ + read: () => this.core.readNamespacedService({ name, namespace: this.namespace }), + create: () => this.core.createNamespacedService({ namespace: this.namespace, body: { + apiVersion: 'v1', + kind: 'Service', + metadata: { name, namespace: this.namespace }, + spec: { + clusterIP: 'None', // Headless: no ClusterIP, DNS A record → Pod IP + selector: podLabels(userId), + ports: [{ port: 80, targetPort: SOCAT_PORT }], + }, + } }), + del: () => this.core.deleteNamespacedService({ name, namespace: this.namespace }), + }) + } + + private async ensureNetworkPolicy(name: string, userId: string): Promise<void> { + await this.replace({ + read: () => this.networking.readNamespacedNetworkPolicy({ name, namespace: this.namespace }), + create: () => this.networking.createNamespacedNetworkPolicy({ namespace: this.namespace, body: { + apiVersion: 'networking.k8s.io/v1', + kind: 'NetworkPolicy', + metadata: { name, namespace: this.namespace }, + spec: { + podSelector: { matchLabels: podLabels(userId) }, + policyTypes: ['Ingress', 'Egress'], + ingress: [{ _from: [{ podSelector: { matchLabels: { app: 'dsh-orchestrator' } } }] }], + egress: [ + { + // DNS to anywhere: the DNS backend varies by distribution (kube-dns / + // coredns / node-local-dns on ACK), so don't pin a pod label. + to: [{ ipBlock: { cidr: '0.0.0.0/0' } }], + ports: [ + { port: 53, protocol: 'UDP' }, + { port: 53, protocol: 'TCP' }, + ], + }, + { + to: this.config.egressCidrs.length > 0 + ? this.config.egressCidrs.map((cidr) => ({ ipBlock: { cidr, except: ['169.254.169.254/32', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] } })) + : [{ ipBlock: { cidr: '0.0.0.0/0', except: ['169.254.169.254/32', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] } }], + ports: [{ port: 443 }], + }, + ], + }, + } }), + del: () => this.networking.deleteNamespacedNetworkPolicy({ name, namespace: this.namespace }), + }) + } + + private async ensureFilesPod(name: string, userId: string, uid: number): Promise<void> { + const mount = userPaths(userId).mount + await this.replace({ + read: () => this.core.readNamespacedPod({ name, namespace: this.namespace }), + create: () => this.core.createNamespacedPod({ namespace: this.namespace, body: { + apiVersion: 'v1', + kind: 'Pod', + metadata: { name, namespace: this.namespace, labels: filesLabels(userId) }, + spec: { + automountServiceAccountToken: false, + imagePullSecrets: pullSecrets(this.config.imagePullSecret), + hostNetwork: false, + hostPID: false, + securityContext: { + runAsNonRoot: true, + runAsUser: uid, + fsGroup: uid, + seccompProfile: { type: 'RuntimeDefault' }, + }, + // Creates `<pvc>/<userId>/{ws,home}` as the user's uid (docs/k8s.md + // §4.9). Runs on the PVC *root* (no subPath), because the subPath dir + // may not exist yet or be root-owned; the user's 0700 dir keeps other + // users' files out of reach. + initContainers: [ + { + name: 'init-user', + image: this.config.controlPlaneImage, + command: ['sh', '-ec', `mkdir -p /mnt/${userId}/${WORKSPACE_DIR} /mnt/${userId}/${HOME_DIR} && chmod 0700 /mnt/${userId}`], + volumeMounts: [{ name: 'data-root', mountPath: '/mnt' }], + securityContext: containerSecurity(uid), + }, + ], + containers: [ + { + name: 'files', + image: this.config.controlPlaneImage, + args: ['file-service'], + env: [{ name: USER_ROOT_ENV, value: mount }], + ports: [{ containerPort: FILE_SERVICE_PORT }], + readinessProbe: { tcpSocket: { port: FILE_SERVICE_PORT }, initialDelaySeconds: 2, periodSeconds: 3 }, + volumeMounts: [{ name: 'data', mountPath: mount, subPath: userId }, tmpMount()], + securityContext: containerSecurity(uid), + resources: { requests: { cpu: '50m', memory: '128Mi' }, limits: { cpu: '200m', memory: '256Mi' } }, + }, + ], + volumes: [...dataVolume(), ...dataRootVolume(), ...tmpVolume()], + }, + } }), + del: () => this.core.deleteNamespacedPod({ name, namespace: this.namespace }), + }) + } + + private async ensureFilesService(name: string, userId: string): Promise<void> { + await this.replace({ + read: () => this.core.readNamespacedService({ name, namespace: this.namespace }), + create: () => this.core.createNamespacedService({ namespace: this.namespace, body: { + apiVersion: 'v1', + kind: 'Service', + metadata: { name, namespace: this.namespace }, + spec: { + clusterIP: 'None', + selector: filesLabels(userId), + ports: [{ port: FILE_SERVICE_PORT, targetPort: FILE_SERVICE_PORT }], + }, + } }), + del: () => this.core.deleteNamespacedService({ name, namespace: this.namespace }), + }) + } + + private async ensureFilesNetworkPolicy(name: string, userId: string): Promise<void> { + await this.replace({ + read: () => this.networking.readNamespacedNetworkPolicy({ name, namespace: this.namespace }), + create: () => this.networking.createNamespacedNetworkPolicy({ namespace: this.namespace, body: { + apiVersion: 'networking.k8s.io/v1', + kind: 'NetworkPolicy', + metadata: { name, namespace: this.namespace }, + spec: { + podSelector: { matchLabels: filesLabels(userId) }, + policyTypes: ['Ingress', 'Egress'], + ingress: [{ _from: [{ podSelector: { matchLabels: { app: 'dsh-orchestrator' } } }], ports: [{ port: FILE_SERVICE_PORT }] }], + // Files only; the sidecar never talks to the LLM API. + egress: [ + { + to: [{ ipBlock: { cidr: '0.0.0.0/0' } }], + ports: [ + { port: 53, protocol: 'UDP' }, + { port: 53, protocol: 'TCP' }, + ], + }, + ], + }, + } }), + del: () => this.networking.deleteNamespacedNetworkPolicy({ name, namespace: this.namespace }), + }) + } + + /** Poll until a Pod's Ready condition is true, or fail after `timeoutMs`. */ + private async waitForPodReady(name: string, timeoutMs = 60_000): Promise<void> { + const deadline = Date.now() + timeoutMs + for (;;) { + const pod = await this.core.readNamespacedPod({ name, namespace: this.namespace }) + const ready = pod.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True') + if (ready) return + if (Date.now() >= deadline) throw new Error(`Pod ${name} not ready within ${timeoutMs}ms`) + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + private isNotFound(err: unknown): boolean { + return (err as { code?: number }).code === 404 + } + + private isConflict(err: unknown): boolean { + return (err as { code?: number }).code === 409 + } + + private async ignoreNotFound(fn: () => Promise<unknown>): Promise<void> { + try { + await fn() + } catch (err) { + // `@kubernetes/client-node` throws `ApiException` with a `code` field. + if (this.isNotFound(err)) return + throw err + } + } +} diff --git a/src/supervisor/leader.ts b/src/supervisor/leader.ts new file mode 100644 index 0000000..c0cae81 --- /dev/null +++ b/src/supervisor/leader.ts @@ -0,0 +1,254 @@ +/** + * Hand-rolled leader election over `coordination.k8s.io/v1` Lease + * (docs/k8s.md §5.3). `@kubernetes/client-node` ships no election helper, so + * this holds the small state machine: try to create the lease, and when it + * already exists either renew (we hold it) or take it over only after the + * holder's renew time has exceeded `leaseDurationSeconds`. + * + * Timings follow the doc's anti-split-brain ordering + * `LeaseDuration > RenewDeadline > RetryPeriod`. + * @module dshs/supervisor/leader + */ + +import * as k8s from '@kubernetes/client-node' +import { hostname } from 'node:os' + +/** The fencing token a controller stamps onto resources it creates. */ +export interface Fencing { + holder: string + operationId: number +} + +export interface LeaderOptions { + identity?: string + leaseName?: string + namespace: string + leaseDurationSeconds?: number + renewDeadlineSeconds?: number + retryPeriodSeconds?: number + /** Injectable clock (tests); defaults to Date.now. */ + now?: () => number + onStartedLeading?: (fencing: Fencing) => void + onStoppedLeading?: () => void + /** Injectable lease client (tests); defaults to the in-cluster config. */ + coordination?: k8s.CoordinationV1Api +} + +/** `@kubernetes/client-node` deserializes `V1MicroTime` back to an ISO string, + * not a Date — `renewTime.getTime()` would throw. Normalize either shape. */ +function toMillis(time: unknown): number { + if (time === undefined || time === null) return 0 + if (typeof time === 'string') { + const ms = Date.parse(time) + return Number.isNaN(ms) ? 0 : ms + } + if (time instanceof Date) return time.getTime() + return 0 +} + +/** Normalize a read-back `V1MicroTime` (string) back to a Date for the replace body. */ +function toMicroTime(time: unknown): k8s.V1MicroTime | undefined { + const ms = toMillis(time) + return ms > 0 ? new k8s.V1MicroTime(ms) : undefined +} + +/** Acquire/keep a Lease, notifying callers across leadership changes. */ +export class LeaderElector { + private readonly coordination: k8s.CoordinationV1Api + private readonly identity: string + private readonly leaseName: string + private readonly namespace: string + private readonly leaseDurationSeconds: number + private readonly renewDeadlineSeconds: number + private readonly retryPeriodSeconds: number + private readonly now: () => number + private onStartedLeading?: (fencing: Fencing) => void + private onStoppedLeading?: () => void + + private leading = false + private operationId = 0 + private lastRenew = 0 + private timer: NodeJS.Timeout | undefined + + constructor(options: LeaderOptions) { + if (options.coordination !== undefined) { + this.coordination = options.coordination + } else { + const kc = new k8s.KubeConfig() + kc.loadFromCluster() + this.coordination = kc.makeApiClient(k8s.CoordinationV1Api) + } + this.identity = options.identity ?? process.env.POD_NAME ?? hostname() + this.leaseName = options.leaseName ?? 'dsh-orchestrator' + this.namespace = options.namespace + this.leaseDurationSeconds = options.leaseDurationSeconds ?? 15 + this.renewDeadlineSeconds = options.renewDeadlineSeconds ?? 10 + this.retryPeriodSeconds = options.retryPeriodSeconds ?? 2 + this.now = options.now ?? Date.now + this.onStartedLeading = options.onStartedLeading + this.onStoppedLeading = options.onStoppedLeading + } + + get isLeader(): boolean { + return this.leading + } + + /** Wire (or rewire) leadership callbacks. The controller owns the reaction, + * not the elector, so it attaches itself here. */ + setLeadershipCallbacks(onStarted?: (fencing: Fencing) => void, onStopped?: () => void): void { + this.onStartedLeading = onStarted + this.onStoppedLeading = onStopped + } + + /** The current fencing token (valid only while {@link isLeader}). */ + get fencing(): Fencing { + return { holder: this.identity, operationId: this.operationId } + } + + /** Start the acquire/renew loop. Resolves once started (does not wait for leadership). */ + async start(): Promise<void> { + if (this.timer !== undefined) return + await this.tryAcquire() + } + + /** Stop the loop and yield leadership if held. */ + stop(): void { + if (this.timer !== undefined) { + clearTimeout(this.timer) + this.timer = undefined + } + if (this.leading) { + this.leading = false + this.onStoppedLeading?.() + } + } + + /** One acquire/renew round, rescheduling itself with retry/backoff. */ + private async tryAcquire(): Promise<void> { + try { + if (this.leading) { + await this.renew() + } else { + await this.acquire() + } + } catch { + // Transient API/network failure: retry. Leadership is only lost after the + // renewDeadline elapses without a successful renew, checked in the loop. + } + this.scheduleNext() + } + + private scheduleNext(): void { + if (this.timer !== undefined) return + // When leader, renew well inside renewDeadline; otherwise back off and retry. + const delay = this.leading ? Math.min(this.renewDeadlineSeconds / 2, this.retryPeriodSeconds) : this.retryPeriodSeconds + this.timer = setTimeout(() => { + this.timer = undefined + if (this.leading && this.now() - this.lastRenew > this.renewDeadlineSeconds * 1000) { + // Too long without a successful renew → another holder may have taken over. + this.leading = false + this.onStoppedLeading?.() + } + void this.tryAcquire() + }, delay * 1000) + this.timer.unref?.() + } + + private lease(now: number, transitions: number): k8s.V1Lease { + return { + apiVersion: 'coordination.k8s.io/v1', + kind: 'Lease', + metadata: { name: this.leaseName, namespace: this.namespace }, + spec: { + holderIdentity: this.identity, + leaseDurationSeconds: this.leaseDurationSeconds, + acquireTime: new k8s.V1MicroTime(now), + renewTime: new k8s.V1MicroTime(now), + leaseTransitions: transitions, + }, + } + } + + private async acquire(): Promise<void> { + try { + await this.coordination.createNamespacedLease({ + namespace: this.namespace, + body: this.lease(this.now(), 0), + }) + this.becomeLeader(0, this.now()) + } catch (err) { + if ((err as { code?: number }).code !== 409) throw err + // Lease exists — take over only if the holder's renew time is stale. + const current = await this.coordination.readNamespacedLease({ + name: this.leaseName, + namespace: this.namespace, + }) + const holder = current.spec?.holderIdentity + const renew = toMillis(current.spec?.renewTime) + if (holder === this.identity) { + // We already hold it (e.g. after a restart) — renew, then resume leading. + await this.renew() + this.becomeLeader(current.spec?.leaseTransitions ?? 0, this.now()) + return + } + const expired = this.now() - renew > this.leaseDurationSeconds * 1000 + if (!expired) return // a live leader holds it; back off + const transitions = (current.spec?.leaseTransitions ?? 0) + 1 + const now = this.now() + // `patchNamespacedLease` uses JSON Patch (an array); a full replace with a + // resourceVersion gives the optimistic-concurrency takeover we want. + await this.coordination.replaceNamespacedLease({ + name: this.leaseName, + namespace: this.namespace, + body: { + apiVersion: 'coordination.k8s.io/v1', + kind: 'Lease', + metadata: { name: this.leaseName, namespace: this.namespace, resourceVersion: current.metadata?.resourceVersion }, + spec: { + holderIdentity: this.identity, + leaseDurationSeconds: this.leaseDurationSeconds, + acquireTime: new k8s.V1MicroTime(now), + renewTime: new k8s.V1MicroTime(now), + leaseTransitions: transitions, + }, + }, + }) + this.becomeLeader(transitions, now) + } + } + + private async renew(): Promise<void> { + const now = this.now() + // Always read the latest lease so we preserve holder/acquireTime/transitions + // and bump only renewTime. + const current = await this.coordination.readNamespacedLease({ + name: this.leaseName, + namespace: this.namespace, + }) + await this.coordination.replaceNamespacedLease({ + name: this.leaseName, + namespace: this.namespace, + body: { + apiVersion: 'coordination.k8s.io/v1', + kind: 'Lease', + metadata: { name: this.leaseName, namespace: this.namespace, resourceVersion: current.metadata?.resourceVersion }, + spec: { + holderIdentity: current.spec?.holderIdentity ?? this.identity, + leaseDurationSeconds: this.leaseDurationSeconds, + acquireTime: toMicroTime(current.spec?.acquireTime), + renewTime: new k8s.V1MicroTime(now), + leaseTransitions: current.spec?.leaseTransitions ?? 0, + }, + }, + }) + this.lastRenew = now + } + + private becomeLeader(operationId: number, now: number): void { + const wasLeader = this.leading + this.leading = true + this.operationId = operationId + this.lastRenew = now + if (!wasLeader) this.onStartedLeading?.({ holder: this.identity, operationId }) + } +} diff --git a/src/supervisor/orchestrator.ts b/src/supervisor/orchestrator.ts new file mode 100644 index 0000000..2a32015 --- /dev/null +++ b/src/supervisor/orchestrator.ts @@ -0,0 +1,1133 @@ +/** + * Per-user DSH supervisor: a resident main DSH plus an **on-demand** watchdog. + * + * The watchdog is not spawned at launch; it is pulled up once when the main + * crashes (to repair) or when a post-restart command must be executed. This + * keeps the steady-state footprint at one process per active user while still + * providing crash repair + command handoff. The watchdog's agent-level + * repair/session-resume is harness-internal and deferred to real-harness + * integration (see docs/blueprint.md §4). + * @module dshs/supervisor/orchestrator + */ + +import { execFileSync, spawn, type ChildProcess, type StdioOptions } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { + appendFileSync, + chmodSync, + chownSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import type { ServerConfig } from '../config.js' +import { handoffPath, homeRoot, userRoot, workspaceRoot } from '../fs/workspace.js' +import { + breakerActive, + breakerCooldownMs, + breakerUntil, + decideCrashAction, + openBreaker, + pruneHistory, + type BreakerPolicy, + type BreakerState, + type CrashPolicyConfig, +} from './crash-policy.js' +import { createPortGuard, type PortGuard } from './firewall.js' +import { findFreePort, scrubEnv } from './spawn.js' +import { + AlreadyRunningError, + CrashBreakerOpenError, + type Endpoint, + type Instance, + type InstanceRole, + type InstanceStatus, + type Spawner, + type UserStatus, +} from './spawner.js' + +// Re-exported so existing importers (routes) keep resolving from this module. +export { + AlreadyRunningError, + CrashBreakerOpenError, + type Instance, + type InstanceRole, + type InstanceStatus, + type UserStatus, +} + +/** Task given to the one-shot headless watchdog so it doesn't error on a + * missing task; executes any post-restart command from the handoff path. */ +const WATCHDOG_TASK = 'Read DSHS_HANDOFF_PATH. If it contains a JSON {"command": ...}, run that command. Then exit.' +/* ───────────────────────────────────────────────────────────────────────────── + * 档案 81 · R1-④:**实例内存配额按「已启用插件集合」推导**(原先写死 384M) + * + * 为什么改(2026-09-13 事故,实测): + * guest 启用 `dsh-univer-office` 后,其 gateway **单进程 RSS ≈390 MB**,而实例 cgroup 上限 + * 写死 384M ⇒ 实例起来即被 **cgroup OOM kill(exitCode 137)** ⇒ 页面「恢复 → 起来 → 又被杀」 + * 死循环(每 ~35s 一次 GET /)。**根因不是恢复逻辑,而是配额与插件集合脱钩。** + * + * 规则:`基准 + Σ(插件内存预估)`,再加下限/上限封顶;V8 老生代上限跟随配额(留 96M 余量)。 + * · 预估表口径承接档案 75/67(插件评估的「资源成本」维度); + * · 未识别的插件按 0 计(保守:宁可少给,也不虚高); + * · 上限 1024M 是单实例硬顶(宿主 1870M,并发数由 maxIdleInstances + available 决定)。 + * 回滚:把下方 PLUGIN_MEM_MB 清空即可退回「MIN_MEM_MB」单一档。 + * ──────────────────────────────────────────────────────────────────────────── */ +const PLUGIN_MEM_MB: Record<string, number> = { + 'dsh-univer-office': 512, // 实测其 gateway 单进程 ≈390 MB(2026-09-13 14:44 384→512:544 仍欠 ~50-120 MiB,实测 gateway 起不来;512 ⇒ 配额 672) + 'dsh-plugin-mcn-suite': 128, // 7 插件整合包(含 xlsx 等重型依赖) +} +const BASE_MEM_MB = 160 // dsh 基座(实测稳态 106~117、峰值约 145) +const MIN_MEM_MB = 384 // 不低于原写死值(等于零回归) +const MAX_MEM_MB = 1024 +const HEAP_HEADROOM_MB = 96 // 配额里留给非堆部分(native/栈/共享) + +/** 读 profile 的 bundles,推导该实例应有的 cgroup 上限(MB)。读不到就用下限。 */ +function instanceMemMb(profileDir: string): number { + try { + const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { + dsh?: { profile?: { bundles?: string[] } } + } + const bundles = pkg.dsh?.profile?.bundles ?? [] + let mb = BASE_MEM_MB + for (const b of bundles) mb += PLUGIN_MEM_MB[b] ?? 0 + return Math.min(Math.max(mb, MIN_MEM_MB), MAX_MEM_MB) + } catch { + return MIN_MEM_MB + } +} + +/** V8 老生代上限跟随配额(不再写死 160)。 */ +function heapMbFor(memMb: number): number { + return Math.max(128, Math.min(256, memMb - HEAP_HEADROOM_MB)) +} + +/** + * 把「堆上限」从既有 NODE_OPTIONS 里摘掉,换成按配额推导的值。 + * + * 为什么不让 env 决定堆:`DSH_INSTANCE_NODE_OPTIONS` 曾在平台进程 env 里被设成 + * `--max-old-space-size=160`(档案 74 的下调),而该值**不在** systemd 的 manager env / unit 里 + * (2026-09-13 实测:`systemctl unset-environment` 后进程 env 里仍有)⇒ 靠改配置改不动它。 + * 规则改为:**堆由代码按配额推导**(与 MemoryMax 同一口径,不可能再对不上); + * env 仍然有效,但只承载**其它**选项(如 `--trace-gc`)。要强行指定堆,用 `DSHS_HEAP_MB_OVERRIDE`。 + */ +function withHeap(base: string | undefined, memMb: number): string { + const override = Number(process.env.DSHS_HEAP_MB_OVERRIDE ?? '') + const mb = Number.isFinite(override) && override > 0 ? override : heapMbFor(memMb) + const rest = (base ?? '') + .split(/\s+/) + .filter((t) => t !== '' && !t.startsWith('--max-old-space-size')) + rest.push(`--max-old-space-size=${mb}`) + return rest.join(' ') +} + + +/** + * Local backend: owns the lifecycle of per-user DSH process pairs via + * child_process. State is in-memory. Implements {@link Spawner}. + */ +export class LocalSpawner implements Spawner { + private readonly mains = new Map<string, Instance>() + private readonly watchdogs = new Map<string, Instance>() + private readonly children = new Map<string, ChildProcess>() + private readonly restartTimers = new Map<string, NodeJS.Timeout>() + /** 熔断窗口内的自动重启时间戳(档案 20)。 */ + private readonly crashHistory = new Map<string, number[]>() + /** 自上次稳定运行以来的连续重启次数(驱动指数退避,档案 20)。 */ + private readonly crashStreak = new Map<string, number>() + /** 稳定判定定时器:连续运行达 `crashStableMs` 即视为已恢复(档案 20)。 */ + private readonly stableTimers = new Map<string, NodeJS.Timeout>() + + /** + * 档案 78:熔断冷却状态(**跨轮存活** —— `resetCrashState()` 刻意不清它)。 + * 冷却期内拒绝**隐式**启动;冷却过后只给一次干净预算。key = userId。 + */ + private readonly breaker = new Map<string, BreakerState>() + /** Last activity per user (ms epoch) — fed by proxied traffic / enter. */ + private readonly lastActive = new Map<string, number>() + private readonly reapTimer: NodeJS.Timeout | undefined + + private readonly portGuard: PortGuard | undefined + + constructor( + private readonly config: ServerConfig, + /** Resolve the user's own API key (decrypted); null = user has none. */ + private readonly resolveApiKey: (userId: string) => Promise<string | null>, + /** Resolve the user's assigned Linux uid (falls back to hash when unset). */ + private readonly resolveUid: (userId: string) => Promise<number>, + ) { + this.portGuard = createPortGuard(config.portGuard) + // 档案 30:portal 启动即清掉遗留实例 scope(重启后无法接管)。 + this.cleanAllStaleScopes() + // Local-mode idle reap: periodically stop mains that are idle past the TTL, + // then cap the resident count (LRU by last activity). Only armed when at + // least one of the two rules is enabled. The timer is unref'd so it never + // keeps the process alive by itself. + if ( + config.idleReapIntervalSeconds > 0 && + (config.instanceIdleTtlSeconds > 0 || config.maxIdleInstances > 0) + ) { + this.reapTimer = setInterval(() => void this.reapOnce(), config.idleReapIntervalSeconds * 1000) + this.reapTimer.unref() + } + } + + /** Record activity so a warm instance is not treated as idle. */ + touch(userId: string): void { + this.lastActive.set(userId, Date.now()) + } + + /** Idle-reap pass: ① stop mains idle past the TTL; ② cap resident count by + * least-recent activity. Only stops processes — never touches user data. */ + private async reapOnce(): Promise<void> { + const now = Date.now() + const ttlMs = this.config.instanceIdleTtlSeconds * 1000 + const max = this.config.maxIdleInstances + const reaped = new Set<string>() + const reaperLog = (userId: string, reason: string): void => { + const idleSecs = Math.round((now - (this.lastActive.get(userId) ?? now)) / 1000) + process.stderr.write(`[idle-reap] stop ${userId} (${reason}; idle ${idleSecs}s)\n`) + } + if (ttlMs > 0) { + for (const userId of [...this.mains.keys()]) { + const last = this.lastActive.get(userId) ?? now + if (now - last > ttlMs) { + reaped.add(userId) + reaperLog(userId, `idle>${this.config.instanceIdleTtlSeconds}s`) + await this.stop(userId) + } + } + } + if (max > 0) { + const running = [...this.mains.keys()].filter((userId) => !reaped.has(userId)) + if (running.length > max) { + const excess = running + .sort((a, b) => (this.lastActive.get(a) ?? 0) - (this.lastActive.get(b) ?? 0)) + .slice(0, running.length - max) + for (const userId of excess) { + reaperLog(userId, `cap>${max} lru`) + await this.stop(userId) + } + } + } + } + + /** Spawn the resident main DSH for a user (watchdog is pulled up on demand). */ + async launch( + userId: string, + folder: string, + patch?: string, + opts?: { force?: boolean }, + ): Promise<Instance> { + if (this.mains.has(userId)) throw new AlreadyRunningError(userId) + // 档案 78:冷却期内拒绝**隐式**启动(`/api/dsh/enter` 与「用户选文件夹」都会走到这里)。 + // 原实现里 circuit-open 会 resetCrashState() 清空预算 ⇒ 只要有人(用户 F5、注入脚本自愈、 + // 脚本直铺)不断重试,崩溃循环就能无限重复。`force: true` 供**平台自身**的显式操作绕开。 + if (opts?.force === true) this.clearBreaker(userId, 'forced-launch') + else this.assertBreakerClosed(userId) + // 显式启动 = 新一轮:清空崩溃计数(用户重新进入后应拿到完整重试预算,档案 20)。 + this.resetCrashState(userId) + // 档案 18 v3 收尾:启动前补齐"受限目录选择器"(幂等;首登 profile 尚未创建时会自行跳过)。 + this.ensurePickerProfile(userId) + this.touch(userId) + const instance = await this.spawnInstance(userId, 'main', folder, patch) + // 等待 dsh web 打印 launch token,保证返回的打开 URL 可直接通过浏览器认证 + await this.waitForLaunchToken(instance) + return instance + } + + /** + * 档案 18 v3 收尾(第二层自愈):确保该用户的 profile 具备"受限目录选择器" + * (平台段 + 插件包)—— 复用平台脚本 `ensure-workspace-picker.cjs --user-id <id>`(幂等)。 + * 异步 fire-and-forget:不阻塞启动;失败只记日志(第一层由 provisioning 钩子兜底)。 + * 覆盖场景:profile 被清空/重建、手工删掉平台段、插件被卸载。 + */ + private ensurePickerProfile(userId: string): void { + const script = + process.env.DSH_PICKER_ENSURE_SCRIPT ?? + join(process.cwd(), 'poc', 'workspace-scoped-picker', 'ensure-workspace-picker.cjs') + if (!existsSync(script)) return + try { + const child = spawn('node', [script, '--user-id', userId], { detached: true, stdio: 'ignore' }) + child.on('error', (err) => { + process.stderr.write(`[picker-ensure] spawn failed: ${err.message}\n`) + }) + child.unref() + } catch (err) { + process.stderr.write( + `[picker-ensure] failed for ${userId}: ${err instanceof Error ? err.message : String(err)}\n`, + ) + } + } + + /** Stop the current main (clean) and respawn it with the same folder/patch. */ + async restartMain(userId: string): Promise<Instance | undefined> { + const current = this.mains.get(userId) + if (current === undefined) return undefined + // 人工/接口触发的重启不是崩溃:重置计数,避免占用熔断预算(档案 20)。 + this.resetCrashState(userId) + this.killInstance(userId, current) + this.touch(userId) + const instance = await this.spawnInstance(userId, 'main', current.folder, current.patch) + await this.waitForLaunchToken(instance) + return instance + } + + /** + * 档案 34:重启实例并**探活**(功能插件启用后的可用性判定)。 + * + * `restartMain` 内部已等 launch token(20s),但它吞掉了结论;这里叠加一个稳定窗口 + * 并给出显式判定,供调用方决定「保留该插件」还是「回滚 + 标记为不兼容」。 + * 返回 `ok:false` 时,调用方必须回滚——否则插件会把实例拖进崩溃循环(档案 25 教训)。 + * 人工/接口触发的重启不计入熔断预算(`restartMain` 内已 resetCrashState)。 + */ + /** + * 档案 34:重启实例并**探活**(功能插件启用后的可用性判定)。 + * + * 判定链:先确保有实例(无则按 /api/dsh/enter 同路径 launch 一个——否则 restartMain 返回 + * undefined,会把无辜插件误判为不兼容)→ 重启 → **轮询**到 launch token 且状态 running → + * 再过稳定窗口确认没闪崩。任一步失败即返回 ok:false 并给出真实原因(例如 dsh 的 + * `duplicate loader entry id`),调用方据此回滚并标记该插件。 + * + * 预算:launch/restartMain 内部各等 20s,这里再给 `budgetMs`(默认 60s)轮询——冷启动较慢的 + * 插件不该被误判(实测 2.5s 固定等待会把好插件判死)。 + */ + async restartAndProbe(userId: string, budgetMs = 60000): Promise<{ ok: boolean; reason: string }> { + if (this.mains.get(userId) === undefined) { + try { + // 档案 78:插件启用/隔离是平台自身的显式操作 → force 绕开熔断冷却 + // (否则「插件把实例搞崩 → 熔断 → 想自动禁用该插件」会被自己的冷却挡住)。 + await this.launch(userId, workspaceRoot(userRoot(this.config.dataRoot, userId)), undefined, { + force: true, + }) + } catch (err) { + return { ok: false, reason: `实例启动异常:${err instanceof Error ? err.message : String(err)}` } + } + } else { + const inst = await this.restartMain(userId) + if (inst === undefined) return { ok: false, reason: "实例未启动" } + await this.spawnWatchdog(userId) + } + + const deadline = Date.now() + budgetMs + let lastReason = "" + while (Date.now() < deadline) { + const m = this.mains.get(userId) + if (m === undefined) return { ok: false, reason: "重启后实例消失" } + if (m.status === "crashed" || m.status === "stopped") return { ok: false, reason: m.lastError ?? "实例启动后崩溃" } + if (m.launchToken !== undefined) { + // 稳定窗口:拿到 token 后立刻崩的插件也算失败(避免"闪一下就死"被当作成功)。 + await new Promise((resolve) => setTimeout(resolve, 2500)) + const a = this.mains.get(userId) + if (a === undefined) return { ok: false, reason: "重启后实例消失" } + if (a.status === "crashed" || a.status === "stopped") { lastReason = a.lastError ?? "实例启动后崩溃"; continue } + if (a.launchToken !== undefined) return { ok: true, reason: "" } + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + return { ok: false, reason: lastReason !== "" ? lastReason : "实例启动超时(未产出 launch token)" } + } + + /** Restart every running main so a swapped global API key takes effect. */ + async restartAllMains(): Promise<void> { + for (const userId of [...this.mains.keys()]) { + try { + await this.restartMain(userId) + } catch (err) { + // One user's spawn failure must not abort the broadcast restart. + console.error(`restartAllMains: restart ${userId} failed`, err) + } + } + } + + /** Spawn a one-shot watchdog for the user's current main (repair / execute). */ + async spawnWatchdog(userId: string): Promise<Instance | undefined> { + if (!this.config.enablePatch) return undefined // watchdog needs the runtime patch + if (this.watchdogs.has(userId)) return this.watchdogs.get(userId) + const main = this.mains.get(userId) + if (main === undefined) return undefined + return await this.spawnInstance(userId, 'watchdog', main.folder, main.patch) + } + + /** Current main + watchdog for a user. */ + async status(userId: string): Promise<UserStatus> { + return { main: this.mains.get(userId), watchdog: this.watchdogs.get(userId) } + } + + /** Endpoint the proxy forwards to (local → the running main's loopback port). */ + async endpointFor(userId: string): Promise<Endpoint | undefined> { + const port = this.mains.get(userId)?.port + return port === undefined ? undefined : { host: '127.0.0.1', port } + } + + /** 等待该用户 main 实例打印 launch token(本地模式启动完成信号),供 enter 复用分支 + * 在返回打开 URL 前调用;无实例/已崩溃/已停则立即返回。 */ + async waitForLaunchTokenForUser(userId: string, timeoutMs = 20000): Promise<void> { + const main = this.mains.get(userId) + if (main === undefined) return + await this.waitForLaunchToken(main, timeoutMs) + } + + /** Stop both processes for a user (cancelling any pending restart). */ + async stop(userId: string): Promise<void> { + const timer = this.restartTimers.get(userId) + if (timer !== undefined) { + clearTimeout(timer) + this.restartTimers.delete(userId) + } + const main = this.mains.get(userId) + const watchdog = this.watchdogs.get(userId) + if (main !== undefined) this.killInstance(userId, main) + if (watchdog !== undefined) this.killInstance(userId, watchdog) + this.mains.delete(userId) + this.watchdogs.delete(userId) + this.lastActive.delete(userId) + this.resetCrashState(userId) + } + + /** Stop every tracked process on shutdown. */ + async teardown(): Promise<void> { + if (this.reapTimer !== undefined) clearInterval(this.reapTimer) + for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId) + } + + /** No-op: local mode has no sidecar — the control plane owns the volume. */ + async ensureFileService(_userId: string): Promise<void> {} + + private handoffPath(userId: string): string { + return handoffPath(userRoot(this.config.dataRoot, userId)) + } + + /** + * 预置默认工作区(幂等,仅在工作区列表为空时写入): + * 把 `<userRoot>/ws` 以短标题「我的工作区」写进 `<home>/storages/workspace.json`, + * 使新用户/清空后无需手动选择工作区即可开始会话;已有工作区时**不覆盖**用户现状。 + * 失败不阻断启动(只记日志)。 + */ + private async seedDefaultWorkspace(userId: string): Promise<void> { + const root = userRoot(this.config.dataRoot, userId) + const dir = join(homeRoot(root), 'storages') + const file = join(dir, 'workspace.json') + try { + mkdirSync(dir, { recursive: true }) + let existing: { global?: { workspaceIds?: unknown } } | undefined + try { + existing = JSON.parse(readFileSync(file, 'utf8')) as { global?: { workspaceIds?: unknown } } + } catch { + existing = undefined + } + const ids = existing?.global?.workspaceIds + if (Array.isArray(ids) && ids.length > 0) return // 已有工作区 → 尊重用户现状 + const ws = workspaceRoot(root) + const id = randomUUID() + const now = new Date().toISOString() + const seed = { + unit: { name: 'workspace', version: 2 }, + global: { initialized: true, workspaceIds: [id], archivedSessionIds: [] }, + tables: { + workspaces: { + [id]: { path: ws, title: '我的工作区', sessionIds: [], createdAt: now, updatedAt: now }, + }, + }, + } + writeFileSync(file, JSON.stringify(seed, null, 2) + '\n') + const uid = await this.resolveUid(userId) + chownSync(file, uid, uid) // 实例以该 uid 运行,需可读写 + chownSync(dir, uid, uid) + process.stderr.write(`[seed-workspace] ${userId} → ${ws}\n`) + } catch (err) { + process.stderr.write( + `[seed-workspace] failed for ${userId}: ${err instanceof Error ? err.message : String(err)}\n`, + ) + } + } + + /** Materialize the rendered patch so the dsh CLI can `--patch <file>` it. + * Per role, so a watchdog spawning alongside its main never races the file. */ + private writePatch(userId: string, role: InstanceRole, patch: string): string { + const dir = join(userRoot(this.config.dataRoot, userId), 'patches') + mkdirSync(dir, { recursive: true }) + const path = join(dir, `${role}.yml`) + writeFileSync(path, patch) + return path + } + + private async baseEnv(userId: string): Promise<Record<string, string>> { + // 档案 81 · R1-④:堆上限跟随「已启用插件集合」推导出的配额(不再写死 160) + const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) + const root = userRoot(this.config.dataRoot, userId) + const home = homeRoot(root) + const workspace = workspaceRoot(root) + const apiKey = await this.resolveApiKey(userId) + return { + ...scrubEnv(process.env), + // HOME drives the child's directory picker default (homedir()); point it + // at the user's workspace so their folders show, not DSH's internal home. + HOME: workspace, + // DSH's own state (profiles/sessions/credentials) stays in `home`. + DSH_HOME: home, + // Shared read-only skill directory (dsh-skill-filesystem bundled layer, + // rank 600). Present only when configured. + ...(this.config.bundledSkillDir !== '' ? { DSH_BUNDLED_SKILL_DIR: this.config.bundledSkillDir } : {}), + // 档案 33:复用官方权限逻辑,只改「默认档位」。dsh-base 的 cordis.patch.yml 读该 env: + // sandbox mode = DSH_PERMISSION_MODE;approval policy = (mode === 'danger-full-access' ? 'never' : 'ask') + // 本机内核 5.10 无 Landlock、bwrap 后端在平台容器内探测失败 → 内置默认 workspace-write + // 会 fail-closed 拒绝执行任何 shell(P0)。改 danger-full-access = 不在实例内再叠加沙箱, + // 隔离交由平台容器(bwrap + uid + cgroup)负责,且不再逐条弹审批确认。 + // 用户仍可在实例「设置 → 权限」里用官方 UI 自行切回。运维可用同名 env 覆盖。 + DSH_PERMISSION_MODE: process.env.DSH_PERMISSION_MODE ?? 'danger-full-access', + // 档案 44:**冻结基础运行时版本**,禁止用户升级 Python / pip / node 造成版本漂移。 + // 已经天然成立的护栏:`/usr` 是 ro-bind → 实例内**改不了** `/usr/local/dsh-runtime/**` + // (pip 默认 install 直接被 Read-only 挡)、`npm/pnpm -g` 也失败、`$HOME/.local/bin` + // 不在实例 PATH 里(PATH 固定为 /usr/local/bin:/usr/bin:/bin)。 + // 唯一剩下的缝是 **Python 的 user-site**:一旦 pip 创建 `$HOME/.local/lib/python3.12/ + // site-packages`,它就会进入 sys.path 且**排在平台 site-packages 之前** → 用户装的同名包 + // 会盖住平台包(2026-09-11 实测:`import packaging` 拿到用户版 26.3)。故: + // · PYTHONNOUSERSITE=1 → user-site 不进 sys.path(平台包永不被盖) + // · PYTHONUSERBASE=/usr/local/dsh-runtime/.no-user-install → `pip install --user` + // 尝试写在只读位 → **明确报权限错**(而不是"装上了却不生效") + // 引导:需要额外依赖 → `pip install --target <ws>/.pylibs` + PYTHONPATH,或自建 venv(不改平台)。 + PYTHONNOUSERSITE: '1', + PYTHONUSERBASE: '/usr/local/dsh-runtime/.no-user-install', + // 档案 58(2026-09-12):给实例内所有 node 进程设「内存上限 + 编译缓存」。 + // + // 为什么必须设 --max-old-space-size:实测一个刚起的实例私有内存 106~117 MiB、 + // 峰值 VmHWM 206~209 MiB,而 **V8 的默认堆上限是按宿主物理内存推算的** + // (本机 1870 MiB → heap_size_limit **960 MiB**),它**感知不到 cgroup 的 MemoryMax**。 + // 后果是内存真涨起来时先撞**内核 SIGKILL**(无优雅退出、无 stderr、日志查不到死因), + // 而不是 V8 自己触发 GC 并抛 JS 异常。设 256 MiB 的依据:dsh 实测老生代仅 27~30 MiB, + // 三倍余量足够;作用是把「失控上限」从 960 压到 256,让 V8 先 GC、不够就抛可诊断的 JS 错。 + // 该 env 会被实例内子进程继承(用户自己跑 node 也受限),用户可 `NODE_OPTIONS= node …` 覆盖。 + // 运维可用 DSH_INSTANCE_NODE_OPTIONS 整体覆盖(与 DSH_PERMISSION_MODE 同风格)。 + // + // 为什么设 NODE_COMPILE_CACHE(Node 22+ 官方特性;本机 v22.23.2 实测 + // `module.enableCompileCache` 为函数、可用):dsh 是 **223 个包 / 717 个 JS 文件 / + // 21.2 MB 源码**的大单体,实测私有内存里 **88 MiB 是 [anon] 段** —— 主要就是 V8 + // 为这些文件即时生成的机器码(code range)。给出缓存目录后编译产物落盘复用, + // 冷启动不必每次重编译,直接削启动开销与峰值。 + // 目录选 <userRoot>/home/.node-compile-cache:不放 ws(会被 ws-cleanup 按平台产物清理)、 + // 不放 /tmp(实例的 /tmp 是私有的,每次重建会丢)。 + // 档案 81 · R1-④:堆上限跟随配额(160 是档案 74 的旧下调值,已不再写死) + NODE_OPTIONS: withHeap(process.env.DSH_INSTANCE_NODE_OPTIONS, memMb), + NODE_COMPILE_CACHE: join(home, '.node-compile-cache'), + // 档案 76:dsh-univer-office 的 bundled Gateway 默认监听 TCP loopback,但本平台实例的 uid + // 被 nft 拒绝连 127.0.0.0/8(**连它自己 spawn 的 gateway 也连不上**)→ 该插件改用 unix + // socket。由平台 env 控制:**不设 = 插件保持原 TCP 行为**(回滚只需删掉这个 env)。 + ...(process.env.DSH_INSTANCE_UNIVER_SOCKET === undefined + ? {} + : { UNIVER_DSH_GATEWAY_SOCKET: process.env.DSH_INSTANCE_UNIVER_SOCKET }), + // Each user's own key; omit entirely when unset so the harness reports + // "no key" instead of a header-hostile value. + ...(apiKey !== null ? { DEEPSEEK_API_KEY: apiKey } : {}), + } + } + + private async spawnInstance(userId: string, role: InstanceRole, folder: string, patch?: string): Promise<Instance> { + const isMain = role === 'main' + const port = isMain ? await findFreePort() : undefined + const instance: Instance = { + id: randomUUID(), + userId, + role, + folder, + port, + status: 'starting', + patch, + } + const map = role === 'main' ? this.mains : this.watchdogs + map.set(userId, instance) + + // 注:曾在此预置默认工作区(seedDefaultWorkspace),但实测 dsh 启动时会按其注册表 + // 不变量(order 与 table 一致性)把外部写入的条目清理掉 → 该做法无效,已移除调用 + // (方法保留但不再使用,见 2026-09-11 记录)。工作区改由用户经官方 picker 自建。 + + const [command = 'dsh', ...args] = this.config.dshCommand + const launchArgs = ['--profile', role === 'main' ? 'web' : 'headless'] + // --patch is a launcher flag and must precede any app/inner args (--host/--port + // for web, the task string for headless): dsh forwards the first unrecognized + // token onward, so a trailing --patch reaches the app as an unknown option. + // Off by default so the child boots even on older dsh versions. + if (this.config.enablePatch && patch !== undefined) { + launchArgs.push('--patch', this.writePatch(userId, role, patch)) + } + if (role === 'main') { + launchArgs.push('--host', '127.0.0.1', '--port', String(port)) + } else { + launchArgs.push(WATCHDOG_TASK) + } + + // 档案 81 · R1-④:本实例的内存配额由「已启用插件集合」推导(算一次,传给 spawnAsUser 用) + + const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) + + const env: Record<string, string> = { + ...(await this.baseEnv(userId)), + DSHS_ROLE: role, + DSHS_HANDOFF_PATH: this.handoffPath(userId), // both roles: main writes, watchdog reads + } + if (isMain) { + env.DSHS_PORT = String(port) + } + + // 档案 30:单实例保证升到 OS 层 —— spawn 前清掉该 uid 名下未纳管的残留 scope, + // 防止 portal 重启后旧 scope 变孤儿、与新实例并存(共享 profile → 会话/settings 冲突)。 + if (this.config.isolationMode === 'account') { + this.cleanStaleScopes(await this.resolveUid(userId)) + } + const { child, unit } = await this.spawnAsUser(userId, command, [...args, ...launchArgs], { cwd: folder, env }, role) + if (unit !== undefined) instance.unit = unit + this.trackChild(userId, instance, child) + return instance + } + + /** Spawn the child inside a bwrap sandbox (mount/pid isolation) with setpriv + * uid isolation, under a systemd-run scope that enforces cgroup resource + * limits. 只读挂载 /usr /lib64 /etc,只暴露用户根目录 + 每用户独立 /tmp, + * 实例读不到宿主其他路径(含其他用户、DB、凭据)。返回 child + scope 名。 */ + private async spawnAsUser( + userId: string, + command: string, + args: string[], + options: { cwd: string; env: Record<string, string> }, + role: InstanceRole = 'main', + ): Promise<{ child: ChildProcess; unit?: string }> { + const stdio: StdioOptions = ['ignore', 'pipe', 'pipe'] + if (this.config.isolationMode !== 'account') { + return { child: spawn(command, args, { ...options, stdio }) } + } + const uid = await this.resolveUid(userId) + const root = userRoot(this.config.dataRoot, userId) + // 每用户独立 tmp(1777):bwrap 的 --tmpfs 权限是 755,dsh spill-local 需 mkdtemp /tmp + const tmpDir = join(root, 'tmp') + mkdirSync(tmpDir, { recursive: true }) + chmodSync(tmpDir, 0o1777) // mkdirSync 的 mode 受 umask 掩码,显式 chmod 保证 1777 + + const bwrapArgs = [ + '--ro-bind', '/usr', '/usr', + '--ro-bind', '/lib64', '/lib64', + // 2026-09-11 修复(实例内 AI 能力核查):合成根缺 /bin /sbin /lib 会导致 + // dsh 的沙箱后端探针 execvp 失败(dsh-sandbox-local 在 Linux 优先用 bwrap, + // Landlock 需内核>=5.13,al8 为 4.19 故不可用)→ SANDBOX_UNAVAILABLE → + // 实例内 bash 工具被整体拒绝("refusing to run the command unconfined")。 + // 用符号链接补齐标准布局(不扩大可见面:仅 /bin /sbin /lib 三个链接)。 + '--symlink', 'usr/bin', '/bin', + '--symlink', 'usr/sbin', '/sbin', + '--symlink', 'usr/lib', '/lib', + // 2026-09-11(档案 39 · 用户要求「实例只能读到自己那份」): + // /etc 由「整体只读挂载」收窄为「空 tmpfs + 文件白名单」。 + // + // 动机:整体挂载会让每个实例读走 /etc 下 576 个 others-readable 文件,其中包含 + // **平台情报**——/etc/systemd/system/dsh-*.{service,path}(4)、 + // /etc/nftables-dsh-egress.nft(出网护栏规则全文)、/etc/cron.d/dsh-*(2)、 + // /etc/letsencrypt/renewal/*.conf。等于把平台架构与护栏策略交给每个租户。 + // (凭据类 dshs.env / shadow / gshadow / sudoers 本就 600/0000,读不到, + // 本次不涉及;/usr 经审计无任何凭据。) + // + // 白名单 = 运行时实测必需的最小集(档案 39 §验证记录): + // node / dsh --version / os.userInfo / DNS / node-TLS / curl 200 全通, + // `dsh --profile web --dump-config` 收窄前后 **544 行 / 170 个 @deepseek-ai 插件逐字一致**。 + // 效果:/etc 可见项 222 → 12,可读文件 576 → 26,平台情报 4/1/2/8 → 0/0/0/0。 + // 注意:symlink 必须绑其 **realpath 目标到 symlink 原路径**,否则实例内是断链 + //(/etc/nsswitch.conf → /etc/authselect/nsswitch.conf、/etc/localtime → /usr/share/zoneinfo/…)。 + '--tmpfs', '/etc', + ...(() => { + const allow = [ + '/etc/ld.so.cache', // 动态链接器缓存(node 启动必需) + '/etc/ld.so.conf', + '/etc/passwd', // os.userInfo() / uid→name + '/etc/group', + '/etc/nsswitch.conf', // DNS 解析(symlink) + '/etc/hosts', + '/etc/resolv.conf', + '/etc/host.conf', + '/etc/services', + '/etc/localtime', // 时区(symlink) + '/etc/os-release', + '/etc/machine-id', + '/etc/pki/tls/certs', // CA bundle(curl / 系统 TLS) + '/etc/pki/ca-trust', + // 2026-09-11 修正(档案 39 补丁):**符号链接枢纽目录,必须整体挂**。 + // 漏掉它的后果(实测):`/usr/bin/python3 -> /etc/alternatives/python3 -> /usr/bin/python3.6`, + // 中间一跳在 /etc → 沙箱内断链 → **21 个命令静默失效**:python3 / python / pip3 / pip-3 / + // pydoc3 / python3-config / pyvenv-3 / easy_install-3 / unversioned-python / ld(→ld.bfd) / + // pax / print-* (lp,lpr,lpq,lprm,lpstat,cancel) / ifup / ifdown / lpc。 + // 其中 `python3`、`pip3`(装技能依赖)、`ld`(node-gyp 编译原生模块)都是我方场景的关键命令。 + // 该目录 33 项**全部指向 /usr 下的程序/man**(已只读挂载)→ **不含任何凭据或平台情报**, + // 挂它不扩大实质可见面,只是让 /usr 里已有程序的软链重新生效。 + // ⚠️ 通用教训:`/etc` 白名单化时,必须用探测器找出**所有穿过 /etc 的符号链接**,逐个覆盖: + // find /usr/bin /usr/sbin /usr/libexec /usr/local/bin -maxdepth 1 | while read f; do + // [ -L "$f" ] || continue; c=$f; n=0 + // while [ -L "$c" ] && [ $n -lt 10 ]; do t=$(readlink "$c") + // case $t in /*) c=$t;; *) c=$(dirname "$c")/$t;; esac + // case $c in /etc/*) echo "$f -> $c";; esac; n=$((n+1)); done + // done | sort -u + '/etc/alternatives', + // 2026-09-11 修正(同 alternatives 一类,**第二次踩**):**`/etc/ssl` 必须挂**。 + // 漏掉它的后果(guest 会话实证):宿主上 `/etc/ssl/certs/ca-bundle.crt` 存在, + // 但实例内 `/etc/ssl` 不存在 → **Python(走 FHS 默认 CA 路径)found 不到 CA** → + // `URLError(SSLCertVerificationError: self-signed certificate in certificate chain)`。 + // curl 之所以一直没事,是因为它读 `/etc/pki/tls/certs/ca-bundle.crt`(也在白名单里)。 + // `/etc/ssl` 只含 CA 证书(公开信息),**不含凭据/平台情报**,挂它不扩大实质可见面。 + // ⚠️ 判定准则再强调:**除"叶子文件"外,还要覆盖"整个目录类枢纽"** + // (`/etc/alternatives` 是软链枢纽、`/etc/ssl` 是 CA 根目录),否则会静默打断一整类工具。 + '/etc/ssl', + ] + const out: string[] = [] + for (const p of allow) { + let src = p + try { + src = realpathSync(p) // symlink → 解析到真实文件 + } catch { + continue // 该机器上不存在则跳过(--ro-bind-try 语义) + } + out.push('--ro-bind-try', src, p) + } + return out + })(), + '--dev', '/dev', '--proc', '/proc', + '--bind', tmpDir, '/tmp', + '--bind', root, root, + // 2026-09-11(档案 38 P0 修复,档案 40 收尾):**共享技能层必须真的挂进命名空间**。 + // `@deepseek-ai/dsh-skill-filesystem` 是在**实例内** `resolve(config.bundledSkillDir ?? + // process.env.DSH_BUNDLED_SKILL_DIR)` 之后**直接读盘**(lib/index.js:84,181),不是编排器 + // 推数据 → 光注入 env 没用,目录必须可见。此前只注入了 env 未挂载 → 档案 10/11 的共享 + // 技能层「投了也发现不了」(实例内 ls = No such file or directory)。 + // + // 权限**不扩大**(2026-09-11 以平台原样参数 + 本行实测): + // · mountinfo = `…/bundled-skills → 同名 ro,nosuid,nodev` → **精确叶子路径 + 只读** + // · `ls …/users/` **只列用户自己那一个**;读 DB = No such file;`touch` 挂载点 = Read-only + // · 对照组(不加本行)里父目录同样存在(bwrap 为用户 root 合成的),**不是本行新暴露的** + // ⛔ 绝不可写成挂父目录(`--ro-bind <dataRoot> <dataRoot>`)= 一次性把全部用户 home + + // DB + 凭据放进每个实例。改完必须逐字复核 args 并重跑可见面实测。 + // 必须放在 '--bind root root' **之后**(bwrap 后写覆盖前写)。 + ...(this.config.bundledSkillDir !== '' + ? (['--ro-bind-try', this.config.bundledSkillDir, this.config.bundledSkillDir] as string[]) + : []), + // 2026-09-11(档案 18 v3 收尾 / 档案 17 §P1):平台策略文件在实例内**只读**。 + // 威胁模型:用户可把 <userRoot>/home/profiles/web 加为工作区,随后用 bash 直接改写 + // cordis.patch.yml(去掉平台段 → 恢复全盘 picker)或 package.json(挂任意 bundle)→ + // 属"绕过平台策略"(跨租户仍不成立,uid/bwrap 隔离不变)。 + // 只读三个文件;**不动 cordis.yml**——实测 dsh 启动时会写它(01:16:22),ro 会导致启动异常。 + // 注意:必须放在 '--bind root root' **之后**(bwrap 后写覆盖前写)。 + ...(() => { + const profileDir = join(homeRoot(root), 'profiles', role === 'main' ? 'web' : 'headless') + const protectedFiles = ['cordis.patch.yml', 'package.json', 'pnpm-lock.yaml'] + const out: string[] = [] + for (const name of protectedFiles) { + const file = join(profileDir, name) + out.push('--ro-bind-try', file, file) + } + return out + })(), + '--unshare-pid', + '--chdir', options.cwd, + '--', 'setpriv', '--reuid', String(uid), '--regid', String(uid), '--clear-groups', command, ...args, + ] + + const unit = `dsh-${uid}-${randomUUID().slice(0, 8)}` + // 档案 81 · R1-④:cgroup 上限由「已启用插件集合」推导(原先写死 384M;见文件头注释的事故) + const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) + // 档案 58(2026-09-12):MemoryMax 512M → 384M。 + // 依据(全部来自实测,见 scripts/probe-instance-mem.cjs): + // · dsh 自身私有内存稳态 106~117 MiB、峰值约 145 MiB(= VmHWM 206 减去共享的 62 MiB + // node 运行时页);两个不同用户冷启动同量 → 这是**基座成本**而非用户行为。 + // · 384 - 145 = **239 MiB 留给用户任务**(python / ffmpeg / pip 编译原生模块), + // 覆盖绝大多数场景;而同样的重任务在 512 下也大概率超限,故不是本次新引入的风险。 + // 作用范围要看清:MemoryMax 是**上限而非预留**(systemd 不预扣),所以它**不决定并发数** + // (并发由宿主 2 核 / 1870 MiB 与实时 available 决定);降它的真实收益是 + // **收窄单实例失控(内存泄漏/重任务)时的破坏半径**,给同宿主其他实例留活路。 + // 若实例被 cgroup OOM kill:该实例的 scope 会非 0 退出 → 编排器按崩溃退避重启(档案 20), + // 并写结构化日志;只影响该租户,不波及其他实例(uid + cgroup 隔离)。 + // 回滚:把 384M 改回 512M,npm run build,drain + 重启 dshs。 + // 配套:baseEnv 里的 NODE_OPTIONS=--max-old-space-size=256 才是**主动**让 V8 提前 GC + // 的那一层;本行是兜底硬限。两者一起看才有意义。 + const sdArgs = [ + '--scope', '--unit', unit, + '-p', 'MemoryMax=' + memMb + 'M', // 档案 81 R1-④:按插件集合推导 + '-p', 'CPUQuota=150%', + '-p', 'TasksMax=128', + '--', 'bwrap', ...bwrapArgs, + ] + return { child: spawn('systemd-run', sdArgs, { ...options, stdio }), unit: `${unit}.scope` } + } + + /** 等待 dsh web 在 stdout 打印 launch token(最多 timeoutMs),用于拼装可直达的打开 URL。 */ + private async waitForLaunchToken(instance: Instance, timeoutMs = 20000): Promise<void> { + const deadline = Date.now() + timeoutMs + while (instance.launchToken === undefined && Date.now() < deadline) { + if (instance.status === 'crashed' || instance.status === 'stopped') return + await new Promise((resolve) => setTimeout(resolve, 150)) + } + } + + private trackChild(userId: string, instance: Instance, child: ChildProcess): void { + this.children.set(instance.id, child) + child.on('spawn', () => { + instance.status = 'running' + instance.pid = child.pid ?? undefined + // 稳定判定(档案 20):连续运行达 crashStableMs 视为已恢复 → 重置退避步数。 + if (instance.role === 'main') this.armStableReset(userId, instance) + if (instance.role === 'main' && instance.port !== undefined && this.portGuard !== undefined) { + try { + this.portGuard.install(instance.port) + } catch (error) { + // Fail closed: without the port guard a co-tenant could reach this + // DSH's loopback RPC directly. Kill the just-spawned child and mark + // the instance crashed rather than serve unguarded. + instance.status = 'crashed' + instance.lastError = error instanceof Error ? error.message : String(error) + child.kill('SIGKILL') + } + } + }) + child.stdout?.on('data', (chunk: Buffer) => { + const text = chunk.toString() + process.stdout.write(text) + if (instance.role === 'main' && instance.launchToken === undefined) { + const m = /dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=([A-Za-z0-9_-]+)/.exec(text) + if (m !== null && m[1] !== undefined) instance.launchToken = m[1] + } + }) + let stderrTail = '' + child.stderr?.on('data', (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString()).slice(-2048) + // Also surface the child's stderr on the orchestrator's own stderr so a + // boot crash is visible in journald instead of only in lastError. + process.stderr.write(`[dsh-child ${instance.role}] ${chunk.toString()}`) + }) + child.on('error', (err) => { + instance.status = 'crashed' + instance.lastError = err.message + this.children.delete(instance.id) + }) + child.on('exit', (code) => { + instance.exitCode = code ?? undefined + this.children.delete(instance.id) + // Release the loopback port guard as soon as the main's process is gone + // (explicit stop, restart, and crash all funnel through this handler). + if (instance.role === 'main' && instance.port !== undefined) { + this.portGuard?.remove(instance.port) + } + const map = instance.role === 'main' ? this.mains : this.watchdogs + // Only act if this instance is still the current one (avoid a stale + // exit handler touching a freshly restarted instance). + if (map.get(userId)?.id !== instance.id) return + if (instance.status === 'stopped') { + map.delete(userId) + return + } + // A one-shot watchdog that finished cleanly is not restarted. + if (instance.role === 'watchdog' && code === 0) { + instance.status = 'stopped' + map.delete(userId) + return + } + instance.status = 'crashed' + instance.lastError = stderrTail.slice(-500) || undefined + if (instance.role === 'main') { + void this.spawnWatchdog(userId) + this.scheduleCrashRestart(userId, instance) + } else { + map.delete(userId) + } + }) + } + + /** 崩溃策略参数(均来自 ServerConfig,可用 env 覆盖)。 */ + private crashPolicy(): CrashPolicyConfig { + return { + baseDelayMs: this.config.restartBackoffMs, + maxDelayMs: this.config.restartBackoffMaxMs, + windowMs: this.config.crashWindowMs, + maxRestartsInWindow: this.config.crashMaxRestarts, + stableResetMs: this.config.crashStableMs, + } + } + + /** 档案 78:熔断冷却参数(均来自 ServerConfig,可用 env 覆盖)。 */ + private breakerPolicy(): BreakerPolicy { + return { + baseCooldownMs: this.config.crashBreakerCooldownMs, + maxCooldownMs: this.config.crashBreakerMaxCooldownMs, + } + } + + /** 结构化自愈日志(stderr → journald,可 grep `event=instance-restart`)。 */ + private crashLog(payload: Record<string, unknown>): void { + process.stderr.write(`[crash-restart] ${JSON.stringify(payload)}\n`) + } + + /** + * 档案 78:熔断**告警**(原来只写一行 stderr,等于没有告警)。 + * + * 双通道:① stderr → journald(`journalctl -u dshs | grep crash-breaker`); + * ② 追加 `/var/log/dsh-crash-breaker.log`(与其它巡检日志同习惯,便于定时巡检 / `tail`)。 + * **只告警、不处置** —— 要不要人工介入由人判断;平台只保证「有痕迹、可 grep、冷却有界」。 + * 写文件失败不影响主流程(best-effort)。 + */ + private alertBreaker(payload: Record<string, unknown>): void { + process.stderr.write(`[crash-breaker] ${JSON.stringify(payload)}\n`) + const logPath = process.env.DSH_CRASH_BREAKER_LOG ?? '/var/log/dsh-crash-breaker.log' + try { + appendFileSync(logPath, `${new Date().toISOString()} ${JSON.stringify(payload)}\n`) + } catch { + /* 无权限 / 路径不存在不致命:stderr 那条仍在 */ + } + } + + /** + * 档案 78:冷却期内拒绝隐式启动;冷却已过则清掉熔断态、放行一次干净尝试。 + * + * **刻意不放进 `resetCrashState()`** —— 那个函数被显式启动调用,而熔断态必须 + * **跨轮存活**,否则又回到「熔断即清预算 ⇒ 无限重来」。 + */ + private assertBreakerClosed(userId: string): void { + const b = this.breaker.get(userId) + if (b === undefined) return + const cfg = this.breakerPolicy() + const now = Date.now() + if (breakerActive(b, now, cfg)) { + const until = breakerUntil(b, cfg) + this.crashLog({ + event: 'crash-breaker-reject', + userId, + opens: b.opens, + cooldownUntil: until, + retryAfterMs: until - now, + }) + throw new CrashBreakerOpenError(userId, until, b.opens) + } + this.breaker.delete(userId) + this.crashLog({ event: 'crash-breaker-cooldown-expired', userId, opens: b.opens }) + } + + /** 清掉熔断态(平台自身显式操作:插件启用/隔离、admin 重启)。 */ + private clearBreaker(userId: string, reason: string): void { + if (this.breaker.delete(userId)) { + this.crashLog({ event: 'crash-breaker-cleared', userId, reason }) + } + } + + /** 档案 78 观测面:当前用户的熔断状态(无则 null)。 */ + breakerInfo(userId: string): { opens: number; openedAt: number; cooldownUntil: number } | null { + const b = this.breaker.get(userId) + if (b === undefined) return null + return { opens: b.opens, openedAt: b.openedAt, cooldownUntil: breakerUntil(b, this.breakerPolicy()) } + } + + /** 清空某用户的崩溃计数(显式启动 / 人工重启 / 停机时调用)。 */ + private resetCrashState(userId: string): void { + this.crashHistory.delete(userId) + this.crashStreak.delete(userId) + const stable = this.stableTimers.get(userId) + if (stable !== undefined) { + clearTimeout(stable) + this.stableTimers.delete(userId) + } + } + + /** main 连续运行达 crashStableMs 即视为恢复:重置退避步数(窗口历史保留)。 */ + private armStableReset(userId: string, instance: Instance): void { + const previous = this.stableTimers.get(userId) + if (previous !== undefined) clearTimeout(previous) + const timer = setTimeout(() => { + this.stableTimers.delete(userId) + if (this.mains.get(userId)?.id !== instance.id) return // 已被新实例取代 + if (instance.status !== 'running') return + if (this.crashStreak.delete(userId)) { + this.crashLog({ event: 'instance-stable', userId, stableMs: this.config.crashStableMs }) + } + }, this.config.crashStableMs) + timer.unref() + this.stableTimers.set(userId, timer) + } + + /** + * 插件加载失败自愈(2026-09-12 新增 · anysearch 事故修复)。 + * + * 背景:门户「功能管理」启用插件走 `restartAndProbe` —— 探活失败会 `restoreProfile` + + * 逐个隔离 + 自动禁用不兼容插件(档案 34)。但**不经过门户的路径**(`ensure-*.cjs` + * 脚本直铺、手工改 profile)没有这一层;某个插件若与当前 dsh 版本不兼容(典型报错 + * `failed to import loader entry <entry> (<pkg>)`),实例会每次启动即崩,而 + * crash-restart 只止损不禁用 → 用户陷入「进不去」的死循环。 + * + * 本方法补上这一层:从 `lastError` 解析出肇事的插件包名,把它从 profile 的 + * `package.json` bundles 摘掉,并清掉 `cordis.patch.yml` 里引用该包的条目 + * (否则 patch 中指向它的字段会报 `CONFIGURED_MISSING` 之类)。**只动 profile 的 + * 文本文件,不碰 node_modules**(重装很快,回滚 node_modules 反而易碎)。 + * + * @returns 摘掉的包名;未命中或修复失败返回 `undefined` + */ + private tryHealPluginFailure(userId: string, lastError: string | undefined): string | undefined { + if (lastError === undefined || lastError === '') return undefined + const m = /failed to import loader entry \S+ \(([^)]+)\)/.exec(lastError) + if (m === null) return undefined + const pkg = m[1] + // 防御:只接受合法包名形状,避免把异常文本当路径用。 + if (!/^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i.test(pkg)) return undefined + + const dir = join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web') + try { + const pkgPath = join(dir, 'package.json') + if (!existsSync(pkgPath)) return undefined + const parsed = JSON.parse(readFileSync(pkgPath, 'utf8')) as { + dsh?: { profile?: { bundles?: string[] } } + } + const bundles = parsed.dsh?.profile?.bundles + if (!Array.isArray(bundles) || !bundles.includes(pkg)) return undefined + + parsed.dsh!.profile!.bundles = bundles.filter((b) => b !== pkg) + writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + '\n') + + // patch 里引用该包的条目一并清掉:删 `name: "<pkg>"` 行 + 紧邻在它上面的 `- id: …` 行。 + const patchPath = join(dir, 'cordis.patch.yml') + if (existsSync(patchPath)) { + const lines = readFileSync(patchPath, 'utf8').split('\n') + const out: string[] = [] + for (const line of lines) { + const t = line.trim() + const quotes = `name: "${pkg}"` + const single = `name: '${pkg}'` + if (t === quotes || t === single || t === `name: ${pkg}`) { + const prev = out[out.length - 1] + if (prev !== undefined && /^\s*-\s*id:/.test(prev)) out.pop() + continue + } + out.push(line) + } + writeFileSync(patchPath, out.join('\n')) + } + return pkg + } catch (err) { + process.stderr.write( + `[plugin-heal] ${userId} ${pkg} 自愈失败:${err instanceof Error ? err.message : String(err)}\n`, + ) + return undefined + } + } + + /** + * 崩溃后的自动重启决策(档案 20 · 方案 A): + * 指数退避(base → max)+ 窗口熔断(窗口内超过上限则停止自动重启并标记 failed)。 + */ + private scheduleCrashRestart(userId: string, instance: Instance): void { + const cfg = this.crashPolicy() + const now = Date.now() + + // ── 插件加载失败自愈(2026-09-12 新增 · anysearch 事故修复)───────────── + // 走门户启用时,探活失败会回滚 profile 并自动禁用不兼容插件(档案 34)。但 + // **不经过门户的路径**(`ensure-*.cjs` 直铺、手工改 profile)没有这层保护:某个插件 + // 若与当前 dsh 版本不兼容,实例会每次启动即崩,而 crash-restart 只止损不禁用 + // → 用户陷入「进不去」的死循环(2026-09-12 admin 实例实测)。 + // 这里在重启决策**之前**先尝试摘掉肇事插件;摘掉成功就清空崩溃计数,给一次干净重启。 + const healedPlugin = this.tryHealPluginFailure(userId, instance.lastError) + if (healedPlugin !== undefined) { + this.crashLog({ event: 'plugin-auto-disabled', userId, plugin: healedPlugin, reason: 'plugin load failure' }) + this.resetCrashState(userId) + } + + const history = this.crashHistory.get(userId) ?? [] + const streak = this.crashStreak.get(userId) ?? 0 + const decision = decideCrashAction(history, streak, now, cfg) + + instance.restarts = (instance.restarts ?? 0) + 1 + instance.lastCrashedAt = now + + if (decision.action === 'circuit-open') { + // 熔断:停止自动重启,标记 failed;同时移除条目让用户重新 enter 时可重新 launch。 + // 档案 78 修正:**不再「白送」满额预算** —— 记一次**跨轮存活**的熔断态,冷却期内 + // 拒绝隐式启动(`assertBreakerClosed`)。原实现在这里 resetCrashState() 把窗口历史 + // 一并清空 ⇒ 下一次 enter 又是满额预算 ⇒ 崩溃循环可以无限重来。 + instance.status = 'failed' + this.mains.delete(userId) + this.resetCrashState(userId) + const bstate = openBreaker(this.breaker.get(userId), now) + this.breaker.set(userId, bstate) + const bcfg = this.breakerPolicy() + const payload = { + event: 'crash-loop-circuit-open', + userId, + opens: bstate.opens, + cooldownMs: breakerCooldownMs(bstate.opens, bcfg), + cooldownUntil: breakerUntil(bstate, bcfg), + windowMs: cfg.windowMs, + restartsInWindow: decision.windowRestarts, + maxRestartsInWindow: cfg.maxRestartsInWindow, + restarts: instance.restarts, + lastError: instance.lastError?.slice(0, 200), + } + this.crashLog(payload) + this.alertBreaker(payload) + return + } + + const nextHistory = pruneHistory(history, now, cfg.windowMs) + nextHistory.push(now) + this.crashHistory.set(userId, nextHistory) + this.crashStreak.set(userId, streak + 1) + this.crashLog({ + event: 'instance-restart', + userId, + attempt: decision.attempt, + delayMs: decision.delayMs, + restartsInWindow: decision.windowRestarts, + restarts: instance.restarts, + exitCode: instance.exitCode, + lastError: instance.lastError?.slice(0, 200), + }) + + const timer = setTimeout(() => { + this.restartTimers.delete(userId) + void this.spawnInstance(userId, instance.role, instance.folder, instance.patch) + }, decision.delayMs) + timer.unref() + this.restartTimers.set(userId, timer) + } + + /** 档案 30:清掉指定 uid 名下的残留 systemd scope(孤儿)。严格前缀匹配,不误伤门户自身。 */ + private cleanStaleScopes(uid: number): void { + this.stopScopesByPrefix(`dsh-${uid}-`) + } + + /** 档案 30:portal 启动时清一次 —— 编排器重启后无法接管已有 scope,统一清掉防孤儿。 */ + private cleanAllStaleScopes(): void { + this.stopScopesByPrefix('dsh-', /^dsh-\d+-[0-9a-f]+\.scope$/) + } + + /** 停止匹配前缀(可选正则)的 systemd scope;list/stop 失败一律静默跳过。 */ + private stopScopesByPrefix(prefix: string, re?: RegExp): void { + try { + const out = execFileSync('systemctl', ['list-units', '--type=scope', '--no-legend', '--plain'], { encoding: 'utf8', timeout: 10000 }) + for (const line of out.split('\n')) { + const name = line.trim().split(/\s+/)[0] + if (name === undefined || name === '') continue + if (!name.startsWith(prefix) || !name.endsWith('.scope')) continue + if (re !== undefined && !re.test(name)) continue + try { execFileSync('systemctl', ['stop', name], { timeout: 10000 }) } catch { /* ignore */ } + } + } catch { /* list-units 失败:跳过 */ } + } + + private killInstance(userId: string, instance: Instance): void { + instance.status = 'stopped' + // bwrap 沙箱模式下,scope 由 systemd 管理:systemctl stop 才能正确终止整个 + // scope(含 bwrap + dsh),child.kill 只杀 systemd-run 进程、不杀 scope 内进程。 + if (instance.unit !== undefined) { + try { + execFileSync('systemctl', ['stop', instance.unit], { timeout: 10000 }) + } catch { + // systemctl stop 失败时走 child.kill 兜底 + } + } + const child = this.children.get(instance.id) + if (child === undefined) return + child.kill('SIGTERM') + const killer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') + }, 5000) + killer.unref() + } +} diff --git a/src/supervisor/patch.ts b/src/supervisor/patch.ts new file mode 100644 index 0000000..1b2b4de --- /dev/null +++ b/src/supervisor/patch.ts @@ -0,0 +1,16 @@ +/** + * cordis patch rendering for a child DSH. Always mounts the runtime plugin + * (`dshs/runtime`) so every child injects the watchdog contract, + * plus one row per enabled folder plugin (id doubles as package name). The real + * harness loads this via `--patch <file>`. + * @module dshs/supervisor/patch + */ + +/** The runtime plugin patch row, mounted in every child DSH. */ +const RUNTIME_ROW = ' - id: dshs-runtime\n name: dshs/runtime' + +/** Render a patch YAML always enabling the runtime plugin plus `enabledPlugins`. */ +export function renderPatch(enabledPlugins: readonly string[]): string { + const rows = [RUNTIME_ROW, ...enabledPlugins.map((id) => ` - id: ${id}\n name: ${id}`)] + return `- insert:\n${rows.join('\n')}\n` +} diff --git a/src/supervisor/proxy.ts b/src/supervisor/proxy.ts new file mode 100644 index 0000000..2de5e7f --- /dev/null +++ b/src/supervisor/proxy.ts @@ -0,0 +1,633 @@ +/** + * Reverse proxy from the orchestrator to a running per-user DSH. + * + * Two entry points: + * - subpath `/u/:slug/dsh/*` (authenticated, legacy), and + * - per-user subdomain `<username>.<baseDomain>` (HTTP + WebSocket). The DSH's + * absolute-path SPA requires the subdomain form: its `/assets/*` and `/api/*` + * resolve against the host root, which only works when each DSH owns a host. + * @module dshs/supervisor/proxy + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Agent, request as httpRequest, type IncomingHttpHeaders, type IncomingMessage } from 'node:http' +import { connect } from 'node:net' +import { hashSessionToken, parseCookie } from '../web/auth.js' +import { requireAuth } from '../web/middleware/authn.js' +import type { Endpoint } from './spawner.js' + +// Keep-alive pool for per-user DSH upstreams. Replaced (not just destroyed) on a +// connection error, because a Pod rebuild changes its IP and any pooled socket +// to the old IP would keep failing (docs/k8s.md §5.4). +let upstreamAgent = new Agent({ keepAlive: true, maxSockets: 32 }) + +// Headers the DSH's browser-trust fence must NOT see from the browser, so the +// proxied request looks like a clean loopback client (its Host is overridden to +// loopback; a mismatched Origin would otherwise 403). +const STRIP_HEADERS = new Set([ + 'origin', + 'referer', + 'sec-fetch-site', + 'sec-fetch-mode', + 'sec-fetch-dest', + 'sec-fetch-user', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', +]) + + +/** + * 档案 50:注入到**实例 HTML**里的「会话过期自愈」脚本。 + * + * 为什么需要它:实例被空闲回收后**重建**(新端口 + 新 launch token),而**已打开的页面** + * 仍带着旧 `dsh-auth-*` cookie → 之后任何 `/api/*` XHR 都会被新实例判 **401**, + * dsh 前端只显示 `transport failure … HTTP 401`,用户只能手动刷新。 + * 导航路径的 401 已在上面处理(302 到当前实例新 token),但 **XHR 无法靠 302 自愈**, + * 所以给页面这段脚本:发现 `/api/*` 返回 401 就显示覆盖层并 reload —— + * reload 会走"导航 401 → 302 新 token"这条**已经工作**的链路,从而自动恢复。 + * 仅改写响应内容,不落盘、不改官方文件(R2);README/技能已留档。 + */ +// 注入到实例子域页面的自愈脚本(档案 50 + 档案 59 增强)。 +// ① 401 自愈:实例被回收重建后 launch token 轮换,页面内 /api/* 会拿到 401 → 亮覆盖层并整页 reload。 +// ② 慢请求提示(档案 59):服务端在实例未就绪时 hold 住请求最长 20 秒等拉起, +// 期间浏览器端原本毫无反馈(点了重连也看不出在等什么)→ 挂起 ≥3 秒即显示「实例正在启动」。 +// ③ 回到页面自检 + 就地恢复(档案 77):判据从「进程在不在跑」改为「页面还能不能连上实例」, +// 并把恢复过程做成**看得见**的(顶部轻提示 → 覆盖层),恢复后原地跳回而不是离开到门户域。 +// 保持多行形式便于维护;注入时整段塞进 <script>,故内容不得含反引号 / ${。 +// 档案 81 · R1-①:从**独立文件**加载(原先是 TS 模板字面量 —— 里面的 \n 会在模板求值时先被转义, +// 曾把整段脚本写崩成 SyntaxError,而校验跳过了"求值"这一步,形同虚设)。 +// 独立文件是纯 JS:可 node --check 直接校验,不再有转义陷阱。 +/** 档案 81 · R1-①:注入脚本从 `assets/inject/` 读取(纯 JS,可 node --check;无模板转义陷阱)。 + * 找不到文件 = **启动即失败**(fail-fast):宁可平台起不来,也不要把"没有注入"的页面静默发给用户。 */ +function loadInject(file: string): string { + // ⚠️ 本项目是 ESM(package.json "type": "module")—— **没有 __dirname**; + // 用 import.meta.url 定位(2026-09-13 实测:写成 __dirname 会让整个平台起不来) + const p = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets', 'inject', file) + try { + return readFileSync(p, 'utf8') + } catch (err) { + throw new Error(`[inject] 读取注入脚本失败: ${p}(档案 81 R1:assets/inject 必须随包部署): ${String(err)}`) + } +} +const SESSION_RECOVERY_JS = loadInject('recovery.js') + +/** + * 档案 56:注入到实例页面的「实例助手」——右下角两个入口: + * ① 我的文件:浏览自己的工作区并**下载**任意文件(走平台 `/api/desktop/tree` + `/api/fs/download`, + * 不暴露宿主绝对路径;此前用户拿到的只是 `/var/lib/...` 路径,浏览器打不开); + * ② 能力:展示平台生成的实例能力清单(与 `bundled-skills/platform-capabilities` 同源)。 + * 另外:读取 `/api/dsh/session-permission`,若**老会话仍处于 workspace-write**(沙箱后端不可用 → + * bash 会被 fail-closed 拒绝)则顶部给一条可操作的提示(含切换办法)。 + * 纯前端注入,不改官方包、不落盘(R2);失败静默,绝不影响实例本身。 + */ +// 档案 81 · R1-①:从**独立文件**加载(原先是 TS 模板字面量 —— 里面的 \n 会在模板求值时先被转义, +// 曾把整段脚本写崩成 SyntaxError,而校验跳过了"求值"这一步,形同虚设)。 +// 独立文件是纯 JS:可 node --check 直接校验,不再有转义陷阱。 +const SESSION_ASSIST_JS = loadInject('assist.js') + +/** 非 HTML / 已压缩 / 无正文的状态直接透传,避免破坏二进制或已编码内容。 */ +function injectRecovery(html: string): string { + const tag = + '<script>' + SESSION_RECOVERY_JS + '</script>' + '<script>' + SESSION_ASSIST_JS + '</script>' + return html.includes('</body>') ? html.replace('</body>', tag + '</body>') : html + tag +} + +function buildUpstreamHeaders(headers: IncomingHttpHeaders, port: number): Record<string, string | string[]> { + const out: Record<string, string | string[]> = {} + for (const [key, value] of Object.entries(headers)) { + if (value === undefined || STRIP_HEADERS.has(key.toLowerCase())) continue + out[key] = value as string | string[] + } + // Keep Host loopback: DSH's /api trust fence requires the Host to be loopback + // or a `--trusted-host` authority — a real domain would 403 every /api call. + // DSH's absolute URLs are rewritten to the real origin in proxyHttp below. + out.host = `127.0.0.1:${port}` + return out +} + +/** 档案 51:从 freshAuthUrl 的 `?token=` 中取出当前实例的 launch token。 */ +function tokenFromAuthUrl(url: string | undefined): string | undefined { + if (url === undefined) return undefined + const m = /[?&]token=([^&]+)/.exec(url) + return m === null || m[1] === undefined ? undefined : decodeURIComponent(m[1]) +} + +/** + * 档案 51:向**当前**实例要一份有效的浏览器凭证 cookie。 + * + * 实例每次 (重)启动都会换 launch token **和** `dsh-auth-<随机后缀>` 的 cookie 名。 + * 已打开的页面还带着旧名字的 cookie → 新实例一律判 401。这里 GET `/?token=<当前>` + * (实例回 303 + Set-Cookie),把 set-cookie 原样取出,供代理重放请求与回写浏览器。 + */ +function fetchInstanceAuthCookies(endpoint: Endpoint, token: string): Promise<string[]> { + return new Promise((resolve) => { + let done = false + const finish = (v: string[]): void => { + if (!done) { + done = true + resolve(v) + } + } + const req = httpRequest( + { + host: endpoint.host, + port: endpoint.port, + path: '/?token=' + encodeURIComponent(token), + method: 'GET', + headers: { host: `127.0.0.1:${endpoint.port}`, 'user-agent': 'dsh-proxy-auth-refresh' }, + }, + (res) => { + const sc = res.headers['set-cookie'] + res.resume() + finish(Array.isArray(sc) ? sc : sc === undefined ? [] : [sc]) + }, + ) + req.on('error', () => finish([])) + req.setTimeout(5000, () => { + req.destroy() + finish([]) + }) + req.end() + }) +} + +/** + * 档案 51:把浏览器带来的 cookie 与实例的新 cookie 合并 —— 丢掉旧的 `dsh-auth-*` + * (新实例只认自己那份;旧名留着也没用),其余(`sid` 等代理不关心)原样保留。 + */ +function mergeCookieHeader(original: string | undefined, fresh: string[]): string { + const keep = (original ?? '') + .split(';') + .map((x) => x.trim()) + .filter((x) => x !== '' && !/^dsh-auth-/.test(x)) + const add = fresh + .map((c) => (c.split(';')[0] ?? '').trim()) + .filter((x) => x !== '') + return [...keep, ...add].join('; ') +} + +/** Extract `<slug>` from `<slug>.<baseDomain>`, or null when not a match. */ +export function parseSubdomain(host: string | undefined, baseDomain: string): string | null { + if (host === undefined || baseDomain === '') return null + const name = host.split(':')[0] ?? '' + if (name === baseDomain) return null + if (name.endsWith('.' + baseDomain)) { + const slug = name.slice(0, -(baseDomain.length + 1)) + return slug !== '' ? slug.toLowerCase() : null + } + return null +} + +/** The per-user subdomain for a username, or null when `baseDomain` is unset. */ +export function subdomainForUser(baseDomain: string, username: string): string | null { + return baseDomain === '' ? null : `${username.toLowerCase()}.${baseDomain}` +} + +/** The browser-facing origin (`<scheme>://<host>`) this request reached us with, + * used to rewrite DSH's loopback absolute URLs back to the real domain. */ +function realOrigin(headers: IncomingHttpHeaders): string | undefined { + const host = clientHost(headers) + if (host === undefined) return undefined + const proto = headers['x-forwarded-proto'] + const scheme = typeof proto === 'string' && proto !== '' ? proto : 'https' + return `${scheme}://${host}` +} + +/** A subdomain resolution: a tunnelable endpoint, an error to return, or null (not a subdomain). */ +type SubdomainAccess = { endpoint: Endpoint; userId: string } | { error: string; code: number; userId?: string } | null + +/** + * The client-facing host, preferring `X-Forwarded-Host`. The control plane sits + * behind an edge proxy (Tencent nginx) that hides the real Host to sidestep the + * cloud provider's ICP check (docs/k8s-deploy.md §7.4); the real domain arrives + * here. The subdomain auth check still validates the cookie against the slug, so + * a spoofed forwarded host cannot reach another user's DSH. + */ +function clientHost(headers: IncomingHttpHeaders): string | undefined { + const fwd = headers['x-forwarded-host'] + if (typeof fwd === 'string' && fwd !== '') return fwd + if (Array.isArray(fwd) && fwd[0] !== '') return fwd[0] + return headers.host +} + +/** + * Resolve a subdomain Host to a DSH port, authenticating the caller: the session + * cookie must belong to a non-disabled user whose username matches the subdomain. + */ +async function resolveSubdomainAccess( + app: FastifyInstance, + host: string | undefined, + cookieHeader: string | undefined, +): Promise<SubdomainAccess> { + const slug = parseSubdomain(host, app.config.baseDomain) + if (slug === null) return null + const target = await app.db.findUserBySlug(slug) + if (target === undefined) return { error: 'unknown_user', code: 404 } + const token = parseCookie(cookieHeader, 'sid') + const session = token === undefined ? undefined : await app.db.findSessionWithUser(hashSessionToken(token)) + if (session === undefined || session.expiresAt <= Date.now() || session.user.role === 'disabled') { + return { error: 'unauthorized', code: 401 } + } + if (session.user.username.toLowerCase() !== slug) { + return { error: 'forbidden', code: 403 } + } + const endpoint = await app.supervisor.endpointFor(session.user.id) + if (endpoint === undefined) return { error: 'not_running', code: 404, userId: session.user.id } + // userId 一并返回:实例侧 401(launch token 过期)时用它取当前实例的新 token(档案 24)。 + // Real user traffic to their own DSH counts as activity (idle-reap signal). + app.supervisor.touch(session.user.id) + return { endpoint, userId: session.user.id } +} + +function proxyHttp( + request: FastifyRequest, + reply: FastifyReply, + endpoint: Endpoint, + targetPath: string, + rewritePrefix?: string, + rewriteLoopbackLocation = false, + useKeepAlive = false, + /** + * 实例侧 401 时的恢复入口(档案 24):浏览器导航遇到 dsh 的 "authentication required" + * (launch token 过期 —— 实例重启/回收后刷新旧标签页)时调用,返回应 302 到的地址。 + * 返回 undefined 则回落到 '/'。 + */ + freshAuthUrl?: () => Promise<string | undefined>, +): void { + reply.hijack() + // 档案 51:为「401 透明重放」准备请求体。 + // 只在 content-length 已知且不大时才缓冲(普通 /api JSON-RPC 请求都很小); + // 未知长度(chunked 上传)不缓冲 → 该请求退回旧行为(401 透传),不做重放。 + const MAX_REPLAY_BODY = 8 * 1024 * 1024 + const reqMethod = (request.raw.method ?? 'GET').toUpperCase() + const mayHaveBody = reqMethod !== 'GET' && reqMethod !== 'HEAD' + const clNum = + typeof request.headers['content-length'] === 'string' ? Number(request.headers['content-length']) : NaN + const bufferable = mayHaveBody && Number.isFinite(clNum) && clNum <= MAX_REPLAY_BODY + let bodyBuf: Buffer | undefined + // ★ 事后修正 1(2026-09-11):重放闸门**不能**用 bufferable —— 它含 mayHaveBody, + // 会让 GET/HEAD(含 dsh 的 SSE 会话流)永远无法重放。GET 没有请求体,反而最该能重放。 + const canReplay = mayHaveBody ? false : true + let authRetryUsed = false + let replayCookies: string[] = [] + + const attempt = (connRetry: boolean, authCookie?: string): void => { + // 档案 50 修正(实测定位):**HTML 导航请求必须向上游要 identity**。 + // 原因:dsh 实例对带 Accept-Encoding 的请求会 **gzip 压缩 HTML**(浏览器就带), + // 于是响应带 content-encoding → 我下面的注入逻辑**主动跳过** → 浏览器拿到的页面 + // **没有自愈脚本** → C 方案对真实用户无效(而 curl 不带 Accept-Encoding,故此前验证 + // 是假阳性)。改法:只要**客户端接受 HTML**,就覆盖转发的 accept-encoding 为 identity, + // 让上游回未压缩 HTML → 注入成功。体积影响可忽略(HTML ~24KB),且边缘 nginx 若开 gzip + // 仍会对浏览器压缩,用户侧无感知。静态资源/SSE 的压缩行为不受影响。 + const upHeaders = buildUpstreamHeaders(request.headers, endpoint.port) + if (String(request.headers.accept ?? '').includes('text/html')) { + upHeaders['accept-encoding'] = 'identity' + } + // 档案 51:重放时用**当前实例的新 cookie** 覆盖浏览器带来的旧 cookie。 + if (authCookie !== undefined) upHeaders.cookie = authCookie + if (bodyBuf !== undefined) { + // 请求体已缓冲 → 显式带上长度并去掉可能存在的 chunked 头,保证重放一致。 + delete upHeaders['transfer-encoding'] + upHeaders['content-length'] = String(bodyBuf.length) + } + const upstream = httpRequest( + { + host: endpoint.host, + port: endpoint.port, + path: targetPath, + method: request.method, + // Keep-alive only where it is required (k8s Headless Service DNS + // re-resolution on retry — docs/k8s.md §5.4). Local mode uses a fresh + // connection per request: the loopback connect cost is negligible and + // this avoids the half-open keep-alive pool that hung the proxy after + // child-instance restarts (2026-09-09 outage: pooled sockets to a + // since-recycled instance port never errored, so requests hung). + agent: useKeepAlive && !connRetry ? upstreamAgent : false, + // Host header stays loopback for the DSH trust fence; the TCP target host + // is endpoint.host above. k8s mode overrides the header port separately. + headers: upHeaders, + }, + (upRes: IncomingMessage) => { + const headers = { ...upRes.headers } + const isNav = + request.raw.method === 'GET' && String(request.headers.accept ?? '').includes('text/html') + // 档案 51:**非导航**请求的实例侧 401 = 页面拿着旧实例的 dsh-auth cookie。 + // 透明重放:取当前实例新 cookie → 覆盖 cookie 头重发同一请求 → 新 cookie 回写浏览器。 + // 只重放一次(authRetryUsed),避免与实例互相刷 401 造成死循环。 + const replayReady = mayHaveBody ? bodyBuf !== undefined : canReplay + if (upRes.statusCode === 401 && !isNav && !authRetryUsed && freshAuthUrl !== undefined && replayReady) { + authRetryUsed = true + upRes.resume() + void (async () => { + let cookieHeader: string | undefined + try { + const url = await freshAuthUrl() + const tk = tokenFromAuthUrl(url) + if (tk !== undefined) { + const fresh = await fetchInstanceAuthCookies(endpoint, tk) + if (fresh.length > 0) { + replayCookies = fresh + cookieHeader = mergeCookieHeader(request.headers.cookie, fresh) + } + } + } catch { + /* 取不到 → 回落到原样 401 */ + } + if (cookieHeader === undefined) { + reply.raw.writeHead(401, headers) + reply.raw.end('unauthorized') + return + } + process.stderr.write( + `[proxy-auth-replay] ${request.raw.method ?? 'GET'} ${targetPath}(旧 cookie → 实例新 cookie,重放)\n`, + ) + attempt(false, cookieHeader) + })() + return + } + // 重放成功后把新 cookie 交给浏览器:之后(含 SSE 自动重连)不再需要重放。 + if (authCookie !== undefined && replayCookies.length > 0) { + const prev = headers['set-cookie'] + headers['set-cookie'] = [ + ...(Array.isArray(prev) ? prev : prev === undefined ? [] : [prev]), + ...replayCookies, + ] + } + const location = upRes.headers.location + if ( + rewritePrefix !== undefined && + typeof location === 'string' && + location.startsWith('/') && + !location.startsWith('//') && + !location.startsWith(rewritePrefix) + ) { + headers.location = rewritePrefix + location + } + // ── 2026-09-12(平台缓存治理):让「改了 client bundle 却看不到变化」不再发生 ── + // + // 背景(档案 67 v0.2.6 实测,排查成本极高): + // dsh 的客户端模块 URL 形如 `/plugins/??<pkg>/client.js,…&rev=<内容 sha1>`, + // `rev` 由 **dsh 官方**按内容计算(`dsh-client-modules`),**平台不得改(R2)**。 + // 平台更新 bundle 后,若 `rev` 未变 → 浏览器认为手上那份缓存仍有效 → **不重新请求** + // ⇒ 服务端已是新版、用户却一直看到旧 UI(本次:SQL 层/磁盘/服务端三处都验过是新的)。 + // + // 处置:对实例的**模块/静态资源路径**统一加 `Cache-Control: no-cache` —— + // **允许缓存,但每次必须回源校验**(配合上游 ETag/Last-Modified 走 304,开销极小)。 + // ⇒ bundle 一变,浏览器下一次请求就拿到新的,**用户无需清缓存/换无痕**。 + // + // ⚠️ 勿删:这是 UI 改动能否被验收的前置机制(`06-工作台UI规范 §6.5`)。 + if (targetPath.startsWith('/plugins/') || targetPath.startsWith('/assets/')) { + headers['cache-control'] = 'no-cache' + } + // local mode only: DSH builds absolute URLs from the loopback Host we + // forward; rewrite any 127.0.0.1 Location to the real origin so the + // browser doesn't jump to the user's own machine. k8s mode keeps its + // verified behavior (no rewrite). + if ( + rewriteLoopbackLocation && + typeof location === 'string' && + realOrigin(request.headers) !== undefined + ) { + headers.location = location.replace(/^https?:\/\/127\.0\.0\.1(:\d+)?/, realOrigin(request.headers)!) + } + // 2026-09-11(档案 24):实例侧 401 = launch token 过期。浏览器导航时不要停在 + // dsh 的 "dsh web authentication required; reopen the URL printed by dsh web." 死端页, + // 而是用当前实例的新 token 302 回同一地址(拿不到则回门户)。 + if ( + upRes.statusCode === 401 && + request.raw.method === 'GET' && + String(request.headers.accept ?? '').includes('text/html') && + freshAuthUrl !== undefined + ) { + upRes.resume() + void freshAuthUrl() + .then((url) => { + reply.raw.writeHead(302, { location: url ?? '/' }) + reply.raw.end() + }) + .catch(() => { + reply.raw.writeHead(302, { location: '/' }) + reply.raw.end() + }) + return + } + // 档案 50:HTML 响应缓冲后注入「会话过期自愈」脚本(其余一切原样 pipe,零影响)。 + const ctype = String(upRes.headers['content-type'] ?? '') + const enc = upRes.headers['content-encoding'] + const status = upRes.statusCode ?? 502 + const canInject = + ctype.includes('text/html') && enc === undefined && status !== 204 && status !== 304 + if (!canInject) { + if (ctype.includes('text/html') && enc !== undefined) { + process.stderr.write(`[inject-recovery] skip: content-encoding=${String(enc)}\n`) + } + reply.raw.writeHead(status, headers) + upRes.pipe(reply.raw) + return + } + const chunks: Buffer[] = [] + upRes.on('data', (c: Buffer) => chunks.push(c)) + upRes.on('end', () => { + const out = injectRecovery(Buffer.concat(chunks).toString('utf8')) + delete headers['content-length'] + delete headers['transfer-encoding'] + headers['content-length'] = String(Buffer.byteLength(out)) + reply.raw.writeHead(status, headers) + reply.raw.end(out) + }) + upRes.on('error', () => reply.raw.destroy()) + }, + ) + upstream.on('error', () => { + if (connRetry) { + reply.raw.destroy() + return + } + // A stale keep-alive socket or a Pod that just restarted: drop the pool, + // replace it with a fresh one, and retry once on a fresh connection. + upstreamAgent.destroy() + upstreamAgent = new Agent({ keepAlive: true, maxSockets: 32 }) + request.raw.unpipe(upstream) + attempt(true) + }) + if (bodyBuf !== undefined) upstream.end(bodyBuf) + else request.raw.pipe(upstream) + } + if (!bufferable) { + attempt(false) + return + } + const pre: Buffer[] = [] + request.raw.on('data', (c: Buffer) => pre.push(c)) + request.raw.on('end', () => { + bodyBuf = Buffer.concat(pre) + attempt(false) + }) + request.raw.on('error', () => reply.raw.destroy()) +} + +export async function registerDshProxy(app: FastifyInstance): Promise<void> { + // Legacy authenticated subpath proxy. + app.all('/u/:slug/dsh/*', { preHandler: requireAuth }, async (request, reply) => { + const slug = (request.params as { slug: string }).slug + if (request.user === null || request.user.id !== slug) { + reply.code(403).send({ error: 'forbidden' }) + return + } + const endpoint = await app.supervisor.endpointFor(slug) + if (endpoint === undefined) { + reply.code(404).send({ error: 'not_running' }) + return + } + app.supervisor.touch(request.user!.id) + const prefix = `/u/${slug}/dsh` + const rawUrl = request.raw.url ?? '/' + const targetPath = rawUrl.startsWith(prefix) ? rawUrl.slice(prefix.length) || '/' : rawUrl + const pathScheme = app.config.secureCookies ? 'https' : 'http' + proxyHttp( + request, + reply, + endpoint, + targetPath, + prefix, + app.config.deployMode === 'local', + app.config.deployMode === 'k8s', + async () => { + const status = await app.supervisor.status(request.user!.id) + const token = status.main?.launchToken + return token !== undefined && token !== '' + ? `${pathScheme}://${app.config.baseDomain}${prefix}/?token=${encodeURIComponent(token)}` + : `${pathScheme}://${app.config.baseDomain}/` + }, + ) + }) + + // Per-user subdomain: HTTP (intercept before normal routing). + app.addHook('onRequest', async (request, reply) => { + const access = await resolveSubdomainAccess(app, clientHost(request.headers), request.headers.cookie) + if (access === null) return + if ('error' in access) { + // 浏览器导航(GET + 期待 HTML)的 401(会话失效/退出后刷新实例子域)→ 302 回门户 + // 登录页,而不是吐 JSON {"error":"unauthorized"} 停在原地。 + if ( + access.code === 401 && + request.raw.method === 'GET' && + (request.headers.accept ?? '').includes('text/html') + ) { + const scheme = app.config.secureCookies ? 'https' : 'http' + reply.redirect(`${scheme}://${app.config.baseDomain}/login.html`) + return + } + // 实例未运行(后台回收/崩溃/停止/熔断后刷新会话页)→ 需要拉起。 + // + // 2026-09-11(档案 49 · 体验修复):**浏览器导航一律"立刻" 302 到平台自有的过渡页**, + // 由过渡页显示加载动画并自己调 /api/dsh/enter 完成「拉起 + 等 token + 跳回」。 + // 原因:此前导航请求会在**服务端阻塞等待 launch token(最长 20 秒)**,这期间浏览器 + // 只看到白屏、没有任何反馈 → 用户以为卡死,只能手动刷新(那时实例已就绪,看似"刷新才好")。 + // 现在导航分支**不阻塞**(0.2s 内返回 302),动画立刻出现,且**不会**在这里触发 launch + //(拉起交给过渡页,单点、可重试)。 + if (access.code === 404 && access.error === 'not_running' && access.userId !== undefined) { + const navScheme = app.config.secureCookies ? 'https' : 'http' + const navHost = clientHost(request.headers) ?? app.config.baseDomain + const isNavigation = + request.raw.method === 'GET' && (request.headers.accept ?? '').includes('text/html') + if (isNavigation) { + const next = `${navScheme}://${navHost}${request.raw.url ?? '/'}` + reply.redirect( + `${navScheme}://${app.config.baseDomain}/wake.html?next=${encodeURIComponent(next)}`, + ) + return + } + // 非导航(XHR / API)——**这是"页面已打开、再对话没反应"的主场景**。 + // 正解不是"回一个错误/动画",而是**等实例就绪后继续转发**:请求最终成功, + // dsh 前端自己会保持它的 loading 态 → 用户无感,不需要刷新。 + try { + const folderAbs = app.userFs.resolvePath(access.userId, '') + try { + await app.supervisor.launch(access.userId, folderAbs, undefined) + } catch { + // 并发进场(另一请求正在拉起 → AlreadyRunningError):等它拿到 token + await app.supervisor.waitForLaunchTokenForUser(access.userId, 20000) + } + } catch { + /* 拉起失败 → 落到下面的 503 */ + } + const again = await resolveSubdomainAccess(app, clientHost(request.headers), request.headers.cookie) + if (again !== null && !('error' in again)) { + proxyHttp( + request, + reply, + again.endpoint, + request.raw.url ?? '/', + undefined, + app.config.deployMode === 'local', + app.config.deployMode === 'k8s', + async () => { + const st = await app.supervisor.status(again.userId) + const tk = st.main?.launchToken + return tk !== undefined && tk !== '' + ? `${navScheme}://${navHost}/?token=${encodeURIComponent(tk)}` + : `${navScheme}://${app.config.baseDomain}/` + }, + ) + return + } + // 真的起不来:给明确的「启动中」+ Retry-After,让前端自行退避重试。 + reply.code(503).header('retry-after', '3').send({ error: 'instance_starting' }) + return + } + reply.code(access.code).send({ error: access.error }) + return + } + const navScheme = app.config.secureCookies ? 'https' : 'http' + const navHost = clientHost(request.headers) ?? app.config.baseDomain + proxyHttp( + request, + reply, + access.endpoint, + request.raw.url ?? '/', + undefined, + app.config.deployMode === 'local', + app.config.deployMode === 'k8s', + async () => { + const status = await app.supervisor.status(access.userId) + const token = status.main?.launchToken + return token !== undefined && token !== '' + ? `${navScheme}://${navHost}/?token=${encodeURIComponent(token)}` + : `${navScheme}://${app.config.baseDomain}/` + }, + ) + }) + + // Per-user subdomain: WebSocket upgrade tunnel. Auth is async (DB lookup), so + // the raw `upgrade` callback defers to an async IIFE before deciding to tunnel. + app.server.on('upgrade', (req, socket, head) => { + void (async () => { + const access = await resolveSubdomainAccess(app, clientHost(req.headers), req.headers.cookie) + if (access === null || 'error' in access) { + socket.destroy() + return + } + const upstream = connect({ host: access.endpoint.host, port: access.endpoint.port }) + upstream.on('connect', () => { + const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`] + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const name = (req.rawHeaders[i] ?? '').toLowerCase() + if (name === 'host' || STRIP_HEADERS.has(name)) continue + lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`) + } + lines.push(`Host: 127.0.0.1:${access.endpoint.port}`) + upstream.write(lines.join('\r\n') + '\r\n\r\n') + if (head !== undefined && head.length > 0) upstream.write(head) + socket.pipe(upstream) + upstream.pipe(socket) + }) + upstream.on('error', () => socket.destroy()) + socket.on('error', () => upstream.destroy()) + })() + }) +} diff --git a/src/supervisor/reconcile.ts b/src/supervisor/reconcile.ts new file mode 100644 index 0000000..3f76b73 --- /dev/null +++ b/src/supervisor/reconcile.ts @@ -0,0 +1,170 @@ +/** + * Leader-only controller: reconciles the cluster against the desired state in + * `dsh_instances` (docs/k8s.md §5.7) and watches the main DSH Pods for crashes. + * + * The k8s backend has no child-process `exit` event the way the local backend + * does — a crashed Pod is observed either by the informer (here) or by the next + * reconcile tick (a desired main whose Pod is gone). Both funnel into the same + * repair path: mark the instance crashed, pull up the one-shot watchdog Job, + * and let the reconcile relaunch the main if its Pod stays absent. + * @module dshs/supervisor/reconcile + */ + +import { makeInformer, type Informer } from '@kubernetes/client-node' +import type { DbAdapter } from '../db/adapter.js' +import type { DshInstance } from '../db/types.js' +import type { LeaderElector } from './leader.js' +import type { K8sSpawner } from './k8s-spawner.js' +import type { LivePod } from './spawner.js' + +/** The result of diffing desired state against live Pods. Pure and testable. */ +export interface ReconcilePlan { + /** Desired mains whose Pod is missing → relaunch. */ + launch: DshInstance[] + /** Live Pods with no desired row → delete (orphans). */ + delete: LivePod[] +} + +/** Diff desired mains vs live Pods. */ +export function planReconcile(desired: DshInstance[], live: LivePod[]): ReconcilePlan { + const liveByUser = new Map(live.map((pod) => [pod.userId, pod])) + const launch: DshInstance[] = [] + for (const instance of desired) { + if (instance.role !== 'main') continue + const pod = liveByUser.get(instance.userId) + if (pod === undefined || !pod.running) launch.push(instance) + } + const desiredUsers = new Set(desired.filter((i) => i.role === 'main').map((i) => i.userId)) + const del = live.filter((pod) => !desiredUsers.has(pod.userId)) + return { launch, delete: del } +} + +/** Loop that reconciles only while leader, plus a Pod informer for crashes. */ +export class ReconcileController { + private informer: Informer<object> | undefined + private timer: NodeJS.Timeout | undefined + private leading = false + private readonly watchdogFired = new Set<string>() + private readonly intervalMs: number + + constructor( + private readonly db: DbAdapter, + private readonly spawner: K8sSpawner, + private readonly elector: LeaderElector, + intervalMs = 10_000, + ) { + this.intervalMs = intervalMs + elector.setLeadershipCallbacks( + (fencing) => { + this.leading = true + this.spawner.setFencing(fencing) + this.startInformer() + this.scheduleTick(0) + }, + () => { + this.leading = false + this.stopInformer() + }, + ) + } + + async start(): Promise<void> { + await this.elector.start() + } + + stop(): void { + this.elector.stop() + this.stopInformer() + if (this.timer !== undefined) { + clearTimeout(this.timer) + this.timer = undefined + } + } + + private startInformer(): void { + if (this.informer !== undefined) return + this.informer = this.spawner.watchMainPods((pod) => this.onPodEvent(pod)) + void this.informer.start().catch(() => { + // The informer is best-effort; the reconcile tick still converges. + this.informer = undefined + }) + } + + private stopInformer(): void { + void this.informer?.stop().catch(() => {}) + this.informer = undefined + } + + private scheduleTick(delayMs: number): void { + if (this.timer !== undefined) return + this.timer = setTimeout(() => { + this.timer = undefined + if (!this.leading) return + void this.tick().finally(() => this.scheduleTick(this.intervalMs)) + }, delayMs) + this.timer.unref?.() + } + + private async tick(): Promise<void> { + const [desired, live] = await Promise.all([ + this.db.listInstancesByRole('main'), + this.spawner.listUserPods(), + ]) + const plan = planReconcile(desired, live) + + for (const orphan of plan.delete) { + // Orphan = a main Pod with no desired row (user deleted or disabled). + await this.safe(`orphan ${orphan.userId}`, async () => { + await this.db.deleteInstance(orphan.name) + await this.spawner.stop(orphan.userId) + }) + } + for (const instance of plan.launch) { + await this.safe(`launch ${instance.userId}`, () => + this.spawner.launch(instance.userId, instance.folder ?? '', instance.patch ?? undefined)) + } + // Recreate a lost Service/NetworkPolicy for any desired main that still has a Pod. + for (const instance of desired) { + if (plan.launch.includes(instance)) continue + await this.safe(`ensure ${instance.userId}`, () => this.spawner.ensureUserResources(instance.userId)) + } + // Idle reap (Phase 4): a desired main whose user has no active session has + // outlived its session TTL — stop the Pod and drop the desired row so the + // next tick does not relaunch it. + for (const instance of desired) { + await this.safe(`reap ${instance.userId}`, async () => { + if (await this.db.hasActiveSession(instance.userId)) return + await this.spawner.stop(instance.userId) + await this.db.deleteInstance(instance.id) + }) + } + } + + /** Run one per-user step without letting its failure abort the rest of the tick. */ + private async safe(label: string, fn: () => Promise<unknown>): Promise<void> { + try { + await fn() + } catch (err) { + // One bad user (e.g. a files Pod that can't become Ready) must not block + // launches/idle-reap for every other user. + this.spawner.logError?.(new Error(`reconcile ${label}: ${err instanceof Error ? err.message : String(err)}`)) + } + } + + private async onPodEvent(pod: LivePod): Promise<void> { + if (!pod.crashed || !pod.running) { + // A healthy transition clears the "already fired" guard so a later crash + // triggers the watchdog again. + if (pod.running && !pod.crashed) this.watchdogFired.delete(pod.userId) + return + } + if (this.watchdogFired.has(pod.userId)) return + this.watchdogFired.add(pod.userId) + try { + await this.spawner.spawnWatchdog(pod.userId) + } catch (err) { + this.watchdogFired.delete(pod.userId) + this.spawner.logError?.(err) + } + } +} diff --git a/src/supervisor/session-preset.ts b/src/supervisor/session-preset.ts new file mode 100644 index 0000000..aed2b09 --- /dev/null +++ b/src/supervisor/session-preset.ts @@ -0,0 +1,118 @@ +/** + * 会话权限档位读取(档案 56)。 + * + * 背景:档位由 `DSH_PERMISSION_MODE` 决定,但 **dsh 在"会话创建时"把它播种进会话**, + * 之后平台改默认值**不会**更新既有会话 → 老会话仍停在 `workspace-write`,而本机沙箱 + * 后端不可用(内核无 Landlock、bwrap 在平台合成根内探测失败)→ dsh **fail-closed 拒绝 + * 任何 shell**。用户只会看到「bash 不可用」,无从判断原因。 + * + * 本模块从会话事件流里读出**最近修改的那个会话**的 `permission/preset`,交给路由层 + * 判断"是否与平台默认不一致",从而在实例页面上给出提示。 + * + * 只读:仅解压文件头部的若干 zstd 帧,不写任何东西。 + * @module dshs/supervisor/session-preset + */ +import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { zstdDecompressSync } from 'node:zlib' + +/** zstd 帧魔数:会话文件是**多帧拼接**,必须按它切分后逐帧解压。 */ +const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]) + +/** 解压文件头部若干帧(够覆盖 session 元信息与 permission/preset 记录,通常在第 1 帧)。 */ +function headFrames(buf: Buffer, maxFrames = 6): string { + const offsets: number[] = [] + for (let i = 0; i + 4 <= buf.length; i++) { + if (buf.compare(MAGIC, 0, 4, i, i + 4) === 0) offsets.push(i) + } + const list = offsets.length > 0 ? offsets.slice(0, maxFrames) : [0] + let out = '' + for (let f = 0; f < list.length; f++) { + const start = list[f] + const end = f + 1 < list.length ? list[f + 1] : buf.length + try { + out += zstdDecompressSync(buf.subarray(start, end)).toString('utf8') + } catch { + /* 单帧损坏/被截断:忽略,继续下一帧 */ + } + } + return out +} + +export interface SessionPreset { + /** 会话目录名(`session-<uuid>` 或裸 uuid)。 */ + sessionId: string + /** 该会话当前生效的权限档位(取最后一次 `permission/preset` 记录)。 */ + preset: string + /** 会话文件最后修改时间(ms)。 */ + updatedAt: number +} + +/** 用户数据根下的会话目录:`<userRoot>/home/sessions`。 */ +export function sessionsDir(homeRoot: string): string { + return join(homeRoot, 'sessions') +} + +/** + * 读一个 `session.jsonl.zstd` 的权限档位。 + * 取**最后一条** `permission/preset`(用户在 UI 里切换档位会追加新记录)。 + */ +function readPreset(file: string): string | undefined { + let text: string + try { + const st = statSync(file) + const HEAD = 256 * 1024 + const TAIL = 64 * 1024 + if (st.size <= 4 * 1024 * 1024) { + // 常见会话 < 4MB:直接整读(切换档位的记录可能在文件尾) + text = headFrames(readFileSync(file), 12) + } else { + const fd = openSync(file, 'r') + try { + const head = Buffer.allocUnsafe(HEAD) + const readHead = readSync(fd, head, 0, HEAD, 0) + const tailLen = Math.min(TAIL, st.size) + const tail = Buffer.allocUnsafe(tailLen) + readSync(fd, tail, 0, tailLen, st.size - tailLen) + text = headFrames(head.subarray(0, readHead), 6) + headFrames(tail, 6) + } finally { + closeSync(fd) + } + } + } catch { + return undefined + } + const re = /"type":"permission\/preset"[^\n]*?"preset":"([^"]+)"/g + let last: string | undefined + for (const m of text.matchAll(re)) last = m[1] + return last +} + +/** 该用户**最近修改的**会话及其档位(跨全部工作区),无会话则返回 undefined。 */ +export function latestSessionPreset(homeRoot: string): SessionPreset | undefined { + const dir = sessionsDir(homeRoot) + if (!existsSync(dir)) return undefined + let best: { file: string; id: string; mtime: number } | undefined + for (const slug of readdirSync(dir)) { + let sids: string[] + try { + sids = readdirSync(join(dir, slug)) + } catch { + continue + } + for (const sid of sids) { + const file = join(dir, slug, sid, 'session.jsonl.zstd') + try { + const st = statSync(file) + if (!st.isFile()) continue + if (best === undefined || st.mtimeMs > best.mtime) best = { file, id: sid, mtime: st.mtimeMs } + } catch { + /* 无会话文件(空目录)→ 跳过 */ + } + } + } + if (best === undefined) return undefined + const preset = readPreset(best.file) + if (preset === undefined) return undefined + return { sessionId: best.id, preset, updatedAt: best.mtime } +} diff --git a/src/supervisor/spawn.ts b/src/supervisor/spawn.ts new file mode 100644 index 0000000..a0991f9 --- /dev/null +++ b/src/supervisor/spawn.ts @@ -0,0 +1,66 @@ +/** + * Child-process helpers for the supervisor: env scrubbing and free-port lookup. + * + * Env scrubbing mirrors the harness `scrubbedParentEnv` / `SENSITIVE_ENV_PATTERN` + * doctrine (packages/subprocess/subprocess/src/index.ts): build the child env + * from a clean allowlist so no orchestrator secret leaks into a user DSH, then + * inject only the resolved per-user values. + * @module dshs/supervisor/spawn + */ + +import { createServer } from 'node:net' + +const ALLOWED_ENV = new Set([ + 'PATH', + 'HOME', + 'USER', + 'TMP', + 'TEMP', + 'TMPDIR', + 'SYSTEMROOT', + 'SystemRoot', + 'PATHEXT', + 'ProgramFiles', + 'ProgramFiles(x86)', + 'LANG', + 'LC_ALL', + // Shared read-only skill dir; injected explicitly in baseEnv, allowlisted here + // so it survives scrubEnv if ever set on the orchestrator process. + 'DSH_BUNDLED_SKILL_DIR', + // 档案 33:实例内权限档位(dsh-base 读 DSH_PERMISSION_MODE 决定 sandbox mode + approval policy)。 + // 只做 allowlist,实际值由 orchestrator.baseEnv 注入。 + 'DSH_PERMISSION_MODE', + // 档案 44:冻结实例的基础运行时版本(Python / pip / node 一律用平台装的那份,禁止版本漂移)。 + // 这两条是**限制性** env(语义为收窄,不是扩大): + // · PYTHONNOUSERSITE=1 —— 不把 `$HOME/.local/lib/python*/site-packages` 加进 sys.path + // (实测:一旦该目录被 pip 创建,它就在 sys.path 里且**优先于平台 site-packages** → + // 用户装的同名包会盖住平台包,正是"版本差异导致插件功能不可用"的来源); + // · PYTHONUSERBASE=<只读占位位> —— 让 `pip install --user` **明确失败**而不是静默无效。 + 'PYTHONNOUSERSITE', + 'PYTHONUSERBASE', +]) + +/** Drop credential-shaped and unknown env vars; keep only a safe allowlist. */ +export function scrubEnv(env: NodeJS.ProcessEnv): Record<string, string> { + const out: Record<string, string> = {} + for (const [key, value] of Object.entries(env)) { + if (ALLOWED_ENV.has(key) && value !== undefined) out[key] = value + } + return out +} + +/** Reserve an ephemeral loopback port, release it, and return its number. */ +export function findFreePort(): Promise<number> { + return new Promise((resolve, reject) => { + const server = createServer() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address !== null ? address.port : undefined + server.close(() => { + if (port !== undefined) resolve(port) + else reject(new Error('could not reserve a free port')) + }) + }) + }) +} diff --git a/src/supervisor/spawner.ts b/src/supervisor/spawner.ts new file mode 100644 index 0000000..16933d8 --- /dev/null +++ b/src/supervisor/spawner.ts @@ -0,0 +1,139 @@ +/** + * Backend abstraction for per-user DSH lifecycle (docs/k8s.md §5.2). + * + * `local` spawns child processes (setuid/iptables) via `LocalSpawner`; `k8s` + * creates/deletes per-user DSH Pods via the K8s API (`K8sSpawner`). The route + * layer depends only on this interface, so both backends coexist behind the + * same API. Shared instance/status types live here so neither backend owns + * them. + * @module dshs/supervisor/spawner + */ + +export type InstanceStatus = 'starting' | 'running' | 'crashed' | 'stopped' | 'failed' +export type InstanceRole = 'main' | 'watchdog' + +/** A tracked DSH instance (main or watchdog). `port`/`pid` are local-only. */ +export interface Instance { + id: string + userId: string + role: InstanceRole + folder: string + port?: number + status: InstanceStatus + pid?: number + exitCode?: number + lastError?: string + /** Rendered cordis patch **content**, not a path — see {@link Spawner.launch}. */ + patch?: string + /** dsh web 一次性 launch token(本地模式从子进程 stdout 解析),用于拼装可直达的打开 URL。 */ + launchToken?: string + /** systemd scope 名(bwrap 沙箱隔离时),stop 时用 systemctl stop 正确终止整个 scope。 */ + unit?: string + /** 崩溃自动重启次数(档案 20 观测面,随实例重建归零)。 */ + restarts?: number + /** 最近一次崩溃时间(ms epoch)。 */ + lastCrashedAt?: number +} + +/** Thrown when a user already has a running main DSH. */ +export class AlreadyRunningError extends Error { + constructor(userId: string) { + super(`user ${userId} already has a running DSH`) + this.name = 'AlreadyRunningError' + } +} + +/** + * 档案 78:该用户的实例刚因崩溃循环被熔断,冷却期内拒绝启动。 + * + * 为什么需要它:`circuit-open` 之后若允许立刻重来,崩溃循环可无限重复 + * (用户 F5 / 注入脚本自愈 / 脚本直铺都可能触发),且平台只留一行 stderr。 + * 冷却过后只给一次干净预算。 + */ +export class CrashBreakerOpenError extends Error { + readonly userId: string + /** 冷却结束时刻(ms epoch)。 */ + readonly retryAt: number + /** 累计熔断次数。 */ + readonly opens: number + + constructor(userId: string, retryAt: number, opens: number) { + const waitSec = Math.max(0, Math.ceil((retryAt - Date.now()) / 1000)) + super(`user ${userId} instance crash-breaker open (opens=${opens}, retry in ${waitSec}s)`) + this.name = 'CrashBreakerOpenError' + this.userId = userId + this.retryAt = retryAt + this.opens = opens + } + + /** 距可重试还剩多少毫秒。 */ + get retryAfterMs(): number { + return Math.max(0, this.retryAt - Date.now()) + } +} + +/** A user's main + watchdog pair. */ +export interface UserStatus { + main?: Instance + watchdog?: Instance +} + +/** The host:port the proxy forwards a user's DSH traffic to. */ +export interface Endpoint { + host: string + port: number +} + +/** A main DSH Pod as observed by the k8s backend (for reconcile + the watch). */ +export interface LivePod { + name: string + userId: string + running: boolean + crashed: boolean +} + +/** + * The lifecycle seam the route layer delegates to. + * + * `endpointFor` is spawner-specific: local → `127.0.0.1:<port>`, k8s → the + * per-user Headless Service DNS (docs/k8s.md §5.4). + * + * `launch` takes the rendered cordis patch as **content**, not a path: under + * k8s the control plane holds no user volume, so it can neither write the patch + * nor read it back. `LocalSpawner` materializes it to a file (it does have the + * volume) and `K8sSpawner` puts it straight into a ConfigMap. + */ +export interface Spawner { + launch(userId: string, folder: string, patch?: string, opts?: { force?: boolean }): Promise<Instance> + restartMain(userId: string): Promise<Instance | undefined> + /** + * 档案 78:熔断观测面(可选 —— 熔断是**本地模式**概念,k8s 模式没有)。 + * 返回 null = 该用户未被熔断冷却;非 null = 正在冷却(含累计熔断次数与解冻时刻)。 + */ + breakerInfo?(userId: string): { opens: number; openedAt: number; cooldownUntil: number } | null + + /** Restart every running main so a swapped global API key takes effect (env is a spawn-time snapshot). */ + restartAllMains(): Promise<void> + spawnWatchdog(userId: string): Promise<Instance | undefined> + status(userId: string): Promise<UserStatus> + endpointFor(userId: string): Promise<Endpoint | undefined> + stop(userId: string): Promise<void> + teardown(): Promise<void> + /** 等待该用户 main 实例打印 launch token(本地模式 = 启动完成的信号)。无实例 / + * 已崩溃 / 已停 → 立即返回;k8s 模式无 token 概念 → no-op。供 enter 复用分支在返回 + * 打开 URL 前等待,避免把浏览器导向「HTTP 已监听但路由未就绪 → 404」的启动窗口。 */ + waitForLaunchTokenForUser(userId: string, timeoutMs?: number): Promise<void> + + /** + * 档案 34:重启该用户实例并**探活**——功能插件启用后判定实例是否还起得来。 + * `ok:false` 时调用方必须回滚/隔离该插件(否则会把实例拖进崩溃循环,档案 25 教训)。 + */ + restartAndProbe(userId: string, settleMs?: number): Promise<{ ok: boolean; reason: string }> + /** Record user activity (proxied traffic / entering the workspace) so idle + * reaping keeps warm instances that are genuinely in use. Local mode tracks + * this in memory; k8s mode relies on its own session-based reconcile. */ + touch(userId: string): void + /** Make the user's file sidecar exist and be ready (k8s). No-op under local, + * where the control plane touches the volume in-process. */ + ensureFileService(userId: string): Promise<void> +} diff --git a/src/tcp-bridge.ts b/src/tcp-bridge.ts new file mode 100644 index 0000000..e26f5d2 --- /dev/null +++ b/src/tcp-bridge.ts @@ -0,0 +1,38 @@ +/** + * Tiny TCP bridge: listen on one address and forward every connection to + * another. Replaces the `alpine/socat` sidecar in the per-user DSH Pod, so the + * Pod no longer depends on a docker.io image (blocked on ACK, docs/k8s-deploy.md + * §7). Runs from the control-plane image's Node runtime. + * @module dshs/tcp-bridge + */ + +import { createConnection, createServer, type Server } from 'node:net' + +/** + * Start a TCP bridge: `listen` (e.g. `0.0.0.0:8081`) → `target` + * (e.g. `127.0.0.1:8080`). Resolves when listening. + */ +export function startTcpBridge(listen: string, target: string): Promise<Server> { + const [targetHost, targetPort] = splitHostPort(target) + const server = createServer((socket) => { + // A fresh outbound connection to the target — NOT `socket.connect`, which + // would try to re-connect the already-connected inbound socket. + const upstream = createConnection(Number(targetPort), targetHost) + upstream.on('error', () => socket.destroy()) + socket.on('error', () => upstream.destroy()) + socket.pipe(upstream) + upstream.pipe(socket) + }) + const [listenHost, listenPort] = splitHostPort(listen) + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(Number(listenPort), listenHost, () => resolve(server)) + }) +} + +/** `host:port` → `[host, port]`, defaulting the host to `0.0.0.0`. */ +function splitHostPort(addr: string): [string, string] { + const idx = addr.lastIndexOf(':') + if (idx === -1) return ['0.0.0.0', addr] + return [addr.slice(0, idx), addr.slice(idx + 1)] +} diff --git a/src/web/auth.ts b/src/web/auth.ts new file mode 100644 index 0000000..f785749 --- /dev/null +++ b/src/web/auth.ts @@ -0,0 +1,73 @@ +/** + * Password hashing, session-token helpers, and cookie assembly. + * + * Uses `node:crypto` scrypt so the scaffold has zero native dependency beyond + * SQLite; production may swap in argon2id without changing call sites. + * @module dshs/web/auth + */ + +import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto' +import { promisify } from 'node:util' + +const scryptAsync = promisify(scrypt) as (password: string, salt: Buffer, keylen: number) => Promise<Buffer> + +const KEY_LENGTH = 64 + +/** Hash a password into a `scrypt$<salt>$<hash>` string. */ +export async function hashPassword(password: string): Promise<string> { + const salt = randomBytes(16) + const derived = await scryptAsync(password, salt, KEY_LENGTH) + return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}` +} + +/** Constant-time password verification against a stored `scrypt$...` string. */ +export async function verifyPassword(password: string, stored: string): Promise<boolean> { + const [scheme, saltHex, hashHex] = stored.split('$') + if (scheme !== 'scrypt' || saltHex === undefined || hashHex === undefined) return false + const expected = Buffer.from(hashHex, 'hex') + const derived = await scryptAsync(password, Buffer.from(saltHex, 'hex'), KEY_LENGTH) + return derived.length === expected.length && timingSafeEqual(derived, expected) +} + +/** Generate a fresh opaque session token. */ +export function newSessionToken(): string { + return randomBytes(32).toString('base64url') +} + +/** Hash a session token for storage; the DB never holds the raw token. */ +export function hashSessionToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +/** Extract a named cookie value from a `Cookie` header. */ +export function parseCookie(header: string | undefined, name: string): string | undefined { + if (!header) return undefined + for (const part of header.split(';')) { + const idx = part.indexOf('=') + if (idx === -1) continue + if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim() + } + return undefined +} + +/** Build a `Set-Cookie` value for the session token. + * + * `SameSite=None` (with `Secure`) when behind HTTPS so the cookie survives the + * cross-subdomain hop `dsh.<domain>` → `<user>.dsh.<domain>`; `SameSite=Lax` + * otherwise. The subdomain auth check still validates cookie ↔ slug, so the + * looser SameSite does not widen who a session can reach. + */ +export function sessionCookie(token: string, maxAgeSeconds: number, secure: boolean, domain?: string): string { + const flags = ['sid=' + token, 'HttpOnly', secure ? 'SameSite=None' : 'SameSite=Lax', 'Path=/', `Max-Age=${maxAgeSeconds}`] + if (secure) flags.push('Secure') + if (domain !== undefined && domain !== '') flags.push(`Domain=${domain}`) + return flags.join('; ') +} + +/** Build a `Set-Cookie` value that expires the session cookie. */ +export function clearSessionCookie(secure: boolean, domain?: string): string { + const flags = ['sid=', 'HttpOnly', secure ? 'SameSite=None' : 'SameSite=Lax', 'Path=/', 'Max-Age=0'] + if (secure) flags.push('Secure') + if (domain !== undefined && domain !== '') flags.push(`Domain=${domain}`) + return flags.join('; ') +} diff --git a/src/web/file-service.ts b/src/web/file-service.ts new file mode 100644 index 0000000..70fb37d --- /dev/null +++ b/src/web/file-service.ts @@ -0,0 +1,150 @@ +/** + * The per-user file sidecar (docs/k8s.md §4.10). + * + * Runs inside the user's own Pod as the user's uid, so it *can* read and write + * their `0700` directory — the thing the control plane (uid 65532, no volume) + * cannot do. It serves exactly one user: the root is fixed at startup, and no + * request carries a user id. + * + * **No authentication.** The boundary is the NetworkPolicy that lets only the + * control plane reach port 8082, the same argument that lets the socat sidecar + * bridge 8081 unauthenticated (docs/k8s.md §6.1 item 4). If that policy is ever + * widened, this service needs a token check *first*. + * @module dshs/web/file-service + */ + +import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify' +import { LocalUserFs } from '../fs/local-user-fs.js' +import { UserFsError } from '../fs/user-fs.js' + +/** Port the sidecar binds; the per-user Service targets it directly. */ +export const FILE_SERVICE_PORT = 8082 + +/** Env var carrying the single user root this sidecar serves. */ +export const USER_ROOT_ENV = 'DSHS_USER_ROOT' + +/** The sidecar serves one user, so the id crossing {@link UserFs} is a constant. */ +const SOLE_USER = 'self' + +const pathSchema = { + body: { + type: 'object', + required: ['path'], + additionalProperties: false, + properties: { path: { type: 'string', maxLength: 512 } }, + }, +} as const + +const entrySchema = { + body: { + type: 'object', + required: ['path', 'name', 'type'], + additionalProperties: false, + properties: { + path: { type: 'string', maxLength: 512 }, + name: { type: 'string', maxLength: 255 }, + type: { type: 'string', enum: ['file', 'dir'] }, + }, + }, +} as const + +const uploadSchema = { + body: { + type: 'object', + required: ['path', 'name', 'data'], + additionalProperties: false, + properties: { + path: { type: 'string', maxLength: 512 }, + name: { type: 'string', maxLength: 255 }, + data: { type: 'string' }, + }, + }, +} as const + +const handoffSchema = { + body: { + type: 'object', + required: ['content'], + additionalProperties: false, + properties: { content: { type: 'string', maxLength: 8192 } }, + }, +} as const + +/** Reply with the seam's own wire form so the client can rebuild the error. */ +function fail(reply: FastifyReply, err: unknown): FastifyReply { + if (err instanceof UserFsError) return reply.code(err.status).send({ error: err.code }) + throw err +} + +/** + * Build the sidecar's Fastify instance. Does not listen; the caller binds. + * @param root - the user's data root inside the Pod. + * @param options - `bodyLimit` must exceed the control plane's upload cap. + */ +export function buildFileService(root: string, options: { bodyLimit: number; logLevel: string }): FastifyInstance { + const fs = new LocalUserFs(() => root) + const app = Fastify({ logger: { level: options.logLevel }, bodyLimit: options.bodyLimit }) + + app.get('/healthz', async () => ({ ok: true })) + + app.get('/fs/tree', async (request, reply) => { + const { path = '' } = request.query as { path?: string } + try { + return { entries: await fs.listDir(SOLE_USER, path) } + } catch (err) { + return fail(reply, err) + } + }) + + app.get('/fs/stat', async (request, reply) => { + const { path = '' } = request.query as { path?: string } + try { + return { isDirectory: await fs.isDirectory(SOLE_USER, path) } + } catch (err) { + return fail(reply, err) + } + }) + + app.get('/fs/plugins', async () => ({ plugins: await fs.listInstalledPlugins(SOLE_USER) })) + + app.post('/fs/init', async () => { + await fs.initUserRoot(SOLE_USER) + return { ok: true } + }) + + app.post('/fs/mkdir', { schema: pathSchema }, async (request, reply) => { + const { path } = request.body as { path: string } + try { + await fs.mkdir(SOLE_USER, path) + return { ok: true } + } catch (err) { + return fail(reply, err) + } + }) + + app.post('/fs/create', { schema: entrySchema }, async (request, reply) => { + const { path, name, type } = request.body as { path: string; name: string; type: 'file' | 'dir' } + try { + return { name: await fs.createEntry(SOLE_USER, path, name, type) } + } catch (err) { + return fail(reply, err) + } + }) + + app.post('/fs/upload', { schema: uploadSchema }, async (request, reply) => { + const { path, name, data } = request.body as { path: string; name: string; data: string } + try { + return { name: await fs.upload(SOLE_USER, path, name, Buffer.from(data, 'base64')) } + } catch (err) { + return fail(reply, err) + } + }) + + app.post('/fs/handoff', { schema: handoffSchema }, async (request) => { + const { content } = request.body as { content: string } + await fs.writeHandoff(SOLE_USER, content) + return { ok: true } + }) + + return app +} diff --git a/src/web/middleware/authn.ts b/src/web/middleware/authn.ts new file mode 100644 index 0000000..da1c615 --- /dev/null +++ b/src/web/middleware/authn.ts @@ -0,0 +1,38 @@ +/** + * Authentication / authorization guards. + * + * `requireAuth` resolves the session cookie into `request.user` (a PublicUser) + * with a single `sessions JOIN users` lookup; `requireAdmin` additionally + * enforces the admin role. Both reject with 401/403 and never continue the + * handler. + * @module dshs/web/middleware/authn + */ + +import type { FastifyReply, FastifyRequest } from 'fastify' +import { toPublicUser } from '../../db/types.js' +import { hashSessionToken, parseCookie } from '../auth.js' + +/** Resolve the session cookie into `request.user`, or reject 401. */ +export async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> { + const token = parseCookie(request.headers.cookie, 'sid') + if (token === undefined) { + reply.code(401).send({ error: 'unauthorized' }) + return + } + const row = await request.server.db.findSessionWithUser(hashSessionToken(token)) + if (row === undefined || row.expiresAt <= Date.now() || row.user.role === 'disabled') { + reply.code(401).send({ error: 'unauthorized' }) + return + } + request.user = toPublicUser(row.user) +} + +/** Require an authenticated admin; rejects non-admins with 403. */ +export async function requireAdmin(request: FastifyRequest, reply: FastifyReply): Promise<void> { + await requireAuth(request, reply) + if (request.user === null) return // requireAuth already sent the response + if (request.user.role !== 'admin') { + reply.code(403).send({ error: 'forbidden' }) + return + } +} diff --git a/src/web/middleware/fs-guard.ts b/src/web/middleware/fs-guard.ts new file mode 100644 index 0000000..93186d1 --- /dev/null +++ b/src/web/middleware/fs-guard.ts @@ -0,0 +1,64 @@ +/** + * Path-traversal guard for every filesystem-facing surface. + * + * `resolveWithinRoot` canonicalizes a user-supplied relative path against a + * root and rejects anything that escapes it; `safeFilename` strips directory + * components from an upload name. + * @module dshs/web/middleware/fs-guard + */ + +import { basename, isAbsolute, posix, resolve, sep } from 'node:path' + +/** The subset of `node:path` the guard needs, so a caller can pin POSIX + * semantics for paths that belong to a *remote* filesystem (a Linux Pod) + * rather than the host the control plane happens to run on. */ +export interface PathFlavor { + isAbsolute(path: string): boolean + resolve(...paths: string[]): string + readonly sep: string +} + +const NATIVE: PathFlavor = { isAbsolute, resolve, sep } + +/** POSIX flavor, for in-Pod paths built by the k8s backend. */ +export const POSIX: PathFlavor = { isAbsolute: posix.isAbsolute, resolve: posix.resolve, sep: posix.sep } + +/** Sentinel error for a path that escapes its allowed root. */ +export class PathEscapeError extends Error { + constructor(path: string, root: string) { + super(`path ${path} escapes root ${root}`) + this.name = 'PathEscapeError' + } +} + +/** + * Resolve `rel` against `root` and reject absolute paths, NUL bytes, and any + * result that escapes the root (including `..` traversal). + * @param root - the user's workspace root (absolute). + * @param rel - a user-supplied relative path (`''` resolves to `root`). + * @param flavor - path semantics; defaults to the host's. Pass {@link POSIX} + * when `root` names a directory inside a Linux Pod, not on this host. + */ +export function resolveWithinRoot(root: string, rel: string, flavor: PathFlavor = NATIVE): string { + const { isAbsolute, resolve, sep } = flavor + if (isAbsolute(rel)) throw new PathEscapeError(rel, root) + if (rel.includes('\0')) throw new PathEscapeError('<nul>', root) + const resolved = resolve(root, rel) + const boundary = root.endsWith(sep) ? root : root + sep + if (resolved !== root && !resolved.startsWith(boundary)) { + throw new PathEscapeError(rel, root) + } + return resolved +} + +/** + * Reduce an upload filename to its base name, rejecting empty/dot/NUL names so + * a client cannot smuggle a path component. + */ +export function safeFilename(name: string): string { + const base = basename(name) + if (base === '' || base === '.' || base === '..' || base.includes('\0')) { + throw new PathEscapeError(name, '<filename>') + } + return base +} diff --git a/src/web/middleware/rate-limit.ts b/src/web/middleware/rate-limit.ts new file mode 100644 index 0000000..6ae5b03 --- /dev/null +++ b/src/web/middleware/rate-limit.ts @@ -0,0 +1,17 @@ +/** + * Global rate limiting. The auth/admin surfaces get their own stricter limits + * in P1; this baseline covers the whole server. + * @module dshs/web/middleware/rate-limit + */ + +import type { FastifyPluginAsync } from 'fastify' +import fastifyRateLimit from '@fastify/rate-limit' + +/** Register the baseline rate limiter (applies to all routes). */ +export const rateLimit: FastifyPluginAsync = async (app) => { + await app.register(fastifyRateLimit, { + max: 100, + timeWindow: '1 minute', + global: true, + }) +} diff --git a/src/web/plugin-compat.ts b/src/web/plugin-compat.ts new file mode 100644 index 0000000..cb1d7f4 --- /dev/null +++ b/src/web/plugin-compat.ts @@ -0,0 +1,337 @@ +/** + * 插件兼容性预检 —— 判据实现在此(档案 71 / 交接单 T05) + * + * 背景:`@anysearch/anysearch-dsh` 与平台 dsh 0.1.2-rc.1 不兼容,装进实例后 + * `plugin tree failed to load`(缺 `assertNever`)→ 实例崩溃循环(档案 70)。 + * 现有链路只查「来源/内容安全」,不查「与平台版本兼不兼容」→ 本模块补上。 + * + * 两条判据(均已在生产实测复现该 bug): + * + * A. 依赖范围(semver,**默认语义,不传 includePrerelease**) + * 插件声明的 `@deepseek-ai/*` 依赖范围必须**接受**平台内置的同名包版本。 + * ⚠️ 为什么必须用默认语义:npm/pnpm 的实际安装判定就是默认语义 —— + * anysearch 写 `>=0.1.1-rc.1 <0.1.2`,默认语义下 **不满足** 0.1.2-rc.1, + * 于是 pnpm 给它装了自带的 `dsh-tool-web@0.1.1-rc.2` → 与平台 dsh-llm 冲突。 + * 若传 includePrerelease 会把它误判为「满足」而漏掉这个 bug(实测已确认)。 + * + * B. 导出符号(运行时真值) + * 插件 bundle 里 `import { X } from '@deepseek-ai/Y'`,`X` 必须在平台包 Y 的 + * **运行时真实导出**里存在。取法 = `await import()` 后取 `Object.keys()`, + * 比解析 `.d.ts` 可靠(实测平台 223 个包中 220 个可取到导出清单)。 + * + * 覆盖边界(重要):判据 B 只看**被扫描目录内**的 import。若插件把平台 API 的调用 + * 藏在**传递依赖**里(anysearch 就是:越界的是它的依赖 dsh-tool-web,不在 tgz 内), + * 本模块扫不出来 —— 那种情况由「启用前扫 profile node_modules」那一层负责(T05 §五.4)。 + * + * 设计原则:**任何异常都不得让上传流程失败** —— 判不出来就返回 `unknown`(放行 + 标记待复核), + * 绝不误伤正常插件。 + */ + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { join, extname } from 'node:path' +import { pathToFileURL } from 'node:url' + +/** 平台内置包根目录(本部署事实;env 可覆盖,便于测试)。 */ +const PLATFORM_DSH_ROOT = + process.env.DSH_COMPAT_ROOT ?? '/usr/local/lib/node_modules/@deepseek-ai/dsh' +const SCOPE_DIR = join(PLATFORM_DSH_ROOT, 'node_modules', '@deepseek-ai') +const DS_SCOPE = '@deepseek-ai/' + +export interface CompatFinding { + /** dep-range = 依赖范围不接受平台版本;missing-export = 平台包缺该导出 */ + kind: 'dep-range' | 'missing-export' + pkg: string + detail: string + file?: string +} + +export interface CompatResult { + /** incompatible = 已判定不兼容(调用方默认拒绝,admin 可显式信任);unknown = 静态判不了(放行 + 标记) */ + level: 'ok' | 'incompatible' | 'unknown' + findings: CompatFinding[] + /** 平台内置包数量(快照,供回显) */ + platformPkgs: number + /** 是否真的看到了 @deepseek-ai 的依赖声明或 import(区分 ok / unknown 用) */ + sawPlatformRef: boolean + /** 判定过程出错时的说明(此时 level=unknown) */ + note?: string +} + +/* ------------------------------------------------------------------ * + * 平台侧:版本表(读 package.json)与导出符号(按需 import,带缓存) + * ------------------------------------------------------------------ */ + +const versionCache = new Map<string, string | null>() +const exportCache = new Map<string, Set<string> | null>() + +function shortName(pkg: string): string { + return pkg.startsWith(DS_SCOPE) ? pkg.slice(DS_SCOPE.length) : pkg +} + +function platformPkgDir(pkg: string): string { + return join(SCOPE_DIR, shortName(pkg)) +} + +/** 平台内置 @deepseek-ai 包的数量(目录级,低成本)。 */ +export function platformPkgCount(): number { + try { + return readdirSync(SCOPE_DIR).filter((n) => !n.startsWith('.')).length + } catch { + return 0 + } +} + +/** 平台内置某包的版本;平台没有该包 → null。 */ +function platformVersion(pkg: string): string | null { + const hit = versionCache.get(pkg) + if (hit !== undefined) return hit + let v: string | null = null + try { + const pj = join(platformPkgDir(pkg), 'package.json') + if (existsSync(pj)) { + const meta = JSON.parse(readFileSync(pj, 'utf8')) as { version?: unknown } + v = typeof meta.version === 'string' ? meta.version : null + } + } catch { + v = null + } + versionCache.set(pkg, v) + return v +} + +function resolveEntryFile(pkg: string): string | null { + const base = platformPkgDir(pkg) + const cands: string[] = [] + try { + const meta = JSON.parse(readFileSync(join(base, 'package.json'), 'utf8')) as { + main?: unknown + module?: unknown + exports?: unknown + } + if (typeof meta.module === 'string') cands.push(meta.module) + if (typeof meta.main === 'string') cands.push(meta.main) + } catch { + /* 忽略:退回默认候选 */ + } + cands.push('lib/index.js', 'dist/index.js', 'esm/index.js', 'index.js', 'lib/index.mjs') + for (const c of cands) { + const f = join(base, c) + try { + if (existsSync(f) && statSync(f).isFile()) return f + } catch { + /* 忽略 */ + } + } + return null +} + +/** + * 平台某包的运行时导出符号集合。返回 null = 无法判定(**不能**据此断言不兼容)。 + * 只对「插件真的 import 了的包」调用,避免启动成本。 + */ +async function platformExports(pkg: string): Promise<Set<string> | null> { + const hit = exportCache.get(pkg) + if (hit !== undefined) return hit + let keys: Set<string> | null = null + try { + const entry = resolveEntryFile(pkg) + if (entry !== null) { + const mod = (await import(pathToFileURL(entry).href)) as Record<string, unknown> + keys = new Set(Object.keys(mod)) + } + } catch { + keys = null // import 失败可能是环境原因,不能当作「插件不兼容」的证据 + } + exportCache.set(pkg, keys) + return keys +} + +type SemverLike = { satisfies(v: string, r: string): boolean } +let semverPromise: Promise<SemverLike | null> | null = null + +function loadSemver(): Promise<SemverLike | null> { + semverPromise ??= (async () => { + try { + const m = (await import('semver')) as unknown as { default?: SemverLike } & SemverLike + return (m.default ?? m) as SemverLike + } catch { + return null + } + })() + return semverPromise +} + +/* ------------------------------------------------------------------ * + * 插件侧:依赖声明 + bundle 内的 @deepseek-ai import + * ------------------------------------------------------------------ */ + +const MAX_SCAN_FILES = 4000 +const SCAN_EXT = new Set(['.js', '.mjs', '.cjs']) +const IMPORT_RE = + /(?:import|export)\s*(?:\*\s*as\s*[\w$]+|\{([^}]*)\})\s*from\s*["'](@deepseek-ai\/[^"']+)["']/g + +interface ImportHit { + pkg: string + named: string[] + file: string +} + +function findPackageJson(root: string): string | null { + const direct = join(root, 'package.json') + if (existsSync(direct)) return direct + try { + for (const e of readdirSync(root, { withFileTypes: true })) { + if (e.isDirectory()) { + const nested = findPackageJson(join(root, e.name)) + if (nested !== null) return nested + } + } + } catch { + /* 忽略 */ + } + return null +} + +function collectJsFiles(dir: string, out: string[] = [], depth = 0): string[] { + if (out.length >= MAX_SCAN_FILES || depth > 8) return out + let entries: import('node:fs').Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return out + } + for (const e of entries) { + if (out.length >= MAX_SCAN_FILES) break + if (e.isDirectory()) { + if (e.name === 'node_modules' || e.name === '.git') continue + collectJsFiles(join(dir, e.name), out, depth + 1) + } else if (SCAN_EXT.has(extname(e.name))) { + out.push(join(dir, e.name)) + } + } + return out +} + +function scanImports(root: string): { hits: ImportHit[]; fileCount: number } { + const files = collectJsFiles(root) + const hits: ImportHit[] = [] + for (const f of files) { + let text: string + try { + text = readFileSync(f, 'utf8') + } catch { + continue + } + if (!text.includes(DS_SCOPE)) continue + IMPORT_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = IMPORT_RE.exec(text)) !== null) { + const spec = m[1] ?? '' + const named = spec + .split(',') + .map((s) => s.trim().split(/\s+as\s+/)[0]?.trim() ?? '') + .filter((s) => s !== '' && !s.startsWith('type ')) + hits.push({ pkg: m[2], named, file: f.slice(root.length + 1) }) + } + } + return { hits, fileCount: files.length } +} + +function declaredDeepseekDeps(pkgPath: string): Array<[string, string]> { + let meta: { + dependencies?: Record<string, string> + peerDependencies?: Record<string, string> + optionalDependencies?: Record<string, string> + } + try { + meta = JSON.parse(readFileSync(pkgPath, 'utf8')) as typeof meta + } catch { + return [] + } + const merged = { ...meta.peerDependencies, ...meta.optionalDependencies, ...meta.dependencies } + return Object.entries(merged).filter(([k]) => k.startsWith(DS_SCOPE)) +} + +/* ------------------------------------------------------------------ * + * 主入口 + * ------------------------------------------------------------------ */ + +/** + * 对一个**已解包**的插件目录做兼容性预检(只读)。 + * @param root 解包后的插件根目录(含 package.json 的那一层或其上层) + */ +export async function checkPluginCompat(root: string): Promise<CompatResult> { + const out: CompatResult = { + level: 'unknown', + findings: [], + platformPkgs: platformPkgCount(), + sawPlatformRef: false, + } + try { + if (out.platformPkgs === 0) { + out.note = `平台包目录不可读:${SCOPE_DIR}` + return out + } + const pkgPath = findPackageJson(root) + if (pkgPath === null) { + out.note = '未找到 package.json' + return out + } + + // ---- 判据 A:依赖范围 ---- + const deps = declaredDeepseekDeps(pkgPath) + if (deps.length > 0) out.sawPlatformRef = true + const semver = deps.length > 0 ? await loadSemver() : null + for (const [pkg, range] of deps) { + const v = platformVersion(pkg) + if (v === null) continue // 平台没这个包:不算不兼容(插件自带) + if (semver === null) { + out.note = 'semver 不可用,依赖范围未判定' + continue + } + let ok: boolean + try { + ok = semver.satisfies(v, range) // **默认语义**:与 pnpm 的实际安装判定一致 + } catch { + continue // 范围写法无法解析:不据此断言 + } + if (!ok) { + out.findings.push({ + kind: 'dep-range', + pkg, + detail: `平台为 ${v},插件要求 ${range}(不满足)`, + }) + } + } + + // ---- 判据 B:导出符号 ---- + const { hits, fileCount } = scanImports(root) + if (hits.length > 0) out.sawPlatformRef = true + for (const h of hits) { + if (h.named.length === 0) continue + if (platformVersion(h.pkg) === null) continue // 非平台内置包:跳过 + const exports = await platformExports(h.pkg) + if (exports === null) continue // 拿不到导出清单 → 不判定 + const missing = h.named.filter((n) => !exports.has(n)) + if (missing.length > 0) { + out.findings.push({ + kind: 'missing-export', + pkg: h.pkg, + detail: `平台包未导出:${missing.join(', ')}`, + file: h.file, + }) + } + } + + out.level = out.findings.length > 0 ? 'incompatible' : out.sawPlatformRef ? 'ok' : 'unknown' + if (out.level === 'unknown' && out.note === undefined) { + out.note = `插件未声明也未直接 import 任何 @deepseek-ai 包(扫描 ${fileCount} 个 js 文件)→ 需装后扫 node_modules 复核` + } + return out + } catch (err) { + // 任何意外都不影响上传流程:降级为 unknown(放行 + 标记) + out.level = 'unknown' + out.note = `预检异常(已降级为待复核):${err instanceof Error ? err.message : String(err)}` + return out + } +} diff --git a/src/web/routes/admin.ts b/src/web/routes/admin.ts new file mode 100644 index 0000000..9b3af9b --- /dev/null +++ b/src/web/routes/admin.ts @@ -0,0 +1,158 @@ +/** + * Admin routes: list users and approve/disable accounts. All guarded by + * `requireAdmin`. + * @module dshs/web/routes/admin + */ + +import type { FastifyPluginAsync } from 'fastify' +import { execFileSync, spawn } from 'node:child_process' +import { rm } from 'node:fs/promises' +import { requireAdmin } from '../middleware/authn.js' +import { userRoot } from '../../fs/workspace.js' + +/** 档案 36:新用户审批通过后自动铺「功能插件」分区的脚本(幂等)。 */ +const ENSURE_BIZ_PLUGINS = + process.env.DSH_ENSURE_BIZ_PLUGINS ?? '/opt/dshs/scripts/ensure-biz-plugins.cjs' + +export const adminRoutes: FastifyPluginAsync = async (app) => { + // 档案 28:存储用量面板(读取维护脚本生成的快照文件,避免每次请求都 du) + app.get('/api/admin/storage', { preHandler: requireAdmin }, async () => { + const { readFileSync } = await import('node:fs') + try { + return JSON.parse(readFileSync(process.env.DSH_STORAGE_REPORT ?? '/var/run/dsh-storage-report.json', 'utf8')) + } catch { + return { generatedAt: null, users: [], note: '报告尚未生成(cron 每小时刷新一次)' } + } + }) + + app.get('/api/admin/users', { preHandler: requireAdmin }, async () => ({ + users: await app.db.listPublicUsers(), + })) + + app.post('/api/admin/users/:id/approve', { preHandler: requireAdmin }, async (request, reply) => { + const { id } = request.params as { id: string } + const user = await app.db.findUserById(id) + if (user === undefined) return reply.code(404).send({ error: 'not_found' }) + if (user.role !== 'pending') return reply.code(409).send({ error: 'not_pending' }) + await app.db.setUserRole(id, 'active', request.user?.id) + await app.db.audit(request.user?.id ?? null, 'approve', JSON.stringify({ userId: id })) + // 档案 36:审批通过后异步铺「功能插件」分区(普通用户才能在设置里启停插件)。 + // fire-and-forget:不阻塞审批响应;失败由巡检(cron 跑同一脚本)兜底。 + const prov = spawn(process.execPath, [ENSURE_BIZ_PLUGINS, id], { stdio: "ignore", detached: true }) + prov.on("error", (err) => app.log.warn({ err }, "ensure-biz-plugins 调用失败(将由巡检兜底)")) + prov.unref() + return { ok: true } + }) + + app.post('/api/admin/users/:id/disable', { preHandler: requireAdmin }, async (request, reply) => { + const { id } = request.params as { id: string } + const user = await app.db.findUserById(id) + if (user === undefined) return reply.code(404).send({ error: 'not_found' }) + if (user.role === 'admin') return reply.code(409).send({ error: 'cannot_disable_admin' }) + await app.db.setUserRole(id, 'disabled', request.user?.id) + await app.db.deleteUserSessions(id) + await app.supervisor.stop(id) // 停掉该用户运行中的 DSH(待办.md §二) + await app.db.audit(request.user?.id ?? null, 'disable', JSON.stringify({ userId: id })) + return { ok: true } + }) + + app.post('/api/admin/users/:id/enable', { preHandler: requireAdmin }, async (request, reply) => { + const { id } = request.params as { id: string } + const user = await app.db.findUserById(id) + if (user === undefined) return reply.code(404).send({ error: 'not_found' }) + if (user.role !== 'disabled') return reply.code(409).send({ error: 'not_disabled' }) + await app.db.setUserRole(id, 'active', request.user?.id) + await app.db.audit(request.user?.id ?? null, 'enable', JSON.stringify({ userId: id })) + return { ok: true } + }) + + // 永久删除用户:admin 不可删;删除 = 停实例 → DB 事务清全部关联行 → + // 删数据目录(users/<id>/)→ 删 provision 创建的 OS 账号 dsh-<short>。 + app.delete('/api/admin/users/:id', { preHandler: requireAdmin }, async (request, reply) => { + const { id } = request.params as { id: string } + const user = await app.db.findUserById(id) + if (user === undefined) return reply.code(404).send({ error: 'not_found' }) + if (user.role === 'admin') return reply.code(409).send({ error: 'cannot_delete_admin' }) + + await app.supervisor.stop(id) // 停掉运行中的 DSH,避免进程占用刚删的目录 + + if (!(await app.db.deleteUser(id))) return reply.code(404).send({ error: 'not_found' }) + + // 数据目录:users/<id>/{home,ws} 整棵删除(force 容忍不存在) + await rm(userRoot(app.config.dataRoot, id), { recursive: true, force: true }) + + // OS 账号:与 provision-new-users.sh 同规则 dsh-<id 去横线前 20 位>;不存在则忽略 + const short = id.replace(/-/g, '').slice(0, 20) + try { + execFileSync('userdel', [`dsh-${short}`], { stdio: 'ignore' }) + } catch { + // 账号可能从未被 provision(如 uid 冲突跳过)——DB/目录已清,可接受 + } + + await app.db.audit(request.user?.id ?? null, 'delete_user', JSON.stringify({ userId: id, username: user.username })) + return { ok: true } + }) + /** + * 档案 47:实例共享运行时/工具清单(admin 只读)。 + * 数据全部用 shell 取(避免为此新增 import):版本 = 直接执行二进制; + * 基线 = cat /opt/dsh/state/runtime-baseline.json;清单 = cat SHARED-TOOLS.md。 + * 升级/卸载不在此做 —— 走 `scripts/install-*.sh`(幂等、带 sha256 校验), + * 升级后必须跑 `runtime-baseline.cjs --accept` 刷新基线(与档案 44 的版本冻结配套)。 + */ + app.get('/api/admin/runtime', { preHandler: requireAdmin }, async () => { + const sh = (cmd: string, args: string[], fallback = ''): string => { + try { return execFileSync(cmd, args, { encoding: 'utf8', timeout: 20000 }).trim() } catch { return fallback } + } + const first = (cmd: string, args: string[]): string => sh(cmd, args, '缺失').split('\n')[0] + + const items = [ + { name: 'python3', group: '运行时', kind: '可移植发行版', version: first('/usr/local/bin/python3', ['-V']), + source: 'python-build-standalone install_only_stripped', script: 'scripts/install-python-runtime.sh', removable: false }, + { name: 'pip3', group: '运行时', kind: '随 python3', version: first('/usr/local/bin/pip3', ['-V']), + source: '随 python-build-standalone', script: 'scripts/install-python-runtime.sh', removable: false }, + { name: 'node', group: '运行时', kind: '平台预装', version: first('/usr/local/bin/node', ['-v']), + source: '官方 node 分发(平台部署时预装)', script: '—(升级需走独立流程)', removable: false }, + { name: 'npm', group: '运行时', kind: '随 node', version: first('/usr/local/bin/npm', ['-v']), + source: '随 node', script: '—(升级需走独立流程)', removable: false }, + { name: 'jq', group: '共享工具', kind: '静态单文件', version: first('/usr/local/bin/jq', ['--version']), + source: 'jqlang/jq 官方静态构建', script: 'scripts/install-shared-tools.sh', removable: true }, + { name: 'rg', group: '共享工具', kind: '静态单文件(musl)', version: first('/usr/local/bin/rg', ['--version']), + source: 'BurntSushi/ripgrep musl 静态', script: 'scripts/install-shared-tools.sh', removable: true }, + { name: 'ffmpeg', group: '共享工具', kind: '静态单文件', version: first('/usr/local/bin/ffmpeg', ['-version']), + source: 'BtbN/FFmpeg-Builds 静态构建', script: 'scripts/install-shared-tools.sh', removable: true }, + { name: 'ffprobe', group: '共享工具', kind: '静态单文件', version: first('/usr/local/bin/ffprobe', ['-version']), + source: 'BtbN/FFmpeg-Builds 静态构建', script: 'scripts/install-shared-tools.sh', removable: true }, + ] + + // 与基线对比(档案 44) + let baseline: unknown = null + let drift: string[] = [] + try { + baseline = JSON.parse(sh('cat', ['/opt/dsh/state/runtime-baseline.json'], '{}')) + const v = (baseline as { versions?: Record<string, string> }).versions ?? {} + const num = (s: string): string => (s.match(/\d+\.\d+(\.\d+)?/) ?? [''])[0] + const pair: Array<[string, string]> = [ + ['python3', num(first('/usr/local/bin/python3', ['-V']))], + ['pip3', num(first('/usr/local/bin/pip3', ['-V']))], + ['node', num(first('/usr/local/bin/node', ['-v']))], + ['npm', num(first('/usr/local/bin/npm', ['-v']))], + ['jq', num(first('/usr/local/bin/jq', ['--version']))], + ['rg', num(first('/usr/local/bin/rg', ['--version']))], + ] + drift = pair.filter(([k, now]) => v[k] !== undefined && v[k] !== now).map(([k, now]) => `${k}: 基线 ${v[k]} → 现在 ${now}`) + } catch { /* 基线缺失不报错 */ } + + const bytes = Number(sh('du', ['-sk', '/usr/local/dsh-runtime'], '0').split(/\s+/)[0] || 0) * 1024 + return { + runtimeDir: { path: '/usr/local/dsh-runtime', bytes, installedAt: sh('stat', ['-c', '%y', '/usr/local/dsh-runtime']) }, + items, + baseline, + drift, + manifest: sh('cat', ['/usr/local/dsh-runtime/SHARED-TOOLS.md']), + installScripts: ['scripts/install-python-runtime.sh', 'scripts/install-shared-tools.sh'], + baselineScript: 'scripts/runtime-baseline.cjs --accept', + note: '实例内 /usr 为只读挂载 + /usr/local/bin 在实例 PATH 首位 → 这里装的东西对全部用户立即生效,用户无法自行安装或修改。', + } + }) + +} diff --git a/src/web/routes/auth.ts b/src/web/routes/auth.ts new file mode 100644 index 0000000..63ea334 --- /dev/null +++ b/src/web/routes/auth.ts @@ -0,0 +1,176 @@ +/** + * Auth routes: self-registration (→ pending), login, logout, and the current + * identity. Registration always yields a `pending` user; an admin approves it. + * @module dshs/web/routes/auth + */ + +import type { FastifyPluginAsync } from 'fastify' +import { randomUUID } from 'node:crypto' +import { requireAdmin, requireAuth } from '../middleware/authn.js' +import { homeRoot, userRoot } from '../../fs/workspace.js' +import { deriveKey, encrypt } from '../../crypto.js' +import { toPublicUser } from '../../db/types.js' +import { + clearSessionCookie, + hashPassword, + hashSessionToken, + newSessionToken, + parseCookie, + sessionCookie, + verifyPassword, +} from '../auth.js' + +const registerSchema = { + body: { + type: 'object', + required: ['username', 'password'], + additionalProperties: false, + properties: { + username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' }, + password: { type: 'string', minLength: 8, maxLength: 128 }, + }, + }, +} as const + +const loginSchema = { + body: { + type: 'object', + required: ['username', 'password'], + additionalProperties: false, + properties: { + username: { type: 'string', maxLength: 64 }, + password: { type: 'string', maxLength: 128 }, + }, + }, +} as const + +interface Credentials { + username: string + password: string +} + +export const authRoutes: FastifyPluginAsync = async (app) => { + app.post( + '/api/auth/register', + { schema: registerSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, + async (request, reply) => { + const { username, password } = request.body as Credentials + if ((await app.db.findUserByUsername(username)) !== undefined) { + return reply.code(409).send({ error: 'username_taken' }) + } + const id = randomUUID() + const homeDir = homeRoot(userRoot(app.config.dataRoot, id)) + const passHash = await hashPassword(password) + // Create the user first so `initUserRoot` resolves the DB-assigned uid + // (baseUid + row_id) instead of the hash fallback — the per-user Pods and + // the DSH Pod must run as the *same* uid or the DSH cannot write its dirs. + const user = await app.db.createUser({ id, username, passHash, role: 'pending', homeDir }) + await app.userFs.initUserRoot(id, user.uid ?? undefined) + await app.db.audit(id, 'register', JSON.stringify({ username })) + return reply.code(201).send({ user: { id, username, role: 'pending' } }) + }, + ) + + app.post( + '/api/auth/login', + { schema: loginSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, + async (request, reply) => { + const { username, password } = request.body as Credentials + const user = await app.db.findUserByUsername(username) + if (user === undefined || !(await verifyPassword(password, user.pass_hash))) { + return reply.code(401).send({ error: 'invalid_credentials' }) + } + if (user.role === 'pending') return reply.code(403).send({ error: 'pending_review' }) + if (user.role === 'disabled') return reply.code(403).send({ error: 'disabled' }) + + const token = newSessionToken() + // 单活跃会话(last-wins):新登录顶掉该账号此前所有会话——旧浏览器的 + // sid 立即失效,proxy/API 对其返回 401,需重新登录后才能继续访问。 + await app.db.deleteUserSessions(user.id) + await app.db.createSession({ + tokenHash: hashSessionToken(token), + userId: user.id, + expiresAt: Date.now() + app.config.sessionTtlSeconds * 1000, + ip: request.ip, + userAgent: request.headers['user-agent'], + }) + await app.db.audit(user.id, 'login', null) + reply.header( + 'set-cookie', + sessionCookie(token, app.config.sessionTtlSeconds, app.config.secureCookies, app.config.cookieDomain), + ) + return { user: toPublicUser(user) } + }, + ) + + app.post('/api/auth/logout', async (request, reply) => { + const token = parseCookie(request.headers.cookie, 'sid') + if (token !== undefined) await app.db.deleteSession(hashSessionToken(token)) + reply.header('set-cookie', clearSessionCookie(app.config.secureCookies, app.config.cookieDomain)) + return { ok: true } + }) + + // ---- 同源退出页:会话内「退出登录」整页跳转(清 sid + 删 session → 回登录页) ---- + app.get('/logout', async (request, reply) => { + const token = parseCookie(request.headers.cookie, 'sid') + if (token !== undefined) await app.db.deleteSession(hashSessionToken(token)) + reply.header('set-cookie', clearSessionCookie(app.config.secureCookies, app.config.cookieDomain)) + return reply.redirect('/') + }) + + app.get('/api/auth/me', { preHandler: requireAuth }, async (request) => ({ user: request.user })) + + const keyAddSchema = { + body: { + type: 'object', + required: ['name', 'apiKey'], + additionalProperties: false, + properties: { + name: { type: 'string', minLength: 1, maxLength: 32 }, + apiKey: { type: 'string', minLength: 1, maxLength: 256 }, + }, + }, + } as const + + // ---- 统一 KEY:仅管理员可管理。全局只认 admin 的启用 key(见 server.ts resolveApiKey), + // 普通用户不再有自配 key 的入口/能力;这些路由对非 admin 一律 403。 ---- + app.get('/api/me/keys', { preHandler: requireAdmin }, async (request) => ({ + keys: await app.db.listCredentialKeys(request.user!.id), + })) + + app.post('/api/me/keys', { preHandler: requireAdmin, schema: keyAddSchema }, async (request, reply) => { + const { name, apiKey } = request.body as { name: string; apiKey: string } + const cleanName = name.trim() + if (!/^[A-Za-z0-9\-_ .]{1,32}$/.test(cleanName)) { + return reply.code(400).send({ error: 'invalid_name' }) + } + // Header-safe charset only: reject spaces, quotes, non-ASCII, etc. + if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(apiKey)) { + return reply.code(400).send({ error: 'invalid_api_key' }) + } + const key = await app.db.setCredentialKey( + request.user!.id, + cleanName, + encrypt(apiKey, deriveKey(app.config.encryptionSecret)), + ) + await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName })) + // 统一 KEY:admin 换 key 后广播重启所有用户的实例(DEEPSEEK_API_KEY 是 spawn 时 + // 注入 env 的快照,不重启运行中的实例不会刷新)。 + await app.supervisor.restartAllMains() + return { key } + }) + + app.post('/api/me/keys/:id/select', { preHandler: requireAdmin }, async (request, reply) => { + const { id } = request.params as { id: string } + if (!(await app.db.selectCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' }) + // 切换启用的全局 key 后同样广播刷新 + await app.supervisor.restartAllMains() + return { ok: true } + }) + + app.delete('/api/me/keys/:id', { preHandler: requireAdmin }, async (request, reply) => { + const { id } = request.params as { id: string } + if (!(await app.db.deleteCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' }) + return { ok: true } + }) +} diff --git a/src/web/routes/business-plugins.ts b/src/web/routes/business-plugins.ts new file mode 100644 index 0000000..09bdbe3 --- /dev/null +++ b/src/web/routes/business-plugins.ts @@ -0,0 +1,742 @@ +/** + * Business-plugin (系统外插件) candidate-pool routes. Admin-only. + * + * Upload contract: a `.tgz` bundle containing a dsh cordis bundle — a + * `package.json` with `name` (npm package-name shape, scope allowed). The + * archive is extracted into staging, its `package.json` is validated, and its + * text files are scanned for obviously-malicious patterns (安全检测) before it + * is admitted to the pool. + * + * Same-name upload REPLACES the pool entry: the previous `.tgz` file is removed + * in full first (替换策略, not overwrite — stale files cannot survive). + * + * The pool is a set of `.tgz` files under `<dataRoot>/business-plugins/` plus a + * `business_plugins` row of metadata. Users enable a plugin per-instance in a + * later phase (档案 16 阶段 2), which copies the bundle into their profile. + * @module dshs/web/routes/business-plugins + */ + +import type { FastifyPluginAsync } from 'fastify' +import { execFileSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdir, rm, rename } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { requireAdmin, requireAuth } from '../middleware/authn.js' +import { httpError, scanDirDetailed, type ScanBlock, type ScanFinding } from '../security-scan.js' +import { checkPluginCompat, type CompatResult } from '../plugin-compat.js' +import { userRoot } from '../../fs/workspace.js' + +/** Route-level body limit for uploads (base64 ~4/3 of archive size). */ +const UPLOAD_BODY_LIMIT = 180 * 1024 * 1024 + +const STAGE_PREFIX = '.biz-plugin-' +const STAGE_ID_RE = /^\.biz-plugin-[0-9a-f]{12}$/ +const STAGE_TTL_MS = 10 * 60 * 1000 + +/** npm package-name shape, scope (`@scope/name`) allowed. */ +const PKG_NAME_RE = /^(?:@[a-z0-9][a-z0-9-]*\/)?[a-z0-9][a-z0-9-]*$/ + +/** Sanitize a package name into a flat, path-safe filename. */ +function sanitizeFileName(name: string): string { + return name.replace(/[^a-zA-Z0-9._-]/g, '_') +} + +interface ProfileManifest { + bundles: string[] + deps: Record<string, string> +} + +function readProfileManifest(dir: string): ProfileManifest { + try { + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { + dsh?: { profile?: { bundles?: string[] } } + dependencies?: Record<string, string> + } + return { bundles: pkg.dsh?.profile?.bundles ?? [], deps: pkg.dependencies ?? {} } + } catch { + return { bundles: [], deps: {} } + } +} + +function writeProfileManifest(dir: string, bundles: string[], deps: Record<string, string>): void { + const path = join(dir, 'package.json') + let pkg: Record<string, unknown> + try { + pkg = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown> + } catch { + pkg = { name: 'dsh-profile-web', private: true } + } + const dsh = (pkg.dsh ?? {}) as Record<string, unknown> + const profile = (dsh.profile ?? {}) as Record<string, unknown> + profile.bundles = bundles + dsh.profile = profile + pkg.dsh = dsh + pkg.dependencies = deps + writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n') +} + +/** Find the first `package.json` under `root` (depth ≤ 3), tolerant of the npm + * `package/` wrapper directory produced by `npm pack`. */ +function findPackageJson(root: string, depth = 0): string | null { + if (depth > 3) return null + const candidate = join(root, 'package.json') + if (existsSync(candidate)) return candidate + for (const entry of readdirSync(root)) { + const abs = join(root, entry) + if (lstatSync(abs).isDirectory() && !entry.startsWith('.')) { + const found = findPackageJson(abs, depth + 1) + if (found !== null) return found + } + } + return null +} + +interface StagedPlugin { + stage: string + tgzName: string + name: string + description: string | null + version: string | null + fileCount: number + /** P0 命中(**不阻断**,交调用方裁决:默认拒绝,或 admin 显式信任后放行)。 */ + blocked: ScanBlock[] + /** P1 告警(不阻断,记录 + 可回显)。 */ + warnings: ScanFinding[] + /** + * 兼容性预检结果(档案 71):`incompatible` 表示与当前平台 dsh 版本不兼容 + * (判据:`@deepseek-ai/*` 依赖范围 / 导出符号),**默认拒绝投放**,admin 可显式信任。 + * `unknown` = 静态判不了(放行 + 标记待装后复核)。 + */ + compat: CompatResult +} + +/** Extract + validate + scan a `.tgz` bundle under `<dataRoot>/<staging>`. */ +export async function stageTgzArchive(dataRoot: string, archive: Buffer): Promise<StagedPlugin> { + const stage = join(dataRoot, STAGE_PREFIX + randomBytes(6).toString('hex')) + const payload = join(stage, 'payload.tgz') + const unzipDir = join(stage, 'unzip') + mkdirSync(unzipDir, { recursive: true, mode: 0o700 }) + try { + writeFileSync(payload, archive) + + // 1. List members, reject absolute / traversal paths. + const members = execFileSync('tar', ['-tzf', payload], { encoding: 'utf8', timeout: 30000 }).split('\n') + let fileCount = 0 + for (let raw of members) { + raw = raw.trim() + if (raw === '' || raw.startsWith('./')) raw = raw.replace(/^\.\/+/, '') + if (raw === '') continue + if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) throw httpError(400, `tgz member uses an absolute path: ${raw}`) + if (raw.split('/').includes('..')) throw httpError(400, `tgz member escapes the root: ${raw}`) + if (!raw.endsWith('/')) fileCount += 1 + } + if (fileCount === 0) throw httpError(400, 'tgz 为空(无文件)') + + // 2. Extract. + execFileSync('tar', ['-xzf', payload, '-C', unzipDir], { timeout: 120000 }) + + // 3. Validate package.json. + const pkgJsonPath = findPackageJson(unzipDir) + if (pkgJsonPath === null) throw httpError(400, 'tgz 缺少 package.json —— 不是 dsh 插件包') + let pkg: { name?: unknown; version?: unknown; description?: unknown } + try { + pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as typeof pkg + } catch { + throw httpError(400, 'package.json 解析失败') + } + const name = typeof pkg.name === 'string' ? pkg.name.trim() : '' + if (name === '' || !PKG_NAME_RE.test(name)) { + throw httpError(400, `package.json name 无效(须为 npm 包名,可含 @scope):"${name}"`) + } + const version = typeof pkg.version === 'string' ? pkg.version.trim() : null + const description = typeof pkg.description === 'string' ? pkg.description.trim() : null + + // 4. Security scan (危险内容扫描). + // **收集式**:P0 命中不在此处抛出,而是随结构返回交调用方裁决。理由:命中常来自文档里的 + // 说明性字样(典型:第三方插件的 README 教用户把 key 写进 `.credentials.yaml`,被 + // `/\.credentials\.yaml/` 规则判成「读取实例会话密钥」)—— 直接 400 会让 admin 看不到 + // 命中详情、也无从对**已人工确认**的包放行。调用方(上传接口)默认拒绝并把逐条命中回显, + // 只有 admin **显式声明信任**(并写 audit)后才放行。 + const scanned = scanDirDetailed(unzipDir) + if (scanned.blocked.length > 0 || scanned.warnings.length > 0) { + process.stderr.write( + `[upload-scan] ${JSON.stringify({ + name, + blocked: scanned.blocked.length, + warnings: scanned.warnings.length, + detail: scanned.blocked.slice(0, 10), + })}\n`, + ) + } + + // 5. 兼容性预检(档案 71)。 + // 与安全扫描同一套裁决方式:**不在此处抛出**,把结果随结构返回交调用方裁决 + // (`incompatible` 默认拒绝并把逐条依据回显,admin 显式信任后才放行并留痕)。 + // 为什么必须在投放时判:不兼容的插件装上去会让实例 `plugin tree failed to load` + // → 崩溃循环(档案 70 的 anysearch 事故就是这么发生的)。 + const compat = await checkPluginCompat(unzipDir) + if (compat.level !== 'ok') { + process.stderr.write( + `[upload-compat] ${JSON.stringify({ + name, + level: compat.level, + findings: compat.findings.slice(0, 10), + note: compat.note ?? null, + })}\n`, + ) + } + + return { + stage, + tgzName: sanitizeFileName(name) + '.tgz', + name, + description, + version, + fileCount, + blocked: scanned.blocked, + warnings: scanned.warnings, + compat, + } + } catch (err) { + rm(stage, { recursive: true, force: true }).catch(() => undefined) + throw err + } +} + +function collectStaleStages(dataRoot: string): string[] { + let names: string[] = [] + try { + names = readdirSync(dataRoot).filter((n) => n.startsWith(STAGE_PREFIX)) + } catch { + return [] + } + const stale: string[] = [] + for (const name of names) { + try { + if (Date.now() - lstatSync(join(dataRoot, name)).mtimeMs > STAGE_TTL_MS) stale.push(name) + } catch { + stale.push(name) + } + } + return stale +} + +// ── web provider 联动(2026-09-12 · 档案 65)────────────────────────────── +// 为什么需要:dsh 的 web provider 选择是**单选** —— 显式配置即硬绑定(provider 一旦不在就报 +// CONFIGURED_MISSING 且**不回落**),未配置时只有「恰好一个可用」才自动选,多个可用直接 +// AMBIGUOUS 报错。于是「两个 provider 共存 + 用户可任意启停」必然出现坏状态。 +// 解法:平台在插件启停后**按当前 bundles 重算**这一段 —— +// 插件在 → 写死它声明的 searchProvider;插件不在 → 整段删除(回到「只有一个可用」时自动选)。 +// 平台策略:`fetchProvider` 恒为本地 `http`(保留 SSRF 防护),**忽略**插件对 fetch 的声明。 +// +// 放在模块级并导出(不依赖 `app`),以便被独立验证。 +export const WEB_PROVIDER_OPEN = '# >>> platform: web-provider (managed by business-plugins)' +export const WEB_PROVIDER_CLOSE = '# <<< platform: web-provider' +/** 历史托管段(`scripts/ensure-anysearch-admin.cjs` 早期直铺时写的),同步时一并清掉。 */ +export const WEB_PROVIDER_LEGACY: ReadonlyArray<readonly [string, string]> = [ + ['# >>> platform: anysearch-search', '# <<< platform: anysearch-search'], +] + +/** 扫描已启用 bundle 自带的 cordis.patch.yml,收集它们对 `dsh-web` 的 provider 声明。 */ +export function collectWebProviderClaims(dir: string, bundles: readonly string[]): { search: string | null } { + let search: string | null = null + for (const b of bundles) { + if (b.startsWith('@deepseek-ai/')) continue + let text: string + try { + text = readFileSync(join(dir, 'node_modules', b, 'cordis.patch.yml'), 'utf8') + } catch { + continue + } + let inWeb = false + let inConfig = false + for (const raw of text.split('\n')) { + const line = raw.replace(/\s+$/, '') + const idm = /^-\s+id:\s*(\S+)/.exec(line) + if (idm !== null) { + inWeb = idm[1] === 'web' + inConfig = false + continue + } + if (!inWeb) continue + if (/^\s+config:\s*$/.test(line)) { + inConfig = true + continue + } + if (!inConfig) continue + const sm = /^\s+searchProvider:\s*(\S+)/.exec(line) + if (sm !== null && search === null) search = sm[1] + } + } + return { search } +} + +/** 按当前 bundles 重算 profile 的 web provider 托管段(幂等:先摘旧段,再按需追加)。 */ +export function syncWebProviderPatch(dir: string): void { + const path = join(dir, 'cordis.patch.yml') + let text = existsSync(path) ? readFileSync(path, 'utf8') : '' + const blocks: ReadonlyArray<readonly [string, string]> = [[WEB_PROVIDER_OPEN, WEB_PROVIDER_CLOSE], ...WEB_PROVIDER_LEGACY] + for (const [open, close] of blocks) { + const s = text.indexOf(open) + const e = text.indexOf(close) + if (s < 0 || e <= s) continue + const lineStart = text.lastIndexOf('\n', s) + text = text.slice(0, lineStart < 0 ? s : lineStart) + text.slice(e + close.length) + } + const { search } = collectWebProviderClaims(dir, readProfileManifest(dir).bundles) + if (search !== null) { + text = text.replace(/\s*$/, '\n') + [ + '', + WEB_PROVIDER_OPEN, + '# 由业务插件的启用/禁用自动维护,请勿手改。策略:只换 search,fetch 恒为本地 http(保留 SSRF 防护)。', + '- id: web', + ' config:', + ` searchProvider: ${search}`, + ' fetchProvider: http', + WEB_PROVIDER_CLOSE, + '', + ].join('\n') + } + writeFileSync(path, text.replace(/^\s*\n/, '')) +} + +export const businessPluginRoutes: FastifyPluginAsync = async (app) => { + const poolDir = (): string => join(app.config.dataRoot, 'business-plugins') + + const gc = (): void => { + for (const stale of collectStaleStages(app.config.dataRoot)) { + rm(join(app.config.dataRoot, stale), { recursive: true, force: true }).catch(() => undefined) + } + } + + app.get('/api/plugins/business', { preHandler: requireAdmin }, async () => { + return { plugins: await app.db.listBusinessPlugins() } + }) + + app.post( + '/api/plugins/business', + { preHandler: requireAdmin, bodyLimit: UPLOAD_BODY_LIMIT }, + async (request, reply) => { + const body = request.body as { + file?: string + filename?: string + /** admin 对 P0 命中的显式信任声明;缺省时命中即拒绝(fail-closed)。 */ + trust?: { confirmed?: boolean; reason?: string } + } + try { + const filename = body.filename ?? '' + if (!basename(filename).toLowerCase().endsWith('.tgz')) throw httpError(400, '仅支持 .tgz 文件') + const archive = Buffer.from(body.file ?? '', 'base64') + if (archive.length === 0) throw httpError(400, 'empty archive payload') + gc() + const staged = await stageTgzArchive(app.config.dataRoot, archive) + + // 安全检测裁决:默认 fail-closed —— P0 命中即拒绝,但把逐条命中回显给 admin + //(旧行为只有一句脱敏文案,admin 无从判断)。admin 显式声明信任后才放行并留痕。 + const trusted = body.trust?.confirmed === true + if (staged.blocked.length > 0 && !trusted) { + await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined) + return reply.code(409).send({ + error: 'scan_blocked', + message: '安全检测命中 P0 规则,已拒绝投放。请逐条查看命中内容;确认该包可信后,再提交并在请求中声明信任(会记入审计日志)。', + blocked: staged.blocked, + warnings: staged.warnings, + }) + } + + // 兼容性裁决(档案 71):同一套 fail-closed + 显式信任。 + // 判据 A = `@deepseek-ai/*` 依赖范围是否接受平台版本;判据 B = import 的符号是否在平台包导出里。 + if (staged.compat.level === 'incompatible' && !trusted) { + await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined) + return reply.code(409).send({ + error: 'compat_incompatible', + message: '该插件与当前平台 dsh 版本不兼容,已拒绝投放(判据:依赖版本范围 / 导出符号)。请逐条查看依据;若已人工确认可用,再提交并在请求中声明信任(会记入审计日志)。', + compat: staged.compat, + warnings: staged.warnings, + }) + } + + // 替换策略:同名先删旧 tgz(旧文件无残留),再落新包。 + const dir = poolDir() + await mkdir(dir, { recursive: true, mode: 0o755 }) + const existing = await app.db.findBusinessPlugin(staged.name) + if (existing !== undefined) { + if (existsSync(existing.tgzPath)) await rm(existing.tgzPath, { force: true }) + } + const destPath = join(dir, staged.tgzName) + await rename(join(staged.stage, 'payload.tgz'), destPath) + + const plugin = await app.db.upsertBusinessPlugin({ + id: staged.name, + name: staged.name, + description: staged.description, + version: staged.version, + tgzPath: destPath, + fileSize: archive.length, + uploadedBy: request.user?.id ?? null, + }) + const compatOverrode = trusted && staged.compat.level === 'incompatible' + const overrode = trusted && (staged.blocked.length > 0 || compatOverrode) + await app.db.audit( + request.user?.id ?? null, + 'upload_business_plugin', + JSON.stringify({ + name: plugin.name, + version: plugin.version, + trustedOverride: overrode, + compatLevel: staged.compat.level, + }), + ) + if (overrode) { + await app.db.audit( + request.user?.id ?? null, + 'trust_business_plugin', + JSON.stringify({ + name: staged.name, + version: staged.version, + blocked: staged.blocked, + compat: compatOverrode ? staged.compat : undefined, + reason: body.trust?.reason ?? '', + }), + ) + } + await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined) + return { + ok: true, + plugin, + replaced: existing !== undefined, + trustedOverride: overrode, + compat: staged.compat, + warnings: staged.warnings, + } + } catch (err) { + const e = err as Error & { statusCode?: number } + return reply.code(e.statusCode ?? 400).send({ error: e.message }) + } + }, + ) + + app.delete('/api/plugins/business/:id', { preHandler: requireAdmin }, async (request, reply) => { + const { id } = request.params as { id: string } + if (!PKG_NAME_RE.test(id)) return reply.code(400).send({ error: 'invalid_plugin_id' }) + const existing = await app.db.findBusinessPlugin(id) + if (existing === undefined) return reply.code(404).send({ error: 'not_found' }) + if (existsSync(existing.tgzPath)) await rm(existing.tgzPath, { force: true }) + await app.db.deleteBusinessPlugin(id) + await app.db.audit(request.user?.id ?? null, 'delete_business_plugin', JSON.stringify({ name: id })) + return { ok: true } + }) + + // ── mine: per-user enable/disable of business plugins(异步 + pnpm) ────── + // 启用 = `pnpm add file:<tgz>`(装插件 + 依赖,pnpm 全局 store 硬链复用依赖)+ reconcile + // bundles;禁用 = `pnpm remove`(依赖从 dependencies 移除,重新启用靠 store 硬链秒恢复)。 + // 改完重启实例。安装异步执行:POST 返回 taskId,前端轮询 GET task/:id 看「阶段 + 状态」 + //(B 方案:不滚日志,失败给友好文案)。 + const profileDir = (userId: string): string => join(userRoot(app.config.dataRoot, userId), 'home', 'profiles', 'web') + /** 该用户的 `<root>/ws` —— pnpm 的 HOME(store / cache 落在这里)。 */ + const wsDir = (userId: string): string => join(userRoot(app.config.dataRoot, userId), 'ws') + + /** + * 该用户的 Linux uid/gid —— 取 `<root>/home` 的属主(编排器创建时就 chown 给了该用户)。 + * 不查 DB:uid 列与本目录属主由同一次创建写入,读目录少一处耦合。 + */ + const uidOf = (userId: string): number => lstatSync(join(userRoot(app.config.dataRoot, userId), 'home')).uid + + /** + * **以该用户身份**跑 pnpm(setpriv 降权)。 + * + * 为什么必须有:这条路(门户候选池启停)早期直接**以平台进程身份**跑 pnpm,装出来的文件属主是 + * root —— 此后该用户自己跑任何 `pnpm add/remove` 都会撞 `EACCES`,插件彻底装不上 + * (2026-09-12 实测:guest profile 下积了 561 个 root 属主项,升级直接失败)。 + * 平台侧脚本(`ensure-biz-plugins.cjs` 等)一直用的是 setpriv 正姿,两条路线不同所以没暴露。 + */ + const runPnpmAs = (userId: string, args: readonly string[], cwd: string): void => { + const uid = uidOf(userId) + execFileSync('setpriv', [ + '--reuid', String(uid), '--regid', String(uid), '--clear-groups', + 'env', `HOME=${wsDir(userId)}`, + 'pnpm', ...args, + ], { cwd, timeout: 180000, stdio: 'pipe' }) + } + + /** + * 属主自愈:把 profile 下 **root 属主**的残留项还给该用户(历史根因留下的存量)。 + * + * 每次改插件前跑一次 —— 这是「修根因 + 防复发」里的防复发那一半:即便将来**别的**写入路径 + * 又污染了属主,用户下次启停插件时也会被自动清理,不必再人工排查。 + * 用 `find -exec chown +` 而不是一次传全部路径,避免路径过多撞命令行长度上限。 + * + * @param userId - 目标用户。 + * @returns 修复的条目数(0 = 本来就干净)。 + */ + const healOwnership = (userId: string): number => { + const dir = profileDir(userId) + let count = 0 + try { + const out = execFileSync('find', [dir, '-user', 'root'], { encoding: 'utf8', timeout: 60000 }) + count = out.split('\n').filter((line) => line.trim() !== '').length + if (count === 0) return 0 + } catch { + return 0 + } + const uid = uidOf(userId) + try { + // `-h`:`.bin/*` 是指向别处的符号链接,要改链接本身而不是它的目标。 + execFileSync('find', [dir, '-user', 'root', '-exec', 'chown', '-h', `${uid}:${uid}`, '{}', '+'], { timeout: 180000 }) + } catch { + return 0 + } + return count + } + // pnpm 的 store 位置由 HOME 决定;dsh 跑 pnpm 时 HOME 指向用户 ws,store 落在 + // `<root>/ws/.local/share/pnpm/store/v3`(.modules.yaml 记录)。平台跑 pnpm 必须设同样的 + // HOME,否则会报 ERR_PNPM_UNEXPECTED_STORE —— 见上面 `runPnpmAs` 里的 `HOME=<ws>`。 + + interface PluginTask { + status: 'pending' | 'running' | 'success' | 'failed' | 'timeout' + stage: string + error?: string + enabled?: string[] + /** 档案 34:本轮因「启用后实例起不来」被自动摘掉的插件。 */ + rejected?: Array<{ id: string; reason: string }> + restarted: boolean + startedAt: number + updatedAt: number + } + const tasks = new Map<string, PluginTask>() + + /** 一个依赖是否带 dsh.bundle.patch(= 是 bundle,对齐 dsh 的 isBundle 判定)。 */ + function isBundle(dir: string, dep: string): boolean { + try { + const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', dep, 'package.json'), 'utf8')) as { + dsh?: { bundle?: { patch?: unknown } } + } + return pkg.dsh?.bundle?.patch !== undefined + } catch { + return false + } + } + + /** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles, + * 非 @deepseek-ai 模板且已不在 dependencies 的从 bundles 移除。返回最终 bundles。 */ + function reconcileBundles(dir: string): string[] { + const path = join(dir, 'package.json') + const pkg = JSON.parse(readFileSync(path, 'utf8')) as { + dependencies?: Record<string, string> + dsh?: { profile?: { bundles?: string[] } } + } + 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 + } + + /** 把 pnpm 的原始报错翻译成用户能懂的友好文案。 */ + function friendlyError(err: unknown): string { + const message = String((err as Error)?.message ?? err) + const stderr = String((err as { stderr?: Buffer })?.stderr ?? '') + const text = stderr || message + if (text.includes('ETIMEDOUT') || text.includes('timed out')) return '安装超时,请重试' + if (text.includes('No matching version')) return '插件版本不匹配' + if (text.includes('ENOTFOUND') || text.includes('getaddrinfo')) return '网络连接失败,请重试' + if (text.includes('Not Found') || text.includes('E404')) return '依赖包不存在,请重试' + return '安装失败,请重试或联系管理员' + } + + // ── 档案 34:profile 快照与还原(插件启用失败时回滚用)──────────────── + // 只快照我们会改动的文件;node_modules 不回滚(无害,重装很快,回滚反而慢且易碎)。 + const SNAPSHOT_FILES = ['package.json', 'pnpm-lock.yaml', 'cordis.patch.yml'] as const + + function snapshotProfile(dir: string): Record<string, string | null> { + const snap: Record<string, string | null> = {} + for (const f of SNAPSHOT_FILES) { + const p = join(dir, f) + snap[f] = existsSync(p) ? readFileSync(p, 'utf8') : null + } + return snap + } + + function restoreProfile(dir: string, snap: Record<string, string | null>): void { + for (const [f, content] of Object.entries(snap)) { + const p = join(dir, f) + if (content === null) { + if (existsSync(p)) rmSync(p, { force: true }) + } else { + writeFileSync(p, content) + } + } + } + + app.get('/api/plugins/mine', { preHandler: requireAuth }, async (request) => { + const user = request.user! + const { bundles } = readProfileManifest(profileDir(user.id)) + const enabled = new Set(bundles) + const plugins = await app.db.listBusinessPlugins() + return { + plugins: plugins.map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + version: p.version, + fileSize: p.fileSize, + enabled: enabled.has(p.id), + })), + } + }) + + app.post('/api/plugins/mine/apply', { preHandler: requireAuth }, async (request, reply) => { + const user = request.user! + const body = request.body as { plugins?: Array<{ id: string; enabled: boolean }> } + const selections = body.plugins ?? [] + for (const sel of selections) { + if (!PKG_NAME_RE.test(sel.id)) return reply.code(400).send({ error: `invalid plugin id: ${sel.id}` }) + } + const dir = profileDir(user.id) + await mkdir(dir, { recursive: true }) + const { bundles } = readProfileManifest(dir) + const enabledSet = new Set(bundles) + const toEnable = selections.filter((s) => s.enabled && !enabledSet.has(s.id)) + const toDisable = selections.filter((s) => !s.enabled && enabledSet.has(s.id)) + if (toEnable.length === 0 && toDisable.length === 0) return { ok: true, restarted: false, noop: true } + + const taskId = randomBytes(8).toString('hex') + tasks.set(taskId, { status: 'pending', stage: '排队中', restarted: false, startedAt: Date.now(), updatedAt: Date.now() }) + + void (async () => { + const task = tasks.get(taskId)! + task.status = 'running' + const setStage = (stage: string): void => { + task.stage = stage + task.updatedAt = Date.now() + } + setStage('准备中…') + + // 档案 34:应用前快照,供探活失败时回滚。 + const snap = snapshotProfile(dir) + const applied: string[] = [] + const rejected: Array<{ id: string; reason: string }> = [] + + const install = async (id: string): Promise<void> => { + const plugin = await app.db.findBusinessPlugin(id) + if (plugin === undefined) throw httpError(404, `插件不在候选池:${id}`) + // 先自愈存量属主污染 —— 否则以用户身份跑 pnpm 连旧文件都覆盖不了(EACCES 直接失败)。 + const healed = healOwnership(user.id) + if (healed > 0) { + await app.db.audit(user.id, 'heal_plugin_ownership', JSON.stringify({ stage: `install:${id}`, healed })) + } + runPnpmAs(user.id, ['add', 'file:' + plugin.tgzPath, '--ignore-workspace-root-check', '--reporter', 'silent'], dir) + reconcileBundles(dir) + syncWebProviderPatch(dir) + } + const uninstall = async (id: string): Promise<void> => { + const healed = healOwnership(user.id) + if (healed > 0) { + await app.db.audit(user.id, 'heal_plugin_ownership', JSON.stringify({ stage: `uninstall:${id}`, healed })) + } + runPnpmAs(user.id, ['remove', id, '-w', '--reporter', 'silent'], dir) + reconcileBundles(dir) + syncWebProviderPatch(dir) + } + const restartProbe = async (): Promise<{ ok: boolean; reason: string }> => { + await app.userFs.writeHandoff(user.id, JSON.stringify({ command: '', createdAt: Date.now() })) + return await app.supervisor.restartAndProbe(user.id) + } + + try { + // ① 禁用项:pnpm remove 不引入新代码,永远安全 → 不需要探活。 + for (const sel of toDisable) { + setStage(`正在停用 ${sel.id}…`) + await uninstall(sel.id) + } + + if (toEnable.length > 0) { + // ② 先整批试一次:正常路径(绝大多数)只花 1 次重启,与旧行为一致。 + setStage(`正在安装 ${toEnable.length} 个插件…`) + for (const sel of toEnable) await install(sel.id) + setStage('正在重启实例并探活…') + const probe = await restartProbe() + if (probe.ok) { + applied.push(...toEnable.map((s) => s.id)) + } else { + // ③ 探活失败 → 回滚到快照,再逐插件隔离定位:坏插件单独摘掉,好插件保留。 + setStage('实例未能启动,正在定位问题插件…') + restoreProfile(dir, snap) + for (let i = 0; i < toEnable.length; i++) { + const sel = toEnable[i] + setStage(`正在定位问题插件(${i + 1}/${toEnable.length}):${sel.id}`) + await install(sel.id) + const p = await restartProbe() + if (p.ok) { + applied.push(sel.id) + continue + } + setStage(`插件 ${sel.id} 与实例不兼容,已自动禁用`) + // 档案 79(2026-09-13):**必须 await** —— uninstall 是 async,缺 await 时它的 rejection + // 会逃出这个同步 try/catch,变成 unhandled rejection ⇒ **整个 orchestrator 进程退出** + // (07:43:58 实证:pnpm remove 报 Unknown option → 平台被带崩、全体用户瞬断)。 + try { await uninstall(sel.id) } catch { /* 尽力而为 */ } + rejected.push({ id: sel.id, reason: p.reason }) + await app.db.audit(user.id, 'plugin_incident', JSON.stringify({ pluginId: sel.id, reason: p.reason })) + } + // 定位循环结束时实例可能停在「刚摘掉坏插件」的状态 → 再确认一次。 + setStage('正在恢复实例…') + await restartProbe() + } + } + + const finalBundles = readProfileManifest(dir).bundles + task.enabled = finalBundles + task.rejected = rejected + task.restarted = true + task.status = 'success' + task.stage = rejected.length === 0 + ? '完成' + : `完成:${applied.length} 个已启用,${rejected.length} 个已自动禁用` + if (rejected.length > 0) { + task.error = `以下插件与实例不兼容,已自动禁用:${rejected.map((r) => r.id).join('、')}` + } + task.updatedAt = Date.now() + await app.db.audit(user.id, 'apply_business_plugins', JSON.stringify({ plugins: selections.map((s) => s.id), applied, rejected })) + } catch (err) { + // 兜底:任何异常都把 profile 还原,绝不让用户实例停在半应用状态。 + try { restoreProfile(dir, snap) } catch { /* ignore */ } + try { await restartProbe() } catch { /* ignore */ } + const friendly = friendlyError(err) + task.status = friendly === '安装超时,请重试' ? 'timeout' : 'failed' + task.stage = '失败' + task.error = friendly + task.updatedAt = Date.now() + } + })().catch((err: unknown) => { + // 档案 79 加固(2026-09-13):**双保险** —— 任何逃出内层 try/catch 的异常都不许终止进程。 + // 没有这一层时,一次 uninstall 失败就把整个 dshs 带崩(全体用户瞬断 + 所有实例被停)。 + // 注意:`task` 是 IIFE 内的局部量,这里只能按 taskId 从 tasks 表取回。 + try { + const t = tasks.get(taskId) + if (t !== undefined) { + t.status = 'failed' + t.stage = '失败' + t.error = String((err as Error)?.message ?? err) + t.updatedAt = Date.now() + } + } catch { /* ignore */ } + try { console.error('[business-plugins] apply task crashed:', err) } catch { /* ignore */ } + }) + return { ok: true, taskId } + }) + + app.get('/api/plugins/mine/task/:id', { preHandler: requireAuth }, async (request, reply) => { + const { id } = request.params as { id: string } + const task = tasks.get(id) + if (task === undefined) return reply.code(404).send({ error: 'not_found' }) + return { ...task } + }) +} diff --git a/src/web/routes/desktop.ts b/src/web/routes/desktop.ts new file mode 100644 index 0000000..dd40a27 --- /dev/null +++ b/src/web/routes/desktop.ts @@ -0,0 +1,155 @@ +/** + * Desktop / filesystem routes: list the user's workspace, create a folder, and + * upload a file. Every path is resolved against the caller's own workspace + * root, so one user can never address another user's files. + * + * The routes never touch `node:fs` themselves — they go through the + * {@link UserFs} seam, which is in-process under `local` and a per-user file + * sidecar under `k8s` (docs/k8s.md §4.10). + * @module dshs/web/routes/desktop + */ + +import type { FastifyPluginAsync, FastifyReply } from 'fastify' +import { requireAuth } from '../middleware/authn.js' +import { UserFsError } from '../../fs/user-fs.js' +import { extname } from 'node:path' + +const mkdirSchema = { + body: { + type: 'object', + required: ['path'], + additionalProperties: false, + properties: { path: { type: 'string', maxLength: 512 } }, + }, +} as const + +const uploadSchema = { + body: { + type: 'object', + required: ['path', 'name', 'data'], + additionalProperties: false, + properties: { + path: { type: 'string', maxLength: 512 }, + name: { type: 'string', maxLength: 255 }, + data: { type: 'string' }, + }, + }, +} as const + +const createSchema = { + body: { + type: 'object', + required: ['path', 'name', 'type'], + additionalProperties: false, + properties: { + path: { type: 'string', maxLength: 512 }, + name: { type: 'string', maxLength: 255 }, + type: { type: 'string', enum: ['file', 'dir'] }, + }, + }, +} as const + +/** 档案 56:下载时的 content-type(只覆盖常见类型,其余 application/octet-stream)。 */ +const DL_TYPES: Record<string, string> = { + '.md': 'text/markdown; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.csv': 'text/csv; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.cjs': 'text/javascript; charset=utf-8', + '.ts': 'text/plain; charset=utf-8', + '.py': 'text/plain; charset=utf-8', + '.sh': 'text/plain; charset=utf-8', + '.yml': 'text/plain; charset=utf-8', + '.yaml': 'text/plain; charset=utf-8', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.xls': 'application/vnd.ms-excel', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.pdf': 'application/pdf', + '.zip': 'application/zip', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.mp4': 'video/mp4', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.m4a': 'audio/mp4', +} +function downloadType(name: string): string { + return DL_TYPES[extname(name).toLowerCase()] ?? 'application/octet-stream' +} + +/** Turn a seam failure into the `{error}` body the desktop UI switches on. */ +export function sendFsError(reply: FastifyReply, err: unknown): FastifyReply { + if (err instanceof UserFsError) return reply.code(err.status).send({ error: err.code }) + throw err +} + +export const desktopRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/desktop/tree', { preHandler: requireAuth }, async (request, reply) => { + const { path = '' } = request.query as { path?: string } + try { + return { path, entries: await app.userFs.listDir(request.user!.id, path) } + } catch (err) { + return sendFsError(reply, err) + } + }) + + app.post('/api/fs/mkdir', { preHandler: requireAuth, schema: mkdirSchema }, async (request, reply) => { + const { path } = request.body as { path: string } + try { + await app.userFs.mkdir(request.user!.id, path) + return { ok: true } + } catch (err) { + return sendFsError(reply, err) + } + }) + + app.post('/api/fs/upload', { preHandler: requireAuth, schema: uploadSchema }, async (request, reply) => { + const { path, name, data } = request.body as { path: string; name: string; data: string } + let buf: Buffer + try { + buf = Buffer.from(data, 'base64') + } catch { + return reply.code(400).send({ error: 'bad_data' }) + } + try { + return { ok: true, name: await app.userFs.upload(request.user!.id, path, name, buf) } + } catch (err) { + return sendFsError(reply, err) + } + }) + + // 档案 56:下载/查看工作区文件 —— 之前**没有**任何读取端点,AI 产出的文件用户既看不到也下不了, + // 而 AI 只能给出 `/var/lib/.../ws/...` 这类宿主绝对路径(浏览器打不开)。 + // 路径经 UserFs 解析(限定在调用者自己的根内,逃逸即 bad_path),并限制单文件大小。 + app.get('/api/fs/download', { preHandler: requireAuth }, async (request, reply) => { + const { path = '' } = request.query as { path?: string } + try { + const { name, data } = await app.userFs.readFile(request.user!.id, path) + return reply + .header('content-type', downloadType(name)) + .header('content-disposition', "attachment; filename*=UTF-8''" + encodeURIComponent(name)) + .header('cache-control', 'no-store') + .send(data) + } catch (err) { + return sendFsError(reply, err) + } + }) + + app.post('/api/fs/create', { preHandler: requireAuth, schema: createSchema }, async (request, reply) => { + const { path, name, type } = request.body as { path: string; name: string; type: 'file' | 'dir' } + try { + return { ok: true, name: await app.userFs.createEntry(request.user!.id, path, name, type), type } + } catch (err) { + return sendFsError(reply, err) + } + }) +} diff --git a/src/web/routes/domain.ts b/src/web/routes/domain.ts new file mode 100644 index 0000000..0e3537c --- /dev/null +++ b/src/web/routes/domain.ts @@ -0,0 +1,59 @@ +/** + * Custom-domain routes: set/get a user's domain, regenerate its nginx config, + * and admin verification. Real ACME/DNS ownership verification is deferred; + * the `verified` flag is set by an admin as a placeholder until then. + * @module dshs/web/routes/domain + */ + +import type { FastifyPluginAsync } from 'fastify' +import { requireAdmin, requireAuth } from '../middleware/authn.js' +import { isValidDomain, renderServerBlock } from '../../nginx/generate.js' + +const putSchema = { + body: { + type: 'object', + required: ['domain'], + additionalProperties: false, + properties: { domain: { type: 'string', minLength: 1, maxLength: 253 } }, + }, +} as const + +export const domainRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/domain', { preHandler: requireAuth }, async (request) => { + const domain = await app.db.findDomainByUser(request.user!.id) + if (domain === undefined) return { domain: null } + return { domain: domain.domain, verified: domain.verified === 1, nginx_config: domain.nginxConfig } + }) + + app.put('/api/domain', { preHandler: requireAuth, schema: putSchema }, async (request, reply) => { + const normalized = (request.body as { domain: string }).domain.toLowerCase().trim() + if (!isValidDomain(normalized)) return reply.code(400).send({ error: 'invalid_domain' }) + const config = renderServerBlock(normalized, request.user!.id, app.config.port) + await app.db.upsertDomain(request.user!.id, normalized, config) + return { domain: normalized, verified: false, nginx_config: config } + }) + + app.post('/api/nginx/regen', { preHandler: requireAuth }, async (request, reply) => { + const domain = await app.db.findDomainByUser(request.user!.id) + if (domain === undefined) return reply.code(404).send({ error: 'no_domain' }) + const config = renderServerBlock(domain.domain, request.user!.id, app.config.port) + await app.db.upsertDomain(request.user!.id, domain.domain, config) + return { domain: domain.domain, nginx_config: config } + }) + + app.get('/api/admin/domains', { preHandler: requireAdmin }, async () => ({ + domains: (await app.db.listDomains()).map((d) => ({ + id: d.id, + userId: d.userId, + domain: d.domain, + verified: d.verified === 1, + })), + })) + + app.post('/api/admin/domains/:id/verify', { preHandler: requireAdmin }, async (request, reply) => { + const { id } = request.params as { id: string } + if ((await app.db.findDomainById(id)) === undefined) return reply.code(404).send({ error: 'not_found' }) + await app.db.setDomainVerified(id, true) + return { ok: true } + }) +} diff --git a/src/web/routes/dsh.ts b/src/web/routes/dsh.ts new file mode 100644 index 0000000..c003903 --- /dev/null +++ b/src/web/routes/dsh.ts @@ -0,0 +1,242 @@ +/** + * DSH launch / supervise / restart routes + the reverse proxy to a running + * instance. Launch resolves the requested folder, reads its enabled plugins, + * writes a cordis patch, and spawns a main+watchdog pair; restart writes a + * post-restart command handoff and respawns the main. + * @module dshs/web/routes/dsh + */ + +import type { FastifyPluginAsync, FastifyReply } from 'fastify' +import { requireAuth } from '../middleware/authn.js' +import { sendFsError } from './desktop.js' +import { AlreadyRunningError, CrashBreakerOpenError } from '../../supervisor/orchestrator.js' +import { renderPatch } from '../../supervisor/patch.js' +import { subdomainForUser } from '../../supervisor/proxy.js' +import { homeRoot, userRoot } from '../../fs/workspace.js' +import { latestSessionPreset } from '../../supervisor/session-preset.js' + +const launchSchema = { + body: { + type: 'object', + required: ['folder'], + additionalProperties: false, + properties: { folder: { type: 'string', maxLength: 512 } }, + }, +} as const + +const restartSchema = { + body: { + type: 'object', + required: ['command'], + additionalProperties: false, + properties: { command: { type: 'string', maxLength: 1024 } }, + }, +} as const + +function alive(status: string | undefined): boolean { + // 'failed' = 崩溃熔断后停止自动重启(档案 20),同样不可复用。 + return status !== undefined && status !== 'crashed' && status !== 'stopped' && status !== 'failed' +} + +/** + * 档案 78:熔断冷却期内的统一响应(503 + 可读信息)。 + * + * 为什么是 503 而不是 500:这不是"服务坏了",而是平台**主动拒绝在冷却期内启动**, + * 客户端应展示「稍后重试」。`retryAfterMs` 供前端提示具体等待时长; + * `opens` 是累计熔断次数(同一实例反复崩 → 该值递增,可据此判断"该找人了")。 + */ +function sendBreakerOpen(reply: FastifyReply, err: CrashBreakerOpenError): FastifyReply { + return reply.code(503).send({ + error: 'instance_circuit_open', + opens: err.opens, + retryAfterMs: err.retryAfterMs, + message: '实例因连续崩溃被暂时熔断,请稍后重试', + }) +} + +function dshUrl(baseDomain: string, user: { id: string; username: string }, token?: string): string { + const sub = subdomainForUser(baseDomain, user.username) + const base = sub !== null ? `https://${sub}/` : `/u/${user.id}/dsh/` + return token !== undefined && token !== '' ? `${base}?token=${encodeURIComponent(token)}` : base +} + +export const dshRoutes: FastifyPluginAsync = async (app) => { + app.post('/api/dsh/launch', { preHandler: requireAuth, schema: launchSchema }, async (request, reply) => { + const { folder } = request.body as { folder: string } + const user = request.user! + const fs = app.userFs + let folderAbs: string + try { + folderAbs = fs.resolvePath(user.id, folder) + if (!(await fs.isDirectory(user.id, folder))) return reply.code(400).send({ error: 'not_a_folder' }) + } catch (err) { + return sendFsError(reply, err) + } + + // Per-folder plugin selection → cordis patch. Rendered here but *not* + // written: the spawner decides where it lands (a file under local, a + // ConfigMap under k8s, where the control plane has no user volume). + let patch: string | undefined + if (app.config.enablePatch) { + const workspace = await app.db.findWorkspaceByPath(user.id, folder) + // Only inject plugins the user still has installed; a stale selection for + // a since-removed bundle would otherwise fail to resolve in the child DSH. + const installed = new Set((await fs.listInstalledPlugins(user.id)).map((plugin) => plugin.id)) + const enabled = (workspace === undefined ? [] : await app.db.getEnabledPluginIds(workspace.id)) + .filter((id) => installed.has(id)) + patch = renderPatch(enabled) + } + + try { + const instance = await app.supervisor.launch(user.id, folderAbs, patch) + return { + instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken }, + url: dshUrl(app.config.baseDomain, user, instance.launchToken), + } + } catch (err) { + if (err instanceof AlreadyRunningError) return reply.code(409).send({ error: 'already_running' }) + // 档案 78:熔断冷却期内的启动被拒(用户选文件夹也会走到这里) + if (err instanceof CrashBreakerOpenError) return sendBreakerOpen(reply, err) + throw err + } + }) + + // 档案 20:handoff 停写。重启后执行命令的能力依赖 watchdog,而 watchdog 需要 + // `ENABLE_PATCH=true`(线上为 false → 永不启动),且 handoff.json 无人消费。 + // 停写避免"写了没人读"的误导;command 若不为空则记日志留痕。 + app.post('/api/dsh/restart', { preHandler: requireAuth, schema: restartSchema }, async (request, reply) => { + const { command } = request.body as { command: string } + const user = request.user! + if (command !== '') { + process.stderr.write( + `[handoff-disabled] restart-with-command ignored (userId=${user.id}, bytes=${command.length})\n`, + ) + } + const instance = await app.supervisor.restartMain(user.id) + if (instance === undefined) return reply.code(404).send({ error: 'not_running' }) + await app.supervisor.spawnWatchdog(user.id) + return { + instance: { + id: instance.id, + port: instance.port, + status: instance.status, + launchToken: instance.launchToken, + restarts: instance.restarts ?? 0, + }, + // 能力未启用:请勿依赖"重启后执行命令"(档案 20 §四 A3)。 + handoff: { accepted: false, reason: 'watchdog_disabled' }, + url: dshUrl(app.config.baseDomain, user, instance.launchToken), + } + }) + + app.post('/api/dsh/stop', { preHandler: requireAuth }, async (request) => { + await app.supervisor.stop(request.user!.id) + return { ok: true } + }) + + // 档案 56 ①:会话权限档位提示 —— 档位是**会话创建时播种**的,平台改默认值不会更新既有会话; + // 老会话停在 workspace-write 时(本机沙箱后端不可用)dsh 会 fail-closed 拒绝任何 shell, + // 用户只看到「bash 不可用」。这里把差异暴露出来,由实例页面提示用户切换。 + app.get('/api/dsh/session-permission', { preHandler: requireAuth }, async (request) => { + const expected = process.env.DSH_PERMISSION_MODE ?? 'danger-full-access' + const home = homeRoot(userRoot(app.config.dataRoot, request.user!.id)) + const session = latestSessionPreset(home) ?? null + // 只有"受限档位 + 沙箱后端不可用"这一种组合会真正挡住用户,故仅对 workspace-write 报警。 + const stale = session !== null && session.preset !== expected && session.preset === 'workspace-write' + return { expected, session, stale } + }) + + // 档案 56 ②:实例能力清单(由 scripts/gen-capabilities.cjs 生成,与实例内 skill 同源)。 + app.get('/api/capabilities', { preHandler: requireAuth }, async () => { + const { readFileSync } = await import('node:fs') + const file = process.env.DSH_CAPABILITIES_FILE ?? '/opt/dsh/state/capabilities.json' + try { + return JSON.parse(readFileSync(file, 'utf8')) + } catch { + return { generatedAt: null, note: '能力清单尚未生成(运行 scripts/gen-capabilities.cjs)' } + } + }) + + app.get('/api/dsh/status', { preHandler: requireAuth }, async (request) => { + const { main, watchdog } = await app.supervisor.status(request.user!.id) + return { + running: alive(main?.status), + instance: main + ? { + id: main.id, + port: main.port, + status: main.status, + exitCode: main.exitCode, + lastError: main.lastError, + // 观测面(档案 20 · A2):自动重启次数 + 最近崩溃时间 + restarts: main.restarts ?? 0, + lastCrashedAt: main.lastCrashedAt ?? null, + } + : null, + watchdog: watchdog ? { id: watchdog.id, status: watchdog.status, exitCode: watchdog.exitCode } : null, + // 观测面(档案 78):熔断状态 —— 非 null 即"该用户正被冷却",供门户/排查直接看到 + breaker: app.supervisor.breakerInfo?.(request.user!.id) ?? null, + url: dshUrl(app.config.baseDomain, request.user!, main?.launchToken), + } + }) + + // 登录直达(方案 05,2026-09-09;admin 亦直达 —— 2026-09-09 决策②补充): + // 所有已放行角色(admin / active)统一:已运行实例复用其 launchToken URL, + // 未运行则在 ws 根目录 launch 后返回带 token 的会话 URL。 + // desktop 管理台不再作为登录落点,admin 经会话侧栏"门户/管理台"入口或直 + // 接访问 /desktop.html 进入。 + app.post('/api/dsh/enter', { preHandler: requireAuth }, async (request, reply) => { + const user = request.user! + if (user.role !== 'active' && user.role !== 'admin') return reply.code(403).send({ error: 'not_allowed' }) + // Entering the workspace is itself activity (idle-reap signal). + app.supervisor.touch(user.id) + + // 复用已运行实例(含刚被其它请求 spawn、仍在启动中的实例):等 launch token 到位 + // 再返回 URL,避免把浏览器直接导向「HTTP 已监听但路由未就绪 → 404」的启动窗口。 + let { main } = await app.supervisor.status(user.id) + if (main !== undefined && alive(main.status)) { + if ((main.launchToken ?? '') === '') await app.supervisor.waitForLaunchTokenForUser(user.id) + main = (await app.supervisor.status(user.id)).main + if (main !== undefined && alive(main.status) && (main.launchToken ?? '') !== '') { + return { + kind: 'session', + instance: { id: main.id, port: main.port, status: main.status }, + url: dshUrl(app.config.baseDomain, user, main.launchToken), + } + } + // 启动超时或中途崩溃:不给空 token URL(否则浏览器会撞 404),改提示稍后重试 + return reply.code(503).send({ error: 'instance_starting' }) + } + + try { + const fs = app.userFs + const folderAbs = fs.resolvePath(user.id, '') + if (!(await fs.isDirectory(user.id, ''))) return reply.code(400).send({ error: 'not_a_folder' }) + // 根目录 launch:无文件夹级插件勾选 → patch 传 undefined(渲染为空) + const instance = await app.supervisor.launch(user.id, folderAbs, undefined) + return { + kind: 'session', + instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken }, + url: dshUrl(app.config.baseDomain, user, instance.launchToken), + } + } catch (err) { + if (err instanceof AlreadyRunningError) { + // 并发进入:另一请求正在 spawn 同一实例 → 同样等 token 到位再返回 + await app.supervisor.waitForLaunchTokenForUser(user.id) + const m = (await app.supervisor.status(user.id)).main + if (m !== undefined && alive(m.status) && (m.launchToken ?? '') !== '') { + return { + kind: 'session', + instance: { id: m.id, port: m.port, status: m.status }, + url: dshUrl(app.config.baseDomain, user, m.launchToken), + } + } + return reply.code(503).send({ error: 'instance_starting' }) + } + // 档案 78:熔断冷却期内拒绝隐式拉起(用户 F5 / 注入脚本自愈都会到这里) + if (err instanceof CrashBreakerOpenError) return sendBreakerOpen(reply, err) + throw err + } + }) + +} diff --git a/src/web/routes/skills.ts b/src/web/routes/skills.ts new file mode 100644 index 0000000..a54d466 --- /dev/null +++ b/src/web/routes/skills.ts @@ -0,0 +1,686 @@ +/** + * Skill management routes. + * + * Two scopes, mirroring the dsh skill layers discovered by + * `@deepseek-ai/dsh-skill-filesystem`: + * - shared (`/api/skills/shared`): admin-only. Targets the platform + * bundled layer (`DSH_BUNDLED_SKILL_DIR`, rank 600, read-only for users). + * - mine (`/api/skills/mine`): any authenticated user. Targets the + * user's own `$DSH_HOME/skills` (rank 400 user-dsh layer). + * + * Upload contract (ZIP only): one base64 `.zip` whose root holds exactly one + * top-level directory containing `SKILL.md` with frontmatter `name` + * (`^[a-z0-9]+(-[a-z0-9]+)*$`) and `description`. Both are REQUIRED — dsh + * ignores skills that miss either (see parseSkillFile / SKILL_NAME in + * @deepseek-ai/dsh-skill). + * + * Two-phase install for overwrite safety: + * 1. POST upload → validate + stage the archive. If no same-name skill + * exists it is installed immediately; if one exists the response carries + * `conflict: true` + `stagedId` and nothing is touched yet. + * 2. POST apply → after the user confirms, atomically REPLACE the target: + * the previous skill directory is removed in full (files absent from the + * new zip are deleted too), then the staged one is renamed in. + * + * Discovery is watch-based on the host, so install/remove take effect without + * an instance restart (verified: addDir/unlinkDir watch events invalidate). + * @module dshs/web/routes/skills + */ + +import type { FastifyPluginAsync } from 'fastify' +import { execFileSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { chmodSync, chownSync, existsSync, lchownSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { requireAdmin, requireAuth } from '../middleware/authn.js' +import { scanDir } from '../security-scan.js' +import { userRoot } from '../../fs/workspace.js' + +/** dsh skill-name rule (mirrors SKILL_NAME in @deepseek-ai/dsh-skill). */ +const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +/** Route-level body limit for uploads (base64 ~4/3 of archive size). */ +const UPLOAD_BODY_LIMIT = 180 * 1024 * 1024 + +const STAGE_PREFIX = '.skill-upload-' +/** A staged upload id is just the staging dir name under `dataRoot`. */ +const STAGE_ID_RE = /^\.skill-upload-[0-9a-f]{12}$/ +/** Staging dirs older than this (ms) are garbage-collected on next upload. */ +const STAGE_TTL_MS = 10 * 60 * 1000 + +/** + * 上传硬上限(档案 41 · zip bomb 防护)。 + * `UPLOAD_BODY_LIMIT` 只限制 **HTTP body(= 压缩体)**;宿主 `quotaon /` 未启用 = **无磁盘配额** + * (档案 38),一个 180MB 的 zip 可解压出几十 GB → 撑爆 40G 单分区、**打瘫全平台**(跨租户)。 + * 因此必须限制**解压后**的规模。 + */ +const MAX_SKILL_FILES = 2000 +const MAX_SKILL_UNCOMPRESSED_BYTES = 200 * 1024 * 1024 + +/** + * 「已禁用」的用户技能存放处(在 `$DSH_HOME` 下、但 **不在** `skills/` 内)。 + * dsh 的 `discoverRoot` 只扫 `$DSH_HOME/skills`,所以放进这里是"保留但不可见"= 禁用, + * 从而在 dsh **没有 disable 机制**的前提下实现用户侧「启用 / 禁用 / 删除」。 + */ +const SKILL_LIBRARY_DIR = 'skills-library' + +function httpError(statusCode: number, message: string): Error & { statusCode: number } { + const e = new Error(message) as Error & { statusCode: number } + e.statusCode = statusCode + return e +} + +/** Parse `name`/`description` from a SKILL.md frontmatter block. */ +function parseSkillMeta(markdown: string): { name?: string; description?: string } { + const block = /^---\s*\r?\n([\s\S]*?)\r?\n---/.exec(markdown) + if (block === null) return {} + const fm = block[1] + const name = /^name:\s*["']?([^"'\n]+)["']?\s*$/m.exec(fm) + const description = /^description:\s*["']?(.*?)["']?\s*$/m.exec(fm) + return { + name: name?.[1]?.trim(), + description: description?.[1]?.trim(), + } +} + +async function skillMetaAt(dir: string): Promise<{ name?: string; description?: string }> { + try { + return parseSkillMeta(await readFileSync(join(dir, 'SKILL.md'), 'utf8')) + } catch { + return {} + } +} + +interface SkillSummary { + /** On-disk directory name, always == frontmatter name. */ + dir: string + /** Frontmatter `name`. */ + name: string + description: string + /** Recursive bytes. */ + size: number + /** Recursive file count. */ + files: number + mtimeMs: number +} + +async function dirSizeAndCount(abs: string): Promise<{ size: number; files: number }> { + let size = 0 + let files = 0 + for (const entry of await readdir(abs, { withFileTypes: true })) { + const st = await stat(join(abs, entry.name)) + if (st.isDirectory()) { + const sub = await dirSizeAndCount(join(abs, entry.name)) + size += sub.size + files += sub.files + } else { + size += st.size + files += 1 + } + } + return { size, files } +} + +async function summarizeSkillDir(abs: string): Promise<SkillSummary> { + const meta = await skillMetaAt(abs) + const st = await stat(abs) + const { size, files } = st.isDirectory() ? await dirSizeAndCount(abs) : { size: st.size, files: 1 } + return { + dir: basename(abs), + name: meta.name ?? basename(abs), + description: meta.description ?? '', + size, + files, + mtimeMs: st.mtimeMs, + } +} + +/** List single-level skill directories under `root` (dirs only, skip hidden). */ +async function listSkills(root: string): Promise<SkillSummary[]> { + let entries + try { + entries = await readdir(root, { withFileTypes: true }) + } catch { + return [] + } + const out: SkillSummary[] = [] + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue + try { + out.push(await summarizeSkillDir(join(root, entry.name))) + } catch { + // entry vanished mid-scan + } + } + out.sort((a, b) => a.name.localeCompare(b.name)) + return out +} + +/** Remove staging dirs left over from abandoned uploads (TTL). */ +function collectStaleStages(dataRoot: string): string[] { + let names: string[] = [] + try { + names = readdirSync(dataRoot).filter((n) => n.startsWith(STAGE_PREFIX)) + } catch { + return [] + } + const stale: string[] = [] + for (const name of names) { + try { + if (Date.now() - lstatSync(join(dataRoot, name)).mtimeMs > STAGE_TTL_MS) stale.push(name) + } catch { + stale.push(name) + } + } + return stale +} + +/** + * 累加 zip 内所有成员的**解压后**字节数(解析 `unzip -l` 的表格区)。 + * 用于 zip bomb 防护:压缩体 180MB 上限对解压规模毫无约束力。 + */ +function sumUncompressedSize(payload: string): number { + const out = execFileSync('unzip', ['-l', payload], { encoding: 'utf8', timeout: 30000 }) + let total = 0 + let inTable = false + for (const line of out.split('\n')) { + if (/^\s*-{5,}/.test(line)) { + if (inTable) break // 第二条分隔线 = 表格结束 + inTable = true + continue + } + if (!inTable) continue + const n = Number(line.trim().split(/\s+/)[0]) + if (Number.isFinite(n)) total += n + } + return total +} + +/** + * 找出任何**非普通文件/目录**的条目(symlink / 设备 / FIFO / socket),返回其相对路径。 + * + * ⚠️ 这是档案 41 修的 P0 的根因所在:zip 的成员**可以是符号链接**,而 `unzip` 默认**原样恢复**它。 + * symlink 的目标写在 **zip 元数据**里,不在任何文件内容里 → `scanDir`(只读文件内容,且遇到 + * `!isFile()` 直接 `continue`)**永远看不到它**。而随后以 **root** 身份执行的 `chownTree` / + * `chmodTree` 会**跟随**该链接 → **改写链接目标(宿主任意文件)的属主与权限**。 + * 实测:`-rw------- root:root` → `-rw-r--r-- <租户uid>`;指向 `/etc/shadow` 即等于 + * 把密码哈希 chmod 成 644 并 chown 给该租户。 + */ +function findSpecialEntry(root: string, rel = ''): string | null { + for (const entry of readdirSync(root)) { + const abs = join(root, entry) + const relPath = rel === '' ? entry : `${rel}/${entry}` + let st: ReturnType<typeof lstatSync> + try { + st = lstatSync(abs) + } catch { + continue + } + if (st.isSymbolicLink()) return relPath + if (st.isDirectory()) { + const hit = findSpecialEntry(abs, relPath) + if (hit !== null) return hit + continue + } + if (!st.isFile()) return relPath + } + return null +} + +/** + * Validate + extract a `.zip` skill archive under `<dataRoot>/<staging>`. + * @returns staged top dir, registered skill name/description, file count. + */ +function stageZipArchive(dataRoot: string, archive: Buffer): { stage: string; topDir: string; name: string; description: string; fileCount: number } { + const stage = join(dataRoot, STAGE_PREFIX + randomBytes(6).toString('hex')) + const payload = join(stage, 'payload.zip') + const unzipDir = join(stage, 'unzip') + mkdirSync(unzipDir, { recursive: true, mode: 0o700 }) + try { + writeFileSync(payload, archive) + + // 1. List members (no extraction yet) and validate every path. + const members = execFileSync('unzip', ['-Z1', payload], { encoding: 'utf8', timeout: 30000 }).split('\n') + const tops = new Set<string>() + let hasSkillMd = false + let fileCount = 0 + for (let raw of members) { + raw = raw.trim().replace(/\\/g, '/').replace(/\/+$/, '') + if (raw === '' || raw === '.' || raw === '__MACOSX' || raw.startsWith('__MACOSX/')) continue + if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) throw httpError(400, `zip member uses an absolute path: ${raw}`) + if (raw.split('/').includes('..')) throw httpError(400, `zip member escapes the root: ${raw}`) + const top = raw.split('/')[0]! + if (top === '.DS_Store') continue + tops.add(top) + if (raw.endsWith('/SKILL.md')) hasSkillMd = true + if (!raw.endsWith('/')) fileCount += 1 + } + if (tops.size !== 1) { + throw httpError(400, `zip 必须包含且仅包含一个顶层技能目录(当前 ${tops.size} 个)`) + } + const top = [...tops][0]! + if (top.startsWith('.')) throw httpError(400, 'zip 顶层目录名无效(不能以 . 开头)') + if (!hasSkillMd) throw httpError(400, 'zip 缺少 <顶层目录>/SKILL.md —— 不是 dsh 技能包') + if (fileCount === 0) throw httpError(400, 'zip 为空(无文件)') + + // 1b. 规模上限(zip bomb 防护;档案 41)——压缩体上限对解压规模无约束力。 + if (fileCount > MAX_SKILL_FILES) { + throw httpError(400, `技能包文件数超限(${fileCount} > ${MAX_SKILL_FILES})`) + } + const uncompressedBytes = sumUncompressedSize(payload) + if (uncompressedBytes > MAX_SKILL_UNCOMPRESSED_BYTES) { + throw httpError( + 400, + `技能包解压后体积超限(${Math.ceil(uncompressedBytes / 1048576)}MB > ${MAX_SKILL_UNCOMPRESSED_BYTES / 1048576}MB)`, + ) + } + + // 2. Extract. + execFileSync('unzip', ['-q', payload, '-d', unzipDir], { timeout: 120000 }) + + // 2a. 拒绝任何非普通文件条目(symlink / 设备 / FIFO / socket)——见 findSpecialEntry 注释。 + // 必须在 scanDir 之前、也必须在任何 chown/chmod 之前拦住。 + const specialEntry = findSpecialEntry(unzipDir) + if (specialEntry !== null) { + throw httpError(400, `技能包不允许包含符号链接/设备/管道文件:${specialEntry}`) + } + + // 2b. Security scan (危险内容扫描) — reject obviously-malicious patterns. + const scanFindings = scanDir(unzipDir) + if (scanFindings.length > 0) { + process.stderr.write( + `[upload-scan-warnings] ${JSON.stringify({ count: scanFindings.length, findings: scanFindings.slice(0, 20) })}\n`, + ) + } + + // 3. Validate the dsh-standard skill files inside. + const skillMd = readFileSync(join(unzipDir, top, 'SKILL.md'), 'utf8') + const meta = parseSkillMeta(skillMd) + if (meta.name === undefined || !SKILL_NAME_RE.test(meta.name)) { + throw httpError( + 400, + `SKILL.md frontmatter name 必须为小写连字符(如 mcn-workstation),当前为 "${meta.name ?? ''}" —— 请修改 SKILL.md 后重新打包`, + ) + } + if (meta.description === undefined || meta.description.trim() === '') { + throw httpError(400, `SKILL.md frontmatter 缺少 description —— dsh 要求 name 与 description 同时存在`) + } + return { + stage, + topDir: join(unzipDir, top), + name: meta.name, + description: meta.description.trim(), + fileCount, + } + } catch (err) { + rm(stage, { recursive: true, force: true }).catch(() => undefined) + throw err + } +} + +function chownTree(root: string, uid: number, gid: number): void { + lchownSync(root, uid, gid) + for (const entry of readdirSync(root)) { + const abs = join(root, entry) + const st = lstatSync(abs) + // 档案 41:绝不跟随符号链接(跟随 = 以 root 改写**链接目标**的属主)。 + // 即便 stageZipArchive 已拒绝 symlink 成员,这里也保持"不跟随"作为纵深防御。 + if (st.isSymbolicLink()) continue + if (st.isDirectory()) chownTree(abs, uid, gid) + else lchownSync(abs, uid, gid) + } +} + +function chmodTree(root: string, dirMode: number, fileMode: number): void { + chmodSync(root, dirMode) + for (const entry of readdirSync(root)) { + const abs = join(root, entry) + const st = lstatSync(abs) + // Linux 无 lchmod(ENOSYS),因此对 symlink 一律**跳过**而不是"不跟随地改"。 + if (st.isSymbolicLink()) continue + if (st.isDirectory()) chmodTree(abs, dirMode, fileMode) + else chmodSync(abs, fileMode) + } +} + +/** + * Phase 2: atomically REPLACE `<targetRoot>/<name>` with the staged skill. + * The previous directory is removed IN FULL first — files that exist only in + * the old skill (absent from the new zip) are deleted as well. + */ +async function applyStaged( + dataRoot: string, + stagedId: string, + targetRoot: string, + owner?: { uid: number; gid: number }, +): Promise<SkillSummary> { + if (!STAGE_ID_RE.test(stagedId)) throw httpError(400, 'invalid staged id') + const stage = join(dataRoot, stagedId) + if (!existsSync(stage)) throw httpError(404, 'staged upload not found or expired — 请重新上传') + const unzipDir = join(stage, 'unzip') + let top: string | undefined + for (const entry of readdirSync(unzipDir)) { + if (lstatSync(join(unzipDir, entry)).isDirectory()) { top = entry; break } + } + if (top === undefined) throw httpError(400, 'staged skill is empty') + const meta = await skillMetaAt(join(unzipDir, top)) + if (meta.name === undefined) throw httpError(400, 'staged skill has no valid SKILL.md') + const target = join(targetRoot, meta.name) + try { + // Full replacement: remove everything the old skill had, then move the new + // one in — files absent from the new zip cannot survive. + if (existsSync(target)) await rm(target, { recursive: true, force: true }) + const topAbs = join(unzipDir, top) + if (owner !== undefined) { + chownTree(topAbs, owner.uid, owner.gid) + chmodTree(topAbs, 0o755, 0o644) + } + await rename(topAbs, target) + return await summarizeSkillDir(target) + } finally { + rm(stage, { recursive: true, force: true }).catch(() => undefined) + } +} + +interface UploadBody { + file: string + filename: string +} + +interface ApplyBody { + stagedId: string +} + +const uploadSchema = { + body: { + type: 'object', + required: ['file', 'filename'], + additionalProperties: false, + properties: { + file: { type: 'string', maxLength: UPLOAD_BODY_LIMIT }, + filename: { type: 'string', maxLength: 255 }, + }, + }, +} as const + +const applySchema = { + body: { + type: 'object', + required: ['stagedId'], + additionalProperties: false, + properties: { stagedId: { type: 'string', maxLength: 64 } }, + }, +} as const + +function requireZipName(filename: string): void { + if (!basename(filename).toLowerCase().endsWith('.zip')) { + throw httpError(400, '仅支持 .zip 文件') + } +} + +export const skillRoutes: FastifyPluginAsync = async (app) => { + const gc = (): void => { + for (const stale of collectStaleStages(app.config.dataRoot)) { + rm(join(app.config.dataRoot, stale), { recursive: true, force: true }).catch(() => undefined) + } + } + + // ── shared: platform bundled layer, admin only ────────────────────────── + app.get('/api/skills/shared', { preHandler: requireAdmin }, async () => { + const root = app.config.bundledSkillDir + await mkdir(root, { recursive: true, mode: 0o755 }).catch(() => undefined) + return { skills: await listSkills(root) } + }) + + // Phase 1: validate + stage. No same-name skill → install immediately. + // Same-name skill exists → 200 with conflict:true + stagedId (nothing touched). + app.post( + '/api/skills/shared', + { preHandler: requireAdmin, bodyLimit: UPLOAD_BODY_LIMIT, schema: uploadSchema }, + async (request, reply) => { + const root = app.config.bundledSkillDir + await mkdir(root, { recursive: true, mode: 0o755 }).catch(() => undefined) + const body = request.body as UploadBody + try { + requireZipName(body.filename) + const archive = Buffer.from(body.file, 'base64') + if (archive.length === 0) throw httpError(400, 'empty archive payload') + gc() + const staged = stageZipArchive(app.config.dataRoot, archive) + const existing = existsSync(join(root, staged.name)) + if (!existing) { + const skill = await applyStaged(app.config.dataRoot, basename(staged.stage), root) + await app.db.audit(request.user?.id ?? null, 'install_shared_skill', JSON.stringify({ name: skill.name })) + return { ok: true, skill } + } + // Conflict: keep the staged copy, ask the user to confirm replacement. + const old = await summarizeSkillDir(join(root, staged.name)) + return { + ok: true, + conflict: true, + stagedId: basename(staged.stage), + skill: { name: staged.name, description: staged.description, files: staged.fileCount }, + existing: { name: old.name, size: old.size, files: old.files, mtimeMs: old.mtimeMs }, + } + } catch (err) { + const e = err as Error & { statusCode?: number } + return reply.code(e.statusCode ?? 400).send({ error: e.message }) + } + }, + ) + + // Phase 2: confirm replacement (full replace — old files not in the new zip are removed). + app.post( + '/api/skills/shared/apply', + { preHandler: requireAdmin, schema: applySchema }, + async (request, reply) => { + const { stagedId } = request.body as ApplyBody + try { + const skill = await applyStaged(app.config.dataRoot, stagedId, app.config.bundledSkillDir) + await app.db.audit(request.user?.id ?? null, 'replace_shared_skill', JSON.stringify({ name: skill.name })) + return { ok: true, skill } + } catch (err) { + const e = err as Error & { statusCode?: number } + return reply.code(e.statusCode ?? 400).send({ error: e.message }) + } + }, + ) + + app.delete('/api/skills/shared/:name', { preHandler: requireAdmin }, async (request, reply) => { + const { name } = request.params as { name: string } + if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' }) + const root = app.config.bundledSkillDir + const target = join(root, name) + if (!existsSync(target)) return reply.code(404).send({ error: 'not_found' }) + await rm(target, { recursive: true, force: true }) + await app.db.audit(request.user?.id ?? null, 'delete_shared_skill', JSON.stringify({ name })) + return { ok: true } + }) + + // ── mine: personal layer under $DSH_HOME/skills, any authenticated user ─ + const mineRoot = (userId: string): string => join(userRoot(app.config.dataRoot, userId), 'home', 'skills') + + /** 「已禁用」用户技能的存放处 —— 在 `$DSH_HOME` 下但不在 `skills/` 内,故 dsh 扫不到。 */ + const libraryRoot = (userId: string): string => + join(userRoot(app.config.dataRoot, userId), 'home', SKILL_LIBRARY_DIR) + + /** 平台共享技能(rank 600)名集合:对用户**只读**,不可禁用/删除/覆盖。 */ + const sharedNames = (): Set<string> => { + const dir = app.config.bundledSkillDir + if (dir === '' || !existsSync(dir)) return new Set() + try { + return new Set(readdirSync(dir).filter((n) => SKILL_NAME_RE.test(n))) + } catch { + return new Set() + } + } + + /** 用户技能名 → 是否被平台共享层占用(占用则 409,避免用户白上传一份永远不生效的技能)。 */ + const assertNotShared = (name: string): void => { + if (sharedNames().has(name)) { + throw httpError(409, `「${name}」是平台共享技能(全员只读,用户不可禁用/删除/覆盖)—— 请改用其他技能名`) + } + } + + async function ensureMineRoot(userId: string): Promise<{ root: string; owner: { uid: number; gid: number } }> { + const root = mineRoot(userId) + const home = join(userRoot(app.config.dataRoot, userId), 'home') + const homeStat = lstatSync(home) + const owner = { uid: homeStat.uid, gid: homeStat.gid } + if (!existsSync(root)) { + mkdirSync(root, { recursive: true, mode: 0o755 }) + chownSync(root, owner.uid, owner.gid) + } + return { root, owner } + } + + // 列表:合并三类 —— 平台共享(locked,只读)/ 用户已启用 / 用户已禁用(库中保留)。 + app.get('/api/skills/mine', { preHandler: requireAuth }, async (request) => { + const user = request.user! + const { root } = await ensureMineRoot(user.id) + const lib = libraryRoot(user.id) + const sharedDir = app.config.bundledSkillDir + const [enabled, disabled, shared] = await Promise.all([ + listSkills(root), + existsSync(lib) ? listSkills(lib) : Promise.resolve([]), + sharedDir !== '' && existsSync(sharedDir) ? listSkills(sharedDir) : Promise.resolve([]), + ]) + return { + skills: [ + // 平台共享层(rank 600,全员只读;dsh 无 disable 机制 → 标 locked,前端不可操作) + ...shared.map((s) => ({ ...s, source: 'shared' as const, enabled: true, locked: true })), + ...enabled.map((s) => ({ ...s, source: 'user' as const, enabled: true, locked: false })), + ...disabled.map((s) => ({ ...s, source: 'user' as const, enabled: false, locked: false })), + ], + } + }) + + app.post( + '/api/skills/mine', + { preHandler: requireAuth, bodyLimit: UPLOAD_BODY_LIMIT, schema: uploadSchema }, + async (request, reply) => { + const user = request.user! + const { root, owner } = await ensureMineRoot(user.id) + const body = request.body as UploadBody + try { + requireZipName(body.filename) + const archive = Buffer.from(body.file, 'base64') + if (archive.length === 0) throw httpError(400, 'empty archive payload') + gc() + const staged = stageZipArchive(app.config.dataRoot, archive) + // 与平台共享技能同名 → 直接拒(否则用户会白上传一份永远被 rank 600 压住的技能)。 + try { + assertNotShared(staged.name) + } catch (err) { + await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined) + throw err + } + if (!existsSync(join(root, staged.name))) { + const skill = await applyStaged(app.config.dataRoot, basename(staged.stage), root, owner) + return { ok: true, skill } + } + const old = await summarizeSkillDir(join(root, staged.name)) + return { + ok: true, + conflict: true, + stagedId: basename(staged.stage), + skill: { name: staged.name, description: staged.description, files: staged.fileCount }, + existing: { name: old.name, size: old.size, files: old.files, mtimeMs: old.mtimeMs }, + } + } catch (err) { + const e = err as Error & { statusCode?: number } + return reply.code(e.statusCode ?? 400).send({ error: e.message }) + } + }, + ) + + app.post( + '/api/skills/mine/apply', + { preHandler: requireAuth, schema: applySchema }, + async (request, reply) => { + const user = request.user! + const { root, owner } = await ensureMineRoot(user.id) + const { stagedId } = request.body as ApplyBody + try { + const skill = await applyStaged(app.config.dataRoot, stagedId, root, owner) + return { ok: true, skill } + } catch (err) { + const e = err as Error & { statusCode?: number } + return reply.code(e.statusCode ?? 400).send({ error: e.message }) + } + }, + ) + + /** + * 启用:库(skills-library)→ `$DSH_HOME/skills`。 + * dsh 的 skill 发现是 watch 驱动的,所以**即时生效、无需重启实例**。 + */ + app.post('/api/skills/mine/:name/enable', { preHandler: requireAuth }, async (request, reply) => { + const user = request.user! + const { name } = request.params as { name: string } + if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' }) + try { + assertNotShared(name) + } catch (err) { + const e = err as Error & { statusCode?: number } + return reply.code(e.statusCode ?? 409).send({ error: e.message }) + } + const { root, owner } = await ensureMineRoot(user.id) + const src = join(libraryRoot(user.id), name) + if (!existsSync(src)) return reply.code(404).send({ error: 'not_found' }) + const dst = join(root, name) + if (existsSync(dst)) return reply.code(409).send({ error: 'already_enabled' }) + await rename(src, dst) + chownTree(dst, owner.uid, owner.gid) + chmodTree(dst, 0o755, 0o644) + await app.db.audit(user.id, 'enable_skill', JSON.stringify({ name })) + return { ok: true, skill: await summarizeSkillDir(dst) } + }) + + /** + * 禁用:`$DSH_HOME/skills` → 库。**保留文件**(可再次启用),只是移出 dsh 的扫描根 + * —— 因为 `dsh-skill-filesystem` **没有 disable/deny 机制**。 + */ + app.post('/api/skills/mine/:name/disable', { preHandler: requireAuth }, async (request, reply) => { + const user = request.user! + const { name } = request.params as { name: string } + if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' }) + try { + assertNotShared(name) + } catch (err) { + const e = err as Error & { statusCode?: number } + return reply.code(e.statusCode ?? 409).send({ error: e.message }) + } + const { root, owner } = await ensureMineRoot(user.id) + const src = join(root, name) + if (!existsSync(src)) return reply.code(404).send({ error: 'not_found' }) + const lib = libraryRoot(user.id) + mkdirSync(lib, { recursive: true, mode: 0o755 }) + chownSync(lib, owner.uid, owner.gid) + const dst = join(lib, name) + if (existsSync(dst)) await rm(dst, { recursive: true, force: true }) + await rename(src, dst) + await app.db.audit(user.id, 'disable_skill', JSON.stringify({ name })) + return { ok: true } + }) + + /** 删除:库与启用位置一起删(彻底移除用户自建技能)。 */ + app.delete('/api/skills/mine/:name', { preHandler: requireAuth }, async (request, reply) => { + const user = request.user! + const { name } = request.params as { name: string } + if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' }) + try { + assertNotShared(name) + } catch (err) { + const e = err as Error & { statusCode?: number } + return reply.code(e.statusCode ?? 409).send({ error: e.message }) + } + const targets = [join(mineRoot(user.id), name), join(libraryRoot(user.id), name)] + const present = targets.filter((t) => existsSync(t)) + if (present.length === 0) return reply.code(404).send({ error: 'not_found' }) + for (const t of present) await rm(t, { recursive: true, force: true }) + await app.db.audit(user.id, 'delete_skill', JSON.stringify({ name })) + return { ok: true } + }) +} diff --git a/src/web/routes/whitelist.ts b/src/web/routes/whitelist.ts new file mode 100644 index 0000000..e5260cd --- /dev/null +++ b/src/web/routes/whitelist.ts @@ -0,0 +1,319 @@ +/** + * 官方白名单来源(方案 C · 方案 B 口径):把 awesome-dsh-plugin 官方目录接入 + * 门户「插件管理」,admin 挑选后导入功能插件候选池。 + * + * 数据源:`https://awesome-dsh-plugin.com/plugins.json`(官方规范地址;npm 镜像 + * `dsh-plugin-catalog`)。约 3400 条,每条含 `npm` 包名 / `install` 规格 / + * `stars` / `downloads` / 分类 / 中英描述。相比 git clone 再解析 3431 个 yml, + * 一次 HTTP 拿全,且字段更全。 + * + * 导入口径 = **仅预构建**(不在平台侧执行任何第三方构建脚本): + * 1. 有 `npm` 包名 → 取 registry.npmjs.org 官方 tarball(预构建,秒装) + * 2. 无 npm、有 `tarball` → 取 GitHub release 资产直链 + * 3. 两者皆无(官方 `install` 形态为 `github:owner/repo`,需源码构建)→ 不支持 + * 一键导入,接口返回明确原因,前端标记「需源码构建」。 + * 覆盖约 54%(1854/3408),官方下载量 TOP10 热门插件全部包含。 + * + * 导入复用 business-plugins 的 `stageTgzArchive`(成员路径校验 + package.json + * 校验 + 危险内容安全扫描)+ 同名替换策略,与管理员手工上传是同一条链路。 + * + * 注:官方清单的「收录」不等于安全审计(上游 README 明确声明)。安全兜底完全 + * 依赖 `stageTgzArchive` 的安全扫描;P0 命中时阻断并把原因原样回显给 admin。 + * @module dshs/web/routes/whitelist + */ + +import type { FastifyPluginAsync } from 'fastify' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdir, rename, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { requireAdmin } from '../middleware/authn.js' +import { httpError } from '../security-scan.js' +import { stageTgzArchive } from './business-plugins.js' + +/** 官方目录规范地址(同一份内容亦镜像为 npm 包 `dsh-plugin-catalog`)。 */ +const CATALOG_URL = 'https://awesome-dsh-plugin.com/plugins.json' +const REGISTRY = 'https://registry.npmjs.org' +/** 索引缓存 TTL:6 小时。 */ +const CACHE_TTL = 6 * 60 * 60 * 1000 +/** + * 缓存结构版本。改字段/换数据源时必须 +1 —— 否则 TTL 内的旧格式缓存会被直接 + * 复用,条目缺字段,后续按字段过滤就会在请求里抛异常(v1 = git clone + yml 版)。 + */ +const CACHE_VERSION = 3 +const FETCH_TIMEOUT_MS = 60_000 +/** 单次导入上限(避免一次拉太多把请求拖死)。 */ +const MAX_IMPORT = 20 +const MAX_ARCHIVE_BYTES = 150 * 1024 * 1024 + +/** 官方 `plugins.json` 的单条原始形态(字段可能缺失,全部按需兜底)。 */ +interface RawEntry { + name?: string + owner?: string + url?: string + page?: string + category?: string + description?: { zh?: string; en?: string } + npm?: string | null + version?: string | null + stars?: number + downloads?: number | null + install?: string + tarball?: string | null + screenshots?: unknown +} + +/** 导入方式:`npm` = registry 官方 tarball;`tarball` = release 资产;`source` = 需源码构建(不支持)。 */ +type ImportKind = 'npm' | 'tarball' | 'source' + +interface WhitelistEntry { + /** `owner/name`,清单内唯一,前端勾选与导入回查都用它。 */ + id: string + name: string + owner: string + url: string + page: string + category: string + /** 分类中文名(来自目录顶层 `categories` 映射,如 `ui` → UI 增强);缺映射时回退为 id。 */ + categoryLabel: string + description: string + npm: string | null + version: string | null + stars: number + downloads: number + install: string + importKind: ImportKind + tarball: string | null + /** 官方目录是否给该插件配了截图(约 17% 条目有;有则可去 `page` 看效果图)。 */ + hasShots: boolean +} + +interface CachedIndex { + version: number + fetchedAt: number + source: string + /** 分类 id → 中文名(目录顶层 `categories` 的映射,随缓存一起留存)。 */ + categoryLabels: Record<string, string> + entries: WhitelistEntry[] +} + +export const whitelistRoutes: FastifyPluginAsync = async (app) => { + const cacheDir = join(app.config.dataRoot, 'whitelist-cache') + const indexFile = join(cacheDir, 'index.json') + + const asString = (v: unknown): string => (typeof v === 'string' ? v : '') + + /** 把官方目录 JSON 归一化成内部条目表 + 分类中文名映射,条目按热度排序。 */ + const buildIndex = (raw: unknown): { entries: WhitelistEntry[]; categoryLabels: Record<string, string> } => { + const obj = raw as { plugins?: RawEntry[]; categories?: Record<string, { en?: string; zh?: string }> } + const list = obj.plugins + if (!Array.isArray(list)) throw httpError(502, '官方目录格式异常:缺少 plugins 数组') + // 顶层 categories = { id: { en, zh } } —— 官方只在这里给分类的中文名。 + const categoryLabels: Record<string, string> = {} + for (const [id, v] of Object.entries(obj.categories ?? {})) { + const label = asString(v?.zh) !== '' ? asString(v.zh) : asString(v?.en) + if (label !== '') categoryLabels[id] = label + } + const entries: WhitelistEntry[] = [] + for (const e of list) { + const name = asString(e.name) + if (name === '') continue + const owner = asString(e.owner) + const category = asString(e.category) + const npm = asString(e.npm) + const tarball = asString(e.tarball) + const importKind: ImportKind = npm !== '' ? 'npm' : tarball !== '' ? 'tarball' : 'source' + entries.push({ + id: owner === '' ? name : `${owner}/${name}`, + name, + owner, + url: asString(e.url), + page: asString(e.page), + category, + categoryLabel: categoryLabels[category] ?? category, + description: asString(e.description?.zh) !== '' ? asString(e.description?.zh) : asString(e.description?.en), + npm: npm === '' ? null : npm, + version: asString(e.version) === '' ? null : asString(e.version), + stars: Number(e.stars ?? 0) || 0, + downloads: Number(e.downloads ?? 0) || 0, + install: asString(e.install), + importKind, + tarball: tarball === '' ? null : tarball, + hasShots: Array.isArray(e.screenshots) && e.screenshots.length > 0, + }) + } + // 下载量降序(未发 npm 的条目 downloads 为 0,自然沉底)→ 热门 = 可预构建安装。 + entries.sort((a, b) => b.downloads - a.downloads || b.stars - a.stars) + return { entries, categoryLabels } + } + + /** 拉官方目录并落缓存。 */ + const fetchCatalog = async (): Promise<{ entries: WhitelistEntry[]; categoryLabels: Record<string, string> }> => { + const res = await fetch(CATALOG_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'follow' }) + if (!res.ok) throw httpError(502, `官方目录拉取失败 HTTP ${res.status}`) + const { entries, categoryLabels } = buildIndex(await res.json()) + if (entries.length === 0) throw httpError(502, '官方目录为空') + mkdirSync(cacheDir, { recursive: true, mode: 0o755 }) + const payload: CachedIndex = { version: CACHE_VERSION, fetchedAt: Date.now(), source: CATALOG_URL, categoryLabels, entries } + writeFileSync(indexFile, JSON.stringify(payload)) + return { entries, categoryLabels } + } + + /** 读缓存:版本或结构不符一律当未命中(旧格式不能被复用)。 */ + const readCache = (): CachedIndex | null => { + if (!existsSync(indexFile)) return null + try { + const cached = JSON.parse(readFileSync(indexFile, 'utf8')) as CachedIndex + if (cached.version !== CACHE_VERSION) return null + if (!Array.isArray(cached.entries) || cached.entries.length === 0) return null + if (typeof cached.entries[0]?.importKind !== 'string') return null + return cached + } catch { + return null + } + } + + /** 取索引:命中 TTL 用缓存;否则拉取;拉取失败但有旧缓存 → 降级用旧缓存并标 `stale`。 */ + const getIndex = async (force = false): Promise<{ fetchedAt: number; entries: WhitelistEntry[]; categoryLabels: Record<string, string>; stale: boolean }> => { + const cached = readCache() + if (!force && cached !== null && Date.now() - cached.fetchedAt < CACHE_TTL) return { fetchedAt: cached.fetchedAt, entries: cached.entries, categoryLabels: cached.categoryLabels ?? {}, stale: false } + try { + const { entries, categoryLabels } = await fetchCatalog() + return { fetchedAt: Date.now(), entries, categoryLabels, stale: false } + } catch (err) { + if (cached !== null) app.log.warn({ err }, 'whitelist: 官方目录拉取失败,降级使用本地缓存') + if (cached !== null) return { fetchedAt: cached.fetchedAt, entries: cached.entries, categoryLabels: cached.categoryLabels ?? {}, stale: true } + throw err + } + } + + /** 解析出可下载的预构建 tarball 直链(方案 B:绝不触发源码构建)。 */ + const resolveTarball = async (entry: WhitelistEntry): Promise<{ url: string; version: string | null }> => { + if (entry.importKind === 'npm' && entry.npm !== null) { + const name = entry.npm + // scoped 包(@scope/name)的 packument 地址:`@` 保留、`/` 编码。 + const res = await fetch(`${REGISTRY}/${encodeURIComponent(name).replace('%40', '@')}`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + if (!res.ok) throw httpError(502, `npm registry 查询失败 HTTP ${res.status}(${name})`) + const meta = (await res.json()) as { + 'dist-tags'?: { latest?: string } + versions?: Record<string, { dist?: { tarball?: string } }> + } + const latest = meta['dist-tags']?.latest + const url = latest === undefined ? undefined : meta.versions?.[latest]?.dist?.tarball + if (typeof url !== 'string' || url === '') throw httpError(502, `npm 包无可下载的已发布版本:${name}`) + return { url, version: latest ?? null } + } + if (entry.tarball !== null) return { url: entry.tarball, version: entry.version } + throw httpError(400, `该插件需从源码构建安装${entry.install === '' ? '' : `(${entry.install})`},暂不支持一键导入`) + } + + app.get('/api/plugins/whitelist', { preHandler: requireAdmin }, async (request) => { + const { q, category, onlyImportable } = request.query as { q?: string; category?: string; onlyImportable?: string } + const { fetchedAt, entries, categoryLabels, stale } = await getIndex() + let list = entries + if (category !== undefined && category !== '') list = list.filter((e) => e.category === category) + if (onlyImportable === '1') list = list.filter((e) => e.importKind !== 'source') + if (q !== undefined && q !== '') { + const kw = q.toLowerCase() + list = list.filter( + (e) => + e.name.toLowerCase().includes(kw) || + (e.description ?? '').toLowerCase().includes(kw) || + (e.npm ?? '').toLowerCase().includes(kw) || + (e.owner ?? '').toLowerCase().includes(kw), + ) + } + // 分类清单带中文名 + 计数(按条目数降序),供前端下拉直接用中文展示。 + const counts = new Map<string, number>() + for (const e of entries) if (e.category !== '') counts.set(e.category, (counts.get(e.category) ?? 0) + 1) + const categories = [...counts.entries()] + .map(([id, count]) => ({ id, label: categoryLabels[id] ?? id, count })) + .sort((a, b) => b.count - a.count) + return { + fetchedAt, + stale, + total: entries.length, + count: list.length, + importable: entries.filter((e) => e.importKind !== 'source').length, + categories, + plugins: list.slice(0, 300), + } + }) + + app.post('/api/plugins/whitelist/refresh', { preHandler: requireAdmin }, async () => { + const { entries, fetchedAt } = await getIndex(true) + return { ok: true, total: entries.length, importable: entries.filter((e) => e.importKind !== 'source').length, fetchedAt } + }) + + app.post('/api/plugins/whitelist/import', { preHandler: requireAdmin }, async (request, reply) => { + const { names } = request.body as { names?: string[] } + if (!Array.isArray(names) || names.length === 0) return reply.code(400).send({ error: 'no_plugins_selected' }) + const { entries } = await getIndex() + const byId = new Map(entries.map((e) => [e.id, e])) + const results: Array<{ id: string; name: string; ok: boolean; kind?: ImportKind; version?: string | null; error?: string }> = [] + for (const id of names.slice(0, MAX_IMPORT)) { + try { + const entry = byId.get(id) + if (entry === undefined) throw httpError(404, `不在官方清单:${id}`) + const { url, version } = await resolveTarball(entry) + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'follow' }) + if (!res.ok) throw httpError(502, `下载失败 HTTP ${res.status}`) + const archive = Buffer.from(await res.arrayBuffer()) + if (archive.length === 0 || archive.length > MAX_ARCHIVE_BYTES) throw httpError(400, '包大小异常') + const staged = await stageTgzArchive(app.config.dataRoot, archive) + + // 投放裁决(与门户上传同一套口径):P0 命中 / 版本不兼容 → 跳过该包并回显依据。 + // ⚠️ 本路径原先只依赖 `stageTgzArchive` 内部 throw;档案 66 把它改成「收集式返回」 + // (P0 不再抛出、由调用方裁决)之后**这里漏了裁决** → P0 会被静默放过。 + // 本次一并补上,并叠加档案 71 的兼容性判定(导入 = 用户要求的第二个入口)。 + // 拒绝以 error 文本回显:批量导入是逐个 try/catch 收集结果,没有单条交互式「显式信任」入口。 + if (staged.blocked.length > 0) { + throw httpError( + 409, + `安全检测命中 P0,已跳过该包:${staged.blocked + .slice(0, 3) + .map((b) => `${b.why}(${b.file})`) + .join(';')}`, + ) + } + if (staged.compat.level === 'incompatible') { + throw httpError( + 409, + `与当前平台 dsh 版本不兼容,已跳过该包:${staged.compat.findings + .slice(0, 3) + .map((f) => `${f.pkg} — ${f.detail}`) + .join(';')}`, + ) + } + const dir = join(app.config.dataRoot, 'business-plugins') + await mkdir(dir, { recursive: true, mode: 0o755 }) + const existing = await app.db.findBusinessPlugin(staged.name) + if (existing !== undefined && existsSync(existing.tgzPath)) await rm(existing.tgzPath, { force: true }) + const destPath = join(dir, staged.tgzName) + await rename(join(staged.stage, 'payload.tgz'), destPath) + const plugin = await app.db.upsertBusinessPlugin({ + id: staged.name, + name: staged.name, + // 说明优先用**目录条目的中文**:`WhitelistEntry.description` 在 `getIndex()` + // 建索引时已按 `zh ?? en` 归一(见该处映射),比 tgz 里 `package.json` 的 + // description(第三方作者写的,通常英文)更贴合中文用户。 + // 之前这里直接写 `staged.description`,等于把目录里现成的中文丢掉 —— + // 门户「已投放插件」表与实例内「功能管理」因此只显示英文 + //(2026-09-12 实测:anysearch / @liustack/modlens / dsh-univer-office 三者全部如此)。 + description: entry.description !== '' ? entry.description : staged.description, + version: staged.version ?? version, + tgzPath: destPath, + fileSize: archive.length, + uploadedBy: request.user?.id ?? null, + }) + await app.db.audit(request.user?.id ?? null, 'import_whitelist_plugin', JSON.stringify({ id, name: plugin.name, kind: entry.importKind })) + await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined) + results.push({ id, name: staged.name, ok: true, kind: entry.importKind, version: plugin.version }) + } catch (err) { + results.push({ id, name: id, ok: false, error: (err as Error).message }) + } + } + return { ok: true, results } + }) +} diff --git a/src/web/security-scan.ts b/src/web/security-scan.ts new file mode 100644 index 0000000..5e5c655 --- /dev/null +++ b/src/web/security-scan.ts @@ -0,0 +1,148 @@ +/** + * Shared upload security scan (安全检测) for skill and business-plugin uploads. + * + * 分级(档案 19 §C6,2026-09-11): + * · **P0(阻断)** —— 明显恶意/提权模式:命中即拒绝上传(HTTP 400),与历史行为一致; + * · **P1(告警)** —— 可疑但常见的模式(动态执行、外联、敏感 env、长 base64、隐藏文件/`.git`): + * **不阻断**,作为 findings 返回并写日志,供管理员在上传时人工复核。 + * 之前只有"命中即 400",缺少分级与整改提示;现在 `scanDir` 返回 P1 findings(调用方可忽略)。 + * @module dshs/web/security-scan + */ + +import { lstatSync, readdirSync, readFileSync, type Stats } from 'node:fs' +import { basename, join } from 'node:path' + +const SCAN_TEXT_EXT = new Set([ + '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.sh', '.bash', '.zsh', '.py', '.json', '.md', '.txt', + '.yml', '.yaml', '.html', '.css', '.toml', '.ini', '.conf', '.env', '.cfg', + // 2026-09-11 扩展:非 JS 生态的常见脚本/配置(上传包里同样可能藏恶意指令) + '.rs', '.go', '.java', '.php', '.rb', '.pl', '.lua', '.sql', '.ps1', '.psm1', '.bat', '.cmd', +]) + +/** 扫描体积上限(旧值 2MB 会漏掉较大脚本;提到 8MB,仍跳过二进制/超大文件)。 */ +const SCAN_MAX_BYTES = 8 * 1024 * 1024 + +/** P0:明显恶意/提权 → 直接阻断上传。 */ +const BLOCK_PATTERNS: Array<{ re: RegExp; why: string }> = [ + { re: /rm\s+-rf\s+(\/|\/\*|~\s*|\$HOME)\b/, why: '含删除根目录/家目录命令' }, + { re: /(?:curl|wget)\s+[^\s|>]+\s*\|\s*(?:sh|bash)\b/, why: '下载并执行远程脚本' }, + { re: /\/etc\/(?:passwd|shadow|sudoers)\b/, why: '读取系统凭证文件' }, + { re: /\.ssh\/(?:id_rsa|id_ed25519|id_ecdsa|authorized_keys)/, why: '读取 SSH 私钥' }, + { re: /\.aws\/(?:credentials|config)\b/, why: '读取云平台凭证' }, + { re: /\/var\/lib\/dshs\b/, why: '读取本平台数据目录' }, + { re: /\.credentials\.yaml/, why: '读取实例会话密钥' }, + // 2026-09-11 新增(档案 17 §S/M 对齐):仍是"明显恶意"档,故归 P0 + { re: /\bnc\s+-[a-z]*e[a-z]*\s+\S+\s+\d+/, why: '反弹 shell(nc -e)' }, + { re: /\/dev\/tcp\/\d{1,3}(\.\d{1,3}){3}\/\d+/, why: '反弹 shell(/dev/tcp)' }, + { re: /(?:base64\s+-d|b64decode|atob)\s*[\s\S]{0,40}?\|\s*(?:sh|bash)\b/, why: 'base64 解码后直接执行' }, + { re: /\bchmod\s+(?:\+s|4[0-7]{3}|6[0-7]{3})\b[^\n]{0,40}(?:\/usr\/bin|\/bin)\//, why: '尝试为系统二进制加 setuid/特权位' }, + { re: /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:/, why: 'fork 炸弹' }, +] + +/** + * P1:可疑但常见 → **不阻断**,作为 findings 上报(管理员人工复核)。 + * 说明书见档案 19 §C6 与档案 17 §S/M。 + */ +const WARN_RULES: Array<{ id: string; re: RegExp; why: string }> = [ + { id: 'dynamic-exec', re: /\b(?:child_process|execSync|execFileSync|spawnSync|require\('child_process'\))/, why: '动态执行子进程(需确认目标命令可信)' }, + { id: 'vm-eval', re: /\b(?:new\s+Function\s*\(|require\('vm'\)|from 'node:vm'|eval\s*\()/, why: '动态求值 vm/eval/new Function' }, + { id: 'sensitive-env', re: /process\.env\s*[.\[]\s*['"]?(?:DEEPSEEK_API_KEY|DSH_[A-Z_]+|[A-Z_]*TOKEN|[A-Z_]*SECRET|[A-Z_]*KEY)/, why: '读取敏感环境变量' }, + { id: 'long-base64', re: /[A-Za-z0-9+/]{600,}={0,2}/, why: '超长 base64 载荷(可能是混淆/内嵌二进制)' }, + { id: 'network-egress', re: /https?:\/\/(?!localhost|127\.0\.0\.1)[a-z0-9.-]+\.[a-z]{2,}/i, why: '外部网络访问(需确认域名可信;配合出网护栏)' }, + { id: 'hidden-or-git', re: /(?:\.git\/|(?:^|\/)\.[a-z][a-z0-9_-]*\/)/i, why: '包含隐藏目录/.git(可能携带仓库元数据或绕过审查)' }, +] + +/** 一条扫描发现(P1 告警)。 */ +export interface ScanFinding { rule: string; why: string; file: string } + +/** 一条 P0 命中(默认阻断;业务插件上传可交由 admin 显式信任后放行)。 */ +export interface ScanBlock { why: string; file: string } + +/** 收集式扫描结果:`warnings` = P1 告警,`blocked` = P0 命中。 */ +export interface ScanResult { warnings: ScanFinding[]; blocked: ScanBlock[] } + +export function httpError(statusCode: number, message: string): Error & { statusCode: number } { + const e = new Error(message) as Error & { statusCode: number } + e.statusCode = statusCode + return e +} + +/** + * Recursively walk text files under `root`, filling `findings` (P1) and `blocks` (P0). + * 遍历本身不抛:是否阻断由调用方({@link scanDir} / {@link scanDirDetailed})决定。 + */ +function walk(root: string, rel: string, findings: ScanFinding[], blocks: ScanBlock[]): void { + let entries: string[] + try { + entries = readdirSync(root) + } catch { + return + } + for (const entry of entries) { + const abs = join(root, entry) + const relPath = rel === '' ? entry : `${rel}/${entry}` + let st: Stats + try { + st = lstatSync(abs) + } catch { + continue + } + if (st.isDirectory()) { + walk(abs, relPath, findings, blocks) + continue + } + if (!st.isFile()) continue + const base = basename(entry) + const dot = base.lastIndexOf('.') + const ext = dot >= 0 ? base.slice(dot).toLowerCase() : '' + const hasShebangCandidate = ext === '' // 无扩展名文件也可能是脚本(按内容判 shebang) + if (!SCAN_TEXT_EXT.has(ext) && !hasShebangCandidate) continue + if (st.size > SCAN_MAX_BYTES) continue // 跳过超大文件(不太可能是源码) + let text: string + try { + text = readFileSync(abs, 'utf8') + } catch { + continue + } + // 二进制探测 + shebang 判定(0x00 视为二进制;无扩展名须含 shebang 才继续) + if (text.includes('\u0000')) continue + if (hasShebangCandidate && !/^#!\s*\S/.test(text.slice(0, 64))) continue + for (const p of BLOCK_PATTERNS) { + if (p.re.test(text)) blocks.push({ why: p.why, file: relPath }) + } + for (const w of WARN_RULES) { + if (w.re.test(text)) { + findings.push({ rule: w.id, why: w.why, file: relPath }) + } + } + } +} + +/** + * Recursively scan text files under `root`. + * P0 命中 → 抛 400(**阻断上传**,既有行为:技能上传等路径依赖它);P1 命中 → 收集并返回。 + */ +export function scanDir(root: string, rel = '', findings: ScanFinding[] = []): ScanFinding[] { + const blocks: ScanBlock[] = [] + walk(root, rel, findings, blocks) + if (blocks.length > 0) { + throw httpError(400, `安全检测未通过(P0 阻断):${blocks[0].why}(${blocks[0].file})`) + } + return findings +} + +/** + * 收集式扫描:**P0 命中不抛**,与 P1 告警一并返回。 + * + * 供「上传者本人就是可信管理员、且需要看到逐条命中再决定」的路径使用 —— + * 目前是业务插件(功能插件)投放:命中的往往是文档里的说明性字样(例如 README 教用户 + * 把 key 写进 `.credentials.yaml`),直接 400 会让 admin 无从判断,也没法对**已人工确认** + * 的包放行。调用方拿到 `blocked` 后可以:默认拒绝并把详情回显给 admin, + * 或在 admin **显式声明信任**(并留痕)后继续入库。 + */ +export function scanDirDetailed(root: string): ScanResult { + const warnings: ScanFinding[] = [] + const blocks: ScanBlock[] = [] + walk(root, '', warnings, blocks) + return { warnings, blocked: blocks } +} diff --git a/src/web/semver-shim.d.ts b/src/web/semver-shim.d.ts new file mode 100644 index 0000000..621b67c --- /dev/null +++ b/src/web/semver-shim.d.ts @@ -0,0 +1,15 @@ +/** + * 极简类型声明(档案 71 / 交接单 T05) + * + * `semver` 是平台的**传递依赖**(`node_modules/semver` 实测 7.8.5,无 `@types/semver`)。 + * 兼容性预检只用到 `satisfies`,故按需声明所需形状 —— 既不引入新依赖,也不让 + * `tsc` 因缺声明而失败(TS7016)。 + * + * ⚠️ 语义提醒:**不传第三参**(即不使用 `includePrerelease`)。 + * 这与 npm/pnpm 的实际安装判定一致;anysearch 的崩溃正是「prerelease 默认不匹配范围」导致的 + * (它写 `<0.1.2`,pnpm 于是给它装了自带的旧版 dsh-tool-web)。详见 `plugin-compat.ts` 头注。 + */ +declare module 'semver' { + /** 版本是否落在范围内(默认语义,与 npm 一致)。 */ + export function satisfies(version: string, range: string): boolean +} diff --git a/src/web/server.ts b/src/web/server.ts new file mode 100644 index 0000000..e839cfc --- /dev/null +++ b/src/web/server.ts @@ -0,0 +1,153 @@ +/** + * Fastify bootstrap: assembles the HTTP server, registers plugins and routes, + * and owns the DB lifecycle via the close hook. + * @module dshs/web/server + */ + +import Fastify, { type FastifyInstance } from 'fastify' +import fastifyStatic from '@fastify/static' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import type { ServerConfig } from '../config.js' +import { createDbAdapter, type DbAdapter, type PublicUser } from '../db/index.js' +import { createUserFs } from '../fs/provider.js' +import type { UserFs } from '../fs/user-fs.js' +import { decrypt, deriveKey } from '../crypto.js' +import { hashUid } from '../isolation.js' +import { LocalSpawner } from '../supervisor/orchestrator.js' +import { K8sSpawner } from '../supervisor/k8s-spawner.js' +import { registerDshProxy } from '../supervisor/proxy.js' +import type { Spawner } from '../supervisor/spawner.js' +import { rateLimit } from './middleware/rate-limit.js' +import { authRoutes } from './routes/auth.js' +import { adminRoutes } from './routes/admin.js' +import { businessPluginRoutes } from './routes/business-plugins.js' +import { desktopRoutes } from './routes/desktop.js' +import { dshRoutes } from './routes/dsh.js' +import { domainRoutes } from './routes/domain.js' +import { skillRoutes } from './routes/skills.js' +import { whitelistRoutes } from './routes/whitelist.js' + +declare module 'fastify' { + interface FastifyInstance { + db: DbAdapter + config: ServerConfig + supervisor: Spawner + userFs: UserFs + } + interface FastifyRequest { + user: PublicUser | null + } +} + +const webRoot = join(dirname(fileURLToPath(import.meta.url)), '../../web') + +/** Whether an `Origin` header belongs to the platform base domain (or a + * per-user subdomain of it). Used to allow cross-subdomain API calls from dsh + * instances (功能插件启停). */ +function isAllowedOrigin(origin: string, baseDomain: string): boolean { + if (baseDomain === '') return false + try { + const host = new URL(origin).hostname + return host === baseDomain || host.endsWith('.' + baseDomain) + } catch { + return false + } +} + +/** + * Build a fully-wired Fastify instance. Does not call `listen`; the caller owns + * bind + shutdown. + * @param config - resolved runtime configuration. + */ +export async function buildServer(config: ServerConfig): Promise<FastifyInstance> { + const db = await createDbAdapter(config) + const encryptionKey = deriveKey(config.encryptionSecret) + const resolveApiKey = async (_userId: string): Promise<string | null> => { + // 统一 KEY 模式:所有用户共用管理员(admin)设置的启用 key,用户不可自配。 + // 忽略入参 userId——不管哪个用户 spawn,都注入同一把管理员 key。 + const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin') + if (admins.length === 0) return null + const ref = await db.getEnabledCredentialKeyRef(admins[0].id) + if (ref === null) return null + try { + return decrypt(ref, encryptionKey) + } catch { + return null // corrupt ref — treat as unset, let the admin re-enter it + } + } + const resolveUid = async (userId: string): Promise<number> => { + const user = await db.findUserById(userId) + return user?.uid ?? hashUid(userId, config.baseUid) + } + const supervisor: Spawner = + config.deployMode === 'k8s' + ? new K8sSpawner(config, db, resolveApiKey, resolveUid) + : new LocalSpawner(config, resolveApiKey, resolveUid) + const userFs = createUserFs(config, (userId) => supervisor.ensureFileService(userId)) + + const app = Fastify({ + logger: { level: config.logLevel }, + trustProxy: true, + bodyLimit: config.maxUploadBytes, + }) + + app.decorate('db', db) + app.decorate('config', config) + app.decorate('supervisor', supervisor) + app.decorate('userFs', userFs) + app.decorateRequest('user', null) + + // Reverse proxy (subdomain + legacy subpath). Registered first so its global + // onRequest hook intercepts per-user subdomain traffic before other hooks. + await registerDshProxy(app) + + // CORS for cross-subdomain API calls from dsh instances (功能插件启停 section + // runs in the browser on `<user>.dsh.alotbuy.com` and calls portal APIs on + // `dsh.alotbuy.com`). Cookie is HttpOnly + SameSite=None (secure mode) with + // Domain=.dsh.alotbuy.com, so credentials ride along; we only need to allow + // the Origin. Restricted to the platform base domain and its subdomains. + app.addHook('onRequest', async (request, reply) => { + const origin = request.headers.origin + if (origin === undefined || origin === '') return + if (!isAllowedOrigin(origin, config.baseDomain)) return + reply.header('Access-Control-Allow-Origin', origin) + reply.header('Access-Control-Allow-Credentials', 'true') + reply.header('Vary', 'Origin') + if (request.raw.method === 'OPTIONS') { + reply.header('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS') + reply.header('Access-Control-Allow-Headers', 'Content-Type') + reply.header('Access-Control-Max-Age', '600') + return reply.code(204).send() + } + }) + + app.addHook('onClose', async () => { + await supervisor.teardown() + await db.close() + }) + + // Rate limiting first so auth/admin surfaces are covered by default. + await app.register(rateLimit) + + // Domain-specific route groups (API). + await app.register(authRoutes) + await app.register(adminRoutes) + await app.register(businessPluginRoutes) + await app.register(desktopRoutes) + await app.register(dshRoutes) + await app.register(domainRoutes) + await app.register(skillRoutes) + await app.register(whitelistRoutes) + + // Static placeholder SPA last, so exact API routes take precedence over the + // wildcard static handler. + await app.register(fastifyStatic, { + root: webRoot, + prefix: '/', + wildcard: true, + index: ['index.html'], + }) + + return app +} diff --git a/test/crash-policy.test.mjs b/test/crash-policy.test.mjs new file mode 100644 index 0000000..fa7d51a --- /dev/null +++ b/test/crash-policy.test.mjs @@ -0,0 +1,115 @@ +/** + * 档案 20 · 崩溃退避与熔断策略单测(纯函数,不 spawn 任何进程)。 + * 运行:node --test test/crash-policy.test.mjs(含在 npm test 中) + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + backoffDelayMs, + breakerActive, + breakerCooldownMs, + breakerUntil, + decideCrashAction, + openBreaker, + pruneHistory, +} from '../lib/supervisor/crash-policy.js' + +const CFG = { baseDelayMs: 1000, maxDelayMs: 30000, windowMs: 600000, maxRestartsInWindow: 5, stableResetMs: 60000 } + +test('backoffDelayMs: 指数退避并在上限处封顶', () => { + assert.equal(backoffDelayMs(0, CFG), 1000) + assert.equal(backoffDelayMs(1, CFG), 2000) + assert.equal(backoffDelayMs(2, CFG), 4000) + assert.equal(backoffDelayMs(3, CFG), 8000) + assert.equal(backoffDelayMs(4, CFG), 16000) + assert.equal(backoffDelayMs(5, CFG), 30000, '超过上限应封顶到 maxDelayMs') + assert.equal(backoffDelayMs(50, CFG), 30000, '极大值也不溢出') +}) + +test('pruneHistory: 丢弃窗口外的时间戳', () => { + const now = 1_000_000 + assert.deepEqual(pruneHistory([now - 700_000, now - 100_000, now - 1_000], now, CFG.windowMs), [ + now - 100_000, + now - 1_000, + ]) +}) + +test('decideCrashAction: 窗口内未超限 → restart(带退避与尝试号)', () => { + const now = 1_000_000 + const d = decideCrashAction([now - 5_000], 0, now, CFG) + assert.equal(d.action, 'restart') + assert.equal(d.delayMs, 1000) + assert.equal(d.attempt, 1) + assert.equal(d.windowRestarts, 2) +}) + +test('decideCrashAction: 达到窗口上限 → circuit-open(熔断)', () => { + const now = 1_000_000 + const history = [now - 50_000, now - 40_000, now - 30_000, now - 20_000, now - 10_000] // 恰好 5 次 + const d = decideCrashAction(history, 5, now, CFG) + assert.equal(d.action, 'circuit-open') + assert.equal(d.windowRestarts, 5) +}) + +test('decideCrashAction: 窗口外的历史不计入熔断', () => { + const now = 1_000_000 + const history = [now - 900_000, now - 800_000, now - 700_000, now - 650_000, now - 610_000] // 全部 > windowMs + const d = decideCrashAction(history, 5, now, CFG) + assert.equal(d.action, 'restart', '窗口外的旧崩溃不应触发熔断') + assert.equal(d.windowRestarts, 1) +}) + +test('decideCrashAction: 稳定后 streak 归零 → 退避回到 base', () => { + const now = 2_000_000 + // 历史上有 3 次(窗口内),但 streak=0(已稳定过),退避应回到 baseDelayMs + const history = [now - 300_000, now - 200_000, now - 100_000] + const d = decideCrashAction(history, 0, now, CFG) + assert.equal(d.action, 'restart') + assert.equal(d.delayMs, 1000) + assert.equal(d.windowRestarts, 4) +}) + +test('decideCrashAction: 第 5 次(窗口内已有 4 次)仍允许重启,第 6 次熔断', () => { + const now = 3_000_000 + const four = [now - 4000, now - 3000, now - 2000, now - 1000] + const d5 = decideCrashAction(four, 4, now, CFG) + assert.equal(d5.action, 'restart') + assert.equal(d5.windowRestarts, 5) + const five = [...four, now] + const d6 = decideCrashAction(five, 5, now, CFG) + assert.equal(d6.action, 'circuit-open') +}) + +/* ── 档案 78:熔断冷却(防「崩溃循环可无限重来」)────────────────────────── */ + +const BCFG = { baseCooldownMs: 600000, maxCooldownMs: 21600000 } + +test('档案 78 breakerCooldownMs: 指数加长并在上限封顶', () => { + assert.equal(breakerCooldownMs(1, BCFG), 600000) + assert.equal(breakerCooldownMs(2, BCFG), 1200000) + assert.equal(breakerCooldownMs(3, BCFG), 2400000) + assert.equal(breakerCooldownMs(99, BCFG), 21600000) +}) + +test('档案 78 openBreaker/breakerActive/breakerUntil: opens 递增、冷却期内 active', () => { + const t0 = 1_000_000 + const b1 = openBreaker(undefined, t0) + assert.deepEqual(b1, { openedAt: t0, opens: 1 }) + assert.equal(breakerActive(b1, t0 + 599_999, BCFG), true) + assert.equal(breakerActive(b1, t0 + 600_000, BCFG), false) + assert.equal(breakerUntil(b1, BCFG), t0 + 600000) + const b2 = openBreaker(b1, t0 + 600_000) + assert.equal(b2.opens, 2) + assert.equal(breakerUntil(b2, BCFG), t0 + 600_000 + 1_200_000) + assert.equal(breakerActive(undefined, t0, BCFG), false) +}) + +test('档案 78 回归:熔断后冷却期内应拒绝、冷却过后才放行一次预算', () => { + const now = 5_000_000 + const history = [now - 5000, now - 4000, now - 3000, now - 2000, now - 1000] + assert.equal(decideCrashAction(history, 5, now, CFG).action, 'circuit-open') + const b = openBreaker(undefined, now) + assert.equal(breakerActive(b, now + 1000, BCFG), true) // 冷却期内 → 拒绝重来 + assert.equal(breakerActive(b, now + 600_000, BCFG), false) // 冷却过后 → 放行一次干净预算 +}) diff --git a/test/db.test.mjs b/test/db.test.mjs new file mode 100644 index 0000000..09020f4 --- /dev/null +++ b/test/db.test.mjs @@ -0,0 +1,214 @@ +// DB adapter regression tests (node:test). Runs against the built `lib/` +// output — `npm test` builds first. Covers the constraint-mapping and +// transaction semantics shared by both backends: +// - unique / foreign-key errors converge on UniqueViolationError / +// ForeignKeyViolationError +// - the "disable-all-then-enable-one" credential upsert keeps exactly one +// enabled key under concurrent writes +// - concurrent createUser / audit writes land cleanly +// The Postgres suite self-skips unless DSHS_TEST_DB_URL is set +// (mirrors the CI e2e self-skip convention). + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { SqliteAdapter } from '../lib/db/sqlite.js' +import { openPgAdapter } from '../lib/db/pg.js' +import { ForeignKeyViolationError, UniqueViolationError } from '../lib/db/errors.js' + +const user = (id, username) => ({ id, username, passHash: 'x', role: 'active', homeDir: `/home/${username}` }) + +function register(backend, makeAdapter) { + test(`${backend}: duplicate username → UniqueViolationError`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + await assert.rejects(() => db.createUser(user('b', 'alice')), UniqueViolationError) + } finally { + await db.close() + } + }) + + test(`${backend}: session for unknown user → ForeignKeyViolationError`, async () => { + const db = await makeAdapter() + try { + await assert.rejects( + () => db.createSession({ tokenHash: 't', userId: 'missing', expiresAt: Date.now() + 1000 }), + ForeignKeyViolationError, + ) + } finally { + await db.close() + } + }) + + test(`${backend}: concurrent setCredentialKey keeps exactly one enabled`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + await Promise.all( + Array.from({ length: 5 }, (_, i) => db.setCredentialKey('a', `k${i}`, `ref${i}`)), + ) + const keys = await db.listCredentialKeys('a') + assert.equal(keys.filter((k) => k.enabled).length, 1, 'exactly one key enabled') + const ref = await db.getEnabledCredentialKeyRef('a') + assert.ok(ref !== null && ref.startsWith('ref'), 'enabled key has a ref') + } finally { + await db.close() + } + }) + + test(`${backend}: selectCredentialKey flips the enabled key`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + const k1 = await db.setCredentialKey('a', 'k1', 'r1') + await db.setCredentialKey('a', 'k2', 'r2') + assert.equal(await db.getEnabledCredentialKeyRef('a'), 'r2') + assert.equal(await db.selectCredentialKey('a', k1.id), true) + assert.equal(await db.getEnabledCredentialKeyRef('a'), 'r1') + } finally { + await db.close() + } + }) + + test(`${backend}: concurrent createUser`, async () => { + const db = await makeAdapter() + try { + await Promise.all(Array.from({ length: 20 }, (_, i) => db.createUser(user(`u${i}`, `user${i}`)))) + assert.equal((await db.listPublicUsers()).length, 20) + } finally { + await db.close() + } + }) + + test(`${backend}: getOrCreateWorkspace is idempotent`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + const w1 = await db.getOrCreateWorkspace('a', 'proj/one') + const w2 = await db.getOrCreateWorkspace('a', 'proj/one') + assert.equal(w1.id, w2.id) + } finally { + await db.close() + } + }) + + test(`${backend}: createUser assigns a unique uid`, async () => { + const db = await makeAdapter() + try { + const a = await db.createUser(user('a', 'alice')) + const b = await db.createUser(user('b', 'bob')) + assert.ok(a.uid !== null && a.uid !== undefined, 'first user has a uid') + assert.notEqual(a.uid, b.uid, 'uids are unique across users') + assert.equal((await db.listUsersWithoutUid()).length, 0, 'no unassigned uids remain') + } finally { + await db.close() + } + }) + + test(`${backend}: concurrent audit writes`, async () => { + const db = await makeAdapter() + try { + await Promise.all(Array.from({ length: 10 }, (_, i) => db.audit('system', 'test', `detail-${i}`))) + } finally { + await db.close() + } + }) + + test(`${backend}: upsertInstance round-trips folder + patch and is idempotent`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + await db.upsertInstance({ id: 'dsh-a', userId: 'a', role: 'main', status: 'starting', folder: '/ws/proj', patch: '- insert:\n' }) + const first = await db.findInstance('dsh-a') + assert.equal(first.folder, '/ws/proj') + assert.equal(first.patch, '- insert:\n') + assert.equal(first.status, 'starting') + assert.ok(first.startedAt > 0, 'started_at stamped') + + // Same deterministic id → overwrite, not a duplicate row. + await db.upsertInstance({ id: 'dsh-a', userId: 'a', role: 'main', status: 'running', folder: '/ws/other' }) + const second = await db.findInstance('dsh-a') + assert.equal(second.folder, '/ws/other') + assert.equal(second.patch, null, 'omitted patch clears the column') + assert.equal((await db.listInstancesByRole('main')).filter((i) => i.userId === 'a').length, 1) + } finally { + await db.close() + } + }) + + test(`${backend}: setInstanceStatus records the exit outcome`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + await db.upsertInstance({ id: 'dsh-a', userId: 'a', role: 'main', status: 'running', folder: '/ws' }) + assert.equal(await db.setInstanceStatus('dsh-a', 'crashed', { exitCode: 137, lastError: 'OOMKilled' }), true) + const row = await db.findInstance('dsh-a') + assert.equal(row.status, 'crashed') + assert.equal(row.exitCode, 137) + assert.equal(row.lastError, 'OOMKilled') + assert.ok(row.lastExit > 0, 'last_exit stamped') + assert.equal(await db.setInstanceStatus('dsh-missing', 'stopped'), false, 'unknown id reports no change') + } finally { + await db.close() + } + }) + + test(`${backend}: instances are scoped by user and role, and cascade on user delete`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + await db.createUser(user('b', 'bob')) + await db.upsertInstance({ id: 'dsh-a', userId: 'a', role: 'main', status: 'running', folder: '/ws' }) + await db.upsertInstance({ id: 'dsh-a-watchdog', userId: 'a', role: 'watchdog', status: 'starting' }) + await db.upsertInstance({ id: 'dsh-b', userId: 'b', role: 'main', status: 'running', folder: '/ws' }) + + assert.equal((await db.findUserInstance('a', 'main')).id, 'dsh-a') + assert.equal((await db.findUserInstance('a', 'watchdog')).id, 'dsh-a-watchdog') + assert.equal((await db.listInstancesByRole('main')).length, 2) + assert.equal((await db.listInstancesByRole('watchdog')).length, 1) + + await db.deleteUserInstances('a') + assert.equal(await db.findUserInstance('a', 'main'), undefined) + assert.equal((await db.listInstancesByRole('main')).length, 1, "b's instance survives") + } finally { + await db.close() + } + }) + + test(`${backend}: hasActiveSession reflects unexpired vs expired sessions`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + assert.equal(await db.hasActiveSession('a'), false, 'no session → inactive') + await db.createSession({ tokenHash: 't1', userId: 'a', expiresAt: Date.now() + 60_000 }) + assert.equal(await db.hasActiveSession('a'), true, 'unexpired session → active') + await db.createSession({ tokenHash: 't2', userId: 'a', expiresAt: Date.now() - 1000 }) + assert.equal(await db.hasActiveSession('a'), true, 'at least one unexpired session → active') + await db.deleteSession('t1') + assert.equal(await db.hasActiveSession('a'), false, 'only expired session left → inactive') + } finally { + await db.close() + } + }) + + test(`${backend}: instance for unknown user → ForeignKeyViolationError`, async () => { + const db = await makeAdapter() + try { + await assert.rejects( + () => db.upsertInstance({ id: 'dsh-ghost', userId: 'missing', role: 'main', status: 'starting' }), + ForeignKeyViolationError, + ) + } finally { + await db.close() + } + }) +} + +register('sqlite', () => new SqliteAdapter(':memory:', 100000)) + +const pgUrl = process.env.DSHS_TEST_DB_URL +if (pgUrl) { + register('pg', () => openPgAdapter(pgUrl, 100000)) +} else { + test('pg: skipped — set DSHS_TEST_DB_URL to run', { skip: 'no DSHS_TEST_DB_URL' }, () => {}) +} diff --git a/test/k8s-spawner.test.mjs b/test/k8s-spawner.test.mjs new file mode 100644 index 0000000..9d29ddc --- /dev/null +++ b/test/k8s-spawner.test.mjs @@ -0,0 +1,163 @@ +// K8sSpawner unit tests against fake API clients. Covers the six defects fixed +// in Stage 4 without needing a cluster: 404-vs-500 error handling, full +// teardown of every generated object, idempotent Secret/ConfigMap, and the +// file-sidecar Pod + init container shape. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { K8sSpawner } from '../lib/supervisor/k8s-spawner.js' +import { resolveConfig } from '../lib/config.js' +import { AlreadyRunningError } from '../lib/supervisor/spawner.js' +import { FILE_SERVICE_PORT } from '../lib/web/file-service.js' + +function notFound() { + const err = new Error('not found') + err.code = 404 + return err +} +function conflict() { + const err = new Error('already exists') + err.code = 409 + return err +} + +/** A tiny in-memory fake for the K8s API surface K8sSpawner touches. */ +function makeClients() { + const pods = new Map() + const services = new Map() + const secrets = new Map() + const configMaps = new Map() + const policies = new Map() + const jobs = new Map() + const deletes = [] + + return { + pods, services, secrets, configMaps, policies, jobs, deletes, + core: { + async readNamespacedPod({ name }) { return pods.get(name) ?? (() => { throw notFound() })() }, + async createNamespacedPod({ body }) { + // Stored pods report Ready so `waitForPodReady` returns immediately. + const pod = { ...body, status: { phase: 'Running', conditions: [{ type: 'Ready', status: 'True' }] } } + pods.set(body.metadata.name, pod) + return pod + }, + async deleteNamespacedPod({ name }) { deletes.push(`pod:${name}`); pods.delete(name) }, + async readNamespacedSecret({ name }) { return secrets.get(name) ?? (() => { throw notFound() })() }, + async createNamespacedSecret({ body }) { secrets.set(body.metadata.name, body); return body }, + async deleteNamespacedSecret({ name }) { deletes.push(`secret:${name}`); secrets.delete(name) }, + async readNamespacedConfigMap({ name }) { return configMaps.get(name) ?? (() => { throw notFound() })() }, + async createNamespacedConfigMap({ body }) { configMaps.set(body.metadata.name, body); return body }, + async deleteNamespacedConfigMap({ name }) { deletes.push(`configmap:${name}`); configMaps.delete(name) }, + async readNamespacedService({ name }) { return services.get(name) ?? (() => { throw notFound() })() }, + async createNamespacedService({ body }) { services.set(body.metadata.name, body); return body }, + async deleteNamespacedService({ name }) { deletes.push(`service:${name}`); services.delete(name) }, + }, + networking: { + async readNamespacedNetworkPolicy({ name }) { return policies.get(name) ?? (() => { throw notFound() })() }, + async createNamespacedNetworkPolicy({ body }) { policies.set(body.metadata.name, body); return body }, + async deleteNamespacedNetworkPolicy({ name }) { deletes.push(`np:${name}`); policies.delete(name) }, + }, + batch: { + async createNamespacedJob({ body }) { jobs.set(body.metadata.name, body); return body }, + async deleteNamespacedJob({ name }) { deletes.push(`job:${name}`); jobs.delete(name) }, + }, + } +} + +function spawner(clients) { + const config = resolveConfig({ deployMode: 'k8s', controlPlaneImage: 'acr/cp:tag', dshImage: 'acr/dsh:tag' }) + const db = { findUserInstance: async () => undefined } + return new K8sSpawner(config, db, async () => 'sk-test', async () => 100042, clients) +} + +test('k8s: launch creates DSH Pod/Service/NP and calls ensureFileService first', async () => { + const clients = makeClients() + const s = spawner(clients) + const inst = await s.launch('u1', '/ws/proj') + assert.equal(inst.id, 'dsh-u1') + assert.ok(clients.pods.has('dsh-files-u1'), 'files Pod created first') + assert.ok(clients.pods.has('dsh-u1'), 'dsh Pod created') + assert.ok(clients.services.has('dsh-u1'), 'dsh Service created') + assert.ok(clients.policies.has('dsh-u1'), 'dsh NetworkPolicy created') + + const filesPod = clients.pods.get('dsh-files-u1') + assert.equal(filesPod.spec.containers[0].args[0], 'file-service', 'files container runs the file-service subcommand') + assert.equal(filesPod.spec.initContainers.length, 1, 'files Pod carries the user-dir init container') + const filesMounts = filesPod.spec.containers[0].volumeMounts + assert.equal(filesMounts.length, 2, 'files container mounts its subPath + /tmp') + assert.equal(filesMounts[0].name, 'data') + assert.equal(filesMounts[0].subPath, 'u1', 'files container mounts <pvc>/u1 via subPath') + assert.equal(filesMounts[1].name, 'tmp', 'files container mounts emptyDir /tmp (read-only rootfs)') + assert.equal(filesPod.spec.containers[0].securityContext.readOnlyRootFilesystem, true, 'files container rootfs is read-only') + const initMount = filesPod.spec.initContainers[0].volumeMounts[0] + assert.equal(initMount.name, 'data-root', 'init container mounts the PVC root (no subPath)') + assert.equal(initMount.subPath, undefined, 'init container has no subPath') + + await assert.rejects(() => s.launch('u1', '/ws/proj'), AlreadyRunningError) +}) + +test('k8s: endpointFor returns the Pod IP (not Service DNS) for a Running Pod', async () => { + const clients = makeClients() + const s = spawner(clients) + assert.equal(await s.endpointFor('u1'), undefined, 'absent Pod → undefined') + clients.pods.set('dsh-u1', { status: { phase: 'Running', podIP: '10.42.0.7' } }) + assert.deepEqual(await s.endpointFor('u1'), { host: '10.42.0.7', port: 8081 }) + clients.pods.set('dsh-u1', { status: { phase: 'Pending' } }) + assert.equal(await s.endpointFor('u1'), undefined, 'non-Running Pod → undefined') +}) + +test('k8s: stop deletes every generated object (Pod/Service/NP/Job/ConfigMap)', async () => { + const clients = makeClients() + const s = spawner(clients) + await s.launch('u1', '/ws/proj', '- insert:\n') + // Pre-seed the patch ConfigMap + watchdog Job as a prior launch would have. + clients.configMaps.set('dsh-u1-patch', { metadata: { name: 'dsh-u1-patch' } }) + clients.jobs.set('dsh-u1-watchdog', { metadata: { name: 'dsh-u1-watchdog' } }) + + await s.stop('u1') + const names = clients.deletes + for (const expected of ['pod:dsh-u1', 'service:dsh-u1', 'np:dsh-u1', 'job:dsh-u1-watchdog', 'configmap:dsh-u1-patch']) { + assert.ok(names.includes(expected), `stop deletes ${expected}`) + } + // stop() must NOT touch the files Pod (it lives independently of the DSH). + assert.ok(clients.pods.has('dsh-files-u1'), 'files Pod survives a DSH stop') +}) + +test('k8s: a 404 is swallowed, but 403/500 propagate', async () => { + const clients = makeClients() + const s = spawner(clients) + // endpointFor must not mask a real failure as "not running". + clients.core.readNamespacedPod = async () => { const e = new Error('forbidden'); e.code = 403; throw e } + await assert.rejects(() => s.endpointFor('u1'), (err) => err.code === 403, '403 surfaces') + clients.core.readNamespacedPod = async () => { throw notFound() } + assert.equal(await s.endpointFor('u1'), undefined, '404 is "no Pod"') +}) + +test('k8s: ensureSecret is idempotent (no 409 on relaunch)', async () => { + const clients = makeClients() + const s = spawner(clients) + // First call: read → 404 → create. + await s.ensureFileService('u1') // exercises the image gate, not the secret + await s.launch('u1', '/ws') + // Simulate a stale Secret still present after a stop that skipped it. + clients.secrets.set('dsh-key-u1', { metadata: { name: 'dsh-key-u1' } }) + clients.pods.delete('dsh-u1') + await s.launch('u1', '/ws') // read → present → no create, no throw + assert.ok(clients.secrets.has('dsh-key-u1'), 'secret still present, no 409') +}) + +test('k8s: ensureFileService fails loudly without a control-plane image', async () => { + const config = resolveConfig({ deployMode: 'k8s', controlPlaneImage: '', dshImage: 'acr/dsh:tag' }) + const s = new K8sSpawner(config, { findUserInstance: async () => undefined }, async () => null, async () => 100042, makeClients()) + await assert.rejects(() => s.ensureFileService('u1'), /CONTROL_PLANE_IMAGE/, 'missing image is a hard failure') +}) + +test('k8s: files NetworkPolicy only allows the control plane on the file port', async () => { + const clients = makeClients() + const s = spawner(clients) + await s.ensureFileService('u1') + const np = clients.policies.get('dsh-files-u1') + assert.equal(np.spec.ingress[0]._from[0].podSelector.matchLabels.app, 'dsh-orchestrator') + assert.equal(np.spec.ingress[0].ports[0].port, FILE_SERVICE_PORT) + // No 443 egress for the files sidecar — it never reaches the LLM API. + assert.equal(np.spec.egress.some((rule) => (rule.ports ?? []).some((p) => p.port === 443)), false) +}) diff --git a/test/leader.test.mjs b/test/leader.test.mjs new file mode 100644 index 0000000..1e541d4 --- /dev/null +++ b/test/leader.test.mjs @@ -0,0 +1,117 @@ +// Leader election + reconcile planning, without a cluster. `planReconcile` is +// pure; `LeaderElector` is driven through a fake CoordinationV1Api and a fake +// clock so the acquire / back-off / take-over state machine is deterministic. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { LeaderElector } from '../lib/supervisor/leader.js' +import { planReconcile } from '../lib/supervisor/reconcile.js' + +// The real client deserializes V1MicroTime back to an ISO *string*, so fake the +// same shape here to exercise the normalize-on-read path. +const lease = (holder, renewMs, transitions = 0, rv = '1') => ({ + metadata: { resourceVersion: rv }, + spec: { + holderIdentity: holder, + renewTime: new Date(renewMs).toISOString(), + leaseTransitions: transitions, + }, +}) + +test('reconcile: relaunch desired mains with a missing/stopped Pod, delete orphans', () => { + const desired = [ + { id: 'dsh-a', userId: 'a', role: 'main', folder: '/ws', patch: null }, + { id: 'dsh-b', userId: 'b', role: 'main', folder: '/ws', patch: null }, + ] + const live = [ + { name: 'dsh-a', userId: 'a', running: true, crashed: false }, + { name: 'dsh-orphan', userId: 'x', running: true, crashed: false }, + ] + const plan = planReconcile(desired, live) + assert.deepEqual(plan.launch.map((i) => i.userId), ['b'], 'missing main is relaunched') + assert.deepEqual(plan.delete.map((p) => p.userId), ['x'], 'orphan Pod is deleted') +}) + +test('reconcile: a crashed (non-running) Pod still counts as absent', () => { + const plan = planReconcile( + [{ id: 'dsh-a', userId: 'a', role: 'main', folder: '/ws', patch: null }], + [{ name: 'dsh-a', userId: 'a', running: false, crashed: true }], + ) + assert.equal(plan.launch.length, 1, 'crashed Pod → relaunch') + assert.equal(plan.delete.length, 0) +}) + +test('leader: first candidate creates the lease and leads', async () => { + let created = false + let patched = [] + const coordination = { + async createNamespacedLease({ body }) { + created = true + return body + }, + async readNamespacedLease() { throw Object.assign(new Error('nf'), { code: 404 }) }, + async replaceNamespacedLease({ body }) { patched.push(body) }, + } + const started = [] + const elector = new LeaderElector({ namespace: 'dsh', identity: 'pod-1', coordination, now: () => 1_000_000 }) + elector.setLeadershipCallbacks((f) => started.push(f)) + await elector.start() + assert.equal(created, true, 'first candidate creates the lease') + assert.equal(elector.isLeader, true) + assert.equal(started[0].holder, 'pod-1') + assert.equal(started[0].operationId, 0) + elector.stop() +}) + +test('leader: a second candidate backs off while a live holder leads', async () => { + const coordination = { + async createNamespacedLease() { throw Object.assign(new Error('exists'), { code: 409 }) }, + async readNamespacedLease() { return lease('pod-1', 10_000_000) }, // renew 1s ago, live + async replaceNamespacedLease() { throw new Error('should not replace') }, + } + const elector = new LeaderElector({ + namespace: 'dsh', + identity: 'pod-2', + coordination, + now: () => 11_000_000, // 1s after the holder's renew, still within 15s + }) + await elector.start() + assert.equal(elector.isLeader, false, 'live holder → back off') + elector.stop() +}) + +test('leader: a stale lease is taken over with a bumped transition', async () => { + let patched + const coordination = { + async createNamespacedLease() { throw Object.assign(new Error('exists'), { code: 409 }) }, + async readNamespacedLease() { return lease('pod-1', 10_000_000, 3, 'rv-9') }, // renew 20s ago, expired + async replaceNamespacedLease({ body }) { patched = body }, + } + const started = [] + const elector = new LeaderElector({ + namespace: 'dsh', + identity: 'pod-2', + coordination, + now: () => 30_000_000, + }) + elector.setLeadershipCallbacks((f) => started.push(f)) + await elector.start() + assert.equal(elector.isLeader, true, 'expired lease → take over') + assert.equal(patched.metadata.resourceVersion, 'rv-9', 'CAS on the read resourceVersion') + assert.equal(patched.spec.leaseTransitions, 4, 'transition bumps') + assert.equal(started[0].operationId, 4, 'fencing token carries the new transition') + elector.stop() +}) + +test('leader: re-acquiring our own lease renews rather than bumps', async () => { + let patched + const coordination = { + async createNamespacedLease() { throw Object.assign(new Error('exists'), { code: 409 }) }, + async readNamespacedLease() { return lease('pod-1', 30_000_000, 0, 'rv-2') }, + async replaceNamespacedLease({ body }) { patched = body }, + } + const elector = new LeaderElector({ namespace: 'dsh', identity: 'pod-1', coordination, now: () => 30_000_000 }) + await elector.start() + assert.equal(elector.isLeader, true) + assert.equal(patched.spec.leaseTransitions, 0, 'renewing preserves transitions (does not bump)') + elector.stop() +}) diff --git a/test/local-user-fs.test.mjs b/test/local-user-fs.test.mjs new file mode 100644 index 0000000..8e0a85a --- /dev/null +++ b/test/local-user-fs.test.mjs @@ -0,0 +1,120 @@ +// LocalUserFs regression tests (node:test, against built lib/ — `npm test` +// builds first). Focus: the symlink-escape guard layered on top of lexical +// path containment. A link planted inside the workspace (`ws/link -> /etc`) +// passes the prefix check, so every operation must reject symlink components +// before touching the filesystem. +// +// Symlink creation is privilege-gated on Windows (junctions work unprivileged, +// file links need dev mode), so the link-based cases self-skip when the FS +// refuses to create one. + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { LocalUserFs } from '../lib/fs/local-user-fs.js' +import { UserFsError } from '../lib/fs/user-fs.js' + +const USER = 'u1' + +function makeUser(t) { + const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-lufs-')) + t.after(() => rmSync(dataRoot, { recursive: true, force: true })) + const ws = join(dataRoot, 'users', USER, 'ws') + mkdirSync(ws, { recursive: true }) + return { fs: new LocalUserFs((id) => join(dataRoot, 'users', id)), dataRoot, ws } +} + +/** Create a link; false when the platform refuses (e.g. unprivileged Windows). */ +function tryLink(target, path, type) { + try { + symlinkSync(target, path, type) + return true + } catch { + return false + } +} + +function isBadPath(err) { + return err instanceof UserFsError && err.code === 'bad_path' +} + +test('upload/mkdir/listDir work through nested directories', async (t) => { + const { fs } = makeUser(t) + await fs.mkdir(USER, 'proj') + assert.equal(await fs.upload(USER, 'proj', 'hello.txt', Buffer.from('hi')), 'hello.txt') + const entries = await fs.listDir(USER, 'proj') + assert.deepEqual(entries.map((e) => e.name).sort(), ['hello.txt']) +}) + +test('lexical traversal is still rejected with bad_path', async (t) => { + const { fs } = makeUser(t) + await assert.rejects(() => fs.upload(USER, '../outside', 'x.txt', Buffer.from('x')), isBadPath) + await assert.rejects(() => fs.listDir(USER, '../../etc'), isBadPath) +}) + +test('upload through an in-workspace directory symlink is rejected', async (t) => { + const { fs, dataRoot, ws } = makeUser(t) + const outside = join(dataRoot, 'outside') + mkdirSync(outside) + const type = process.platform === 'win32' ? 'junction' : 'dir' + if (!tryLink(outside, join(ws, 'link'), type)) { + t.skip('symlink creation unavailable on this host') + return + } + await assert.rejects(() => fs.upload(USER, 'link', 'escaped.txt', Buffer.from('x')), isBadPath) + await assert.rejects(() => fs.mkdir(USER, 'link/sub'), isBadPath) + await assert.rejects(() => fs.listDir(USER, 'link'), isBadPath) + await assert.rejects(() => fs.isDirectory(USER, 'link'), isBadPath) +}) + +test('upload onto an existing symlinked filename does not follow it', async (t) => { + const { fs, ws } = makeUser(t) + writeFileSync(join(ws, 'real.txt'), 'original') + if (!tryLink(join(ws, 'real.txt'), join(ws, 'alias.txt'), 'file')) { + t.skip('symlink creation unavailable on this host') + return + } + await assert.rejects(() => fs.upload(USER, '', 'alias.txt', Buffer.from('evil')), isBadPath) + assert.equal(readFileSync(join(ws, 'real.txt'), 'utf8'), 'original', 'target untouched') +}) + +test('missing path components end the walk and surface as not_found', async (t) => { + const { fs } = makeUser(t) + await assert.rejects( + () => fs.isDirectory(USER, 'ghost/deep'), + (err) => err instanceof UserFsError && err.code === 'not_found', + ) +}) + + +// ─── 档案 56:readFile(门户/实例页「我的文件」下载) ─────────────── +test("readFile: 返回文件名与内容", async (t) => { + const { fs, ws } = makeUser(t) + writeFileSync(join(ws, "报告.md"), "# 内容\n") + const out = await fs.readFile(USER, "报告.md") + assert.equal(out.name, "报告.md") + assert.equal(out.data.toString("utf8"), "# 内容\n") +}) + +test("readFile: 目录→not_a_file;缺失→not_found;超限→too_large", async (t) => { + const { fs, ws } = makeUser(t) + mkdirSync(join(ws, "dir")) + await assert.rejects(() => fs.readFile(USER, "dir"), (e) => e instanceof UserFsError && e.code === "not_a_file") + await assert.rejects(() => fs.readFile(USER, "ghost.txt"), (e) => e instanceof UserFsError && e.code === "not_found") + writeFileSync(join(ws, "big.bin"), Buffer.alloc(4096)) + await assert.rejects(() => fs.readFile(USER, "big.bin", 1024), (e) => e instanceof UserFsError && e.code === "too_large") +}) + +test("readFile: 路径逃逸被拒", async (t) => { + const { fs } = makeUser(t) + await assert.rejects(() => fs.readFile(USER, "../outside.txt"), isBadPath) + await assert.rejects(() => fs.readFile(USER, "../../etc/passwd"), isBadPath) +}) + +test("readFile: 符号链接逃逸被拒(不跟随工作区内的链接)", async (t) => { + const { fs, ws } = makeUser(t) + if (!tryLink("/etc/passwd", join(ws, "link.txt"), "file")) return + await assert.rejects(() => fs.readFile(USER, "link.txt"), isBadPath) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..039fb0e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "outDir": "lib", + "rootDir": "src", + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/web/admin.html b/web/admin.html new file mode 100644 index 0000000..35c3507 --- /dev/null +++ b/web/admin.html @@ -0,0 +1,146 @@ +<!doctype html> +<html lang="zh-CN"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>管理台 + + + + + + +

+
+ + DeepSeek +
+

仅限管理员

+ + + + +
+ + + + diff --git a/web/deepseek-text.svg b/web/deepseek-text.svg new file mode 100644 index 0000000..8e137b5 --- /dev/null +++ b/web/deepseek-text.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/web/deepseek.webp b/web/deepseek.webp new file mode 100644 index 0000000..4994b65 Binary files /dev/null and b/web/deepseek.webp differ diff --git a/web/design.css b/web/design.css new file mode 100644 index 0000000..d47ba2d --- /dev/null +++ b/web/design.css @@ -0,0 +1,268 @@ +/* design system — OS-desktop style */ +:root { + --accent: #4d7cfe; + --accent-2: #38d6d0; + --ink: #0f1c33; + --ink-soft: #5a6b85; + --line: #e6ebf4; + --surface: #ffffff; + --surface-2: #f6f8fc; + --danger: #e5484d; + --ok: #1fb56a; + --warn: #f5a623; + --radius: 14px; + --shadow: 0 18px 50px -18px rgba(9, 24, 58, 0.35); + --shadow-sm: 0 6px 18px -8px rgba(9, 24, 58, 0.28); + --mono: "SFMono-Regular", "Cascadia Code", Consolas, monospace; +} +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + color: var(--ink); + background: #0b1a33; + -webkit-font-smoothing: antialiased; +} + +/* ---------- auth pages ---------- */ +.auth-bg { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: + radial-gradient(60rem 40rem at 80% -10%, rgba(56, 214, 208, 0.18), transparent 60%), + radial-gradient(50rem 36rem at 10% 110%, rgba(77, 124, 254, 0.28), transparent 60%), + linear-gradient(160deg, #0d1f3c, #0a1530); +} +.auth-card { + width: 100%; + max-width: 380px; + background: var(--surface); + border-radius: 20px; + padding: 32px 30px 26px; + box-shadow: var(--shadow); +} +.auth-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; } +.auth-brand .logo { width: 34px; height: 34px; border-radius: 9px; } +.auth-brand h1 { font-size: 19px; margin: 0; letter-spacing: 0.2px; } +.wordmark { display: inline-flex; align-items: center; } +.wordmark svg { height: 100%; width: auto; display: block; } +.auth-brand .wordmark { height: 22px; color: var(--ink); } +.topbar .wordmark { height: 20px; color: #dbe5ff; } +.auth-sub { color: var(--ink-soft); font-size: 13px; margin: 6px 0 22px; } +.field { margin-bottom: 14px; } +.field label { display: block; font-size: 13px; color: var(--ink-soft); margin-bottom: 6px; } +.field input { + width: 100%; + padding: 11px 13px; + font-size: 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-2); + color: var(--ink); + outline: none; + transition: border-color .15s, box-shadow .15s; +} +.field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(77, 124, 254, 0.15); background: #fff; } +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + border: 0; cursor: pointer; font-size: 14px; font-weight: 600; + padding: 11px 16px; border-radius: 10px; transition: transform .06s, filter .15s, background .15s; +} +.btn:active { transform: translateY(1px); } +.btn.primary { width: 100%; background: var(--accent); color: #fff; } +.btn.primary:hover { filter: brightness(1.06); } +.btn.ghost { background: var(--surface-2); color: var(--ink); border: 1px solid var(--line); } +.btn.ghost:hover { background: #eef1f8; } +.btn.danger { background: var(--danger); color: #fff; } +.btn.small { padding: 6px 10px; font-size: 13px; border-radius: 8px; } +.msg { min-height: 20px; font-size: 13px; margin-top: 10px; } +.msg.err { color: var(--danger); } +.msg.ok { color: var(--ok); } +.auth-alt { margin-top: 16px; text-align: center; font-size: 13px; color: var(--ink-soft); } +.auth-alt a { color: var(--accent); text-decoration: none; font-weight: 600; } + +/* ---------- desktop ---------- */ +.desktop { position: fixed; inset: 0; display: flex; flex-direction: column; } +.wallpaper { + position: absolute; inset: 0; z-index: 0; + background: + radial-gradient(70rem 40rem at 75% -20%, rgba(56, 214, 208, 0.20), transparent 55%), + radial-gradient(60rem 40rem at 10% 110%, rgba(77, 124, 254, 0.30), transparent 55%), + linear-gradient(160deg, #0e2246 0%, #0a1630 60%, #071021 100%); +} +.wallpaper::after { + content: ""; position: absolute; inset: 0; + background-image: radial-gradient(rgba(255,255,255,0.05) 1px, transparent 1px); + background-size: 26px 26px; +} + +.topbar { + position: relative; z-index: 2; + display: flex; align-items: center; justify-content: space-between; + padding: 0 18px; height: 52px; + background: rgba(13, 26, 52, 0.55); backdrop-filter: blur(14px); + border-bottom: 1px solid rgba(255,255,255,0.08); + color: #dbe5ff; +} +.topbar .brand { display: flex; align-items: center; gap: 9px; font-weight: 600; } +.topbar .brand .logo { width: 24px; height: 24px; border-radius: 6px; } +.topbar .right { display: flex; align-items: center; gap: 12px; font-size: 13px; } +.topbar .who { font-weight: 600; } +.topbar .role { + font-size: 11px; padding: 2px 8px; border-radius: 999px; + background: rgba(77, 124, 254, 0.25); color: #bcd0ff; border: 1px solid rgba(77,124,254,0.4); +} +.topbar .logout { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: #dbe5ff; } +.topbar .logout:hover { background: rgba(255,255,255,0.14); } + +.icons { position: relative; z-index: 1; flex: 1; display: flex; flex-wrap: wrap; align-content: flex-start; gap: 10px; padding: 22px; } +.desk-icon { + width: 96px; display: flex; flex-direction: column; align-items: center; gap: 7px; + padding: 10px 6px; border-radius: 12px; cursor: pointer; user-select: none; + border: 1px solid transparent; +} +.desk-icon:hover { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.10); } +.desk-icon .tile { + width: 62px; height: 62px; border-radius: 16px; display: grid; place-items: center; + color: #fff; box-shadow: 0 10px 24px -10px rgba(0,0,0,0.5); +} +.desk-icon .tile svg { width: 30px; height: 30px; } +.desk-icon .label { color: #e6edff; font-size: 12.5px; text-align: center; text-shadow: 0 1px 3px rgba(0,0,0,0.5); } +.tile.dsh { background: linear-gradient(135deg, #4d7cfe, #38d6d0); } +.tile.files { background: linear-gradient(135deg, #f5b14d, #f58a4d); } +.tile.admin { background: linear-gradient(135deg, #9b6cff, #4d7cfe); } + +.taskbar { + position: relative; z-index: 2; display: flex; align-items: center; justify-content: center; gap: 10px; + height: 56px; background: rgba(13, 26, 52, 0.6); backdrop-filter: blur(14px); + border-top: 1px solid rgba(255,255,255,0.08); +} +.taskbar .app { + width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; cursor: pointer; + border: 1px solid transparent; transition: background .15s, transform .1s; +} +.taskbar .app:hover { background: rgba(255,255,255,0.1); } +.taskbar .app.active { background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.18); } +.taskbar .app svg { width: 22px; height: 22px; } +.taskbar .clock { position: absolute; right: 18px; color: #cfe0ff; font-size: 13px; } + +/* ---------- windows ---------- */ +.window { + position: absolute; z-index: 10; min-width: 380px; min-height: 220px; + background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); + display: flex; flex-direction: column; overflow: hidden; + animation: win-in .18s ease; +} +@keyframes win-in { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } } +.window.focused { box-shadow: 0 26px 70px -20px rgba(0,0,0,0.55); } +.window .rz, .window .resize { position: absolute; z-index: 3; touch-action: none; } +.window .rz-n { top: 0; left: 16px; right: 16px; height: 6px; cursor: ns-resize; } +.window .rz-s { bottom: 0; left: 16px; right: 16px; height: 6px; cursor: ns-resize; } +.window .rz-e { right: 0; top: 48px; bottom: 0; width: 6px; cursor: ew-resize; } +.window .rz-w { left: 0; top: 48px; bottom: 0; width: 6px; cursor: ew-resize; } +.window .rz-ne { top: 30px; right: 0; width: 14px; height: 14px; cursor: nesw-resize; } +.window .rz-nw { top: 30px; left: 0; width: 14px; height: 14px; cursor: nwse-resize; } +.window .rz-se, .window .resize { bottom: 0; right: 0; width: 16px; height: 16px; cursor: nwse-resize; } +.window .rz-sw { bottom: 0; left: 0; width: 14px; height: 14px; cursor: nesw-resize; } +.window .rz-se::after, .window .resize::after { + content: ""; position: absolute; right: 4px; bottom: 4px; width: 9px; height: 9px; + border-right: 2px solid rgba(120, 132, 160, 0.55); + border-bottom: 2px solid rgba(120, 132, 160, 0.55); + border-bottom-right-radius: 4px; +} +.window-title { + display: flex; align-items: center; justify-content: space-between; + padding: 0 14px; height: 44px; background: var(--surface-2); border-bottom: 1px solid var(--line); + cursor: grab; user-select: none; +} +.window-title .t { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 14px; } +.window-title .t svg { width: 16px; height: 16px; color: var(--accent); } +.window-title .controls { display: flex; gap: 6px; } +.window-title .controls button { + width: 26px; height: 26px; border: 0; border-radius: 7px; background: transparent; cursor: pointer; + color: var(--ink-soft); font-size: 13px; line-height: 1; display: grid; place-items: center; +} +.window-title .controls button:hover { background: #e8ecf5; } +.window-title .controls button.close:hover { background: var(--danger); color: #fff; } +.window-body { flex: 1; overflow: auto; padding: 16px; } + +/* file table */ +.pathbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; } +.pathbar .crumbs { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--accent); } +.pathbar .crumbs .sep { color: var(--ink-soft); } +.pathbar .crumb { cursor: pointer; } +.pathbar .crumb:hover { text-decoration: underline; } +table.files { width: 100%; border-collapse: collapse; font-size: 13.5px; } +table.files th, table.files td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); } +table.files th { color: var(--ink-soft); font-weight: 600; font-size: 12px; } +table.files tbody tr:hover { background: var(--surface-2); } +table.files .dir { color: var(--accent); cursor: pointer; font-weight: 600; } +table.files .dir:hover { text-decoration: underline; } + +/* dsh status */ +.stat { display: flex; align-items: center; gap: 8px; font-size: 14px; padding: 10px 12px; border-radius: 10px; background: var(--surface-2); margin-bottom: 12px; } +.dot { width: 9px; height: 9px; border-radius: 50%; } +.dot.on { background: var(--ok); box-shadow: 0 0 0 4px rgba(31,181,106,0.15); } +.dot.off { background: var(--ink-soft); } +.row-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; } +.section-label { font-size: 12px; color: var(--ink-soft); font-weight: 600; margin: 16px 0 8px; } +.plugin-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-radius: 8px; font-size: 13.5px; } +.plugin-item:hover { background: var(--surface-2); } + +/* admin */ +table.admin { width: 100%; border-collapse: collapse; font-size: 13.5px; } +table.admin th, table.admin td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); } +table.admin th { color: var(--ink-soft); font-weight: 600; font-size: 12px; } +.badge { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 600; } +.badge.admin { background: #e7edff; color: #3f5fd0; } +.badge.pending { background: #fff3dd; color: #b0761a; } +.badge.active { background: #e4f7ec; color: #15995a; } +.badge.disabled { background: #eceff4; color: #6a7588; } + +.hidden { display: none !important; } + +/* ---------- mobile ---------- */ +@media (max-width: 768px) { + .auth-card { max-width: 100%; overflow-x: auto; } + .desktop { overflow: hidden; } + .topbar { padding: 0 10px; height: 48px; } + .topbar .role { display: none; } + .icons { padding: 10px; gap: 6px; } + .desk-icon { width: 80px; padding: 8px 4px; } + .desk-icon .tile { width: 52px; height: 52px; border-radius: 14px; } + .desk-icon .label { font-size: 11px; } + .desk-path { padding: 8px 10px 0; font-size: 12px; } + .desk-path .loc { max-width: 70vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .taskbar { height: 50px; } + .taskbar .clock { display: none; } + /* windows go full-screen on phones */ + .window { + position: fixed !important; + inset: 0 !important; + left: 0 !important; top: 0 !important; + width: 100% !important; height: 100% !important; + min-width: 0 !important; min-height: 0 !important; + max-width: none !important; + border-radius: 0 !important; + } + .window .rz, .window .resize { display: none; } + .window-body { -webkit-overflow-scrolling: touch; } + .window-body table { min-width: 520px; } + .window-title { height: 48px; } + .window-title .controls button { width: 34px; height: 34px; } +} + +/* desktop file grid + path bar */ +.desk-path { position: relative; z-index: 1; display: flex; align-items: center; gap: 10px; padding: 12px 22px 0; color: #cfe0ff; font-size: 13px; } +.desk-path .back { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16); color: #dbe5ff; } +.desk-path .back:hover { background: rgba(255,255,255,0.14); } +.desk-path .loc { opacity: .85; } +.tile { overflow: hidden; } +.tile img { min-width: 0; min-height: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; } +.tile.file { background: linear-gradient(135deg, #7f9cf5, #5a74d0); } +.desk-divider { width: 100%; height: 0; border-top: 1px solid rgba(255,255,255,0.14); margin: 4px 2px 10px; } +#fileIcons { display: contents; } +#fileIcons .desk-icon .label { font-size: 12px; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/web/desktop.html b/web/desktop.html new file mode 100644 index 0000000..d509014 --- /dev/null +++ b/web/desktop.html @@ -0,0 +1,11 @@ + + + + + +跳转中… + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8354100 --- /dev/null +++ b/web/index.html @@ -0,0 +1,31 @@ + + + + + + 工作台 + + + + + diff --git a/web/login.html b/web/login.html new file mode 100644 index 0000000..6ebd83b --- /dev/null +++ b/web/login.html @@ -0,0 +1,109 @@ + + + + + + 登录 + + + + + +
+
+ + DeepSeek +
+

登录你的 DSH 云桌面

+ +
+
+ + +
+
+ + +
+ + +

提示:若会话中 shell 频繁要求审批,新开一个会话即可使用完全权限(无需逐条确认)。

+

+
+ +

还没有账号?注册

+
+ + + + diff --git a/web/logo.svg b/web/logo.svg new file mode 100644 index 0000000..bd25936 --- /dev/null +++ b/web/logo.svg @@ -0,0 +1,3 @@ +DeepSeek + + diff --git a/web/plugins.html b/web/plugins.html new file mode 100644 index 0000000..a3a7c78 --- /dev/null +++ b/web/plugins.html @@ -0,0 +1,11 @@ + + + + + +跳转中… + + + + + diff --git a/web/portal.html b/web/portal.html new file mode 100644 index 0000000..0d07905 --- /dev/null +++ b/web/portal.html @@ -0,0 +1,709 @@ + + + + + +管理门户 + + + + + +
+
管理门户
+ +
+ + + +
+
+ +
加载中…
+ +
+ + + + diff --git a/web/register.html b/web/register.html new file mode 100644 index 0000000..8fc8b2f --- /dev/null +++ b/web/register.html @@ -0,0 +1,85 @@ + + + + + + 注册 + + + + + +
+
+ + DeepSeek +
+

注册后需管理员审核方可登录

+ +
+
+ + +
+
+ + +
+ +

+
+ +

已有账号?登录

+
+ + + + diff --git a/web/skills.html b/web/skills.html new file mode 100644 index 0000000..0c24afb --- /dev/null +++ b/web/skills.html @@ -0,0 +1,11 @@ + + + + + +跳转中… + + + + + diff --git a/web/wake.html b/web/wake.html new file mode 100644 index 0000000..c89fd6e --- /dev/null +++ b/web/wake.html @@ -0,0 +1,159 @@ + + + + + +正在启动工作区 + + + + +
+
+
+
+
+
+
+

正在启动你的工作区…

+

空闲一段时间后实例会被回收,首次访问需要几秒重新拉起。

+
+
正在连接…
+
dsh · auto recovery
+ +

+ 若长时间无反应,可点「重试」。本页会自动跳转,无需手动刷新。 +

+
+
+ + +