闲得慌自研「手机局域网遥控电脑」开源项目,零密钥安全架构设计

257 阅读4分钟

电脑搁家里,人经常不在跟前。有时候就想看看它是不是还活着,进程有没有抽风,实在不行远程给它关个机。

市面上的远程控制软件我装过,功能是猛,但每次打开都要登录,还要忍受各种广告弹窗,慢,还重。我寻思就这点需求,犯得着为它掏钱包、被广告糊脸吗?

算了,自己写一个吧。

本项目是一套纯局域网、轻量化、高安全的手机遥控电脑方案,全程仅依赖内网 WiFi 通信,不依赖任何公网隧道、不提供中转服务、不暴露公网端口,所有交互封闭在本地局域网内,极大降低入侵风险。

整体采用C/S架构,Android作为客户端,Windows平台运行Node.js作为服务端,底层基于WebSocket长连接双向通信。客户端APK不硬编码任何密钥、Token。项目主动阉割高危能力,不实现鼠标操控、剪贴板劫持、锁屏解锁,只保留监控查看以及可控开关机能力。

业务处理流程

exported_image (1).png

白名单运维流程

exported_image (2).png

系统分层

层级模块主要职责
客户端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编码。

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编码。授权设备时组装设备信息对象,包含序列号、别名、型号、允许标记、添加时间,操作对象属性完成后调用保存函数落地文件。

$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

白名单存储样例:

{
  "devices": {
    "device_serial_xxxx": {
      "serial":"device_serial_xxxx",
      "name":"我的小米手机",
      "model":"Xiaomi 14",
      "allow":true,
      "addedAt":"2026‑08‑20 10:20:00"
    }
  }
}

Node服务收到每一条WebSocket消息都要执行鉴权,除校验白名单allow字段,增加客户端时间戳校验用来防御重放攻击,抓包得到的旧指令时间偏差过大直接拒绝。

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,不会向下执行业务分发。

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一次性读取系统指标。

$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调用脚本,不会拼接任何业务参数。

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核心循环:

while ($true) {
    $line = [Console]::In.ReadLine()
    if ($null -eq $line) { break }
    $s = Get‑ScreenShotBase64
    [Console]::Out.WriteLine($s)
    [Console]::Out.Flush()
}

Node通过spawn拉起常驻进程,复用进程管道交互,避免反复创建子进程。

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:

function pushQQNotify(text){
    const filename = `./qq_queue/${Date.now()}.json`;
    fs.writeFileSync(filename,JSON.stringify({text}),"utf‑8");
}

worker处理队列片段:

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解析报错。

$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实现后台无黑窗口运行,配置全部通过环境变量注入,不硬编码配置文件。

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

工程踩坑总结

  1. PowerShell编码区分两套:.ps1脚本保存为UTF‑8‑BOM;输出给Node读取的JSON文件必须无BOM UTF‑8,混用直接解析报错。
  2. JS层禁止拼接shell命令字符串,全部调用独立脚本文件,从根源规避Shell注入漏洞。
  3. 高频操作尽量复用常驻子进程,截图这类频繁调用场景,反复创建销毁进程性能损耗巨大。
  4. 安全边界严格把控:白名单修改权限仅限本地脚本,网络链路只做读取校验,不能变更配置。
  5. WebSocket消息强制统一{code,msg,data}结构,前后端协议对齐,避免业务正常但客户端解析失败的隐性bug。

项目开源地址:github.com/YANYIyes/RemoteControl,完整脚本放在GitHub仓库,文章只展示关键代码片段。