Skip to content

Latest commit

 

History

History
1127 lines (869 loc) · 39.1 KB

File metadata and controls

1127 lines (869 loc) · 39.1 KB

SiteOps — 审批优先的宝塔面板控制台

Approval-First Baota Panel Control Plane — 一个自托管的、单操作者的宝塔服务器管理控制台。AI 提方案,人类做审批,daemon 为每一步操作留下不可篡改的证据。

你的本地项目 → 本地 daemon(只监听 127.0.0.1)→ 私有局域网/VPN → 宝塔面板目标
                      ↑
          控制台 UI / CLI / MCP —— 三个入口,同一套 API

目录


这是什么

SiteOps 是一个本地运行的宝塔面板管理控制台,核心设计原则是:让 AI 安全地辅助部署,但绝不把服务器钥匙交给 AI 自动操作

核心特点

特点 说明
自托管 daemon 只监听 127.0.0.1,不暴露公网,不依赖云服务
审批优先 每次变更操作都必须经过人类审批,审批绑定到精确的操作摘要
类型安全 所有操作都是类型化的(typed),在信任边界用 Zod 解析
证据链 只读追加的回执(receipt),诚实标注恢复等级
三端一致 控制台 UI、CLI、MCP 三个入口走同一套 daemon REST API
AI 受限 AI 可以总结、诊断、起草方案,但不能执行任务、造审批、访问密钥库

