全栈之路4---zmserver 开发实现

前情提要

上一篇我们看完了 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也搞不定的时候,该怎么办?

比如:

  1. 有多种命令字需要处理,每种命令字跨度都很大,这之间是如何协作的?改了一个地方,会不会影响其他地方?
  2. web层下发了任务,如何全流程标记这个任务?当这个任务删除的时候,又该怎么办?
  3. host注册失败、或者token模式认证不对,又或者已经退出,或者数据库注册失败,这个协作流程这么长,AI也分析不清楚,怎么办?

总之,思路清晰才是最后的护城河,设计者需要先自己把事情想清楚,然后再指挥AI实现,才有可能搭起来不出错。在这个领域,谨以此文共勉吧。

十、预告

下一篇:《全栈之路5---数据建模与告警实现》,看如何设计表结构、如何触发告警处理。再下一篇收尾 web 呈现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值