内网中枢:四个接口收拢四台机器,和一次「点了没反应」的返工

内网中枢:四个接口收拢四台机器,和一次「点了没反应」的返工

到昨天为止,狸花云的内网是散的。PVE 有 PVE 的用法,控制台自己查数据库,AstrBot 容器独立跑着,每加一个服务就在某个地方多写一段脚本。谁在线、谁挂了、该重启谁——全靠人记。

今天把它收进一个中枢。

一、中枢只做三件事

不做服务发现,不做配置中心,不做网关。就三件:

1. 注册 —— 服务启动时自报家门,领一个 token

2. 心跳 —— 每 60 秒报一次状态和指标

3. 指令 —— 中枢放指令进队列,节点自己来取,执行完回报

六个接口,都是几十行:

接口 谁调 作用
POST /api/hub/register 服务首次接入 拿 token
POST /api/hub/heartbeat 服务每 60s 上报 status + metrics
GET /api/hub/nodes 管理端 看全内网
POST /api/hub/command 管理端 下发指令
GET /api/hub/commands 节点每 30s 取待执行的
POST /api/hub/report 节点 回报结果

节点信息落一个 JSON 文件,够用。几百个节点以内不值得上数据库,hub_nodes.json 一把梭,读的时候算 lastSeen 距现在多久判在线:


online: !!(n.lastSeen && now - new Date(n.lastSeen).getTime() < 180000)

三分钟没心跳就算离线。心跳间隔 60 秒,留了三次容错,不会因为一次网络抖动就跳到红色。

二、拉模式,不是推模式

这是今天唯一一个真正的设计决定。

中枢可以主动去连每台机器——SSH 进去,执行命令,拿结果。也可以让每台机器装一个小 agent,主动来问「有活吗」。

选了后者。原因是安全边界:

  • 推模式:中枢必须持有全内网所有机器的 SSH 凭据。中枢被打穿,全内网一起完
  • 拉模式:中枢只对外开一个 HTTP 端口,节点自己拿 token 来取。中枢不知道任何机器的密码

代价是中转延迟——指令下发到执行最多 30 秒。对运维指令这个量级完全可以接受,查个负载、拉个 docker ps,不需要毫秒级。

三、agent 只认白名单

agent 跑在各节点上,能执行 shell 命令。这是最危险的部分,所以从第一天就锁死:


ALLOW = [
    "uptime", "df -h", "free -m", "uname -a",
    "systemctl is-active", "systemctl status",
    "docker ps", "docker stats --no-stream",
    "pct list", "pveversion",
]

前缀匹配,命中才执行,不命中直接返回「拒绝执行(不在白名单)」,把原因也回报上去,不是静默失败。没有 eval,没有任意 shell。

一台机器失守,最坏结果是攻击者能看这台机器的负载和容器列表。拖不下整个内网。

四、上线实测

四台机器,全部 systemd 常驻,Restart=always:

节点 类型 状态
pve01 主节点 PVE 在线
pve02 节点2 PVE 在线
console 狸花云控制台 在线
astrbot-main AstrBot 主控 在线

装的过程里没有踩坑,hub-agent.service 一把起来。这在我这几天的记录里算罕见。

五、然后被自己的接口呛了一下

中枢面板做完了,回头看养鱼套餐的一键安装——用户点了,屏幕什么都不动。

查下来不是前端的问题:


const r = await shopDeps.execInGuest(ip, cmd)   // ← 这里
return ok(res, { started: true })

后端在等 SSH 把后台进程拉起来才回话。而 execInGuest 要建连、执行、拿输出,二三十秒。

于是浏览器干等,用户看到的是一个不动的按钮。技术上没失败,体验上等于失败。

改成先回话再干活:


shopDeps.execInGuest(ip, cmd).then(r => {
  logger.info('[lihua-bot] 安装已启动 out=' + String(r.out).slice(-120))
}).catch(e => logger.warn('[lihua-bot] 安装启动失败: ' + e.message))

return ok(res, { started: true, async: true })   // 立刻返回

前端同步改:点击立刻渲染进度面板,不等接口。

这条要记住:任何「触发一个长任务」的接口,都不能在请求周期里等任务完成。你等的是 SSH 的连接时间,用户等的是「这网站是不是坏了」。

六、顺手修的两个小问题

养鱼套餐没有密码框。 购买弹窗的显示条件是 p.type === 'vm',而养鱼套餐类型是 bot。容器类套餐就该有 root 密码,判断从「等于 vm」改成「属于容器类型」:


CONTAINER_TYPES = ['vm', 'bot']
LINK_TYPES = ['vless']

白名单枚举,不是 !== 'vless' 排除法。以后再加类型,往数组里丢一个就行,不会意外落进错的分支。

套餐类型简化为两个。 界面上只调「容器」和「VLESS」。养鱼不是第三种类型,它就是容器 + 预装 AstrBot。少一个概念,少一处要同步的判断。

七、还没做完的

  • 中枢面板还没有指令历史,下发完只能去日志翻
  • 密钥还没做成「一把通用、只能执行固定脚本」的单向形式
  • hub_commands.json 只保留 500 条,超了从头砍,没有归档

明天先做指令历史——不然这套东西永远只能现场用,事后查不了。


狸花网络 · 2026-11-03

💬 留下你的爪印