mini-agent 双实例部署与免备案 API 暴露
双实例架构:同一份代码,两种运行姿态
mini-agent 的云端部署不是”把项目拷一份到服务器”那么简单——它是一份代码在两台机器上以两种截然不同的姿态运行,靠配置差异和一对同步端点把两边粘合起来。两台机器分别是家里的 Arch Linux 主机和阿里云北京的 ECS 。两边都跑 systemd user service,但角色完全不同:
- 本地(origin=0):主动方。Telegram bot 在线、调度器全开、博客同步推送、所有定时任务(todo 提醒、每日摘要、记忆压缩、todo/schedule 同步)从这里发起。
- 云端(origin=1):被动方。Telegram 关、调度器关、Notifier 只写日志、
/sync/*端点只被动响应。它的存在不是为了”接管业务”,而是为了给公网 API 一个稳定的入口,并在本地挂掉时维持静态博客和反向代理的可用性。
这种切分不是 HA(高可用)——本地死了 Telegram 和所有 cron 都会停。它是一种单向容错:云端的冗余覆盖的是”反向代理 + 静态博客”这一层,而不是业务逻辑层。明确这个边界很重要:把被动方当成热备会得出错误的容量规划。
两份配置的差异:五个开关,一个源头
云端 config.yaml 顶部有一段注释,列出了相对本地配置的五处分歧。把它们摊开成一张表比逐条读 YAML 清晰:
| 配置项 | 本地 (origin=0) | 云端 (origin=1) | 含义 |
|---|---|---|---|
telegram.enabled | true | false | 云端不跑 bot,避免和本地抢 webhook/long-poll |
scheduler.enabled | true | false | 云端不发起任何 cron_tick;本地是唯一的时序驱动源 |
web.expose_public | false | true | 云端绑 0.0.0.0:port,本地只绑 127.0.0.1 |
notifications.telegram | true | false | 云端 Notifier 退化为日志通道 |
sync.enabled | true(主动) | false(被动) | 云端只 serve /sync/*,本地是同步的发起方 |
web.expose_public 这个开关在 WebChannel 里同时干了两件事:决定监听地址(0.0.0.0 vs 127.0.0.1),以及是否启用”静默错误模式”(self._silent_errors = expose_public)。后者是隐蔽但关键的一笔——暴露到公网时,认证失败和格式错误不再返回 JSON 错误体,而是让上游返回 444,由 nginx 拦截后直接断开连接,不给扫描器任何信号。这是一个通过沉默实现的纵深防御。
同步机制:复合主键 + 最后写入胜出
两台实例之间唯一需要双向复制的是 todos 和 schedules——这两类数据会被 Telegram 命令和 Web UI 在任意一端修改,并且需要在另一端可见。聊天上下文、记忆摘要、博客内容都不在同步范围内(它们是端本地的状态)。
同步走 WebChannel 暴露的两个端点:/sync/todos 和 /sync/schedules,复用标准的 Bearer token 认证(和 /auth、/chat 同一个 password)。流向是:
本地 cron (每日 11:00) → SyncTodosSchedulesActor → SyncClient → POST 到云端 /sync/todos 和 /sync/schedules → 云端 TodoManager.upsert_sync_row / ScheduledMessageManager 等价物读路径用 list_changed_since 做增量——按 updated_at 拉取对端自上次同步以来变更过的行。冲突解决策略简单到几乎不需要解释:updated_at 大的赢(last-write-wins)。这对个人 agent 是足够的,因为只有一个人在操作,并发改同一行的概率极低,且没有”合并语义”的需求。
最值得展开的设计是主键。早期版本用单纯的 id 做主键,双向复制时立刻撞上冲突:本地新建的 todo id=42 和云端新建的 todo id=42 是两条毫不相关的数据,但写入对方库时会互相覆盖。修复方案是给主键加一个 origin 维度:
复合主键 = (origin, id) origin = 0 → 本地产生 origin = 1 → 云端产生origin 列同时起到了”数据来源”的标签作用和”主键命名空间隔离”的机械作用。这样两端的 id=42 可以在同一个表里和平共处,互不干扰。
Schema 迁移:为什么必须在 CREATE INDEX 之前
这个 origin 列是后加的。旧 schema 里既没有 origin,也没有 updated_at,但新的索引 CREATE INDEX IF NOT EXISTS ... ON ... (updated_at) 依赖 updated_at。SQLite 不能直接 ALTER TABLE 改主键或加列约束,所以迁移走经典的 _new 表重建模式:建带新 schema 的临时表 → 复制数据 → drop 旧表 → 重命名。
迁移必须在 CREATE INDEX 之前跑,否则索引语句会因为 updated_at 列不存在而失败。这个顺序约束看起来显然,但因为是”启动时自动跑的 schema 初始化”,一旦写错顺序,bug 会表现为”每次冷启动索引建不起来 + 查询走全表扫描”,而不是一个清晰的报错。迁移函数本身是幂等的——origin 列已存在就直接返回,不会重复重建。
离线追赶:处理笔记本休眠
本地是家用机,会休眠、会断网。如果某天本地睡了 25 小时,11:00 的同步 cron 错过了,下一次正常同步要等到第二天——中间产生的变更最多滞后一天。check_offline_catchup() 是这个场景的兜底:启动后 60 秒触发一次,如果 last_pull > 25h,立刻补跑一次同步。阈值设在 25h(而不是 24h)是为了避免和正常 cron 的边界抖动产生重复触发。
API 暴露:两条路径,一条主一条辅
公网访问 api.sgjki547.top 实际上有两条独立的物理路径,CF 边缘会根据自身路由决定走哪条。它们是共存关系,不是主备。
路径 A:Cloudflare Tunnel(当前主路径)
浏览器 → api.sgjki547.top (CF orange-cloud DNS → CF 边缘 IP) → CF Tunnel 长连接 (cloudflared 本地 outbound 拨号) → 家里机器的 localhost:portContext: ~/.cloudflared/config.yml 定义 ingress,把域名映射到本地的 mini-agent。
Why this way: Tunnel 是 outbound 长连接——cloudflared 主动拨到 CF 边缘,家里路由器不需要任何入站端口映射,也不需要公网 IP。这对家宽环境(运营商不给固定 IP、入站 80/443 经常被封)是近乎唯一可行的方案。
Key params:
tunnel: mini-agent— 隧道名,对应 dashboard 上的隧道实体credentials-file— 隧道凭证 JSON,cloudflared用它和 CF 边缘做 mTLS 握手cert.pem— 282 字节的 Cloudflare origin cert,用于边缘 ↔ cloudflared 的双向认证(不同于后面 nginx 用的 LE/CF Origin CA)
cloudflared 跑成 systemd user service,After=network-online.target,保证网络起来后再拨号。
路径 B:ECS nginx 直连(与路径 A 共存)
浏览器 → api.sgjki547.top (CF orange-cloud → CF 边缘) → CF origin pull → ECS nginx :443 → proxy_pass http://127.0.0.1:port (ECS 自己的 mini-agent)ECS 上的 nginx vhost 用 Let’s Encrypt 证书(/etc/letsencrypt/live/api.sgjki547.top/,Certbot 管理),通过 proxy_pass 转发到 ECS 本机的 mini-agent。这个 vhost 有几处值得圈出来的配置:
Context: 公网入口的 nginx 需要同时满足”对扫描器静默”和”对 SSE 流式响应友好”。
proxy_intercept_errors on;error_page 444 = @silent;location @silent { return 444; }
proxy_buffering off;proxy_read_timeout 3600s;proxy_send_timeout 3600s;Why this way: 上游 mini-agent 对认证/格式错误返回 444,但 444 不是标准 HTTP 状态——nginx 默认会把它包成 500/502 返回给客户端,泄露后端实现细节。proxy_intercept_errors on + error_page 444 = @silent 让 nginx 拦截这个状态码,进入 @silent location 后用 return 444 直接断开连接,不给任何响应体。扫描器看到的是”连接被重置”,而不是”这里有个会回错误信息的 API”。
Key params:
proxy_intercept_errors on— 启用上游非标准状态码的拦截proxy_buffering off— SSE 必须关缓冲,否则 delta 事件会在 nginx 攒成大块再 flush,流式语义被破坏proxy_read_timeout 3600s— SSE 连接可能长时间不发数据(pi RPC 在思考),默认 60s 超时会误杀长任务proxy_send_timeout 3600s— 对称设置,避免反向方向的超时
ECS 公网监听用 ss -tlnp 验证:0.0.0.0:80、0.0.0.0:443、0.0.0.0:port(mini-agent 直接监听)、[::]:80/443。expose_public: true 让 mini-agent 在 ECS 上直接监听公网,nginx 只是对 443 的 TLS 终止 + 反向代理。
主域名的证书策略:CF Full (Strict)
主博客域名 sgjki547.top 走的是另一套证书——不是 Let’s Encrypt,而是 Cloudflare Origin CA。nginx vhost serve /var/www/sgjki547.top 的静态文件,证书在 /etc/nginx/ssl/sgjki547.top.pem,subject 是 O=CloudFlare, Inc., OU=CloudFlare Origin CA, CN=CloudFlare Origin Certificate,有效期 15 年(2041 年到期)。
这是教科书式的 CF “Full (Strict)” 配置:CF 边缘终止面向用户的 TLS(用 CF 边缘证书),回源时 CF 用 Origin CA 证书验证源站,源站(ECS nginx)只信任 CF 的回源 IP。80 端口的 vhost 只做一件事:return 301 https://$host$request_uri;(nginx 的 HTTP→HTTPS 重定向),把 HTTP 全部重定向到 HTTPS。
注意主域名用 CF Origin CA 而 api.sgjki547.top 用 LE——这不是疏忽,是两条路径的天然分工:主域名只通过 CF 回源,CF Origin CA 是最优解;API 子域需要在路径 B(ECS nginx 直连)上工作,且 LE 证书对非 CF 客户端也有效。
“免备案”的真相:灰区,不是魔法
这部分需要诚实——它不是一个”如何绕过 ICP 备案”的技术方案,而是”为什么这个特定配置在当前环境下还没被封”的现实分析。ECS 在阿里云北京,80/443 确实在公网监听,但博客 HTML 里 grep 不到任何 ICP 备案号。能跑起来的几个条件叠加在一起:
- CF orange-cloud 隐藏了源站 IP 的 DNS 绑定。公网 DNS 解析
sgjki547.top返回的是 CF 边缘 IP,不是阿里云的 IP。阿里云 IDC 层面的 ICP 执法主要扫描”域名 → 大陆 IP”的 DNS 绑定,CF 代理把这层绑定遮掉了。 - 入站流量来源是 CF IP 段。阿里云在 ECS 上看到的连接全是 Cloudflare 的回源 IP,不是带着
Host: sgjki547.top的终端用户直连。从流量特征上看,这更像一个”在用 CDN 的合法业务”,而不是”裸跑 Web 服务的未备案域名”。 - 主域名用 CF Origin CA,进一步强化了”这是个走 CF 的站”的指纹。
但这不是”绕过”——它是”对阿里云 IDC 层扫描不可见”。真正的风险点:
- 如果阿里云做主动的 SNI 探测(直接连 ECS 的 443,读 ClientHello 里的 SNI 字段),这个配置立刻暴露。
- 如果收到具体的滥用举报指向这个 IP + 域名,封禁照常发生。
- Let’s Encrypt 续期时,
_acme-challenge.api.sgjki547.top需要短暂 grey-cloud 指向 ECS IP 完成 HTTP-01 验证——每次续期有几秒钟的暴露窗口。
把它理解成”通过降低可见度来降低被自动扫描命中概率”,比理解成”绕过监管”更准确。这不是稳定状态,是动态博弈。
两个已知运维雷点
re-encrypt-creds.sh 的存在是因为内核更新。Arch 滚动更新的内核更新会触发重启,重启后 systemd 的 LoadCredentialEncrypted= 因为 State not recoverable 解密失败(主机密钥变了)。这个脚本负责把 ~/.config/mini-agent/creds/*.cred 重新加密一遍,让它能被新主机密钥解开。属于 Arch + systemd-creds 组合的固有摩擦。
单向容错的边界要反复强调。云端被动方不能扛业务——本地死了,Telegram、所有 cron、同步全部停止,只有静态博客和 API 反向代理还活着。如果有人误以为这是 HA,会在本地宕机时发现”云端怎么也不工作”,然后把锅甩给同步机制。
整体拓扑
把前面所有路径画到一张图里,能看清两台机器和两个 CF 路径的关系:
Telegram bot ──┐ ├──→ 本地 mini-agent (origin=0, 主动) ──┐本地 cron ──────┘ │ │ /sync/* 双向 │ (todos/schedules, │ origin+id 复合主键, │ last-write-wins) ▼ 云端 ECS mini-agent (origin=1, 被动) │ 路径 A (CF Tunnel, 主): │ 路径 B (ECS nginx 直连, 共存): api.sgjki547.top │ api.sgjki547.top │ │ │ ▼ │ ▼ CF 边缘 ──Tunnel 长连接──→ 本地 localhost:port │ CF 边缘 ──origin pull──→ ECS nginx :443 (cloudflared outbound 拨号) │ → 127.0.0.1:port │ ▼ ECS nginx :443 (LE cert) → /var/www/sgjki547.top 静态博客 (CF Origin CA cert)这张图回答了用户最初的问题:“云端部署怎么实现”——是双实例 + 配置差异 + /sync/* 同步;“免备案怎么暴露 API”——是 CF Tunnel 主路径 + ECS nginx 共存路径,外加对”灰区可见度”的清醒认知。两件事是一体的:正因为用了 CF Tunnel 这种 outbound 方案,本地机器才能在无公网 IP、无入站端口的情况下成为 API 的实际服务端,而 ECS 退化成一个可选的反向代理冗余。