目录
[一、为什么需要 CommandServer](#一、为什么需要 CommandServer)
[1. EchoServer 的问题](#1. EchoServer 的问题)
[2. 什么是 CommandServer](#2. 什么是 CommandServer)
[1. 模块划分](#1. 模块划分)
[2. 工作流程](#2. 工作流程)
[三、Command 模块](#三、Command 模块)
[1. 为什么封装 Command](#1. 为什么封装 Command)
[2. popen 执行命令](#2. popen 执行命令)
[3. popen 原理](#3. popen 原理)
[4. Command 接口设计](#4. Command 接口设计)
[四、CommandServer 实现](#四、CommandServer 实现)
[1. 服务器初始化](#1. 服务器初始化)
[2. 业务层编排](#2. 业务层编排)
[3. main.cc 组装](#3. main.cc 组装)
[4. 多线程架构](#4. 多线程架构)
[1. 前置声明](#1. 前置声明)
[2. telnet 连接](#2. telnet 连接)
一、为什么需要 CommandServer
在上一篇博客中,我们利用内核连接队列与单例线程池,手写了一个可以完美应对多端并发冲击的 TCP EchoServer。但冷静下来想一想:只把用户发来的字符串原样回显,在真实的工业生产中有什么用?答案是:几乎没用
网络服务的核心价值在于利用网络传输来驱动业务逻辑 。本篇博客我们将对整个系统进行底层业务维度的升级------打造一个能将客户端指令在服务端落地执行的 TCP CommandServer(远端命令执行服务器)
1. EchoServer 的问题
EchoServer 的底层架构:主线程处理连接请求,线程池异步管理长连接,并发模型已臻完善。然而其业务层的功能实现太过单薄
在这种模式下,TCP 协议栈传输的数据既未被转化为数据库订单,也未转换为硬件控制指令,而是直接被原样返回。这就像一条精心设计的工业流水线,两端连接的却只是重复拆装零件的死循环
在真实的互联网应用中,客户端发来的每一个字节,本质上都是在向服务端下达一种指令或意图:
-
浏览器发来 GET /index.html -> 服务端需要去磁盘读取对应的文件
-
手机 App 发来 Pay Order_ID_1002 -> 服务端需要调用支付网关更新数据库
因此,我们需要给让这个服务器学会去解析并执行这些输入
2. 什么是 CommandServer
CommandServer正是这样一种具备行为能力的架构
它的基本定义是:服务端不再扮演单纯的字节搬运工。当客户端通过 TCP 通道发送过来一个明文字符串(如 "ls -l")时,服务端在接收到该数据后,会将其视作一条 Linux 操作系统命令
接下来,服务端在本地调用操作系统的底层接口,拉起对应的系统命令并捕获其运行结果,最后将这些文本通过 TCP 字节流回传给客户端
我们每天都在使用的 SSH(安全外壳协议) 远程登录终端,或者是 Xshell、SecureCRT 等工具,其底层最核心的业务形态,就是一个高度加密且安全的 CommandServer
通过搭建一个 CommandServer,我们便能够感受到通过自己写的网络代码去隔空操控一台远端 Linux 服务器
二、整体架构
为了实现服务器安全高效执行远程命令的目标,必须避免将网络传输代码与进程控制代码混杂的 "面条式" 编程。通过前文对 TcpServer 进行的 "去业务化 Lambda 回调" 重构,我们能够以更优雅的方式实现这一功能
下面我们从高内聚、低耦合的软件工程视角,来看看 CommandServer 的整体架构布局
1. 模块划分
-
网络引擎层(TcpServer ):
-
职责:纯粹的底层网络 I/O 驱动。它依然只负责 socket、bind、listen 以及在主循环中高效执行 accept 迎接连接
-
特性 :它完全不了解应用层需要执行哪些 Linux 命令。该系统仅持有 client_sockfd 这个文件描述符,当接收到客户端发送的命令字符串时,便会触发外部注入的回调函数,将处理任务转交给上层逻辑
-
-
异步任务并发层(ThreadPool):
-
职责:高并发缓冲地带。负责维护工作线程池,提供任务队列
-
特性:将主线程从耗时的 "命令执行 + 结果等待" 中解放出来,确保有新的客户端连接涌入时,主线程能迅速响应
-
-
命令执行层(Command 业务模块):
-
职责:核心业务大脑。它负责将网络引擎递交过来的纯文本字符串,转化为操作系统能够识别的进程指令
-
特性:它内部不包含任何 Socket 套接字操作,只专注于进程控制、管道 I/O 数据的读取,以及对危险命令的过滤和防御
-
通过这样划分,你会发现,我们之前写的 TcpServer 连一行代码都不需要修改,真正做到了 "对修改关闭,对扩展开放" 的开闭原则。
2. 工作流程
当一个远端的客户端敲击回车发送指令时,数据在服务器内核与用户态线程之间的流转轨迹如下:

-
事件捕获:TcpServer 通过 accept 捕获到连接,随后通过 recv 从特定套接字读取到客户端发来的原始命令文本(例如:"pwd")
-
异步派发:主线程将该文本、客户端地址以及 Command 执行逻辑,就地打包成一个 Lambda 匿名函数任务,塞入单例线程池的任务队列。随后主线程立即回到 accept 处继续等待连接
-
任务处理:线程池中某个休眠的后台工作线程被条件变量唤醒,从队列中竞争捞出这个 Task,进入业务执行阶段
-
进程执行 :工作线程通过 Linux 底层接口在操作系统内核中 fork 出一个子进程执行这条 Linux 命令,并将执行完的屏幕输出,强行重定向重定向进一条内存管道(Pipe)中
-
管道通信:工作线程在内核管道的另一端,像读取普通文件一样 fread 捞取命令结果,并将其拼接成一个长字符串
-
回弹响应:工作线程调用 send,将结果数据返回给客户端。最后,工作线程主动执行 close,释放连接
三、Command 模块
网络开发中有一条铁律:**永远不要信任用户的输入。**既然我们要赋予服务器执行远端命令的能力,我们就必须在网络引擎和操作系统内核之间,筑起一道坚固的防线
这就是我们要独立封装 Command 模块的核心原因
1. 为什么封装 Command
最容易犯的错误就是直接在主循环或者业务回调里写 system("ls -l") 这样的系统调用。这种做法存在两个严重的工程隐患:
-
业务与网络硬耦合:如果哪天我们需要将执行本地命令改为执行远端 SQL 脚本,或者升级安全过滤规则,你就必须深入网络底层去修改代码,极其不符合模块化设计
-
防御性编程:如果用户发送的字符串是 "rm -rf /",而服务器未经任何防护措施直接执行该命令,整个系统将在瞬间被彻底摧毁
-
输出结果 :C 语言经典的 system 函数虽然能执行命令,但它会将结果直接输出到服务器的屏幕上。我们在应用层根本拿不到执行结果,更别提通过 send 发送回远端的客户端了
因此,我们需要一个独立的 Command 模块,它不仅负责执行,更是一个过滤器和数据收集器
2. popen 执行命令
Linux 提供了 popen 和 pclose 这对经典系统接口,既能执行命令,又能捕获原本输出到服务器屏幕的执行结果
cpp
#include <cstdio>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
- command:要执行的 Linux Shell 命令字符串
- type:如果是 "r",代表我们要读取该命令的输出;如果是 "w",代表我们要向该命令灌入输入。在我们的场景中,无一例外都使用 "r"
返回值:一个标准的 FILE* 文件流指针
popen 的巧妙之处在于,它将运行中的 Linux 进程抽象成普通文件。我们可以像使用 fgets 或 fread 读取文本文件那样,逐行获取命令执行的结果
3. popen 原理
为什么调用一个 popen 就能取得子进程的屏幕输出?操作系统在幕后其实悄悄执行了四个经典的系统调用:pipe -> fork -> dup2 -> exec
popen 底层分四个步骤:
pipe:操作系统开辟一块环形缓冲区(管道),创建两个文件描述符:读端和写端
fork:主进程克隆,分裂出一个子进程。子进程继承了这条管道
dup2:子进程在执行命令前,调用 dup2,把自己的标准输出(1号文件描述符)绑定到管道的写端
exec:子进程调用 exec 函数,变成执行具体命令(如 ls)的进程
此时,子进程仍在向屏幕输出内容,但实际上所有输出都被重定向到内核管道中。与此同时,父进程通过 popen 返回的 FILE* 指针,从管道的读取端获取这些数据

4. Command 接口设计
为保持代码的现代感,我们避免直接暴露 C 风格的 FILE*。所有命令安全性检查和进程执行流程处理都被封装在一个简洁的 Command 工具类中:
cpp
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <algorithm>
class Command {
public:
Command() = default;
// 过滤掉极度危险的系统级破坏命令
static bool IsSafe(const std::string& cmd) {
// 定义黑名单词组
static const std::vector<std::string> blacklist = {
"rm", "shutdown", "reboot", "init", "mv", "chmod", "kill", "top"
};
// 转为小写进行模糊匹配
std::string lower_cmd = cmd;
std::transform(lower_cmd.begin(), lower_cmd.end(), lower_cmd.begin(), ::tolower);
for (const auto& bad_word : blacklist) {
// 检测命令中是否包含黑名单关键词
if (lower_cmd.find(bad_word) != std::string::npos) {
return false;
}
}
return true;
}
// 核心命令执行接口:将命令转化为系统回传字符串
static std::string Execute(const std::string& cmd) {
// 1. 安全前置审查
if (!IsSafe(cmd)) {
return "[安全警告] 检测到危险指令! 拒绝执行该操作。\n";
}
// 2. 调用 popen 孵化子进程并绑定内核管道
// 使用 "r" 代表父进程要读取子进程的标准输出
FILE* fp = popen(cmd.c_str(), "r");
if (!fp) {
return "[错误] 系统底层拉起子进程失败。\n";
}
// 3. 循环捞取管道中的字节流
char buffer[1024];
std::string result;
while (fgets(buffer, sizeof(buffer), fp) != nullptr) {
result += buffer;
}
// 4. 关闭管道,回收子进程的退出状态码
int status = pclose(fp);
if (status == -1) {
return "[错误] 进程资源回收异常。\n";
}
// 5. 如果命令执行成功但没有任何屏幕输出
if (result.empty()) {
result = "[系统提示] 指令执行完毕 (无屏幕输出)\n";
}
return result;
}
};
四、CommandServer 实现
明确了架构划分并掌握了 Command 模块的核心机制后,我们开始尝试实现 CommandServer
得益于我们在上一篇博客中对 TcpServer 网络引擎所做的 "全面去业务化" 重构**,** 编写 CommandServer 的过程将不再是枯燥地重写一遍 socket、bind、listen 和 accept**。相反,这更像是一次业务拼积木过程**
1. 服务器初始化
如果一个程序员每换一个业务(比如从回显服务换到远程控制),都要把 Socket 初始化的那几十行 C 语言底层代码重新复制粘贴一遍,那他的工程架构设计显然是失败的
在现代工业级开发中,网络引擎(通信层)应当像一条坚固的公路,而业务逻辑(应用层)则是跑在公路上的各式车辆。公路不需要知道车里装的是红烧肉(Echo)还是远程指令(Command)
因此,我们不需要动此前写的 TcpServer.hpp 的任何一行代码,直接复用它的高并发主循环与单例线程池托管机制。我们唯一要做的,就是编写一个全新的、符合套接字规范的命令执行业务回调函数(Command Service),然后将其注入到网络引擎中
2. 业务层编排
业务层回调编写中存在一个容易被忽视的 "协议细节陷阱":字符清洗
当远端的客户端(如通过 nc 或 telnet)向我们发送命令 "ls -l" 并敲击回车时,通过 TCP 字节流传到服务端的实际字符串往往是 "ls -l\n" 或者是 "ls -l\r\n"。 如果我们不把末尾的换行符清洗掉,直接丢给 Command::Execute(),Linux 系统的 Shell 解析器就会报错,因为它根本找不到一个叫做 "ls -l\n" 的可执行程序
下面我们直接切入核心业务层并优雅地完成字节流读取、命令清洗、指令执行与结果回弹:
cpp
#include "TcpServer.hpp"
#include "Command.hpp"
// 核心应用层业务:远端命令执行会话
void CommandService(int sockfd, const InetAddr& peer)
{
char buffer[1024];
std::cout << " -> 工作线程已对接来自 " <<
peer.PrintStr() << " 的终端会话。" << std::endl;
while (true)
{
// 从 TCP 流式套接字中捞取远端发来的命令文本
ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0) {
buffer[n] = '\0';
std::string orig_cmd = buffer;
// 字符清洗:剔除末尾的回车换行
while (!orig_cmd.empty() &&
(orig_cmd.back() == '\n' || orig_cmd.back() == '\r'))
{
orig_cmd.pop_back();
}
// 过滤掉纯敲回车的空指令
if (orig_cmd.empty()) continue;
// 预留退出指令
if (orig_cmd == "quit" || orig_cmd == "exit") {
std::string goodbye = "再见\n";
send(sockfd, goodbye.c_str(), goodbye.size(), 0);
break;
}
std::cout << "[" << peer.PrintStr() << "] 执行命令: "
<< orig_cmd << std::endl;
// 调用 Command 模块,解体子进程、通过管道安全捕获执行结果
std::string cmd_result = Command::Execute(orig_cmd);
// 将从内核管道中抽上来的文本,全速通过 TCP 字节流打回客户端屏幕
send(sockfd, cmd_result.c_str(), cmd_result.size(), 0);
}
else if (n == 0) {
std::cout << " -> 客户端 " << peer.PrintStr()
<< " 主动断开了控制台连接。" << std::endl;
break;
}
else {
std::cerr << " -> 链路读取发生异常。" << std::endl;
break;
}
}
}
3. main.cc 组装
有了高度解耦的复用组件,我们现在只需要在 main 函数里,像拼积木一样把 CommandService 业务注入到 TcpServer 网络引擎中。这就诞生了我们真正具备行为能力的通用 CommandServer:
cpp
#include "TcpServer.hpp"
#include <memory>
// 业务回调函数的声明
void CommandService(int sockfd, const InetAddr& peer);
int main(int argc, char* argv[]) {
// 默认绑定 8888 端口
uint16_t port = 8888;
if (argc == 2) {
port = std::stoi(argv[1]);
}
std::cout << " TCP CommandServer 启动中... " << std::endl;
// 实例化网络引擎,注入我们刚刚量身定制的 Command 业务回调
std::unique_ptr<TcpServer> server(new TcpServer(port, CommandService));
if (server->Init()) {
// 主线程将深陷 Start() 循环中,全速扮演 accept() 迎宾经理的角色
server->Start();
}
return 0;
}
4. 多线程架构
虽然我们在这一段没有重复贴出 ThreadPool(单例线程池)的代码,但它的宏观运转在这个阶段起到了决定性的作用
如果没有后台的多工作线程支持,当客户端 A 连入并执行一个极为耗时的系统命令(例如 sleep 10)时,整个服务端的单线程执行流就会直接卡死在 Command::Execute() 内部的 fgets 管道死等上。在此期间,整个服务器将无法再接收任何其他人的连接请求
而现在,主线程在 accept 成功后,将上面的整个 CommandService 连同各自独立的 client_sockfd 打包推给了线程池。 每个连入的用户,都会在后台独占一个工作线程的私有栈空间去循环 recv 自己的命令,彼此之间数据隔离、互不干扰。这,就是网络通信引擎在面向对象与高并发设计下的终极魅力
五、程序测试
经过多线程架构与业务逻辑的双重优化,我们的服务器已构建起坚实的防护体系。现在可以正式启动服务,体验亲手编写的网络代码 "远程操控 Linux 内核" 的技术魅力。不过在正式测试前,我们仍需进行一次全面的 "安全审计"
1. 前置声明
在网络安全领域,我们本篇博客所实现的 CommandServer,在黑客领域里有著名的概念------RCE(Remote Code Execution,远端代码执行漏洞 / 远程后门)
我们在 Command::IsSafe() 中简单过滤了 "rm"、"shutdown" 等危险命令,但这种基于字符串黑名单的防御机制在实际攻击面前形同虚设,漏洞百出
**如何完美绕过:**如果黑客想破坏你的服务器,他根本不需要直接发 "rm -rf /"。他只需要利用 Linux Shell 强大的拼接特性,发送如下变形体:
r'm' -r' f' / (利用单引号隐蔽拼接)
a=rm; b=-rf; a b / (利用环境变量定义)
echo "cm0gLXJmIC8=" | base64 -d | sh (将 rm 命令进行 Base64 加密后在内存中解密执行)
如果上述任意一条指令绕过防线并传递给内核的 popen 函数,由于服务器进程通常以 root 权限运行,整台物理服务器或云主机可能立即被彻底清除数据,甚至沦为黑客挖掘加密货币的傀儡机器
本博客介绍的 CommandServer 严禁部署在可被外网直接访问的云服务器上!否则,全球自动化扫描僵尸网络将在 5 分钟内攻破端口并植入恶意程序
在真实的工业生产中,如果需要实现远端命令控制,必须废除这种赤裸裸的明文 TCP 传输模型,转而拥抱 SSH 协议。SSH 在我们的模型之上,加入了更强大的安全措施:
-
非对称加密:全链路字节流高强度加密,防止中间人嗅探和命令篡改
-
严格的权限隔离:即使命令被执行,也是在一个被阉割的、完全与物理机隔离的容器环境内,根本碰不到底层的核心系统文件
2. telnet 连接
现在,让我们在安全的本地回环环境(127.0.0.1)下启动并测试程序
步骤 1:编译并拉起服务
在终端 1 中,执行编译并开启服务器,指定监听 8888 端口:
bash
g++ -std=c++11 main.cc Command.hpp TcpServer.hpp ThreadPool.hpp -lpthread -o command_server
./command_server 8888
服务端冷启动:
bash
TCP CommandServer (远端命令执行服务器) 启动中...
TcpServer: Init success. Listen socket fd: 3
步骤 2:远端客户端接入并下达控制指令
打开终端 2,利用 nc 连入服务器:
bash
nc 127.0.0.1 8888
连入成功后,我们可以在控制台里敲下命令:
bash
pwd
/home/ecs-user/network_code/command_server
uname -a
Linux ecs-instance 5.10.0-60.9.0.v2101.x86_64
whoami
root
我们可以看见,原本属于服务端本机的路径、内核版本、甚至当前的执行权限,全部呈现在了客户端的屏幕上
步骤 3:触发拦截测试
现在我们在客户端尝试发送一条非法指令:
bash
rm -rf test.txt
客户端屏幕弹出报错信息:
bash
[安全警告] 检测到危险指令! 拒绝执行该操作。
回到服务端的控制台,我们会看到清晰的越权意图:
bash
成功获取新连接, 分配临时 fd: 4 来自: 127.0.0.1:53210
-> 工作线程已对接来自 127.0.0.1:53210 的终端会话
[127.0.0.1:53210] 申请执行命令: pwd
[127.0.0.1:53210] 申请执行命令: uname -a
[127.0.0.1:53210] 申请执行命令: rm -rf test.txt
步骤 4:多客户端并发稳定性验证
在终端 2 的会话不退出的情况下,我们再次拉起终端 3,执行:
bash
nc 127.0.0.1 8888
ifconfig
终端 3 仍可即时获取网卡信息,这表明终端 2 的长连接会话(由工作线程 A 处理)完全不影响主线程的迎宾管理器继续向工作线程 B 分发新任务,成功实现了多客户端的高并发远程控制
总结
综上所述,从 TCP Socket API,到 listen、accept、recv、send 等核心接口,再到基于线程池实现的 CommandServer,我们已经迈向了网络服务的真正工程实践阶段
其中,TCP 相比 UDP 最大的不同,不再只是可靠传输,而是连接管理。服务端需要长期维护客户端连接状态,而 accept 返回的新 socket,则真正代表了一条已经建立完成的 TCP 通信连接。与此同时,通过 Command 模块与网络模块的解耦,我们也进一步体会到了现代服务器程序中网络层与业务层之间分工协作的重要性
而更进一步思考会发现,目前我们的服务器仍然存在一个问题:
客户端与服务端之间,实际上仍然只是 "随便发字符串"
也就是说,双方并没有真正意义上的通信协议
但在真实网络环境中,一个成熟的网络程序必须明确:
- 数据如何组织
- 如何区分不同请求
- 如何解决粘包问题
- 如何保证双方正确解析数据
而这些问题,本质上都离不开自定义协议与序列化
在下一篇中,我们将正式开始设计属于自己的应用层协议,进一步理解网络程序中的报文结构、序列化、反序列化以及协议解析机制
