一个轻量级设备管理系统是如何工作的:从 PHP 后台到 C 客户端的完整链路解析
本文最后更新于42 天前,其中的信息可能已经过时,如有错误请发送邮件到2177367423@qq.com

这个设备管理系统是我去年做的一个项目。最开始的想法其实很简单:做一套足够轻、足够直接的 Android 设备远程管理方案,不上太重的架构,也不追求花哨,把“设备接入、状态上报、命令下发、脚本执行”这条链路先完整跑通。一个月前我已经把它开源到了酷安,这次想在博客里认真做一次技术复盘,把它的设计思路和实现细节完整写下来。

这篇文章会重点讲清楚:

  • 我当时为什么会这样设计这个系统
  • PHP 后台在整个系统里负责什么
  • MySQL 是怎么承担命令中转的
  • C 语言客户端怎么轮询后台、领取命令并执行脚本
  • 一条完整的“后台下命令 → 设备执行脚本”链路是怎么跑起来的

如果你也在做类似的远程控制、设备运维、脚本分发或者轻量 agent 项目,这套实现思路应该会有一些参考价值。它并不复杂,但胜在主链路干净、部署轻、实现直接。


一、我当时想做一个什么样的系统?

这个项目从一开始就不是冲着“做成大而全的平台”去的,我想解决的是一类很具体的问题:

  • 设备能不能自己接进来
  • 后台能不能知道设备是不是还在线
  • 我能不能从后台给设备下发命令
  • 设备能不能拿到命令后执行我写好的脚本

如果把需求压缩到本质,其实就是:

做一套轻量级的 Android 设备远程管理系统,重点解决“接入、保活、下发、执行”四件事。

所以最后这套系统的技术栈我也刻意压得比较轻:

  • 后端:PHP
  • 数据库:MySQL / MariaDB
  • 前端:原生 HTML、CSS、JavaScript
  • 设备端:C 语言写的常驻程序
  • 执行单元:Shell 脚本
  • 通信方式:HTTP + JSON + Bearer Token

现在回头看,这个取舍其实挺符合当时的目标:我不是要先把架构做得多高级,而是要先把最核心的一条业务链做完整。

二、整个系统是怎么分层的?

如果把这套系统拆开看,实际上就两个角色:

  1. Web 管理后台
  2. 设备端常驻程序

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_id
  • user_id
  • sn
  • model
  • android_version
  • last_seen
  • command_status
  • is_deleted
  • first_seen

你在后台看到的设备状态,基本都来自这张表。

2)device_commands:命令中转层

这张表是整套系统的命令队列。管理员不会直接和设备建立长连接,而是通过数据库把命令暂存起来,等设备下一轮轮询时领取。

它的工作方式非常简单:

  1. 后台把命令写进去
  2. 设备来请求接口
  3. 后台把最新命令返回给设备
  4. 返回后删除这条命令

本质上,这是一个非常轻量的:

数据库队列 + 客户端轮询消费

没有用什么复杂中间件,但在这个项目里完全够用了。

四、后台是怎么把命令下发到设备的?

后台命令下发的核心逻辑在 dashboard.phpset_command()

它主要做这些事:

  1. 拿到设备 ID 和命令内容
  2. 校验当前用户是否有权限操作该设备
  3. 把命令写入 device_commands
  4. 同步更新 devices.command_status
  5. 写日志

核心代码如下:

$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);

换句话说,设备端每一轮其实只做三件事:

  1. 采集设备信息
  2. 把信息 POST 给后台
  3. 等待后台返回命令

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() 的执行链路大概是这样:

  1. 拼接脚本下载 URL
  2. 把脚本下载到 /data/local/tmp
  3. 检查文件大小是否正常
  4. 赋予执行权限
  5. 调用 sh 执行
  6. 删除临时文件

例如拼接脚本地址:

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 → 清理临时文件。

九、把整条链路串起来:一次“执行脚本”到底发生了什么?

如果把整套系统的关键路径压缩成一条完整流程,大概是这样:

  1. 我在后台写好脚本
  2. 后台把脚本保存到 scripts/<device_id>.sh
  3. 我点击“执行脚本”
  4. 后台往 device_commands 写入 run_script:<device_id>.sh
  5. 设备下一轮轮询 api.php
  6. 后台返回命令并删除这条命令
  7. 设备解析命令,下载脚本
  8. 设备本地通过 sh 执行脚本
  9. 设备再次回连后,后台把状态从“待执行”更新成“已执行”

这条主链路可以概括成一句话:

后台写命令,设备来领取;后台提供脚本,设备下载并执行。

现在回头看,我觉得这个项目最成功的地方,不是设计有多复杂,而是这条链真的很顺。

十、这个系统的请求—响应模型有什么特点?

因为采用的是轮询模型,所以它有几个很明显的特征:

  • 请求由设备主动发起
  • 后台响应内容极简,核心就是 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 脚本。

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