在 Oracle VPS 上通过 Caddy 部署 Token Monitor Hub

在不直接开放 Hub 端口的前提下,用 systemd、Caddy 和 UFW 部署官方 Token Monitor Hub,并验证 ingest、stats 与 SSE 同步链路。

#type / howto #status / evergreen #tech / ops #tech / ai #resource / caddy #resource / oracle-cloud #platform / server #protocol / http

[!info] related notes

在 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: truerole: hub
  • 未认证访问 /api/stats 返回 401;
  • 带共享密钥访问 /api/stats 返回包含 periodsdevices 的官方结构;
  • 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[] } 事件列表,而是带 deviceIdtodaymonthallTime 等字段的设备快照。自定义服务即使“所有 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: truerole: hub
  • 未认证 /api/stats 返回 401
  • 已认证 /api/stats 包含 periodsdevices
  • /api/stats/stream 的 Content-Type 是 text/event-stream
  • 客户端不再显示“连接中断,正在重连”
  • 上传后 devices.json 出现对应设备,统计随之更新
  • /api/usage/summary 等旧接口仍按原设计可用

踩坑点与证据

现象根因区分证据修复
客户端持续重连Hub URL 填成单个 ingest 路径服务端看到 /api/usage/ingest/api/healthHub 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 timeoutUFW 拒绝 Docker 网桥访问新端口宿主 curl 成功,容器访问 172.18.x.x:8901 超时只放行网桥 CIDR 到 8901
Hub 绑 127.0.x.x 时 Caddy 502容器与宿主机不共享 loopbackss 只显示 127.0.x.x:8901监听 0.0.x.x,用 UFW 限来源
reload 后仍走旧路由Caddyfile 单文件 bind mount 指向旧 inode宿主文件有 8901,容器内文件没有强制重建 Caddy 容器
怀疑 OCI 没开放 8901混淆公网入口与内部上游端口公网 443 可访问,Caddy 日志显示内部 dial timeoutOCI 只开放 443;修 Docker 网桥/UFW

为什么不默认使用 Tailscale

Tailscale 适合“所有访问者都在同一 tailnet,服务完全不需要公网消费者”的场景。它可以把 Hub 收口到 tailnet,但所有采集设备必须安装 Tailscale,Cloudflare Pages 等公网运行时也无法直接访问 tailnet 内的 Hub。

当前架构已经通过 Caddy、Bearer 密钥和 UFW 建立三层边界,因此不需要为了隐藏 8901 再引入 Tailscale。若未来取消 Cloudflare Pages 消费、且只保留个人设备同步,再考虑用 Tailscale Serve 替换公网 Caddy 入口。

回滚

  1. 在 Caddy 中移除或回退 @token_monitor matcher;
  2. 重新验证并重载或重建 Caddy;
  3. sudo systemctl disable --now token-monitor-hub.service
  4. 从 UFW 删除仅针对 8901 的 Docker 网桥规则;
  5. 保留 /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,并保留可验证的恢复流程。

参考资料

创建于 2026/8/8 更新于 2026/8/8