技术栈

  • 运行时: Node.js 22, TypeScript 5.8 (strict mode)
  • 包管理: pnpm 10 monorepo
  • HTTP 服务: Fastify 5
  • 前端: React 19 + Vite 7
  • 校验: Zod 4(在每个信任边界解析)
  • 测试: Vitest 3(528 个单测), Playwright 1.54(浏览器 E2E)
  • 代码检查: ESLint 9 + Prettier 3
  • 数据库: Node.js 内置 SQLite(node:sqlite

解决什么问题

场景: 你有一些部署在宝塔面板上的网站,你想让 AI 辅助管理(部署、更新配置、监控),但你不敢:

  1. 把宝塔 API 密钥直接交给 AI
  2. 让 AI 自动执行任何变更操作
  3. 在没有人工确认的情况下修改生产服务器

SiteOps 通过一个五道闸管道解决这个问题——每次变更操作都必须依次通过:

能力校验 → 策略评估 → 审批绑定 → 幂等+互斥锁 → 不可变回执

AI 的角色: AI 可以分析证据、诊断问题、起草类型化的操作方案。

AI 不能做的事: 执行任务(jobs)、创建审批、访问加密密钥库、选择任意工具、推断不存在的图关系、执行任何变更操作。


当前能力与限制

✅ 已实现

能力 描述
模拟器发布管道 针对确定性 Baota HTTP 模拟器的完整发布流程:目标纳管、能力发现、签名清单、intake(本地/Git/制品/镜像)、staging、健康检查、promotion、恢复分类
静态站点 staging(宝塔 11.7.0) 将校验过的静态 ZIP 发布到宝塔 11.7.0 目标上新建的、release 专属的目录。回执写明 staged only; traffic unchanged——不 promotion、不绑域名、不改生产根目录
站点设置更新 针对模拟器目标的可逆 site.settings.update 操作(draft → approve → execute)
加密密钥库 口令加密的本地密钥库,密钥从不写入日志、回执或证据
连接器(可选) Cloudflare DNS/WAF/CDN、Google Search Console、DNS/邮件姿态、OpenTelemetry——凭据缺失时只降级连接器本身

❌ 不支持(诚实声明)

能力 状态 原因
Node.js / PM2 部署 未开始 需要宝塔 Node 管理器的全新能力合约
Docker / compose 部署 未开始 需要容器管理 API 的能力合约
域名绑定 / 建站 未开始 需要宝塔建站 API 的能力合约
生产 promotion / 流量切换 未开始 当前只有 staging,没有 promotion 能力
回滚 不声称 如果不支持回滚,回执会明确标注 irreversible
公网监听 / 多租户 永不做 架构设计上禁止

系统架构

┌──────────────────────────────────────────────────────────┐
│                  本地 daemon (loopback)                    │
│              Fastify, 端口 3210, 仅 127.0.0.1              │
│                                                          │
│  ┌───────────┐  ┌───────────┐  ┌────────────────────┐   │
│  │  策略引擎   │  │  审批存储   │  │  加密密钥库         │   │
│  │  Policy    │  │  Approval  │  │  Vault (口令加密)   │   │
│  └─────┬─────┘  └─────┬─────┘  └─────────┬──────────┘   │
│        │               │                   │              │
│  ┌─────┴───────────────┴───────────────────┴────────────┐ │
│  │              发布编排器 (Release Orchestrator)          │ │
│  │     fencing → claim → execute → receipt               │ │
│  └────────────────────────┬─────────────────────────────┘ │
│                           │                               │
│  ┌────────────────────────┴─────────────────────────────┐ │
│  │          宝塔适配器 / 客户端 (Baota Adapter)            │ │
│  │     签名 HTTP, 仅允许白名单内的 API 调用                │ │
│  └────────────────────────┬─────────────────────────────┘ │
└───────────────────────────┼──────────────────────────────┘
                            │ 私有局域网/VPN HTTPS
                   ┌────────┴────────┐
                   │   宝塔面板目标    │
                   │   Baota Target   │
                   └─────────────────┘

三个入口,同一套 API:
  控制台 UI (React) ──┐
  CLI (commander)   ──┼──→ daemon REST API (127.0.0.1:3210)
  MCP (stdio)       ──┘

数据流说明

  1. 操作员通过 UI/CLI/MCP 发起请求(如"发布这个静态网站")
  2. daemon 接收请求,首先做能力校验——确认目标服务器真的支持这个操作
  3. 策略引擎评估这个操作是否需要审批
  4. 操作员审批精确的操作摘要(改一个字节审批就失效)
  5. 发布编排器获取互斥锁,执行操作,每一步通过签名 HTTP 调用宝塔 API
  6. 回执以只读追加方式持久化,包含完整的证据链

安全模型:五道闸

每个变更操作(mutation)必须依次通过五道闸。任何一道闸失败,操作被拒绝,零副作用。

第一道:能力校验(Capability)

目标服务器必须主动证明它支持这个操作。不是"连得上就行",而是:

  • 面板版本精确匹配(如静态 staging 要求精确 11.7.0
  • 六个白名单 API 端点全部实测探测,确认存在且响应格式匹配
  • 探测证据写入 HMAC-SHA-256 签名的本地 JSON 快照(文件权限 0600
  • 绝不从连通性、用户标签或部分端点集推断支持
能力快照 (CapabilitySnapshot):
{
  "targetId": "...",
  "source": "linux",
  "version": "11.7.0",
  "accessMode": "read-write",
  "capabilities": [
    { "capabilityId": "site.static.stage", "operationClass": "reversible", ... }
  ],
  "signature": { "algorithm": "hmac-sha256", "digest": "..." }
}

第二道:策略评估(Policy)

策略引擎根据操作类型决定是否需要审批:

操作等级 是否需要审批 示例
read_only 不需要 查询目标列表
reversible 需要 静态 staging、站点设置更新
compensatable 需要 + 补偿命令 数据库迁移(带补偿)
snapshot_restorable 需要 + 快照备份 数据库迁移(带快照)
irreversible 需要 + 类型化确认 不可逆迁移

第三道:审批绑定(Approval)

你审批的是这个具体操作的这个具体摘要

{
  "operationId": "018f86d9-...",
  "capability": "site.static.stage",
  "payloadDigest": "sha256:abc123...",
  "declaredImpact": { "digest": "sha256:abc123...", "summary": "stage static site" }
}

审批有过期时间。操作摘要变了(比如换了文件),审批自动失效。

第四道:幂等 + 互斥锁(Idempotent + Lock)

  • 幂等键: 每次操作携带唯一的 idempotency key。重复提交同一个 key,返回原始回执,不重复执行
  • 互斥锁: 同一目标的同一操作正在执行时,新请求被拒绝(release_busy
  • 崩溃恢复: 崩溃后标记 needs_recovery绝不自动重试——超时/断连的结果分类为 unknown_outcome,等待人工检查

第五道:不可变回执(Receipt)

操作完成后,写入只读追加的回执:

{
  "outcome": "staged",
  "wording": "staged only; traffic unchanged",
  "releaseDigest": "sha256:...",
  "artifactDigest": "sha256:...",
  "capabilityGeneration": 1,
  "capabilityDigest": "abc123...",
  "endpointSequence": ["/data", "/files", "/files", "/files", "/files", "/files"],
  "postconditionObservations": [...],
  "redactedErrorCode": null
}

回执表有数据库触发器,禁止 UPDATE 和 DELETE

操作等级(Operation Class)

const OPERATION_CLASSES = [
  "read_only",            // 只读,无需回滚
  "reversible",           // 可逆,自动回滚
  "compensatable",        // 可补偿,执行补偿动作
  "snapshot_restorable",  // 快照可恢复,恢复快照
  "irreversible",         // 不可逆,只能手动恢复
] as const;

如果回滚不可用,回执明确标注——绝不虚假承诺。

能力词汇表

const RELEASE_CAPABILITIES = {
  actorManage: "release.manage",           // 操作员令牌能力
  targetSimulatorExecute: "release.simulator.execute",  // 模拟器发布
  staticStage: "site.static.stage",        // 静态 staging(已实现)
  staticPromote: "site.static.promote",    // 静态 promotion(保留,未实现)
} as const;

快速开始

前置条件

  • Node.js 22.x(引擎要求;24.x 可用但有警告)
  • pnpm 10.x
  • Docker(可选——仅浏览器 E2E 需要)

安装和验证

git clone https://github.com/cat9999aaa/baota-ai-siteops.git
cd baota-ai-siteops
pnpm install
pnpm verify

预期输出:

✓ 124 个测试文件通过
✓ 528 个测试通过
✓ ESLint 通过
✓ Prettier 检查通过
✓ TypeScript 类型检查通过

运行静态 staging E2E(不需要 Docker)

这是最有代表性的端到端测试,30 秒内跑完整个发布闭环:

bash test/fixtures/e2e/run-static-stage.sh

它自动完成:

  1. 启动模拟宝塔面板(loopback HTTP fixture)
  2. test/fixtures/static-site/(HTML + CSS)构建成确定性 ZIP
  3. 走完整流程:intake → 签名计划 → 审批 → staging
  4. API / CLI / MCP 三个入口读回同一个 release
  5. 断言三个入口的摘要一致 + staged only; traffic unchanged
  6. 再跑一次故障场景(撤掉能力 → 三个入口一致报 blocked)
  7. trap 自动清理所有进程和临时目录

退出码 0 = 通过。证据输出在 .omo/evidence/baota-ai-ops-control-plane/task-32-e2e.log

运行浏览器 E2E(需要 Docker)

bash test/fixtures/e2e/run.sh

在 Docker 容器中启动 daemon + 模拟器 + 前端控制台,运行 Playwright 浏览器测试。

手动启动 daemon

Docker 方式:

docker build -t siteops .
docker run -d --name siteops \
  -p 127.0.0.1:3210:3210 \
  -v siteops-data:/var/lib/siteops \
  -e SITEOPS_VAULT_PASSPHRASE="<你的口令>" \
  siteops

# 验证
curl http://127.0.0.1:3210/healthz
# 预期: 200 OK

本地方式:

SITEOPS_PORT=3210 \
SITEOPS_DATA_DIR=./data \
SITEOPS_VAULT_PASSPHRASE="<你的口令>" \
  pnpm --filter @siteops/daemon start

启动前端控制台

# 终端 1: daemon 已运行(上面)
# 终端 2:
cd apps/console
VITE_SITEOPS_API=http://127.0.0.1:3210 \
VITE_SITEOPS_TOKEN="<console-token>" \
  pnpm dev
# 打开 http://127.0.0.1:5173

项目结构

baota-ai-siteops/
├── apps/                          # 应用层
│   ├── daemon/                    # 本地控制台 daemon (Fastify + SQLite)
│   │   ├── src/
│   │   │   ├── auth/              # 本地认证 + 令牌签发
│   │   │   ├── db/migrations/     # SQLite 迁移脚本 (001-009)
│   │   │   ├── http/              # REST API 路由
│   │   │   ├── jobs/              # 类型化任务执行器
│   │   │   ├── releases/          # 发布编排器 + 静态 staging 执行器
│   │   │   ├── targets/           # 目标纳管 + 能力协商 + 对账
│   │   │   ├── vault/             # 加密密钥库
│   │   │   └── runtime/           # 运行时组装
│   │   └── package.json
│   ├── console/                   # 前端控制台 (React 19 + Vite)
│   │   ├── src/
│   │   │   ├── app.tsx            # 主应用
│   │   │   ├── release-panel.tsx  # 发布面板
│   │   │   ├── operation-panel.tsx# 操作面板
│   │   │   └── daemon-api.ts      # daemon API 客户端
│   │   └── DESIGN.md              # 设计系统文档
│   ├── cli/                       # CLI 客户端 (commander)
│   │   └── src/
│   │       ├── program.ts         # 命令注册
│   │       ├── release-commands.ts# 发布命令
│   │       └── daemon-api.ts      # daemon API 客户端
│   ├── mcp/                       # MCP 工具服务器 (stdio)
│   │   └── src/
│   │       ├── tools.catalog.ts   # 工具清单定义
│   │       ├── tools.facade.ts    # 工具门面
│   │       └── main.ts            # stdio 入口
│   └── shared/                    # 共享 E2E 基础设施
│
├── packages/                      # 共享包
│   ├── contracts/                 # 领域类型 + 能力词汇 + Zod schema
│   │   └── src/
│   │       ├── domain.ts          # 实体 schema (Target, Site, Release...)
│   │       ├── security.ts        # 操作等级 + 能力常量
│   │       ├── site-graph.ts      # Site Graph 类型
│   │       └── static-stage.ts    # 静态 staging 词汇 (版本/操作/原因)
│   ├── baota-adapter/             # 宝塔 HTTP 客户端
│   │   └── src/
│   │       ├── client.ts          # 签名 HTTP 传输层
│   │       ├── capabilities.ts    # 能力发现 + HMAC 签名快照
│   │       ├── static-stage-*.ts  # 静态 staging 客户端 (6 个模块)
│   │       └── operations.ts      # 模拟器操作适配器
│   ├── baota-testkit/             # 确定性宝塔 HTTP 模拟器
│   │   └── src/
│   │       ├── index.ts           # 基线模拟器
│   │       └── static-stage-*.ts  # 静态 staging fixture (12 个模块)
│   ├── release-manifest/          # 清单解析 + intake + ZIP 构建
│   │   └── src/
│   │       ├── schema.ts          # baota.yaml Zod schema
│   │       ├── planner.ts         # 签名发布计划
│   │       └── intake/
│   │           ├── static-archive*.ts  # 确定性 ZIP 构建器
│   │           └── ...
│   ├── core/                      # 策略引擎
│   ├── site-graph/                # Site Graph 类型
│   ├── ai-proposals/              # AI 提案层 (无执行能力)
│   └── connectors/                # 可选连接器
│       ├── cloudflare/            # Cloudflare DNS/WAF/CDN
│       ├── google/                # Google Search Console
│       ├── dns-mail/              # DNS/邮件姿态
│       └── sdk/                   # 连接器 SDK
│
├── test/
│   ├── fixtures/
│   │   ├── e2e/                   # E2E 测试脚本和 fixture
│   │   │   ├── run.sh             # 浏览器 E2E (Docker)
│   │   │   ├── run-static-stage.sh# 静态 staging E2E (无 Docker)
│   │   │   ├── daemon.ts          # 测试 daemon 启动器
│   │   │   ├── simulator.ts       # 模拟宝塔面板
│   │   │   └── static-fixture.ts  # 静态 staging fixture
│   │   ├── static-site/           # 静态测试项目 (HTML + CSS)
│   │   │   ├── index.html
│   │   │   ├── assets/app.css
│   │   │   └── baota.yaml         # 示例清单
│   │   └── e2e.compose.yml        # Docker Compose 配置
│   └── e2e/                       # Playwright 浏览器测试
│
├── docs/                          # 文档
│   ├── baota-static-staging.md    # 静态 staging 合约 (机器可验证)
│   ├── baota-compatibility.md     # 宝塔兼容性矩阵
│   └── operations/                # 运维文档
│       ├── self-hosting.md
│       └── static-staging.md
│
├── scripts/
│   ├── verify-plan.mjs            # 计划结构校验器
│   └── verify-static-stage-contract.mjs  # 静态 staging 合约校验器
│
├── Dockerfile                     # 生产镜像 (node:22-bookworm-slim)
├── package.json                   # monorepo 根配置
├── tsconfig.json                  # TypeScript strict 配置
├── eslint.config.mjs              # ESLint 配置
└── pnpm-lock.yaml

CLI 使用手册

环境配置

export SITEOPS_API_URL=http://127.0.0.1:3210
export SITEOPS_TOKEN=<你的-cli-token>

daemon 启动时会在运行时目录生成 auth.json,包含 cliconsolemcp 三个 channel 的 token。

targets list — 列出目标

pnpm --filter @siteops/cli dev -- targets list

输出示例:

Target scope: fixture-a (11111111-1111-4111-8111-111111111111)
State: online (declared active)
Capabilities: inventory.read, site.list, release.simulator.execute, site.settings.update

Target scope: fixture-b (22222222-2222-4222-8222-222222222222)
State: online (declared active)
Capabilities: inventory.read, site.list, release.simulator.execute, site.settings.update

JSON 格式:

pnpm --filter @siteops/cli dev -- targets list --json
{
  "targets": [
    {
      "id": "11111111-1111-4111-8111-111111111111",
      "name": "fixture-a",
      "declaredState": "active",
      "discoveredState": "online",
      "capabilities": ["inventory.read", "site.list", "release.simulator.execute", "site.settings.update"]
    }
  ]
}

releases get — 读取发布回执

pnpm --filter @siteops/cli dev -- releases get --id <releaseId> --json

返回不可变的静态 staging 回执,包含全部 13 个合约字段。

releases plan — 创建签名发布计划

pnpm --filter @siteops/cli dev -- releases plan --manifest-yaml "$(cat baota.yaml)"

releases prepare — 准备发布工作流

pnpm --filter @siteops/cli dev -- releases prepare \
  --plan-digest sha256:abc123... \
  --staging-target 11111111-1111-4111-8111-111111111111 \
  --intake-json '<intake 输出的 JSON>'

jobs execute-site-setting — 执行站点设置更新

pnpm --filter @siteops/cli dev -- jobs execute-site-setting \
  --target 11111111-1111-4111-8111-111111111111 \
  --site aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa \
  --impact-digest sha256:aaa... \
  --impact "更新站点备注为 'v2.0'" \
  --idempotency-key deploy-20260726-001 \
  --timeout-at 2026-07-26T23:59:59Z \
  --approve

输出:

Target scope: 11111111-1111-4111-8111-111111111111
Site scope: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa
Capability: site.settings.update
Operation class: reversible
Declared impact: 更新站点备注为 'v2.0'
Preflight: daemon policy evaluation
Approval: explicitly requested
Job: <jobId> (succeeded)
Receipt: <receiptId>
Outcome: succeeded
Receipt hash: sha256:...

MCP 工具手册

环境配置

export SITEOPS_API_URL=http://127.0.0.1:3210
export SITEOPS_TOKEN=<你的-mcp-token>

工具清单

工具名 类型 用途
list_targets 只读 列出所有目标和能力快照
get_release 只读 读取静态 staging 发布回执
get_job_receipt 只读 读取不可变 job 回执
create_release_plan 写入 创建签名发布计划
prepare_release 写入 从计划 + intake 创建发布工作流
approve_release 写入 绑定审批(stage / migration / promotion)
stage_release 变更⚡ 执行 staging(幂等)
promote_release 变更⚡ 执行 promotion(幂等,基线功能)
propose_site_settings_update 只读 起草站点设置操作(不执行)
approve_site_settings_update 写入 审批站点设置操作
execute_site_settings_update 变更⚡ 执行站点设置操作(幂等)

调用示例

列出目标:

pnpm --filter @siteops/mcp test:stdio -- \
  --tool list_targets --input '{}'

读取发布回执:

pnpm --filter @siteops/mcp test:stdio -- \
  --tool get_release --input '{"releaseId":"018f86d9-1f4b-7a01-8d62-2df80a30e216"}'

创建发布计划:

pnpm --filter @siteops/mcp test:stdio -- \
  --tool create_release_plan \
  --input '{"manifestYaml":"version: v1\nsource:\n  type: artifact\n..."}'

审批 + 执行 staging:

# 审批
pnpm --filter @siteops/mcp test:stdio -- \
  --tool approve_release \
  --input '{"releaseId":"<id>","kind":"stage","expiresAt":"2026-07-27T00:00:00Z","typedAcknowledgement":null}'

# 执行
pnpm --filter @siteops/mcp test:stdio -- \
  --tool stage_release \
  --input '{"releaseId":"<id>","stageApprovalId":"<approvalId>","migrationApprovalId":null}'

站点设置更新(三步流程):

# 1. 起草(不执行任何操作)
pnpm --filter @siteops/mcp test:stdio -- \
  --tool propose_site_settings_update \
  --input '{"operationId":"<UUID>","targetId":"...","siteId":"...","impactDigest":"sha256:...","impactSummary":"改备注"}'

# 2. 审批
pnpm --filter @siteops/mcp test:stdio -- \
  --tool approve_site_settings_update \
  --input '{"operationId":"<UUID>","expiresAt":"2026-07-27T00:00:00Z"}'

# 3. 执行
pnpm --filter @siteops/mcp test:stdio -- \
  --tool execute_site_settings_update \
  --input '{"operationId":"<UUID>","approvalId":"<approvalId>","targetId":"...","siteId":"...","idempotencyKey":"key-1","timeoutAt":"2026-07-27T00:00:00Z"}'

连接 MCP 客户端

MCP 服务器从环境变量读取 SITEOPS_API_URLSITEOPS_TOKEN。将你的 MCP 客户端指向 apps/mcp/src/main.ts(通过 tsx 运行)。


REST API 参考

daemon 在回环端口(默认 3210)暴露 REST API。所有请求需要:

authorization: Bearer <token>
x-siteops-channel: cli|console|mcp
content-type: application/json

目标管理(Targets)

方法 路径 说明
POST /v1/targets 纳管宝塔目标
GET /v1/targets 列出目标和能力快照
GET /v1/targets/:id 读取单个目标
POST /v1/targets/:id/reconcile 对账目标状态和能力

纳管目标请求体(静态 staging):

{
  "targetId": "33333333-3333-4333-8333-333333333333",
  "name": "my-baota-server",
  "endpoint": "https://192.168.1.100:8888",
  "groupLabels": ["staging"],
  "apiSecret": "<宝塔 API 密钥>",
  "stagingRoot": "/var/lib/siteops/static-staging",
  "siteId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
  "siteName": "my-static-site",
  "observedPanelVersion": "11.7.0",
  "runtime": "static",
  "source": "linux",
  "endpointEvidence": [
    { "action": "getData", "responseSchemaId": "baota.static-stage.getData.v1" },
    { "action": "CreateDir", "responseSchemaId": "baota.static-stage.CreateDir.v1" },
    { "action": "UploadFile", "responseSchemaId": "baota.static-stage.UploadFile.v1" },
    { "action": "mutil_unzip", "responseSchemaId": "baota.static-stage.mutil_unzip.v1" },
    { "action": "GetDir", "responseSchemaId": "baota.static-stage.GetDir.v1" },
    { "action": "test_path", "responseSchemaId": "baota.static-stage.test_path.v1" }
  ]
}

任务(Jobs)

方法 路径 说明
POST /v1/jobs 起草类型化操作
POST /v1/jobs/:id/approve 绑定审批
POST /v1/jobs/:id/execute 执行(幂等键 + 超时)
GET /v1/jobs/:id 读取任务状态
GET /v1/jobs/:id/receipt 读取不可变回执

发布(Releases)

方法 路径 说明
POST /v1/releases/intake 接收源(静态目录 / git / 制品 / 镜像)
POST /v1/releases/plans 创建签名发布计划
POST /v1/releases/prepare 准备发布工作流
POST /v1/releases/:id/approvals 绑定审批
POST /v1/releases/:id/stage 执行 staging(幂等)
GET /v1/releases/:id 读取发布回执

健康检查

方法 路径 说明
GET /healthz 存活探针

静态站点发布完整流程

以下是从本地静态目录到宝塔服务器的完整 staging 流程(6 步):

Step 1: Intake — 构建确定性 ZIP

curl -X POST http://127.0.0.1:3210/v1/releases/intake \
  -H "authorization: Bearer $TOKEN" \
  -H "x-siteops-channel: cli" \
  -H "content-type: application/json" \
  --data '{"kind":"static_directory","path":"/home/user/my-website"}'

daemon 将:

  • O_NOFOLLOW fd 锁定遍历目录树(防 TOCTOU 符号链接攻击)
  • 检测硬链接(nlink + dev:ino 双重检测)
  • 构建确定性 ZIP(固定时间戳、排序条目、UTF-8 标志)
  • 计算 SHA-256 摘要,生成不可变清单
  • 压缩后再做一次完整 SHA-256 校验(防压缩窗口期篡改)

返回包含 artifactDigest 的 intake 结果。

Step 2: Plan — 创建签名发布计划

准备 baota.yaml 清单:

version: v1
source:
  type: artifact
  uri: file:///tmp/site.zip
  digest: sha256:<Step1 返回的 artifactDigest>
runtime:
  kind: static
  documentRoot: public
target:
  targetId: 33333333-3333-4333-8333-333333333333
  siteId: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb
build:
  kind: prebuilt
  artifact: site.zip
environment: []
migration:
  class: none
health:
  - kind: http
    path: /index.html
    expectedStatus: 200
    timeoutSeconds: 5
strategy:
  kind: blue_green
recovery:
  class: irreversible
  action: manual_recovery_only
curl -X POST http://127.0.0.1:3210/v1/releases/plans \
  -H "authorization: Bearer $TOKEN" \
  -H "x-siteops-channel: cli" \
  -H "content-type: application/json" \
  --data '{"manifestYaml":"<上面 baota.yaml 的内容>"}'

返回包含 digest 的签名计划。

Step 3: Prepare — 创建发布工作流

curl -X POST http://127.0.0.1:3210/v1/releases/prepare \
  -H "authorization: Bearer $TOKEN" \
  -H "x-siteops-channel: cli" \
  -H "content-type: application/json" \
  --data '{
    "planDigest": "sha256:<Step2 的 digest>",
    "stagingTargetId": "33333333-3333-4333-8333-333333333333",
    "intake": <Step1 的 intake 输出>
  }'

返回 releaseId

Step 4: Approve — 审批 stage 操作

curl -X POST http://127.0.0.1:3210/v1/releases/<releaseId>/approvals \
  -H "authorization: Bearer $TOKEN" \
  -H "x-siteops-channel: cli" \
  -H "content-type: application/json" \
  --data '{
    "kind": "stage",
    "expiresAt": "2026-07-27T00:00:00Z",
    "typedAcknowledgement": null
  }'

返回 approvalId

Step 5: Stage — 执行 staging

curl -X POST http://127.0.0.1:3210/v1/releases/<releaseId>/stage \
  -H "authorization: Bearer $TOKEN" \
  -H "x-siteops-channel: cli" \
  -H "content-type: application/json" \
  --data '{
    "stageApprovalId": "<Step4 的 approvalId>",
    "migrationApprovalId": null
  }'

daemon 执行:

  1. fencing 校验:计划摘要 → 操作摘要 → 快照代次 → 快照签名 → 能力授予 → 制品摘要 → 审批绑定(全部匹配才继续,否则零副作用拒绝)
  2. 获取互斥锁(BEGIN IMMEDIATE 事务)
  3. 签名 HTTP 调用getDataCreateDirUploadFilemutil_unzipGetDirtest_path
  4. 后置条件验证:确认文件存在、摘要匹配
  5. 持久化回执staged only; traffic unchanged

Step 6: Read — 读取回执(三端一致)

# API
curl http://127.0.0.1:3210/v1/releases/<releaseId> \
  -H "authorization: Bearer $TOKEN" -H "x-siteops-channel: cli"

# CLI
pnpm --filter @siteops/cli dev -- releases get --id <releaseId> --json

# MCP
pnpm --filter @siteops/mcp test:stdio -- \
  --tool get_release --input '{"releaseId":"<releaseId>"}'

三个入口返回完全相同的 release/receipt 摘要。


宝塔 11.7.0 静态 staging 合约

完整的操作员面向合约在 docs/baota-static-staging.md,包含机器可验证的 JSON 块。要点:

支持版本

精确 11.7.0——不是 11.7.x,不是 11.8。官方文档只在此版本上验证过。

白名单 API 调用(六个,缺一不可)

调用 编码 必需字段
POST /data action=getData table=sites type=-1 form action, table, type
POST /files action=CreateDir path form action, path
POST /files action=UploadFile path zunfile request_time request_token multipart action, path, zunfile, request_time, request_token
POST /files action=mutil_unzip sfile_list dfile coding=utf-8 type1=zip form action, sfile_list, dfile, coding, type1
POST /files action=GetDir path form action, path
POST /files action=test_path path form action, path

不接受 shell 文本、未列出的端点、stagingRoot 之外的路径。

认证

request_time = Unix 时间戳(秒)
request_token = MD5(request_time + MD5(api_sk))

每次请求都生成新的签名。不声称有 TTL 或重放保护。HTTPS + IP 白名单是 SiteOps 策略要求。

归档限制

限制
归档类型 仅 ZIP
压缩后大小 ≤ 64 MiB
解压后大小 ≤ 256 MiB
常规文件数 ≤ 5,000
单个文件大小 ≤ 32 MiB
路径编码 UTF-8 POSIX 相对路径
路径长度 ≤ 240 字节

拒绝:绝对路径、..、反斜杠、NUL、重复规范化名称、重复大小写折叠名称、符号链接、硬链接、设备文件、FIFO、socket。

路径归属

操作员配置一个绝对、专用的 stagingRoot。SiteOps 从 release UUID + 制品摘要派生一个子目录。调用者不能提供远程路径。

这是客户端侧的路径限制,不是宝塔侧的沙箱。不能防御被入侵的面板或 root 账户。

回执措辞

成功回执的措辞精确为 staged only; traffic unchanged。在所有 postcondition 验证完成后才发出。被拦截的 preflight 或 unknown_outcome 绝不收到这个成功措辞。


测试指南

完整验证(一条命令)

pnpm verify
# ESLint + Prettier + TypeScript + Vitest = 528 个测试

分包测试

pnpm --filter @siteops/contracts test        # 领域类型 + 能力词汇 (14 个测试)
pnpm --filter @siteops/baota-adapter test     # 客户端 + 能力协商 (50 个测试)
pnpm --filter @siteops/baota-testkit test     # 模拟器 fixture (26 个测试)
pnpm --filter @siteops/release-manifest test  # ZIP 构建 + intake (55 个测试)
pnpm --filter @siteops/daemon test            # 执行器 + fencing + 恢复 (218 个测试)
pnpm --filter @siteops/cli test               # CLI 命令
pnpm --filter @siteops/mcp test               # MCP 工具

端到端测试

# 静态 staging 切片(不需要 Docker)
bash test/fixtures/e2e/run-static-stage.sh

# 完整浏览器界面(需要 Docker)
bash test/fixtures/e2e/run.sh

合约校验器

# 计划结构校验(32 个任务 + F1-F4)
node scripts/verify-plan.mjs .omo/plans/baota-ai-ops-control-plane.md

# 静态 staging 合约校验
node scripts/verify-static-stage-contract.mjs docs/baota-static-staging.md

安全相关测试覆盖

测试类别 覆盖内容
TOCTOU 符号链接攻击 fd 锁定遍历 + lstat/fstat 身份校验
压缩窗口期篡改 ZIP 后完整 SHA-256 重扫
计划摘要绑换 plan.digest 必须匹配 workflow + policy
快照签名篡改 HMAC 重验 + 签名摘要绑定
能力证据伪造 实测探测证据(不接受调用者声明)
路径逃逸 stagingRoot 客户端侧限制
审批过期/消费 审批有过期时间,消费后不可重用
unknown_outcome 不重试 超时/崩溃后标记 needs_recovery,不自动重试

配置参考

环境变量

变量 默认值 说明
SITEOPS_BIND_HOST 127.0.0.1 daemon 绑定地址(仅回环)
SITEOPS_PORT 3210 daemon 监听端口
SITEOPS_DATA_DIR /var/lib/siteops SQLite + 快照存储目录
SITEOPS_VAULT_PASSPHRASE 必填 加密密钥库的口令
SITEOPS_API_URL http://127.0.0.1:3210 CLI/MCP 的 daemon URL
SITEOPS_TOKEN 必填 CLI/MCP 的 Bearer token
SITEOPS_TARGET_ID 可选 MCP expected-target scope

Token 获取

daemon 启动时在运行时目录(SITEOPS_DATA_DIR 或 E2E 的 runtime 目录)生成 auth.json

cat $SITEOPS_DATA_DIR/auth.json
# { "cli": "siteops_cli_...", "console": "siteops_console_...", "mcp": "siteops_mcp_..." }

Docker 部署

FROM node:22-bookworm-slim
# 完整配置见 Dockerfile
# 健康检查以无特权用户运行,无任何 capability
# 构建
docker build -t siteops .

# 运行(仅回环)
docker run -d --name siteops \
  -p 127.0.0.1:3210:3210 \
  -v siteops-data:/var/lib/siteops \
  -e SITEOPS_VAULT_PASSPHRASE="<口令>" \
  siteops

# 健康检查
curl http://127.0.0.1:3210/healthz

# 停止和清理
docker stop siteops && docker rm siteops && docker volume rm siteops-data

E2E Docker Compose

# 启动 daemon + 模拟器 + 前端(4 个容器)
docker compose -f test/fixtures/e2e.compose.yml up -d --wait

# 访问前端: http://127.0.0.1:4173
# daemon API: http://127.0.0.1:4123

# 清理
docker compose -f test/fixtures/e2e.compose.yml down --volumes

开发指南

提交规范

Conventional Commits(英文):

feat(scope): 简短描述
fix(scope): 简短描述
docs(scope): 简短描述
test(scope): 简短描述
refactor(scope): 简短描述
chore(scope): 简短描述

每个 commit 包含其测试,独立通过其验证命令。

文件大小纪律

每个 TypeScript 源文件保持在 250 纯代码行(非空、非注释)以内。接近上限的文件按职责拆分。

代码规范

  • TypeScript strict 模式(noUncheckedIndexedAccess, exactOptionalPropertyTypes, verbatimModuleSyntax
  • 禁止:any, as unknown, @ts-ignore, @ts-expect-error, 非空断言 !, 空 catch
  • 信任边界用 Zod 解析,内部用 plain type
  • 判别联合(discriminated union)用 switch + assertNever 穷尽匹配

实现计划

完整实现计划在 .omo/plans/baota-ai-ops-control-plane.md(32 个任务 + F1-F4 最终验证波)。所有 32 个任务已完成并通过独立对抗性验证。


路线图

已完成

  • 任务 1-25:基线控制台(目标、密钥库、策略、任务、发布、连接器、打包)
  • 任务 26-32:有界宝塔 11.7.0 静态站点 staging 切片
  • F1-F4:计划合规、安全审计、界面 QA、范围保真

未来波次(未开始)

能力 需要的工作
Node.js 运行时部署 宝塔 Node 管理器 API 研究 → 能力合约 → fencing → 回执
域名/站点管理 宝塔建站 API → 域名绑定 → 证书管理 → 安全合约
生产 promotion 原子文档根切换 → 流量割接 → 回滚语义
数据库迁移 类型化迁移命令 → 备份/恢复
更多宝塔版本 扩展兼容性矩阵(目前仅 11.7.0)

FAQ

Q: 能部署 Node.js / PM2 项目吗?

目前不能。 静态 staging 切片只支持纯静态文件(HTML/CSS/JS)。Node.js 部署需要宝塔 Node 管理器的全新能力合约。这是未来波次的工作。

Q: 能自动绑定域名吗?

目前不能。 当前切片的回执写明 staged only; traffic unchanged。域名绑定和流量切换需要 promotion 能力,还未实现。

Q: 支持哪些宝塔版本?

精确 11.7.0(不是 11.7.x)。官方文档只在此版本上验证过。其他版本的 API 行为没有证据支撑,系统会拒绝。

Q: daemon 必须和宝塔在同一台机器上吗?

不需要。 daemon 通过私有局域网/VPN HTTPS 连接宝塔面板。只要网络可达且 IP 在宝塔 API 白名单里即可。

Q: 崩溃后会不会自动重试?

不会。 超时/断连/畸形响应被分类为 unknown_outcome,标记 needs_recovery,等待人工检查。绝不自动重试——宁可报"结果未知"也不冒险重复执行。

Q: AI 能执行操作吗?

不能。 AI 只能总结证据、诊断问题、起草类型化方案。执行任务、创建审批、访问密钥库——这些 AI 一律不能做。

Q: 这个项目安全吗?

安全模型的核心是五道闸管道。每次变更都必须通过能力校验、策略评估、审批绑定、幂等+锁、不可变回执。系统不执行任意 shell,不抓取面板页面当 API,不存放明文密钥,不虚假声称回滚能力。


License

Private project.