Files
HomeAgent/docs/zh/site-infra-runbook.md
JianFeeeee cf14b840ef docs: 补齐文档状态分类 + 校正两轮代码改动后的失真
## 背景

上一次文档校订(b9b5ac0)之后又有两次代码提交(5194a96 / 8293282),
把 future-plan §1.1 整节变成了错的 —— 它还写着「档位没有生效点」
和「在 prepareInputTask 里加一次 activateSecurityProfile 调用」,
而后者正是被后来的收敛**否定掉的方案**。

## 新增 docs/zh/README.md(文档索引)

32 篇文档此前混着三种时态(当时实测 / 历史推演 / 设计规划),外观相同。
索引给出**状态分类与可信度规则**:

| 状态 | 能不能当事实用 |
|---|---|
| 已实现并部署 | ✅ 直接信 |
| 部分实现 | ⚠️ 只信标了"生效"的部分 |
| 设计 / 未实现 | ❌ 只能当设计意图 |
| 归档说明 | ❌ 不要改、也不要信 |

并写死**判据名可信度规则**:文档里的 `TestXxx` 不保证还存在。
给了核对命令,以及当前基线(27 条缺失的分布 —— 18 条属"设计未实现"类
文档的规划名、7 条是 §2.7.3 有意保留的历史对照表左栏、2 条属视频能力
未纳入契约)。判定标准:**在"已实现"类文档正文里找不到才是缺陷**。

## 重写 future-plan §1.1(已结案)

原节写的是错的方案。改成记录**真正的缺陷分三层**:

```
第 3 层(表面):activateSecurityProfile 零调用方
第 2 层:agent/core 在替 security 做策略决策
         (自己抠 security_profile / 自己选判定链 /
           自己写 Kind:"resident" 声明主体类别 —— 提权口子)
第 1 层(根因):security 缺少「输入接入点」
```

并补上「每 agent 一个实例」的最终形态(共享注册表 + per-agent 执行视图,
sessionProfile / longtimeProfile 两层,优先级 session > longtime 本身是安全语义)。

§〇「机制 ≠ 接线」表格里档位那一条从 🔴 改为 ✅。

## 各文档状态行

此前 14 篇缺状态行(runbook / 实测报告 / 计划类)。逐篇补上**符合实际内容**
的声明,而不是统一套一句。特别地:

- `commondev-design.md` 标题本就写「设计 · 未实现」→ 归到"设计"类
- `live-run-evidence-2026-10-04.md` → 明确标注"历史证据快照,不要用它的
  结论覆盖现在的实现判断"
- `legacy-issues.md` → 写明它与 future-plan 的分工(前者收已知问题,
  后者收能力缺失与零调用方)

## 正文里两处判据名漂移

- `TestBootCtxCloseAllIsReverseOrder` → 实际是
  `TestKernelStateCloseAllIsReverseOrder`(`BootCtx` 改名时判据跟着改的)
- `TestNilTypedBusSafety` → 标注为"规划名,仓库中不存在"

## 新增 §2.7.5 模块产物的实例化粒度

依赖图保证"设施已就绪",但没说该有几个实例 —— 这是独立决策。
记下 security 的两个答案:全局基线共享(否则插件声明对部分 agent 不生效)、
轮次状态 per-agent。并给出给未来新模块的两条判断标准。

## 安全文档

状态头补「架构形态」段(防火墙定位、per-agent 视图、两层档位);
接线状态表里 `ActivateProfile` 从 🔴 改为 ✅(2026-10-31 接通)。

## README

文档列表加索引与 future-plan 入口 —— 此前从 README 找不到 docs/zh 的任何指引。

## 核对

- docs/zh 32 篇全部有状态行
- 索引里列的文档全部存在,无遗漏
- 分类与各文档自述一致(逐篇抽查)
- go build 仍通过(纯文档改动)
2026-10-31 07:50:58 +08:00

14 KiB
Raw Blame History

站点基础设施运维手册

状态:操作手册(现行)。 站点基础设施的操作说明。

适用:本项目文档站与产品站的生产部署(多站点、统一入口、多层 TLS 终止)。 读者:后续维护者 / AI agent。 本文件不含任何密钥、token、真实域名与 IP —— 全部用占位符,取值见本机 deploy/ 外的私有笔记或基础设施面板。

相关:部署脚本见 deploy/;分支与发布流程见 docs/git-branching.md。


0. 一句话拓扑

