电脑搁家里,人经常不在跟前。有时候就想看看它是不是还活着,进程有没有抽风,实在不行远程给它关个机。
市面上的远程控制软件我装过,功能是猛,但每次打开都要登录,还要忍受各种广告弹窗,慢,还重。我寻思就这点需求,犯得着为它掏钱包、被广告糊脸吗?
算了,自己写一个吧。
本项目是一套纯局域网、轻量化、高安全的手机遥控电脑方案,全程仅依赖内网 WiFi 通信,不依赖任何公网隧道、不提供中转服务、不暴露公网端口,所有交互封闭在本地局域网内,极大降低入侵风险。
整体采用C/S架构,Android作为客户端,Windows平台运行Node.js作为服务端,底层基于WebSocket长连接双向通信。客户端APK不硬编码任何密钥、Token。项目主动阉割高危能力,不实现鼠标操控、剪贴板劫持、锁屏解锁,只保留监控查看以及可控开关机能力。
业务处理流程

白名单运维流程

系统分层
| 层级 | 模块 | 主要职责 |
|---|---|---|
| 客户端 | Android APP | 界面渲染、组装请求指令,无密钥硬编码 |
| 接入层 | WebSocket连接管理 | 连接管理、心跳保活、消息接收分发 |
| 安全层 | 设备鉴权模块 | 读取本地白名单、设备序列号校验、时间戳防重放 |
| 调度层 | 消息协议处理 | 统一JSON编解码、指令路由、统一返回格式 |
| 执行层 | PowerShell脚本集合 | 采集系统信息、查询进程、截图、文件遍历、开关机 |
| 辅助模块 | QQ通知Worker | 本地文件队列,异步推送告警消息,不阻塞主循环 |
| 本地运维工具 | auth.ps1、deploy.ps1 | 设备授权拉黑、一键部署、注册Windows服务 |
核心模块实现
设备鉴权模块(安全核心)
鉴权的核心思路:whitelist.json白名单文件只能本机脚本读写,WebSocket接口不能新增修改设备。手机连接上报设备序列号,服务端读取本地文件做校验。修改白名单之后不需要重启服务,设备下次重连即可生效。
这里有一个PowerShell很容易踩的编码坑:如果输出JSON带UTF‑8 BOM,Node的JSON.parse会直接解析报错。ps1脚本保存为UTF‑8‑BOM,但是输出存储的JSON文件,必须使用无BOM的UTF‑8编码。
php
function Load-WL { Get-Content $wlFile -Raw -Encoding UTF8 | ConvertFrom-Json }
function Save-WL($wl) {
$json = $wl | ConvertTo-Json -Depth 6
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($wlFile, $json, $utf8NoBom)
}
Load‑WL读取磁盘白名单,Save‑WL把对象写回磁盘,强制无BOM编码。授权设备时组装设备信息对象,包含序列号、别名、型号、允许标记、添加时间,操作对象属性完成后调用保存函数落地文件。
bash
$val = @{ serial = $serial; name = $name; model = $model; allow = $true; addedAt = (Get-Date -Format 'yyyy‑MM‑dd HH:mm:ss') }
if ($wl.devices.PSObject.Properties[$serial]) {
$wl.devices.PSObject.Properties[$serial].Value = $val
}else{
$wl.devices | Add‑Member -NotePropertyName $serial -NotePropertyValue $val
}
Save-WL $wl
白名单存储样例:
json
{
"devices": {
"device_serial_xxxx": {
"serial":"device_serial_xxxx",
"name":"我的小米手机",
"model":"Xiaomi 14",
"allow":true,
"addedAt":"2026‑08‑20 10:20:00"
}
}
}
Node服务收到每一条WebSocket消息都要执行鉴权,除校验白名单allow字段,增加客户端时间戳校验用来防御重放攻击,抓包得到的旧指令时间偏差过大直接拒绝。
javascript
function authDevice(serial, ts) {
const now = Date.now();
if(Math.abs(ts - now) > 30*1000){
return {ok:false, msg:"timestamp invalid, replay reject"};
}
const wl = JSON.parse(fs.readFileSync("./whitelist.json","utf‑8"));
const dev = wl.devices[serial];
if(!dev) return {ok:false, msg:"device not found"};
if(!dev.allow) return {ok:false, msg:"device rejected"};
return {ok:true};
}
鉴权逻辑放在消息处理最前面,所有业务指令先过鉴权,鉴权失败直接返回403,不会向下执行业务分发。
ini
ws.on("message",(raw)=>{
const payload = JSON.parse(raw);
const authRes = authDevice(payload.serial, payload.ts);
if(!authRes.ok){
ws.send(JSON.stringify({code:403,msg:authRes.msg,data:null}));
return;
}
dispatchCmd(payload.cmd, payload.data, ws);
})
未授权设备可以建立WebSocket连接,但是全部业务请求被拦截,仅返回待授权提示,APP据此提示用户回到电脑本地执行授权脚本。
PowerShell子进程调用设计,解决注入与乱码问题
早期踩坑:直接在JS代码内部拼接powershell命令字符串,遇到中文、空格、特殊符号就会乱码,同时存在命令注入风险。
最终方案:业务逻辑全部写独立.ps1脚本,Node只调用脚本文件,不在JS层拼接业务参数。系统信息采集脚本为了适配APP高频轮询,放弃性能较差的Get‑CimInstance,改用WMI一次性读取系统指标。
ini
$os = ([wmi]"\.\root\cimv2:Win32_OperatingSystem=@")
$cs = ([wmi]"\.\root\cimv2:Win32_ComputerSystem=@")
$cpuLoad = (Get‑WmiObject -Class Win32_Processor | Measure‑Object -Property LoadPercentage -Average).Average
$obj = @{
ok = $true
cpuLoad = [math]::Round($cpuLoad, 1)
totalMem = [long]$cs.TotalPhysicalMemory
freeMem = [long]$os.FreePhysicalMemory * 1KB
upDays = [math]::Floor($uptime.TotalDays)
disks = $disks
}
$obj | ConvertTo‑Json -Depth 4 -Compress
脚本内部完成查询,输出压缩JSON到标准输出,Node使用execFile调用脚本,不会拼接任何业务参数。
javascript
async function getSysInfo(){
return new Promise((resolve,reject)=>{
execFile("powershell.exe",[
"-NoProfile","‑ExecutionPolicy","Bypass",
"-File","./sysinfo_fast.ps1"
],{encoding:"utf‑8"},(err,stdout)=>{
if(err) return reject(err);
const data = JSON.parse(stdout.trim());
resolve(data);
})
})
}
截图常驻进程优化
如果每次截图都新建PowerShell进程,进程创建销毁开销高,连续截图延迟大。采用常驻进程方案:Node只拉起一次powershell,脚本循环等待stdin输入;Node往stdin写入换行就触发截图,截图完成输出base64图片字符串到stdout。
PowerShell核心循环:
php
while ($true) {
$line = [Console]::In.ReadLine()
if ($null -eq $line) { break }
$s = Get‑ScreenShotBase64
[Console]::Out.WriteLine($s)
[Console]::Out.Flush()
}
Node通过spawn拉起常驻进程,复用进程管道交互,避免反复创建子进程。
php
const cp = spawn("powershell.exe",["‑NoProfile","‑ExecutionPolicy","Bypass","‑File","./screenshot.ps1"]);
function triggerScreenshot(){
cp.stdin.write("\n");
}
cp.stdout.on("data",buf=>{
const base64Img = buf.toString().trim();
ws.send(JSON.stringify({code:200,cmd:"screenshot",data:{img:base64Img}}))
})
QQ通知异步队列解耦
WebSocket回调中不能执行同步网络IO,QQ消息发送一旦发生阻塞,会卡住Node整个事件循环,全部客户端受影响。
实现方式:业务不直接发送消息,通知内容写入本地json队列文件;独立qq_worker.js扫描队列目录逐个发送。为规避powershell命令行引号转义bug,消息文本写入临时文件,ps脚本读取文件而不是读取命令行参数。
业务只写入队列,不阻塞WebSocket:
javascript
function pushQQNotify(text){
const filename = `./qq_queue/${Date.now()}.json`;
fs.writeFileSync(filename,JSON.stringify({text}),"utf‑8");
}
worker处理队列片段:
ini
const files = fs.readdirSync(DIR).filter(f => f.endsWith('.json'));
for (const f of files) {
const fp = path.join(DIR, f);
const payload = JSON.parse(fs.readFileSync(fp, 'utf8'));
const msgFile = path.join(os.tmpdir(), 'rc_qq_msg_' + Date.now() + '.txt');
fs.writeFileSync(msgFile, payload.text, 'utf8');
execFileSync(PS1, ['‑File', SEND_PS1, '‑MessageFile', msgFile]);
fs.unlinkSync(fp);
}
deploy.ps1部署关键逻辑
部署脚本完成环境校验、依赖安装、配置初始化,支持后台隐藏运行和NSSM注册Windows服务两种模式。
白名单文件不存在的时候生成初始模板,同样使用无BOM UTF‑8,防止Node解析报错。
ruby
$wlFile = Join‑Path $ServerDir 'server\whitelist.json'
if (-not (Test‑Path $wlFile)) {
$tpl = '{"devices":{}}'
[System.IO.File]::WriteAllText($wlFile, $tpl, [System.Text.UTF8Encoding]::new($false))
}
启动Node进程设置CreateNoWindow = $true实现后台无黑窗口运行,配置全部通过环境变量注入,不硬编码配置文件。
ini
$psi = New‑Object System.Diagnostics.ProcessStartInfo
$psi.FileName = (Get‑Command node).Source
$psi.Arguments = '"' + $ServerJs + '"'
$psi.CreateNoWindow = $true
$psi.EnvironmentVariables['RC_PORT'] = "$Port"
$p = [System.Diagnostics.Process]::Start($psi)
工程踩坑总结
- PowerShell编码区分两套:
.ps1脚本保存为UTF‑8‑BOM;输出给Node读取的JSON文件必须无BOM UTF‑8,混用直接解析报错。 - JS层禁止拼接shell命令字符串,全部调用独立脚本文件,从根源规避Shell注入漏洞。
- 高频操作尽量复用常驻子进程,截图这类频繁调用场景,反复创建销毁进程性能损耗巨大。
- 安全边界严格把控:白名单修改权限仅限本地脚本,网络链路只做读取校验,不能变更配置。
- WebSocket消息强制统一
{code,msg,data}结构,前后端协议对齐,避免业务正常但客户端解析失败的隐性bug。
项目开源地址:github.com/YANYIyes/RemoteControl,完整脚本放在GitHub仓库,文章只展示关键代码片段。