第 20 章:安全、部署、观测、测试与发布
20.1 先画出信任边界
Multica 不是“一个 Web 服务加一个数据库”。部署时至少有六个安全域:
每条边都使用不同证明:
- Browser:JWT Cookie + CSRF,或 Bearer Token;
- CLI:
mul_PAT; - Cloud Node:
mcn_PAT; - Daemon:
mdt_Token,兼容旧 PAT/JWT; - Agent Task:
mat_Token; - Webhook:URL Secret、HMAC 或 Provider Token;
- Workspace:认证之后再做 Membership/Role 校验。
安全分析必须把“谁在调用”“代表谁”“作用于哪个 Workspace”“是否属于某个 Task”拆开。
20.2 Token 只在创建时出现明文
auth/jwt.go 使用 crypto/rand 生成 20 字节随机值,再编码成 40 位 Hex,并加类型前缀:
| 前缀 | 身份 | 主要绑定 |
|---|---|---|
| mul_ | 人的 Personal Access Token | User |
| mcn_ | Cloud Node PAT | Cloud Owner,由 Fleet 验证 |
| mdt_ | Daemon Token | Workspace + Daemon |
| mat_ | Agent Task Token | User + Agent + Task + Workspace |
数据库保存 SHA-256 Hash,而不是 Token 明文。请求到达后 Hash 再查询;缓存也用 Hash 作 Key。明文只应存在于创建响应、用户配置或任务进程环境。
Prefix 不是权限证明,只是让 Middleware 尽早选择正确验证器;真正身份来自数据库绑定或 Cloud Fleet 响应。
20.3 Auth Middleware 重写身份,而不是信任 Header
middleware/auth.go 的关键规则是:
- 先删除客户端传入的
X-Actor-Source; - Bearer 优先于 Cookie;
mat_查 Task Token Row;- 用 Row 中的 User/Agent/Task/Workspace 覆盖客户端 Header;
- 将 Actor Source 标成
task_token; - 下游只信任这个 Server-set 标记。
因此,Agent 不能带一个合法 Task Token,再伪造另一个 X-Agent-ID 或 X-Workspace-ID 扩大作用域。
mcn_ 也单独分支:若 Fleet Verifier 未配置,直接 401;若 Cloud 暂时不可达,返回 503 让客户端重试,而不是把有效 Token 误判成失效。
20.4 Cookie、CSRF 与 PAT 的差异
浏览器 JWT 可放在 multica_auth HttpOnly Cookie。对会改变状态的方法,Cookie 路径必须通过 CSRF 校验。
Bearer Token 不依赖浏览器自动附带 Cookie,所以不走相同 CSRF 条件;它的风险转为 Token 泄漏与本机存储安全。
JWT 只接受 HMAC Signing Method,再读取 sub 作为 User ID。Server 若没有 JWT_SECRET 会使用开发默认值并记录警告;这让本地启动简单,但生产必须把它当硬性配置错误处理。
20.5 Workspace 是认证后的第二道门
middleware/workspace.go 将请求解析为 Workspace UUID,再查询 Member:
- 无 Workspace 标识:400;
- Slug 不存在:404;
- User 不是 Member:对外仍表现为 Not Found;
- Role 不足:403。
对 Task Token,Workspace 绑定优先于所有 Slug/Header/Query;即使路由从 URL 参数解析 Workspace,Middleware 最后还会再对比 Token 绑定,形成 Catch-all。
这就是多租户系统的核心不变量:
对象 ID、Workspace Header 和用户身份三者不能任选其一作为授权证明。
20.6 Owner-only 操作为什么还要看 Actor Source
Task Token Row 中包含可追责 User,但这不表示 User 本人刚刚批准了敏感操作。
Agent 环境变量、账号级计费等 Owner-only Endpoint 会拒绝 task_token 或 cloud_pat 这种机器身份。否则“可代表用户发表评论的 Agent”会意外继承“可修改用户秘密或账户设置”的能力。
身份归因和人类授权不是同一概念。
20.7 Daemon 的认证迁移
middleware/daemon_auth.go 优先处理 mdt_:
- Token 绑定 Workspace 与 Daemon ID;
- Expiry 限制缓存 TTL;
- Context 记录认证路径供日志与指标使用。
为了兼容旧 Daemon,Middleware 仍允许 mul_ PAT、mcn_ 和 JWT。新路径把机器长期身份从人的 PAT 中拆出,缩小撤销和审计边界。
Daemon 登录使用存储 Profile Token完成注册/续约,但 Agent 子进程拿到的是 mat_,不是 Daemon Token。
20.8 CORS、WebSocket Origin 与 CSP
cmd/server/router.go 统一配置:
- CORS Allowed Origins;
- Browser 可发送的自定义 Header;
- Credentials;
- WebSocket Allowed Origins;
- Trusted Proxy CIDR。
若 Header 未加入 CORS Allowlist,请求会死在浏览器 Preflight,Handler 根本看不到它。新增客户端协议 Header 时必须同步更新这里。
- Script 只允许 Self;
- Object 禁止;
- Base/Form 限制 Self;
- Frame Ancestors 禁止。
Attachment Preview 路由单独允许同源 Frame,既支持 PDF/HTML Preview,又不把全站 Frame Policy 放宽。
20.9 Reverse Proxy 信任必须显式声明
Webhook 限流和 WebSocket Host 判断不能无条件信任 X-Forwarded-For / X-Forwarded-Host。
源码的默认是:
MULTICA_TRUSTED_PROXIES为空:不信任代理 Header;- 只在直接连接 IP 落入配置 CIDR 时读取代理链;
- 从右向左找最后一个非受信 Hop 作为客户端 IP;
- 非法 CIDR 记录警告并跳过。
如果把公网任意地址列成 Trusted Proxy,攻击者就能伪造来源;如果忘记配置实际反代,所有请求可能按代理 IP 限流。
MULTICA_PUBLIC_URL 也不从 Host Header 动态推导,避免错误反代配置下的 Host Spoofing 进入 Webhook URL。
20.10 Rate Limit 是可选 Redis 能力
middleware/ratelimit.go 用 Redis Lua 原子执行 INCR + EXPIRE,覆盖:
- 发送验证码;
- 验证验证码;
- Google Login;
- Contact Sales。
Redis 未配置或运行时出错时,限流 Fail-open:
- 单节点开发可工作;
- Redis 故障不拖垮登录;
- 但公网生产会失去应用层暴力破解/滥用保护。
所以生产反代/WAF 的限流不能完全依赖这一层,且应对 Redis Error Log 建告警。
20.11 Webhook 的认证矩阵
公开路由不等于无认证:
| 入口 | 证明 | Workspace 来源 |
|---|---|---|
| Autopilot | URL 中高熵 Trigger Token | Trigger Row |
| GitHub | Raw Body + HMAC-SHA256 | Installation Binding |
| Forgejo/Gitea | Connection Secret HMAC | Connection Row |
| GitLab | Connection Secret Token | Connection Row |
| Slack/Lark | Provider Signature/Challenge 与 Installation | Installation Row |
| OAuth Callback | 密封或签名 State | State Payload/Server Row |
所有路径都应先验证 Raw Body/State,再解析业务字段;不能从未经验证的 Payload 或 Workspace Header选择租户。
Autopilot 与 VCS Handler 对 Body 设置大小上限;未知但合法事件通常 ACK,避免 Provider 无休止重试。
20.12 集成 Secret 的静态保护
util/secretbox 提供对称加密盒。Slack/Lark/VCS 等使用部署级 Key 加密每 Workspace 的:
- Bot/App Token;
- App Secret;
- VCS Access Token;
- VCS Webhook Secret。
能力开关与加密 Key 分开:
- Product Surface 可用但未配置 Key:返回 Operator 配置错误;
- Managed Deployment 可完全关闭某种集成;
- 列表接口不回传明文;
- Webhook Secret 只在创建或 Rotate 时展示一次。
轮换部署级 Key 不是简单改环境变量:旧 Ciphertext 必须先解密再重加密,否则新进程无法读取历史连接。
20.13 Agent 是受约束身份,不是无害沙箱
Task Token 限制的是 Server API 权限;Agent Process 仍拥有 Daemon 为它提供的本机能力:
- Workdir 文件读写;
- Git Credential;
- Provider CLI 的模型账号;
- 任务 Skills 与 MCP;
- Agent Custom Env;
- 用户允许的 Local Directory。
不同 Provider 的原生 Sandbox 能力不完全一致。Daemon 做进程树终止、路径检查、任务 HOME/Config 隔离和 Marker 注入,但不能把“运行第三方 CLI/仓库代码”变成绝对安全。
安全部署应把 Daemon 当高权限开发工具:
- 用专用 OS 账号运行;
- 不让其 Home 暴露无关生产凭据;
- 谨慎配置 Custom Env;
- Local Directory 只选明确项目目录;
- 云 Runtime 用一次性或隔离机器;
- 对 Provider CLI 与 Skill 来源做供应链审查。
20.14 文件与附件边界
Server 抽象 Local Storage 与 S3:
- 无 S3 时写
/app/data/uploads; - S3 可配 CDN、CloudFront Signed URL、S3 Presign 或 Server Proxy;
- 下载 URL 有 TTL;
- Private Object 不把 Raw URL 当永久公开地址;
- Upload、Archive Import、Webhook 等对 Body 限长;
- 本地 Static Route 仍经过 Attachment 安全 Header。
Compose 将 Local Upload Directory 挂到 Volume。Kubernetes 若使用多 Backend Replica:
- S3 是最直接共享方案;
- 或选择 ReadWriteMany Storage;
- 默认 ReadWriteOnce PVC 会阻止第二个 Pod正常挂载。
删除数据库 Attachment 与删除对象存储不是一个原子事务;实现包含补偿清理,但运维仍应监测孤儿对象。
20.15 Docker 镜像构造
Dockerfile 使用 Go Multi-stage Build:
- Builder:Go 1.26 Alpine;
- 构建 Server、CLI、Migrate 与两个 Backfill 工具;
CGO_ENABLED=0;- Runtime:Alpine 3.21,只加入 CA 与时区;
- 带全部 SQL Migration;
- Entrypoint 先 Migrate,再 Exec Server。
- Node 22 Alpine;
- Corepack 激活仓库声明的 pnpm;
- Frozen Lockfile 安装;
- Next Standalone Output;
- Runtime 使用非 Root
nextjs用户。
Go Runtime Image 当前未显式切换非 Root 用户;容器边界、只读 Root FS 与 Pod Security Context 需要部署侧补齐。
20.16 Compose Self-host 的安全默认
docker-compose.selfhost.yml 启动:
- PostgreSQL 17 + pgvector;
- Backend;
- Frontend;
- Postgres 与 Upload Volume。
Backend/Frontend 端口只绑定 127.0.0.1。文件注释明确警告不要直接改成 0.0.0.0,因为 Docker Port Publishing 可能绕过主机 Firewall 预期。
公网访问应由 Caddy/nginx/Tunnel:
- 终止 TLS;
- 转发到 Loopback;
- 设置正确 Origin;
- 仅把反代 CIDR列入 Trusted Proxies;
- 对 WebSocket Upgrade 与超时做配置。
make selfhost 首次会从模板生成随机 JWT、Postgres Password 和 VCS Key。直接手写 Compose 又不提供环境变量时,仍会落到示例默认值;不能把模板中的 change-me-in-production 当生产 Secret。
20.17 Compose 不是多节点模板
Self-host Compose 没有默认 Redis,也没有对象存储服务。它代表简单单节点模式:
- Realtime Hub 在内存;
- Request Store/Cache 回退本进程或数据库;
- Rate Limit 关闭;
- Upload 在本地 Volume;
- 一个 Backend。
需要 HA 时不能只把 docker compose up --scale backend=3 当完成。必须补 Redis Relay/Store、共享对象存储、负载均衡、TLS、Session/Cookie Domain 和监控。
20.18 Helm Chart 的资源模型
- Backend Deployment、Service 与 Upload PVC;
- Frontend Deployment 与 Service;
- 内置 PostgreSQL Deployment、Service 与 PVC,或 External PostgreSQL;
- Frontend/Backend Ingress;
- ConfigMap;
- 可选 PrometheusRule。
Secret 不由 Chart 模板生成。Operator 先创建 existingSecret,让真实值不进入 Values/Git。
Backend 和 Frontend Image Tag 默认取 Chart.appVersion,Release Pipeline 会把它同步成 Git Tag,降低 Chart 与 Image 版本漂移。
20.19 Kubernetes Probe 与迁移
容器 Entrypoint 每次启动都运行 migrate up。cmd/migrate 使用 PostgreSQL Session-level Advisory Lock:
- 固定一条 Pool Connection;
- 阻塞获取稳定 Lock Key;
- 逐 Migration 检查是否已应用;
- 后到进程排队,获取锁后把已完成项视为 No-op;
- Session 结束自动释放 Lock。
迁移不包成一个大事务,因为仓库存在 CREATE INDEX CONCURRENTLY。特殊版本还有幂等 Pre-migration Backfill Hook。
Chart 的 Startup Probe 给迁移约五分钟预算;Readiness/Liveness 都访问 /healthz。
/health:只证明进程活着;/healthz//readyz:Ping DB,并检查所有当前二进制要求的 Migration Version,不是只看最高版本。
Chart 用 Readiness 端点做 Liveness,意味着持续 DB/Migration 故障也会触发 Pod 重启;这是当前运维语义,不应误读为纯进程存活检查。
20.20 多实例条件清单
| 子系统 | 单节点默认 | 多实例需要 |
|---|---|---|
| Migration | Entrypoint 串行执行 | Advisory Lock 已处理并发 |
| Browser Realtime | 内存 Hub | Redis Streams Relay |
| Daemon Wakeup | 本进程 Hub | Redis Relay |
| Auth/Member Cache | 可无缓存查 DB | Redis 可降低热点 DB |
| Local Skill Request Store | 内存可用 | Redis 共享 Pending Queue |
| Rate Limit | Redis 缺失即关闭 | 共享 Redis 或外部 Gateway |
| Upload | Local Volume | S3 或 RWX Volume |
| Scheduler/Worker | 单实例直观 | 依赖 DB Lease/幂等;监测重复与滞留 |
| Web | 单 Next 实例 | 无状态副本 + 同一 API/WS Origin |
源码注释认为 API/WS 多副本可行,但 Chart 仍默认一个 Backend,并使用 Recreate Strategy。水平扩容要逐项满足表中共享状态条件。
20.21 Server 启动与优雅关闭
cmd/server/main.go 的启动顺序大致是:
- 初始化 Logger 与配置警告;
- 建 PostgreSQL Pool;
- 选择 Redis/Realtime Hub;
- 注册同步 Event Listener;
- 构建 Metrics;
- 组合 Router/Handler/Service;
- 启动 Runtime Sweeper、Heartbeat Batch、Autopilot Monitor、Webhook Worker 等;
- 启动 HTTP 与独立 Metrics Server。
关闭时先停止接收信号、取消 Worker Context、Flush Heartbeat,再关闭 HTTP/Metrics 与外部 Client。顺序的目标是避免新请求进入后后台消费者已消失,也避免未 Flush 心跳造成 Runtime 误下线。
20.22 Prometheus 暴露方式
internal/metrics 只有在 METRICS_ADDR 非空时启用,并启动独立 Listener 的 /metrics:
- Go Runtime/Process;
- Build Info;
- HTTP 请求数、耗时与 In-flight;
- PostgreSQL Pool;
- Browser Realtime;
- Daemon WebSocket;
- Task Queue/Run/Failure;
- LLM Token、请求与估算成本;
- Scheduler Expiry;
- 产品/运行事件;
- Scrape-time Business Sampler。
非 Loopback Metrics 地址会记录警告,但 Handler 本身没有认证。生产应放在私网、NetworkPolicy、Allowlist 或带认证的监控代理之后。
Helm Chart 提供可选 PrometheusRule,但当前没有 ServiceMonitor 模板,也没有把独立 Metrics Port加入 Backend Service;Operator 需要自行暴露并配置 Scrape。
20.23 为什么 Business Sampler 使用独立连接池
Scrape-time 查询会读 Active User、Workspace、Queued/Running/Stuck Task、Runtime Heartbeat 等聚合。
为了不让 Prometheus Scrape 抢占业务连接:
- Sampler 使用独立小 Pool;
- 每条查询有紧凑 Statement Timeout;
- 查询失败只让指标变旧,不拖垮
/metrics; - 自身暴露 Query Error 与 Latency;
- PrometheusRule 对持续错误和 p95 超过 300 ms 告警。
这是一种“观测系统不得反向击穿业务”的隔离。
20.24 指标标签为什么受约束
Task/LLM 指标只使用规范化的 Source、Runtime Mode、Provider、Model、Status 和 Canonical Failure Reason。
不能把 Task ID、Issue ID、Workspace ID 或任意错误文本放进 Label,否则时间序列基数会随业务对象增长。
源码将 Failure Reason 预热和标签表集中维护,并用测试防止 Event-time Metric 与 Sampler 派生逻辑漂移。
20.25 Realtime 的双重观测
除了 Prometheus,还有 /health/realtime JSON:
- Connection;
- Slow-client Eviction;
- Event Send/Drop;
- Redis Connect/XADD/XREAD/ACK;
- Mirror Divergence;
- Daemon Wakeup Hit/Miss。
配置 REALTIME_METRICS_TOKEN 时要求 Bearer;未配置时只允许 Loopback。它适合快速排障,Prometheus 适合趋势和告警。
20.26 日志与 Analytics
请求链使用 Chi Request ID 和结构化 slog。Client Platform/Version/OS、Daemon Auth Path、Workspace/Task 等受控字段可辅助关联,但 Secret、Raw Token 和大型 Prompt 不应进入日志。
Analytics Client 在 POSTHOG_API_KEY 缺失或 ANALYTICS_DISABLED 时 No-op,队列满时丢事件而不阻塞请求。当前快照的 Server Event Contract 已标成 Prometheus-only;PostHog 基础设施仍在,但不能据此假设每个 Server Event 都会外发。
20.27 测试金字塔
当前快照约有:
- 489 个 Go
*_test.go文件; - 423 个 TS/TSX Test/Spec 文件;
- 10 个命名为 Spec/E2E 的端到端文件。
数量不是质量证明,但覆盖面显示测试并非只集中在 Handler:
- SQL/Service 事务与竞态;
- Auth、Workspace、Rate Limit、CSP;
- Daemon Poll/WS/Lease/Cancel;
- Exec Env、Worktree、Path 与 Process Tree;
- 17 类 Agent Protocol Parser;
- Frontend Schema、Store、Hook 与 View;
- Webhook 乱序、去重与签名;
- Installer、Compose 与 Helm Config。
20.28 本地验证入口
Makefile 的关键目标:
make setup:安装依赖、确保 DB、迁移;make start:迁移后启动 Server/Web;make check:Typecheck、TS Test、Go Test 与 Playwright E2E;make test:迁移后执行 Go Race Test;make sqlc:重新生成 Query Code;make db-reset:只允许 Localhost DB,拒绝 Remote Target。
Worktree 有独立 Env/Port/Database 初始化,避免多个分支测试相互覆盖。
20.29 CI 的分层
- Frontend 按 Path Filter 跳过无关 PR,但主分支总跑;
- Node 22 + pnpm;
- Reserved Slug 生成物做 Drift Check;
- Turbo Build/Typecheck/Lint/Test;
- Backend 使用 pgvector PostgreSQL 17 和 Redis 7;
- Go 1.26.1 Build、Migration、Race Test;
- Helm Config Test;
- Windows 专项验证 Process Tree、PowerShell Argv/Stdin、Codex Cleanup;
- Linux/macOS 验证 Installer。
mobile-verify.yml 独立跑 Mobile Typecheck/Lint/Test。
desktop-smoke.yml 手动构建 Linux/Windows x64/arm64 安装包。
把 Windows Exec Env 单列非常重要:Linux 模拟无法证明 Job Object 与 PowerShell 重序列化行为。
20.30 Release Pipeline
release.yml 由 vX.Y.Z 或带 Suffix 的 Tag 触发,并再次严格验证:
- Go Race Test;
- GoReleaser 构建 Darwin/Linux/Windows、amd64/arm64 CLI;
- 生成 Legacy 与 Versioned Archive;
- 生成
checksums.txt; - 更新 Homebrew Tap;
- Backend/Web 分别在原生 amd64/arm64 Runner 构建;
- 按 Digest 合并 Multi-arch Manifest;
- Stable Release 才打
latest; - 同步 Chart Version/AppVersion;
- Lint、打包并推送 OCI Helm Chart;
- 发布 Linux/Windows Desktop。
macOS Desktop 仍走手工签名/Notarization 流程。工作流注释也说明 Linux/Windows Desktop 当前未代码签名。
20.31 发布供应链还覆盖了什么、没覆盖什么
已有保护:
- Tag 格式校验;
- Release 前 Race Test;
- Frozen Dependency Lockfile 的镜像构建;
- Image 按 Digest 组装;
- CLI Checksum Manifest;
- Homebrew Formula;
- Upstream Owner Guard,Fork Tag 不向官方 Registry 发布。
当前工作流中未看到:
- OCI Image Signature;
- Provenance Attestation;
- SBOM 生成/发布;
- Container Vulnerability Scan;
- Linux/Windows Desktop Code Signing。
这是对当前源码快照的静态观察,不表示外部 Registry 或组织策略一定没有额外保护;若部署受合规约束,应在发布链补齐并独立验证。
20.32 生产上线检查表
Secret
- 随机且唯一的 JWT Secret;
- 强 PostgreSQL Credential,外部 DB 使用 TLS;
- Slack/Lark/VCS/Composio Key;
- GitHub/Webhook/SMTP/S3 Key;
- Secret 不进 Values、Git、日志或 Client Bundle;
- 明确 Key Rotation 与历史 Ciphertext 迁移方案。
Network
- Backend/Frontend 只经 TLS Reverse Proxy 暴露;
- 精确 CORS Origin;
- 正确 WebSocket Upgrade;
- 最小 Trusted Proxy CIDR;
- Metrics 走私网;
- Redis/PostgreSQL 不暴露公网;
- Webhook Body/Timeout/Rate Limit 配置合理。
State
- PostgreSQL Backup 与恢复演练;
- S3 Version/Lifecycle 或 Upload Volume Backup;
- Redis 是否只承载可重建状态逐项确认;
- Migration 前备份;
- 多实例满足 Redis、共享 Upload 和 DB Lease 条件。
Runtime
- Daemon 使用专用 OS 账号;
- Agent Provider 与 Skill 来源可信;
- Local Directory 最小化;
- Custom Env 定期审计;
- Workdir/Repo Cache 容量与 GC 告警;
- Task Token、Daemon Token 可撤销。
Observability
- Health 与 Readiness 分开监测;
- Queue Wait、Stuck、Lease Expiry、Runtime Offline;
- Task Failure Reason;
- LLM Cost/Unpriced Token;
- Realtime Drop/Slow Eviction/Redis Error;
- Migration、Webhook Worker 与 Scheduler 日志;
- Rate Limiter Fail-open 告警。
20.33 当前设计中值得显式接受的取舍
| 取舍 | 得到 | 代价 |
|---|---|---|
| Redis 可选 | 单机安装简单 | 无限流、无跨节点实时共享 |
| Entrypoint 自动迁移 | 升级一步完成 | Pod 启动依赖 DB 与迁移时长 |
| Local Upload 默认 | 不依赖对象存储 | 多副本与备份更复杂 |
| Task Token | Agent 细粒度归因 | 任务环境注入/撤销链更复杂 |
| Provider CLI 本地执行 | 复用用户订阅与原生能力 | 本机权限/供应链风险 |
| Metrics 独立 Listener | 与业务路由隔离 | 需额外暴露、认证与 Scrape 配置 |
| 同步事件总线 | 事务后顺序直观 | 慢 Listener 可能影响请求 |
| Recreate + 单副本默认 | 运维简单 | 默认不是无停机 HA |
20.34 本章结论
Multica 已在源码中建立了相当完整的安全与运维骨架:
- Credential 类型化并 Hash 存储;
- Task Token 绑定四元身份;
- Workspace/Role 二次授权;
- Webhook 独立验签;
- Secret 静态加密;
- Redis-backed 多节点中继;
- Migration Advisory Lock;
- Readiness 校验完整 Migration Set;
- Prometheus、Race Test 与 Multi-arch 发布。
但“支持 Self-host”不等于“默认配置就是生产 HA”。最关键的 Operator 责任仍是:替换所有开发 Secret、正确终止 TLS、配置 Redis/共享存储、隔离 Daemon、保护 Metrics,并为发布 Artifact 增加组织要求的签名与供应链证明。
下一章把前 20 章压成一次任务的完整调用链。