这个设备管理系统是我去年做的一个项目。最开始的想法其实很简单:做一套足够轻、足够直接的 Android 设备远程管理方案,不上太重的架构,也不追求花哨,把“设备接入、状态上报、命令下发、脚本执行”这条链路先完整跑通。一个月前我已经把它开源到了酷安,这次想在博客里认真做一次技术复盘,把它的设计思路和实现细节完整写下来。
这篇文章会重点讲清楚:
- 我当时为什么会这样设计这个系统
- PHP 后台在整个系统里负责什么
- MySQL 是怎么承担命令中转的
- C 语言客户端怎么轮询后台、领取命令并执行脚本
- 一条完整的“后台下命令 → 设备执行脚本”链路是怎么跑起来的
如果你也在做类似的远程控制、设备运维、脚本分发或者轻量 agent 项目,这套实现思路应该会有一些参考价值。它并不复杂,但胜在主链路干净、部署轻、实现直接。
一、我当时想做一个什么样的系统?
这个项目从一开始就不是冲着“做成大而全的平台”去的,我想解决的是一类很具体的问题:
- 设备能不能自己接进来
- 后台能不能知道设备是不是还在线
- 我能不能从后台给设备下发命令
- 设备能不能拿到命令后执行我写好的脚本
如果把需求压缩到本质,其实就是:
做一套轻量级的 Android 设备远程管理系统,重点解决“接入、保活、下发、执行”四件事。
所以最后这套系统的技术栈我也刻意压得比较轻:
- 后端:PHP
- 数据库:MySQL / MariaDB
- 前端:原生 HTML、CSS、JavaScript
- 设备端:C 语言写的常驻程序
- 执行单元:Shell 脚本
- 通信方式:HTTP + JSON + Bearer Token
现在回头看,这个取舍其实挺符合当时的目标:我不是要先把架构做得多高级,而是要先把最核心的一条业务链做完整。
二、整个系统是怎么分层的?
如果把这套系统拆开看,实际上就两个角色:
- Web 管理后台
- 设备端常驻程序
1)Web 管理后台
后台负责的是“控制平面”,主要做这些事:
- 管理员登录和权限控制
- 设备信息管理和状态展示
- 接收设备端请求并更新在线状态
- 下发命令
- 保存和管理脚本
从文件结构来看,核心文件大概是这些:
common.php:配置、数据库连接、公共函数、安全逻辑dashboard.php:主控台,负责设备列表、命令下发、状态展示script_editor.php:编辑单设备脚本common_scripts.php:公共脚本库和批量执行api.php:设备端通信接口
这里还有个小细节,项目文档里写的是 remote_api.php,但源码实际存在并工作的文件是 api.php。这也算是我自己做项目时一个挺真实的“后期遗留”:文档没完全跟上代码改动。所以这篇文章我也尽量都以源码为准来讲。
2)设备端常驻程序
设备端则是一个用 C 写的常驻 agent,它的职责很明确:
- 获取设备信息
- 定时请求后台接口
- 获取后台返回的命令
- 如果命令要求执行脚本,就下载脚本并通过
sh执行
关键代码集中在:脚本/源码.c
也就是说,这套系统不是后台主动推送,而是:
设备主动轮询后台,后台按需返回命令。
这是我当时刻意选的一种轻实现方式。原因也很简单:如果设备大多在复杂网络环境里,让后台去主动连设备会麻烦很多;反过来让设备主动回连,整个系统会简单得多。
三、数据库在系统里扮演什么角色?
虽然系统整体不重,但数据库是绝对的中枢。主要表包括:
users:管理员账户devices:设备主表device_commands:待执行命令logs:系统日志login_attempts:登录尝试记录api_rate_limits:接口限流记录
其中最关键的是两张表:
1)devices:设备主表
这张表记录每台设备当前的主要状态,例如:
device_iduser_idsnmodelandroid_versionlast_seencommand_statusis_deletedfirst_seen
你在后台看到的设备状态,基本都来自这张表。
2)device_commands:命令中转层
这张表是整套系统的命令队列。管理员不会直接和设备建立长连接,而是通过数据库把命令暂存起来,等设备下一轮轮询时领取。
它的工作方式非常简单:
- 后台把命令写进去
- 设备来请求接口
- 后台把最新命令返回给设备
- 返回后删除这条命令
本质上,这是一个非常轻量的:
数据库队列 + 客户端轮询消费
没有用什么复杂中间件,但在这个项目里完全够用了。
四、后台是怎么把命令下发到设备的?
后台命令下发的核心逻辑在 dashboard.php 的 set_command()。
它主要做这些事:
- 拿到设备 ID 和命令内容
- 校验当前用户是否有权限操作该设备
- 把命令写入
device_commands - 同步更新
devices.command_status - 写日志
核心代码如下:
$stmt = $db->prepare('INSERT INTO device_commands (device_id, command, timestamp)
VALUES (:device_id, :command, :timestamp)');
$stmt->bindValue(':device_id', $device_id, PDO::PARAM_STR);
$stmt->bindValue(':command', $command, PDO::PARAM_STR);
$stmt->bindValue(':timestamp', time(), PDO::PARAM_INT);
$stmt->execute();
紧接着,它会根据命令内容更新设备状态:
if (strpos($command, 'run_script:') === 0) {
$script_name = substr($command, 11);
$status = "待执行$script_name";
} elseif ($command === 'stop') {
$status = '未执行命令';
}
这样设计的好处是,管理员在后台点击按钮之后,不只是命令进了队列,后台界面也会立刻看到状态变化。
五、Shell 脚本和命令是怎么关联起来的?
这个地方是我当时比较在意的一点:我不想把整段 Shell 脚本直接塞进命令字段里,而是把脚本文件和命令本身拆开。
脚本最终是这样保存的:
$script_file = $script_dir . $post_device_id . '.sh';
也就是说,服务端脚本最终会落到:
scripts/<device_id>.sh
而数据库里真正写入的命令只是:
run_script:<device_id>.sh
例如在 common_scripts.php 中就是这样写的:
$stmt->bindValue(':command', 'run_script:' . $device_id . '.sh', PDO::PARAM_STR);
也就是说,系统在传达的是这样一种意思:
设备去服务器上的 scripts 目录里,把指定脚本下载下来,再本地执行。
这种设计非常适合这类项目,因为:
- 命令字段足够轻
- 脚本可以独立维护
- 脚本内容可以覆盖和复用
- 批量执行也更容易做
六、设备通信接口 api.php 是怎么工作的?
设备和后台之间的通信入口是 api.php。它承担三件事:
- 校验设备凭证
- 处理设备注册 / 心跳更新
- 返回待执行命令
1)Bearer Token 认证
设备端请求头里必须带:
Authorization: Bearer <YOUR_API_KEY>
后台会在 verify_api_key() 中做解析:
if (!preg_match('/Bearer\s+(\S+)/', $auth_header, $matches)) {
http_response_code(401);
die(json_encode(['error' => 'Unauthorized']));
}
然后去数据库里把 API Key 找出来进行比对:
$stmt = $db->prepare('SELECT id, api_key FROM users');
$stmt->execute();
while ($user = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (hash_equals($user['api_key'], $token)) {
return $user['id'];
}
}
这说明当前的凭证模型是:设备归属于某个用户的 API Key,而不是每台设备单独一套 token。
2)设备注册与心跳
设备请求体一般包含:
{
"sn": "设备序列号",
"model": "设备型号",
"android_version": "Android版本",
"device_id": "设备唯一ID",
"timestamp": 1730000000
}
后台通过 log_device_data() 处理这些数据。
如果设备已存在,就刷新信息和 last_seen;如果设备不存在,就自动插入一条新记录。这意味着整个系统天生支持:
首次接入即注册,后续请求即心跳。
其中有一段逻辑我觉得很值得提一下:
if (strpos($device['command_status'], '待执行') !== false) {
$new_status = str_replace('待执行', '已执行', $device['command_status']);
}
这里的“已执行”并不严格等于“脚本执行成功”,更像是:设备已经重新回连,后台默认认为这条命令已经被设备领取并处理过了。
3)返回并消费命令
接口最后会查找这台设备的待执行命令:
$stmt = $db->prepare('SELECT command FROM device_commands
WHERE device_id = :device_id
ORDER BY timestamp DESC LIMIT 1');
如果取到了命令,就会马上删除:
$delStmt = $db->prepare('DELETE FROM device_commands WHERE device_id = :device_id');
$delStmt->execute();
然后返回给设备:
echo json_encode(['command' => $command ? $command : '']);
这就是一个典型的:
取出命令 → 返回命令 → 删除命令
的“一次性消费”模型。
七、C 语言客户端是怎么轮询后台的?
设备端常驻程序写在 脚本/源码.c 中。启动前,它依赖三个核心配置:
#define API_KEY "你的后台api密钥"
#define SERVER_URL "http://你的后台地址/api.php"
#define SCRIPT_BASE_URL "http://你的后台地址/scripts/"
也就是说,客户端至少得知道:
- 后台接口地址
- API Key
- 脚本下载地址
1)设备信息采集
客户端会直接执行系统命令来读取 Android 设备信息:
sn = exec_command("getprop ro.serialno");
model = exec_command("getprop ro.product.model");
android_version = exec_command("getprop ro.build.version.release");
device_id = exec_command("settings get secure android_id");
然后把这些内容拼成 JSON,再发给后台。
这类实现方式很“接地气”:不依赖复杂 SDK,完全靠系统命令就把设备信息采上来了。
2)通过 libcurl 发 POST 请求
请求发送逻辑在 send_data() 中:
headers = curl_slist_append(headers, "Content-Type: application/json");
snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", API_KEY);
headers = curl_slist_append(headers, auth_header);
curl_easy_setopt(curl, CURLOPT_URL, SERVER_URL);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_data);
换句话说,设备端每一轮其实只做三件事:
- 采集设备信息
- 把信息 POST 给后台
- 等待后台返回命令
3)解析后台返回
这里我当时没有引入完整 JSON 解析库,而是直接做字符串查找:
const char* command_key = "\"command\":\"";
char* command_start = strstr(json_response, command_key);
这种方式实现简单、依赖少,但确实会更依赖返回格式的稳定性。对于这个项目来说够用了,但如果往更成熟的产品方向走,这里还是值得升级。
八、设备端是怎么执行脚本的?
在 process_command() 里,客户端当前主要识别两类命令:
if (strstr(command, "run_script:") == command) {
char* script_name = (char*)(command + strlen("run_script:"));
execute_script(script_name);
} else if (strcmp(command, "stop") == 0) {
// 目前基本为空实现
}
也就是说,当前这套系统真正稳定可用的核心能力,就是:远程下载并执行指定 Shell 脚本。
execute_script() 的执行链路大概是这样:
- 拼接脚本下载 URL
- 把脚本下载到
/data/local/tmp - 检查文件大小是否正常
- 赋予执行权限
- 调用
sh执行 - 删除临时文件
例如拼接脚本地址:
snprintf(script_url, sizeof(script_url), "%s%s", SCRIPT_BASE_URL, script_name);
执行脚本:
snprintf(execute_cmd, sizeof(execute_cmd), "sh \"%s\" >/dev/null 2>&1", tmp_script);
system(execute_cmd);
如果第一次执行失败,还会尝试切换到临时目录再执行一次。
所以设备端的核心动作就是:
拿到命令 → 下载脚本 → 赋权 → 调用 Shell → 清理临时文件。
九、把整条链路串起来:一次“执行脚本”到底发生了什么?
如果把整套系统的关键路径压缩成一条完整流程,大概是这样:
- 我在后台写好脚本
- 后台把脚本保存到
scripts/<device_id>.sh - 我点击“执行脚本”
- 后台往
device_commands写入run_script:<device_id>.sh - 设备下一轮轮询
api.php - 后台返回命令并删除这条命令
- 设备解析命令,下载脚本
- 设备本地通过
sh执行脚本 - 设备再次回连后,后台把状态从“待执行”更新成“已执行”
这条主链路可以概括成一句话:
后台写命令,设备来领取;后台提供脚本,设备下载并执行。
现在回头看,我觉得这个项目最成功的地方,不是设计有多复杂,而是这条链真的很顺。
十、这个系统的请求—响应模型有什么特点?
因为采用的是轮询模型,所以它有几个很明显的特征:
- 请求由设备主动发起
- 后台响应内容极简,核心就是
command字段 - 命令是一旦领取就删除的一次性消费模型
这样做的好处是实现简单、依赖轻、部署门槛低;代价则是任务回执和失败重试机制不够完整。
十一、这套系统做了哪些安全措施?
从代码来看,基础安全措施其实是有的:
- 管理员密码哈希(bcrypt)
- CSRF 防护
- SQL 预处理
- XSS 输出转义
- 安全响应头
- API 限流
- 登录安全控制
但也有比较明显的局限:
- 设备认证是静态 API Key,粒度比较粗
- 客户端没有严格 JSON 解析
- “已执行”不等于真正执行成功
- 设备端 Shell 执行权限较高
所以从安全定位上看,这套系统更适合:
可控环境下的内部设备管理或实验性部署场景。
十二、设备端有一个我自己挺满意的设计:失败退避
虽然客户端整体逻辑不重,但我当时还是加了一套失败退避机制。源码里定义了:
#define BASE_INTERVAL 30
#define MAX_INTERVAL 300
#define BACKOFF_FACTOR 1.5
#define RESET_AFTER_SUCCESS 5
这意味着:
- 默认每 30 秒轮询一次
- 如果连续失败,轮询间隔逐渐增大
- 最大退避到 300 秒
- 连续成功若干次后恢复基础频率
这套逻辑虽然不复杂,但实际很有用:后台异常时不会被设备疯狂轰接口,网络不稳定时也不会产生大量无意义重试。
十三、为什么我觉得这个系统值得写出来?
原因其实很简单:它代表了一类很典型、很现实的工程问题。
很多系统第一版并不需要把架构堆满,而是先把最短闭环做完整。这个项目就是一个很典型的例子。它没有上复杂组件,但已经把这些事做通了:
- 用户认证
- 设备注册
- 在线状态维护
- 命令队列
- 脚本编辑
- 脚本下载执行
- 基础日志
很多时候,一个系统真正难的不是“技术选型”,而是:
你能不能把最关键的那条路径稳稳跑通。
而这个项目的主链路非常明确:
写脚本 → 下命令 → 设备轮询接口 → 设备领取命令 → 下载脚本 → 执行脚本
只要这一条链跑通,系统就已经具备很强的实用价值。
十四、如果以后继续迭代,我会优先改什么?
如果未来继续往更成熟的方向升级,我觉得优先级最高的点有这些:
- 把任务状态机做完整
- 增加执行结果和日志回传
- 每台设备独立凭证
- 增加脚本签名与完整性校验
- 把命令协议做得更规范
- 如果场景需要,再从轮询升级到更实时的通信方式
不过站在这套系统原本的目标上看,我依然觉得它已经完成了最重要的事:用很轻的架构,把设备控制这条主链路真正做通了。
结语
这个项目是我去年做的,一个月前刚在酷安开源。现在回头看,它当然还有很多可以继续打磨的地方,但我依然很喜欢它的一点:它不复杂,却把核心问题解决得很直接。
如果你也在做类似的系统,希望这篇复盘能给你一点参考。尤其是当你面对一个项目,纠结要不要一开始就上很重的架构时,也许可以先问自己一句:
我是不是可以先把最关键的那条业务链做通?
对很多系统来说,答案往往是:可以,而且这样反而更稳。
最后一句话总结:这套系统的核心原理就是——PHP 后台负责管理设备、保存命令和脚本;C 客户端定时向后台发请求,领取命令后再去下载并执行对应的 Shell 脚本。