用户 ──443──▶ <PUBLIC_IP>(公网入口机)
                 └─ 反向代理 / WAF          ← ★ TLS 在这里终止(第一层)
                      ├─ 默认站点:<WILDCARD_CERT>(两级通配,覆盖 *.example.com)
                      └─ 每条三级子域一个 drop-in(专属证书 + 精确 server_name)
                           └─ 隧道 / 上行到内网
                                └─ 内网网关机:3080(第二层 TLS 终止)
                                     └─ 各静态站根目录 /app/data/sites/<name>/

关键认知:流量要穿两层 TLS 终止。改一层不等于改完 —— 这是本手册多数坑的根源。


1. 部署一个静态站(照抄流程)

以新增 foo.example.com 为例,共四步。

1.1 站点文件

静态产物放到内网网关机的站点根:

# 产物目录名即站名,与 nginx drop-in 里的 root 对应
sudo mkdir -p <SITES_ROOT>/foo
sudo tar xzf foo-site.tar.gz -C <SITES_ROOT>/foo
sudo find <SITES_ROOT>/foo -type f | wc -l    # 核对文件数

1.2 内部层:nginx drop-in

⚠️ 不要改主配置 —— 它由管理后台生成,会被覆盖。正确做法是加一个独立文件:

sudo tee <GATEWAY_DATA>/gateway/zz-foo.conf >/dev/null <<'EOF'
server {
    listen 3080 ssl http2;
    listen 8443 ssl http2;
    server_name foo.example.com;

    ssl_certificate     "/etc/nginx/certs/foo.example.com.fullchain.pem";
    ssl_certificate_key "/etc/nginx/certs/foo.example.com.privkey.pem";
    ssl_protocols TLSv1.2 TLSv1.3;

    root /app/data/sites/foo;
    index index.html;

    auth_request off;           # 公开站点;需鉴权则删掉此行
    location / {
        auth_request off;
        # ★ 纯静态多页站必须用 =404,不能回落 index.html
        #   回落会让不存在的路径返回首页 + 200(假装成功,最难查的一类问题)
        try_files $uri $uri/ =404;
    }
    location = /index.html { auth_request off; add_header Cache-Control "no-store"; }

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml text/markdown;
}
EOF
docker exec <GATEWAY_CONTAINER> nginx -t && docker exec <GATEWAY_CONTAINER> nginx -s reload

