前情提要
上一篇我们解决了"能不能通"的问题:用 C++ Socket 自建通信层,替代了 SFTP 定时轮询,实现了秒级上报。但通信层一旦跑通,紧接着就要回答两个问题:怎么保证安全?怎么做到可扩展?
这篇我们把架构定下来。
一、安全:Server 是唯一的挡板
既然数据库不直接暴露,那 Server 的监听端口就成了唯一的攻击面。我们不能只依赖防火墙,必须在协议层做几道关卡。
1.1 两道连接门槛
模式 | 说明 | 适用场景 |
开放模式 | 符合协议格式即可连,只校验魔数和帧长 | 内网可信环境,快速部署 |
授权模式 | 需携带有效 Token 才能完成注册 | 生产环境,默认开启 |
Client 首次连接时,先走 REGISTER 帧。若 Server 配置为授权模式,帧载荷必须包含 Token,否则直接断连。
1.2 Token 的传输:用 RSA 做端到端加密
- Token 不能明文传。我们在部署阶段预置密钥对:
- Server:持有私钥,用于解密 Client 上报的 Token
- Client:持有公钥,用于加密本地 Token
Client: Token(明文) --[RSA公钥加密]--> Token(密文) --传输--> Server --[RSA私钥解密]--> 校验
这样即使抓包,拿到的也只是密文。Token 本身定期由 Admin 统一轮换。
1.3 连接保持:SessionID + Seq
Token 只验一次,后续靠会话机制防重放和劫持:
- SessionID:注册成功后 Server 下发,32 字节随机串,后续每帧必带
- Seq:每帧自增,Server 校验连续性,跳号或复用直接断连
1.4 文件传输的安全边界
Client 支持文件上报(如日志、dump),但必须设死几条红线:
cpp
// 文件名安全检查示例
bool ValidateFilename(const std::string& name) {
if (name.empty() || name.size() > 128) return false;
if (name[0] == '/' || name[0] == '\\') return false; // 拒绝绝对路径
if (name.find("..") != std::string::npos) return false; // 拒绝路径穿越
if (name.find('\0') != std::string::npos) return false; // 拒绝截断
return true;}
// 文件大小检查if (file_size > MAX_FILE_SIZE) { // 例如 50MB
return ERR_FILE_TOO_LARGE;}
原则:通信层只认"文件名"和"二进制流",不认路径。Server 端按 session_id + timestamp + filename 落盘,杜绝 Client 指定存储位置。
二、扩展性:通信层不动,应用层随便改
安全有了,但如果每次加一张表、加一个功能都要改 C++ 通信代码,这架构就是死的。我们要做到通信层一次写死,业务层随意扩展。
2.1 Client 端:指令转 Shell
Client 本地维护一个指令表。Server 下发指令时,Client 不做业务处理,而是调用 Shell完成:
cpp
// Client 指令处理极简逻辑
if (frame.type == CMD_EXEC) {
std::string cmd = ParseCmdFromPayload(frame.payload);
// 白名单校验(可选)
if (Whitelist::Contains(cmd)) {
system(cmd.c_str()); // 或 popen 捕获输出
}}
新增采集项?不用改 Client。Server 下发一条新指令即可。
2.2 Client 端:文件传输独立封装
文件传输走独立帧类型,与指令、数据上报解耦:
plain
FRAME_TYPE_FILE_START // 通知文件名、大小、MD5
FRAME_TYPE_FILE_CHUNK // 传输分片
FRAME_TYPE_FILE_END // 校验完成
应用层想传文件,就调用封装好的接口,与定时任务上报互不干扰。
2.3 Server 端:通信与入库解耦
Server(C++)只负责三件事:收数据、鉴权、转发。它绝不直接连数据库。
Server 收到数据后,通过本地 IPC(Unix Domain Socket 或 TCP 回环)丢给DB Service(Python 服务):
[Server: C++] -->(protobuf/json)--> [DB Service: Python] --> [MySQL/TDengine]
好处显而易见:
- C++ 专注高并发网络,Python 专注数据建模和入库
- 加新表?改 Python 的 ORM,C++ 不用动
- DB Service 崩溃?Server 把数据暂存本地队列,DB Service 重启后继续消费
2.4 管理端:独立的 Admin Client
不把管理功能做进 Web Server 进程,而是单独做一个Admin Client:
- Web 后台下发管理指令(如:更新 Token、下发脚本、查询 Agent 状态)
- Admin Client 作为特殊 Client,连到 Server,拥有更高权限的指令集
- Server 对 Admin Client 的帧做额外标记校验(如 FLAG_ADMIN = 0x80)
这样 Web 层完全无状态,重启不影响已建立的 Agent 连接。
三、两条链路
以上设计落地后,系统形成两条清晰的链路:
上行链路(数据上报)
Shell/采集脚本
--> zmAgent(Client)
--> zmServer(C++)
-->db_service(Python)
--> Database
--> WebServer
--> 前端展示
下行链路(指令下发)
Web 管理台
--> WebServer
--> zmAdmin(Admin Client)
--> zmServer(C++)
--> zmAgent(Client)
--> Shell 执行
--> 结果返回(可选走上行链路回传)
Server 是枢纽,但只做转发,不碰业务逻辑。
整体架构示意图如下:
四、协议头定义(示例版)
结合安全和扩展需求,协议头定死为 12 字节固定头 + 变长载荷:
cpp
#pragma pack(push, 1)
struct ProtocolHeader {
uint8_t magic; // 0x5A 'Z'
uint8_t version; // 0x01
uint8_t type; // 帧类型
uint8_t flags; // 标志位
uint16_t length; // 载荷长度,大端
uint32_t seq; // 序列号,大端
uint32_t session; // 会话标识(注册前为0)
};
#pragma pack(pop)
flags 位定义:
表格
位 | 含义 |
0x01 | 是否加密(RSA/AES) |
0x02 | 是否压缩(zlib) |
0x04 | 是否分片(大文件/大数据) |
0x80 | 是否管理帧(Admin Client 专属) |
帧类型(部分):
cpp
enum FrameType : uint8_t {
HEARTBEAT = 0x01,
REGISTER = 0x02,
REGISTER_ACK= 0x03,
REPORT = 0x10, // 指标上报
CMD_EXEC = 0x20, // 指令下发
CMD_RESULT = 0x21, // 指令结果
FILE_START = 0x30,
FILE_CHUNK = 0x31,
FILE_END = 0x32,
ADMIN_CMD = 0x40 // 管理指令
};
魔数 + 版本 + 类型 + 标志 + 长度 + Seq + Session,12 字节。Server 收包时先读 12 字节,校验魔数和长度,再读载荷。粘包、错包一眼识别。
五、小结
这一篇我们把骨架搭好了:
- 安全:Token + RSA 防窃听,Session + Seq 防重放,文件名白名单防穿越
- 扩展:Client 转 Shell、文件传输独立接口封装、Server 与入库解耦、Admin 独立
- 链路:上行采集,下行管控,Server 只做枢纽
- 协议:12 字节定长头,支持加密、压缩、分片、管理标记
下一篇:《全栈之路3---zmagent 开发实现》,我们开始写代码,先把 Client 端跑起来。