前情提要
上一篇我们看完了 zmagent——多线程常驻进程,注册、心跳、双通道任务执行。这一篇转向系统的枢纽:zmserver。
如果说 agent 是"手脚",server 就是"神经中枢":维持上千条长连接,识别每个 agent 是谁,把 web 下发的指令准确送达,把 agent 上报的结果转给入库服务。所有压力最终都汇聚到这里。
一、定位:不做业务,只做枢纽
zmserver 的核心原则:协议解析 + 连接管理 + 命令转发,不碰数据库,不碰业务逻辑。
|
主线程 |
accept 连接,spawn 处理线程 |
不解析业务 |
|
ClientHandler |
单连接生命周期:收包、分发、心跳维护 |
不直接 SQL |
|
ConnectionManager |
全局连接池,hostid 路由 |
不执行命令 |
|
CommandDispatcher |
命令暂存、按 hostid 分队列 |
不碰网络 |
二、线程模型:per-connection,不是线程池

cpp
// server.cpp 主循环
while (running) {
int clientSocket = accept(listenSocket, (sockaddr*)&clientAddr, &addrLen);
std::string clientIP = inet_ntoa(clientAddr.sin_addr);
// 每个连接一个独立线程,生命周期与 TCP 连接绑定
std::thread([clientSocket, clientIP]() {
ClientHandler handler;
handler.handleClient(clientSocket, clientIP);
}).detach();
}
为什么不用线程池?
|
考量 |
结论 |
|
连接数 |
内网监控场景,单机房通常 < 1000 台,线程数可控 |
|
复杂度 |
per-connection 代码简单,无任务窃取、无锁竞争 |
|
隔离性 |
单 agent 异常(死循环发包)只影响自己的线程,不污染全局 |
|
代价 |
线程栈 8MB × 1000 = 8GB 虚拟内存,现代服务器可接受 |
三、HostID 注册中心:IP 到身份的唯一映射
agent 首次连接,发送 0xA101 注册。server 怎么识别"这是谁"?
3.1 懒加载 + 持久化
cpp
// hostid_manager.cpp(推断结构)
class HostIDManager {
static std::mutex hostIDMapMutex;
static std::unordered_map<std::string, int> hostIDMap; // IP -> hostid
static int nextHostID;
static bool mapLoaded;
public:
int getOrAssignHostID(const std::string& clientIP) {
std::lock_guard<std::mutex> lock(hostIDMapMutex);
if (!mapLoaded) {
loadFromFile("host_ids.txt"); // 懒加载,首次注册时读盘
mapLoaded = true;
}
auto it = hostIDMap.find(clientIP);
if (it != hostIDMap.end()) {
return it->second; // 已知 IP,返回已有 ID
}
// 新 IP,分配自增 ID
int newID = nextHostID++;
hostIDMap[clientIP] = newID;
appendToFile("host_ids.txt", clientIP, newID);
return newID;
}
};
设计要点:
基于 IP 而非 hostname:hostname 可改,IP 在 DHCP 租期内稳定
纯文本而非数据库:server 不依赖外部服务即可自举
懒加载:避免启动时读盘,首次注册请求触发
3.2 注册流程
Agent connect ──► send 0xA101 ──► Server 查 IP/分配 ID
│
▼
写入 host_ids.txt
│
▼
回复 hostid 字符串(纯文本,非二进制)
│
▼
Agent 写入 basecomm.conf,后续携带
注意:注册回复是纯文本,不是协议帧。这是早期简化设计,后续版本可改为二进制 0xA101_ACK 。
四、连接管理:知道谁在线,才能指挥谁
4.1 ConnectionManager 核心结构
cpp
struct ClientInfo {
int socket; // 连接句柄
std::string ip; // 连接 IP
int hostid; // 注册后分配,注册前为 0
ClientType type; // UNKNOWN / AGENT / ADMIN
ClientCap caps; // 能力位图(预留)
time_t last_heartbeat; // 最后心跳时间
bool is_admin_authed; // Admin 是否已认证
};
class ConnectionManager {
std::mutex connMutex;
std::unordered_map<std::string, ClientInfo> conns; // connKey=IP:port
std::unordered_map<int, std::string> hostidToConnKey; // hostid 反查
public:
void addClient(const std::string& connKey, const ClientInfo& info);
void removeClient(const std::string& connKey);
void updateHeartbeat(const std::string& connKey);
bool sendToHostid(int hostid, const char* data, size_t len);
};
4.2 关键能力:hostid 路由
Admin 下发命令时,只指定 target_hostid=1001 。Server 怎么找到对应的 socket?
cpp
bool ConnectionManager::sendToHostid(int hostid, const char* data, size_t len) {
std::lock_guard<std::mutex> lock(connMutex);
auto it = hostidToConnKey.find(hostid);
if (it == hostidToConnKey.end()) {
return false; // 目标离线,命令暂存 pending 队列
}
auto connIt = conns.find(it->second);
if (connIt == conns.end()) {
return false; // 连接已断开,但 hostid 映射未清理(异常)
}
return send(connIt->second.socket, data, len, 0) == (ssize_t)len;
}
为什么需要 hostid → connKey 反查?
一个 hostid 同一时间只能有一个活跃连接(agent 重连会清理旧连接)
但 connKey(IP:port)每次连接都变,hostid 是稳定的业务标识
4.3 心跳维护与超时
cpp
// ClientHandler 每次收到 0xAf0f 心跳
ConnectionManager::updateHeartbeat(connKey);
// 回复 ACK:同样的 0xAf0f 包头,datalen=0
// 当前代码无主动超时清理,依赖:
// 1. TCP keepalive(系统级)
// 2. 连接断开时 recv 返回 0,触发 removeClient
待改进:可增加定时扫描线程,关闭 last_heartbeat > 5分钟 的僵尸连接。
五、命令调度:Admin 的命令怎么到达 Agent
5.1 两条路径,两种时序
|
场景 |
路径 |
特点 |
|
Agent 已在线 |
Admin → Server → 立即转发 Agent |
1 秒内到达 |
|
Agent 离线 |
Admin → Server → 暂存 pending → Agent 上线后下发 |
异步,可能延迟 |
5.2 CommandDispatcher 结构
cpp
struct PendingCmd {
uint32_t cmd_id; // 全局唯一,Server 分配
uint16_t cmd_code; // 原始命令类型
std::string script; // 脚本名
std::string params; // 参数
uint32_t taskid; // Admin 下发的原始 taskid,用于链路追踪
time_t enqueue_time; // 入队时间,可设超时
};
class CommandDispatcher {
std::mutex pendingMutex;
std::unordered_map<int, std::queue<PendingCmd>> pendingQueues; // hostid -> queue
public:
void dispatch(int targetHostid, const PendingCmd& cmd, const CmdMeta& meta);
std::optional<PendingCmd> checkPendingCommands(int hostid);
CmdMeta getMeta(uint32_t cmd_id); // 结果上报时查 taskid
void removeMeta(uint32_t cmd_id); // 结果入库后清理
};
5.3 完整调度流程
Admin 发送 0xA402 ──► 解析 target_hostid, cmd_type, taskid, params
│
▼
CommandDispatcher::dispatch(1001, cmd, meta)
│
├──► Agent 1001 在线? ──是──► ConnectionManager::sendToHostid
│ 封装 0xA301 下发
│
└──► Agent 1001 离线? ──否──► 存入 pendingQueues[1001]
等待 Agent 心跳间隙拉取
Agent 侧如何拉取?
ClientHandler 的 1 秒 recv 超时间隙:
cpp
// client_handler.cpp 主循环
while (running) {
int n = recv(clientSocket, buffer, sizeof(buffer), 0);
if (n > 0) {
processPacket(buffer, n); // 处理正常数据
} else if (n < 0 && errno == EAGAIN) {
// 1秒超时,不是错误,检查 pending
if (assignedID > 0) {
auto cmd = CommandDispatcher::checkPendingCommands(assignedID);
if (cmd) {
sendCmdExecute(*cmd); // 封装 0xA301 下发
}
}
}
}
5.4 链路追踪:cmd_id 与 taskid
为什么需要两个 ID?
|
ID |
作用 |
谁生成 |
|
taskid |
业务层标识,Admin 下发时指定,用于 web 追踪"这条命令发给谁了" |
Admin / Web |
|
cmd_id |
协议层标识,Server 生成,用于匹配"哪条命令的执行结果" |
Server |
cpp
// 结果上报时(0xA303),Agent 回显 cmd_id
// Server 查 meta,还原 taskid,写入 JSON 入库
void handleResultReport(uint32_t cmd_id, const std::string& output) {
auto meta = CommandDispatcher::getMeta(cmd_id);
Json::Value json;
json["cmd"] = "process_status";
json["host"] = meta.target_ip;
json["name"] = meta.params;
json["value"] = std::stoi(output); // 注意:当前未处理非数字输出
json["cmd_id"] = cmd_id;
json["taskid"] = meta.taskid; // 关键:链路追踪
PythonServiceClient::notify(json.toStyledString());
CommandDispatcher::removeMeta(cmd_id); // 清理,防内存泄漏
}
六、协议处理:一个循环,九种分支
ClientHandler 的核心是 handleClient 的收包循环:
cpp
void ClientHandler::handleClient(int socket, const std::string& clientIP) {
char buffer[4096];
size_t offset = 0; // 半包处理关键
while (running) {
int n = recv(socket, buffer + offset, sizeof(buffer) - offset, 0);
if (n <= 0) break; // 断开或错误
offset += n;
// 循环拆包,可能多个完整包
while (offset >= sizeof(STRUCT_PACKET_HEADER)) {
auto* header = reinterpret_cast<STRUCT_PACKET_HEADER*>(buffer);
if (header->syncmessage != 0x1234) {
close(socket); return; // 非法连接,直接断开
}
uint32_t totalLen = 8 + header->datalen;
if (offset < totalLen) break; // 半包,等下次 recv
// 完整包,分发处理
processPacket(header);
// 前移剩余数据
memmove(buffer, buffer + totalLen, offset - totalLen);
offset -= totalLen;
}
}
// 连接断开,清理
ConnectionManager::removeClient(connKey);
}
九种 datatype 分支(精简版):
|
命令 |
处理 |
关键动作 |
|
`0xA101` 注册 |
查/分配 hostid,回复,更新 ConnectionManager |
写 host_ids.txt |
|
`0xAf0f` 心跳 |
更新 last_heartbeat,回复 ACK |
检查 pending |
|
`0xA202` 文件 |
6 层安全校验,落盘 recv/{IP}/ |
流量累计 |
|
`0xA301` 命令下发 |
封装 PendingCmd,sendToHostid |
仅 Server→Agent 时 |
|
`0xA303` 结果上报 |
查 meta,构造 JSON,notify Python |
stoi 转换 |
|
`0xA304` 定时上报 |
直接 JSON 转发 Python |
无 meta 查询 |
|
`0xA402` Admin 命令 |
解析,dispatch 入 pending 队列 |
生成 cmd_id |
|
`0xA403` 配置更新 |
查 hostid,sendToHostid 透传 |
原始 buffer 转发 |
七、安全防护:六层文件校验 + 流量封顶
文件传输( 0xA202 )是攻击面最大的点:
cpp
bool validateFileTransfer(const std::string& filename, uint32_t size) {
if (filename.empty()) return false;
if (filename.find("..") != string::npos) return false; // 1. 路径遍历
if (filename[0] == '/' || filename[0] == '\\') return false; // 2. 绝对路径
if (filename.find('/') != string::npos ||
filename.find('\\') != string::npos) return false; // 3. 目录分隔符
if (filename.find('\0') != string::npos) return false; // 4. 截断攻击
if (!isValidChars(filename)) return false; // 5. 字符白名单
if (size > 100 * 1024 * 1024) return false; // 6. 100MB 上限
return true;
}
单连接流量封顶:累计接收 > 500MB 直接断连,防恶意 agent 无限发包。
八、AI介入(kimi/trae/qoder):排查内存泄漏与连接状态机
写 ConnectionManager 时,我遇到一个问题:agent 重连后,旧连接未清理,hostidToConnKey 指向已关闭的 socket,sendToHostid 返回 EBADF。
我自己加了 removeClient 调用,但不确定是否覆盖全路径。把 handleClient 的退出点和 reconnect 场景贴给 Kimi:
"agent 正常断连、网络闪断、进程崩溃、服务器主动关闭,四种场景下,ConnectionManager 的 removeClient 是否都会被调用?有没有遗漏的 fd 泄漏?"
Kimi 帮我梳理了状态机:
正常断连: recv 返回 0 ──► handleClient 退出 ──► removeClient ✓
网络闪断: recv 返回 -1, ECONNRESET ──► break ──► removeClient ✓
进程崩溃: TCP RST ──► recv 返回 -1, ECONNRESET ──► removeClient ✓
服务器主动关闭: close(socket) ──► 但谁调用 removeClient?
发现遗漏:server 主动重启、或某个连接被判定僵尸时, close(socket) 后需要显式调用 removeClient 。当前代码只在 handleClient 退出时清理,如果主线程直接 close 某个连接(比如超时清理),会漏掉。
修复:把 removeClient 封装为 forceDisconnect(connKey) ,统一处理 socket 关闭 + 映射清理。
另一个问题: hostidToConnKey 更新竞态。agent 重连,新连接分配同 hostid,旧连接还没退出。
Kimi 建议:新连接 addClient 时,检查 hostid 是否已存在,若存在先 forceDisconnect 旧连接。这需要 hostIDMapMutex 和 connMutex 的锁顺序,避免死锁。
九、反思:当代码复杂到一定程度,AI 也帮不上忙,怎么办?
我多次借用了AI的力量,AI能搭骨架,但当代码复杂到一定程度,AI也搞不定的时候,该怎么办?
比如:
- 有多种命令字需要处理,每种命令字跨度都很大,这之间是如何协作的?改了一个地方,会不会影响其他地方?
- web层下发了任务,如何全流程标记这个任务?当这个任务删除的时候,又该怎么办?
- host注册失败、或者token模式认证不对,又或者已经退出,或者数据库注册失败,这个协作流程这么长,AI也分析不清楚,怎么办?
总之,思路清晰才是最后的护城河,设计者需要先自己把事情想清楚,然后再指挥AI实现,才有可能搭起来不出错。在这个领域,谨以此文共勉吧。
十、预告
下一篇:《全栈之路5---数据建模与告警实现》,看如何设计表结构、如何触发告警处理。再下一篇收尾 web 呈现。

345

被折叠的 条评论
为什么被折叠?



