在 Oracle VPS 上通过 Caddy 部署 Token Monitor Hub
在不直接开放 Hub 端口的前提下,用 systemd、Caddy 和 UFW 部署官方 Token Monitor Hub,并验证 ingest、stats 与 SSE 同步链路。
[!info] related notes
- 所属 MOC: Observability Engineering MOC
- 前置概念: Token 用量追踪、Caddy 反向代理与自动 HTTPS
- 相关运维: VPS MOC、Tailscale Serve 原理
- 实际资产: [[oracle-osaka-arm-development-vps|Oracle 大阪 ARM 开发 VPS(oracle2)]]
在 Oracle VPS 上通过 Caddy 部署 Token Monitor Hub
[!abstract] 速记结论 Token Monitor 客户端必须连接官方 Hub 协议,Hub URL 只填写站点根地址。公网只开放 Caddy 的 HTTPS 443;Hub 端口通过 UFW 仅允许 Caddy 所在 Docker 网桥访问。官方实时通道是
/api/stats/stream的 SSE,不是 WebSocket。
目标与成功状态
本笔记回答一个问题:如何让多台设备通过公网 HTTPS 连接自托管 Token Monitor Hub,同时不把 Hub 的原始端口直接暴露到公网。
成功状态:
- 客户端 Hub URL 使用
https://<hub-domain>后显示连接成功; GET /api/health返回ok: true和role: hub;- 未认证访问
/api/stats返回 401; - 带共享密钥访问
/api/stats返回包含periods和devices的官方结构; POST /api/ingest能写入设备快照;/api/stats/stream持续返回text/event-stream;- OCI 不开放 Hub 原始端口,UFW 只允许 Docker 网桥访问它。
最终拓扑
flowchart LR
Client["Token Monitor 客户端"] -->|"HTTPS 443 + Bearer secret"| Caddy["Caddy 公网入口"]
Caddy -->|"172.18.x.x:8901"| Hub["官方 Token Monitor Hub"]
Hub --> DB["devices.json"]
Caddy -->|"172.18.x.x:8900"| Legacy["旧 AI Usage Summary"]
Caddy -->|"172.18.x.x:8008"| Nezha["Nezha Dashboard"]
这套结构保留三个边界:443 是公网入口,8901 是 Token Monitor 原生协议,8900 是个人主页仍在使用的旧汇总协议。它们不能因为都处理 Token 数据就共用一套不兼容的请求体和响应结构。
前置条件
- Oracle VPS 已运行 Node.js 22.13 或更新版本;本次验证使用 Node.js 24;
- Caddy 在 Docker 容器中运行,并能通过宿主机 Docker 网桥访问后端;
- 域名已经指向 VPS,OCI 云侧防火墙已允许 HTTPS 443;
- 共享密钥保存在服务器和客户端的私密配置中,不写入 Git;
- 修改 Caddy、systemd 和 UFW 前已保留可回滚配置。
本次验证对应 Token Monitor 0.42.1。升级后应重新核对官方 docs/API.md 和 Hub 源码。
步骤一:确认使用的是原生 Hub 协议
Token Monitor 当前原生 API 至少包含:
| 路径 | 用途 | 鉴权 |
|---|---|---|
/api/health | 发现 Hub、确认角色和版本 | 不需要 |
/api/ingest | 上传一台设备的完整用量快照 | 需要 |
/api/stats | 读取聚合统计 | 需要 |
/api/stats/stream | 通过 SSE 接收统计快照和更新 | 需要 |
/api/devices/* | 查询或删除设备 | 需要 |
/api/history | 读取历史聚合 | 需要 |
/api/subscriptions | 同步订阅成本配置 | 需要 |
客户端上传的不是简单的 { machine_id, records[] } 事件列表,而是带 deviceId、today、month、allTime 等字段的设备快照。自定义服务即使“所有 GET 返回 200、所有 POST 都接受”,只要路径或 JSON 契约不一致,仍不是可连接的 Token Monitor Hub。
步骤二:部署官方 Hub
将官方仓库部署到独立目录,避免覆盖旧的 AI Usage 服务:
sudo install -d -o ubuntu -g ubuntu -m 0755 /opt/token-monitor-hub
git clone --depth 1 https://github.com/Javis603/token-monitor.git /opt/token-monitor-hub
cd /opt/token-monitor-hub
npm install --omit=dev --ignore-scripts
sudo install -d -o ubuntu -g ubuntu -m 0750 /opt/token-monitor-hub/data
使用 systemd 管理进程。下面省略真实密钥,生产环境优先把密钥放入权限为 600 的 EnvironmentFile:
[Unit]
Description=Token Monitor Official Hub
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
Group=ubuntu
WorkingDirectory=/opt/token-monitor-hub
Environment=TOKEN_MONITOR_PORT=8901
Environment=TOKEN_MONITOR_HOST=0.0.x.x
Environment=TOKEN_MONITOR_DATA_FILE=/opt/token-monitor-hub/data/devices.json
EnvironmentFile=/etc/token-monitor-hub.env
ExecStart=/usr/local/bin/node /opt/token-monitor-hub/src/hub/server.js
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/opt/token-monitor-hub/data
[Install]
WantedBy=multi-user.target
/etc/token-monitor-hub.env:
TOKEN_MONITOR_SECRET=<生成并安全保存的随机密钥>
启动并验证宿主机端点:
sudo chmod 600 /etc/token-monitor-hub.env
sudo systemctl daemon-reload
sudo systemctl enable --now token-monitor-hub.service
systemctl is-active token-monitor-hub.service
curl -fsS http://127.0.x.x:8901/api/health
预期健康检查包含:
{
"ok": true,
"role": "hub",
"secretRequired": true
}
步骤三:让 Caddy 只代理官方路径
Token Monitor 原生路由必须在 Nezha 的默认代理之前匹配:
vps.example.com {
@token_monitor {
path /api/health
path /api/stats /api/stats/*
path /api/ingest
path /api/devices /api/devices/*
path /api/history
path /api/subscriptions
}
handle @token_monitor {
reverse_proxy 172.18.x.x:8901 {
flush_interval -1
}
}
@legacy_ai_usage path /api/usage/*
handle @legacy_ai_usage {
reverse_proxy 172.18.x.x:8900
}
reverse_proxy 172.18.x.x:8008
}
flush_interval -1 让 SSE 数据及时向客户端刷新。不能只配置 /api/ingest,因为客户端建立连接前后还会访问 health、stats 和 stats stream。
修改后先验证配置:
docker exec cpa-caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
[!warning] 单文件 bind mount 的 inode 陷阱 若 Compose 使用
./Caddyfile:/etc/caddy/Caddyfile:ro,在宿主机通过原子替换生成新 Caddyfile 后,运行中的容器可能仍绑定旧 inode。此时caddy reload会再次读取旧内容。应核对容器内文件;必要时执行docker compose up -d --force-recreate caddy让挂载重新建立。
步骤四:只允许 Caddy 网桥访问 Hub
Hub 监听 0.0.x.x:8901 是因为 Caddy 位于另一个 network namespace,无法访问宿主机 127.0.x.x。安全边界由 UFW 的来源限制建立:
sudo ufw allow from 172.18.x.x/16 to any port 8901 proto tcp \
comment 'token-monitor-hub from cpa-caddy'
sudo ufw status numbered
这里不需要在 OCI Security List 或 NSG 中开放 8901。公网客户端只访问已经开放的 443;8901 只承担 VPS 内部的 Caddy 到 Hub 链路。
从 Caddy 容器验证:
docker exec cpa-caddy \
wget -qO- -T 5 http://172.18.x.x:8901/api/health
步骤五:客户端只填写根 URL
客户端设置应为:
模式: 连接到 Hub
Hub URL: https://vps.example.com
密钥: <与 TOKEN_MONITOR_SECRET 相同>
设备 ID: <稳定且唯一的设备名>
不要填写 https://vps.example.com/api/usage/ingest。客户端会在 Hub URL 后自行追加 /api/health、/api/ingest、/api/stats 和 /api/stats/stream。把单个 ingest 接口当成 base URL,会产生 /api/usage/ingest/api/health 一类错误路径。
验证
先在 shell 中设置密钥变量,避免命令进入历史记录时直接出现密钥:
read -rsp 'Token Monitor secret: ' TOKEN_MONITOR_SECRET
echo
执行协议检查:
# 健康检查无需鉴权
curl -fsS https://vps.example.com/api/health
# 未认证的 stats 应返回 401
curl -sS -o /dev/null -w '%{http_code}\n' \
https://vps.example.com/api/stats
# 已认证的 stats 应返回 periods 和 devices
curl -fsS \
-H "Authorization: Bearer ${TOKEN_MONITOR_SECRET}" \
https://vps.example.com/api/stats
# SSE 应先返回 event: snapshot,随后保持连接
curl -N \
-H "Authorization: Bearer ${TOKEN_MONITOR_SECRET}" \
https://vps.example.com/api/stats/stream
验证清单:
-
/api/health返回ok: true、role: hub - 未认证
/api/stats返回 401 - 已认证
/api/stats包含periods和devices -
/api/stats/stream的 Content-Type 是text/event-stream - 客户端不再显示“连接中断,正在重连”
- 上传后
devices.json出现对应设备,统计随之更新 -
/api/usage/summary等旧接口仍按原设计可用
踩坑点与证据
| 现象 | 根因 | 区分证据 | 修复 |
|---|---|---|---|
| 客户端持续重连 | Hub URL 填成单个 ingest 路径 | 服务端看到 /api/usage/ingest/api/health | Hub URL 改为域名根地址 |
| health、stats 返回哪吒错误 | Caddy 未匹配 Token Monitor 原生路径 | /api/health 返回 real ip header not found | 在默认 Nezha 路由前增加完整 matcher |
| 所有 GET/POST 都返回 200,客户端仍离线 | 自定义 JSON 契约不兼容 | health 没有 role: hub,stats 没有 periods | 部署官方 Hub,而非宽松接受请求 |
| 101 握手后仍重连 | 把实时通道误判为 WebSocket | 官方客户端请求 /api/stats/stream | 实现或部署 SSE,不伪造 WebSocket 101 |
| Caddy 返回 502 timeout | UFW 拒绝 Docker 网桥访问新端口 | 宿主 curl 成功,容器访问 172.18.x.x:8901 超时 | 只放行网桥 CIDR 到 8901 |
Hub 绑 127.0.x.x 时 Caddy 502 | 容器与宿主机不共享 loopback | ss 只显示 127.0.x.x:8901 | 监听 0.0.x.x,用 UFW 限来源 |
| reload 后仍走旧路由 | Caddyfile 单文件 bind mount 指向旧 inode | 宿主文件有 8901,容器内文件没有 | 强制重建 Caddy 容器 |
| 怀疑 OCI 没开放 8901 | 混淆公网入口与内部上游端口 | 公网 443 可访问,Caddy 日志显示内部 dial timeout | OCI 只开放 443;修 Docker 网桥/UFW |
为什么不默认使用 Tailscale
Tailscale 适合“所有访问者都在同一 tailnet,服务完全不需要公网消费者”的场景。它可以把 Hub 收口到 tailnet,但所有采集设备必须安装 Tailscale,Cloudflare Pages 等公网运行时也无法直接访问 tailnet 内的 Hub。
当前架构已经通过 Caddy、Bearer 密钥和 UFW 建立三层边界,因此不需要为了隐藏 8901 再引入 Tailscale。若未来取消 Cloudflare Pages 消费、且只保留个人设备同步,再考虑用 Tailscale Serve 替换公网 Caddy 入口。
回滚
- 在 Caddy 中移除或回退
@token_monitormatcher; - 重新验证并重载或重建 Caddy;
sudo systemctl disable --now token-monitor-hub.service;- 从 UFW 删除仅针对 8901 的 Docker 网桥规则;
- 保留
/opt/token-monitor-hub/data/devices.json备份,确认不再需要后再处理。
回滚不应删除旧的 8900 AI Usage 服务或修改 Nezha 的 8008 路由,因为它们是并列服务,不是官方 Hub 的组成部分。
安全维护
- 共享密钥一旦出现在聊天、终端输出或截图中,应同步轮换 Hub、客户端和所有消费方;
/api/health可以公开,但 stats、ingest、devices、history 和 subscriptions 必须鉴权;- 不把密钥写入笔记、Git、Caddyfile或可读日志;
- 升级 Token Monitor 后重新执行 health、401、stats、ingest 和 SSE 回归;
- 定期备份
devices.json,并保留可验证的恢复流程。