为什么用 drop-in:主配置由管理后台生成(标注「勿手改」),但 nginx 侧是 include <confdir>/*.conf,独立文件既不被覆盖也不污染生成件。回滚 = 删文件 + reload。

1.3 证书(三级子域必须单独签)

两级通配证书不覆盖三级子域,且上层反代的域名白名单也只支持通配(同样只到两级), 所以 foo.example.com 这类必须单独签发。

acme.sh --issue --dns dns_ali -d foo.example.com --server letsencrypt   # 或 zerossl
acme.sh --install-cert -d foo.example.com --ecc \
  --key-file       <CERT_DIR>/foo.example.com.privkey.pem \
  --fullchain-file <CERT_DIR>/foo.example.com.fullchain.pem \
  --reloadcmd      "sudo /opt/acme_reload_hook.sh"      # 见 §3

1.4 公网层:专属证书 + drop-in

公网入口机与内网机可能是两台机器(本项目即如此)。公网机上:

# ① 证书放到公网机的证书目录
#    宿主 <WAF_DATA>/resources/nginx ≡ 容器内 /etc/nginx
# ② 加一个与内网 drop-in 同构的 server 块,但多一段「上行到隧道」:
cat > <WAF_DATA>/resources/nginx/conf.d/foo-bypass.conf <<'EOF'
upstream foo_backend {
    server 127.0.0.1:<TUNNEL_VHOST_PORT>;
    keepalive 32;
    keepalive_timeout 75;
}

server {
    listen 0.0.0.0:443 ssl;
    server_name foo.example.com;

    ssl_certificate     /etc/nginx/certs/foo.example.com.crt;
    ssl_certificate_key /etc/nginx/certs/foo.example.com.key;

    location / {
        proxy_pass https://foo_backend;
        proxy_set_header Host              $http_host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_ssl_server_name on;
        proxy_ssl_name $host;      # ★ 保住 SNI,否则上游证书校验会错
        proxy_buffering off;
        proxy_request_buffering off;
        chunked_transfer_encoding on;
        # ★ 关闭缓冲攒包:否则小响应体攒不满就不下发(首屏空白类问题)
        postpone_output 0;
        tcp_nopush off;
        tcp_nodelay on;
        sendfile off;
    }
}
EOF
docker exec <WAF_CONTAINER> nginx -t && docker exec <WAF_CONTAINER> nginx -s reload

nginx 里精确 server_name 优先于通配/默认站点,所以加这个块只影响本域名。


2. 验收(必须走真实路径)

2.1 为什么不能用本机 curl 直接验

内网 DNS 常把 *.example.com 解析到内网机。此时本机 curl 测的是内网那条路, 公网层的问题一个都测不出来 —— 本项目就因此误报过「已修好」两次。

正确做法:强制把域名指向目标 IP 再验。

# 指定走公网入口机
curl -s -o /dev/null -w "%{http_code} verify=%{ssl_verify_result}\n" \
  --resolve foo.example.com:443:<PUBLIC_IP> https://foo.example.com/
# 期望:200 verify=0

# 看实际下发的证书
echo | openssl s_client -servername foo.example.com -connect <PUBLIC_IP>:443 2>/dev/null \
  | openssl x509 -noout -subject -dates

浏览器验证(跟随真实证书链、不跳校验):

// Playwright:用 host-resolver-rules 把域名钉到公网 IP
// ★ ignoreHTTPSErrors 必须为 false —— 跳过了就等于没测
const b = await chromium.launch({
  args: [`--host-resolver-rules=MAP foo.example.com <PUBLIC_IP>`],
});
const ctx = await b.newContext({ ignoreHTTPSErrors: false });

2.2 验收清单

  • 域名走公网 IP:verify=0
  • 证书 CN 是该子域自己,不是通配 / 默认证书
  • 证书未过期(openssl x509 -noout -enddate)
  • 不存在的路径返回 404(不是 200 + 首页)
  • 真实浏览器无 SSL 错误
  • 多视口无横向溢出、无控制台错误
  • 站内链接与锚点全部可达

3. 证书续期(全自动)

3.1 流程

acme.sh cron(每日一次)
  └─ 到期前 30 天自动续期(LE 现用 ARI 建议窗口)
       └─ 续期成功 → --reloadcmd: /opt/acme_reload_hook.sh
            ├─ ① 内网 nginx reload(本机立刻用上新证书)
            └─ ② 推送到公网入口机(先 dry-run 校验,通过才真推)

钩子始终 exit 0:公网推送失败只落日志告警,不把 acme 的续期标记为失败 (内网证书已装好,那是两件事)。日志:/var/log/push-cert.log。

3.2 推送到公网机的方式

公网机通常 SSH 不通,走云厂商的「运行命令」API(本项目用阿里云云助手):

# 思路(脱敏伪码):复用现成封装,别自己重写签名
sys.path.insert(0, '/opt')
import push_cert            # 内含 AK/实例 ID 与调用封装,import 时 __main__ 保护
status, output = push_cert.call('RunCommand', {...})

⚠️ CommandContent 有体积上限:本项目实测 3 份证书 base64 后约 13KB 会被拒, 单份约 6.5KB 正常。逐条推送,不要合并。

3.3 ★ 重写推送脚本时必须保留的四道防线

本项目曾因缺这些防线造成全站 TLS 故障(详见 §5.1)。新版 push_cert.py 的设计:

# 防线 作用
1 绝不使用 glob 证书目录名可能含字面 *,glob 会误匹配到别的证书
2 显式映射表(源路径 → 目标文件 → 期望 CN) 没在表里就不推
3 本地断言:CN / SAN / 私钥配对 / 未过期 写之前就拦住
4 远端复核:写完用 openssl 验 CN,不符即退出 最后一道保险

外加默认 --dry-run(只校验不打印敏感内容、不写文件)。

3.4 手动验证 install + reload 链路(不消耗签发额度)

--install-cert 只重装本地已签的证书并跑钩子;配合 --force 才是真重签。

stat -c '%y' <CERT_DIR>/foo.example.com.fullchain.pem   # 记下 mtime
acme.sh --install-cert -d foo.example.com --ecc \
  --key-file <CERT_DIR>/foo.example.com.privkey.pem \
  --fullchain-file <CERT_DIR>/foo.example.com.fullchain.pem \
  --reloadcmd "sudo /opt/acme_reload_hook.sh"
stat -c '%y' <CERT_DIR>/foo.example.com.fullchain.pem   # mtime 应更新

4. 管理后台接口(改配置的正确方式)

部署在本机的管理后台用 host 路由 + admin token 暴露配置接口。

4.1 先取自描述清单

A=http://127.0.0.1:<ADMIN_PORT>/api     # 容器内直连;外部走 /admin/api
curl -s "$A/schema"                     # ★ 别猜字段名

schema 会列出所有可配置项(含当前为空的)与各自的写入口、请求体示例。

4.2 常用写入

TOK=<ADMIN_TOKEN>
# 站点文案
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
  -d '{"title":"...","portalTitle":"..."}' "$A/pageinfo"

# 路由表(★ 整表替换 → 必须先 GET 现状,改完 POST,再 apply)
curl -s -H "Authorization: Bearer $TOK" "$A/gateway/routes"   # 先读全表
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
  -d '{"routes":[ ...完整列表... ]}' "$A/gateway/routes"
curl -s -X POST -H "Authorization: Bearer $TOK" "$A/gateway/apply"   # ★ 不 apply 不生效

# 导航条目(★ 嵌套形状,扁平形状会被拒)
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
  -d '{"sectionIndex":N,"item":{"title":"...","url":"...","icon":"auto"}}' "$A/item"

4.3 三条硬规矩

  1. 路由表是整表替换:必须提交完整 {"routes":[...]}。只提交单条会把整张表换掉。
  2. 写配置后要验证,不能以接口 200 当成功:改完轮询探测目标 URL (配置应用是异步的,立刻探测会拿到旧结果,表现为「apply 了却 404」)。
  3. 系统管理的分组不要手工加条目(本项目里是「常用服务」「历史访问」)—— 它们由程序按点击/浏览记录重建,加进去会被下次重建挤掉,还会污染排序。 要加就加到用户分组。

5. 已知陷阱与事故复盘

5.1 ★ 字面 * 交给 glob → 写坏全局默认证书(本项目真实事故)

经过:推送通配证书的脚本写了 glob.glob('/root/.acme.sh/*.example.com_ecc/fullchain.cer')。 而 acme.sh 存放通配证书的目录名,字面上就叫 *.example.com_ecc(星号是真实字符)。 glob 把它当通配符展开,同时匹配到 foo.example.com_ecc,且返回顺序不定 —— [0] 拿到别的证书,写进了全局默认证书,导致所有走默认证书的域名同时 TLS 报错。

教训:

  • 字面 * 绝不能交给 glob;读证书一律用字面路径 open()(Python 的 open 不展开通配)。
  • 写生产前必须断言「读到的是什么」 —— 校验 CN 成本极低,漏了就是全站故障。

5.2 只测内网 → 误报「已修好」

见 §2.1。验收必须走真实路径。

5.3 删证书/配置记录前先查引用

删 acme 记录或配置前,grep 全盘引用(含 /opt/ 这类容易漏的地方):

grep -rln "<证书目录名>" /etc/nginx/ /opt/ <GATEWAY_DATA>/ /home/ 2>/dev/null

本项目曾删掉一条证书记录后,才发现 /opt/ 下的推送脚本正指向它。

5.4 nginx 的 mime.types 没有 .md

给 agent 直读的 Markdown 会以 application/octet-stream 下发,部分客户端拒收。 显式声明:

location ~* \.md$ { default_type text/markdown; try_files $uri =404; }
location ~* \.txt$ { default_type text/plain;  try_files $uri =404; }
# 并把这些类型加进 gzip_types

5.5 静态站不要回落 index.html

try_files $uri $uri/ /index.html 会让不存在的路径返回首页 + 200, 监控和链接检查都会「通过」。纯静态多页站一律:

try_files $uri $uri/ =404;

5.6 反代要关缓冲攒包

上游反向代理默认 postpone_output 1460,小响应体攒不满就不下发(首屏空白)。 旁路静态站时统一关掉(见 §1.4 的 server 块)。


6. 为 agent 提供直读入口(推荐)

纯 HTML 站点对 agent 不友好(样板占大头、易漏内容)。推荐随构建产出:

路径 内容
/llms.txt 目录:每页一行,带 URL 与一句话说明
/llms-full.txt 全部正文拼一份,一次读完(记得开 gzip)
/<page>.md 每页 Markdown 原文(text/markdown)

⚠️ 两个必踩的坑:

  1. 静态站点生成器通常只把 .md 渲染成 HTML,不复制原文 —— 需在构建流程里额外复制一份,否则 llms.txt 里的链接全 404。
  2. nginx 需显式声明 .md/.txt 的类型(见 §5.4)。

7. 脱敏约定(写文档/脚本时遵守)

绝不写进仓库:

类别 示例形态 替代写法
真实域名 *.example.com <域名> / foo.example.com
公网 IP 任意公网地址 <PUBLIC_IP>
内网 IP 任意内网地址 <LAN_IP> / <GATEWAY_IP>
token / 密钥 各类 admin/sync token、云 AK/SK <ADMIN_TOKEN> 等占位符
资源 ID 云主机实例 ID <INSTANCE_ID>
目录/容器名 具体部署路径 <SITES_ROOT> / <GATEWAY_DATA> 等

取值放哪:本机私有笔记、密码管理器、基础设施面板 —— 不进版本库。 脚本里通过环境变量读取,不硬编码:

AK_ID = os.environ['ALI_AK_ID']          # 而不是字面量