Files
nodeagent/scripts/install.ps1
T
jkooe 0d59d6032b feat(v21): 第一批安全加固 —— 证书指纹钉住 + 来源网段白名单
对应审查建议的 P0 两条:这两项是**真实可利用的攻击路径**,其余(发现最小化/失败封禁等)
留待后续批次。

## ① 证书指纹钉住(TOFU)—— 挡同网段中间人
自签证书没有 CA 链,历史上客户端只能 `insecure` 接受任意证书 → 攻击者可**透明中继**:
拿不到钥匙(挑战-应答挡得住冒名),但能读到截屏/剪贴板/键入/exec 输出。

- 被控端:`install.ps1` 打印证书指纹(sha256 of DER)供**带外核对**;
  `system.info.build.cert_sha256` 与 `auth_ok.build.cert_sha256` 上报
- 控制端:首次连接自动钉进 `nodes.<名>.cert_sha256`;之后**严格比对**,
  不一致即 `E_CERT_MISMATCH`(-32422) 拒连,并提示 `--forget-cert` 重钉
- 额外做**交叉确认**:实际握手证书 vs auth_ok 自报,不一致同样拒绝(防自报被篡改)
- 指纹基于 **DER**(不是 PEM 字节),保证与 Windows .NET `GetRawCertData()` 同值

**踩坑记录(写进代码注释,避免后人重走)**:
1. `ws` 客户端的 `upgrade` 事件**只传 IncomingMessage**,拿不到 socket
2. socket 要在 `open` 之后从 `ws._socket` 取(TLSSocket)
3. Node ≥15 的 `getPeerCertificate()` 返回**对象**,DER 在 `.raw` 上(老版本才是 Buffer)
   —— 现已双兼容

## ② 来源网段白名单 `allow_from` —— 收敛暴露面
被控端默认监听 `0.0.0.0`(远程接入所需),但**换到不可信网络就自动暴露**。

- 支持 CIDR / 单 IP / `*`;**未配置 = 放行全部**(保持向后兼容,不打断现有部署)
- 拒绝发生在**业务握手之前**(不做 TLS/挑战应答,省资源),并记 `net.deny` 审计
- 判定按**前缀比特**而非字符串相等:Node 在 IPv6 监听上把 IPv4 客户端报成
  `::ffff:a.b.c.d`,已归一化(单测覆盖)
- `system.info.network.allow_from` 上报状态;`nodeagent info` 一眼看出是否已收敛

## 验证(本地真跑,非纸面)
- 白名单不含本机 → 连接被拒(agent 侧 WARN + 审计留痕)✓
- 白名单含本机 → 连上,并**自动钉住**指纹 ✓
- **换掉被控端证书后重连 → `E_CERT_MISMATCH` 拒绝**(期望 2ed4957b… / 实际 ca8dc20c…)✓
- 新增 e2e 用例(自起 TLS agent)覆盖上述四种情形 + agent 自报与实际证书一致性 ✓
- 新增单测 9 例(CIDR 前缀匹配 / IPv4-mapped 归一 / IPv6 zone / PEM≡DER / 篡改即变)
- 全量:单测 **159**(150→159)· e2e **29**(28→29)全绿

## 文档
新增 `docs/SECURITY.md`:信任模型(钥匙=root)、认证现状与**已知限制**(ACL 身份维度仅在
ed25519 有效)、本次两项加固的用法、尚未收敛的 6 项、应急处置表。
2026-10-05 02:25:19 +08:00

277 lines
12 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
nodeagent - Windows agent installer.
⚠️ 本文件必须以 **UTF-8 BOM** 保存(文件头那个看不见的 U+FEFF 就是 BOM)。
Windows PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(中文 Windows = GBK)
解析,本文件里的中文注释会解成乱码并被误认为引号 → 整份脚本 ParseError
(报错位置彼此无关,真正的病灶在靠前处)。真机踩过:2026-10-04。
另:第 4 步写 agent.json 时反而必须**无 BOM**(BOM 对 .ps1 必需、对 JSON 有害)。
.DESCRIPTION
Registers the nodeagent agent as a scheduled task (auto-start on logon),
opens the inbound firewall port, and prints connection info for the Mac side.
Prerequisites on the Windows machine:
1. Node.js 22+ (https://nodejs.org)
2. This repository (git clone, or copy the folder over)
3. Built artifacts (run `pnpm install` then `pnpm build` in the repo root)
.EXAMPLE
# Run as Administrator from the repo root
powershell -ExecutionPolicy Bypass -File .\scripts\install.ps1
.EXAMPLE
.\install.ps1 -Port 8765 -NodeId my-pc
.EXAMPLE
# 无人值守:重启/注销后仍运行(无图形界面能力)
.\install.ps1 -Unattended -AtStartup -AllowInput:$false
#>
[CmdletBinding()]
param(
[int]$Port = 8765,
[string]$NodeId = $env:COMPUTERNAME,
[string]$Key = "",
[switch]$NoTls,
[switch]$AllowInput,
[string]$ProjectRoot = "",
# 无人值守模式:用 S4U 登录类型注册任务 —— 无需保存密码、注销/重启后仍自动运行。
# 代价:进程运行在非交互会话(Session 0),**图形能力不可用**
# (截屏 / UIA 找元素 / 鼠标键盘注入都依赖交互桌面)。
[switch]$Unattended,
# 追加「开机即启动」触发器(不依赖用户登录)
[switch]$AtStartup
)
$ErrorActionPreference = "Stop"
function Write-Step($m) { Write-Host "[*] $m" -ForegroundColor Cyan }
function Write-Ok($m) { Write-Host "[+] $m" -ForegroundColor Green }
function Write-Warn($m) { Write-Host "[!] $m" -ForegroundColor Yellow }
function Write-Err($m) { Write-Host "[x] $m" -ForegroundColor Red }
Write-Host ""
Write-Host " nodeagent installer" -ForegroundColor White
Write-Host " -------------------" -ForegroundColor DarkGray
# 1. Require Administrator
$isAdmin = ([Security.Principal.WindowsPrincipal] `
[Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Err "Please run this script as Administrator."
exit 1
}
# 2. Locate runtime (bundled portable package first, else system Node.js + repo build)
Write-Step "Locating runtime..."
$bundledNode = Join-Path $PSScriptRoot "node.exe"
$bundledAgent = Join-Path $PSScriptRoot "agent.mjs"
if ((Test-Path $bundledNode) -and (Test-Path $bundledAgent)) {
# 便携包:使用包内运行时与被打包好的被控端,无需本机安装 Node.js
$nodeExe = $bundledNode
$agentJs = $bundledAgent
if (-not $ProjectRoot) { $ProjectRoot = $PSScriptRoot }
Write-Ok "Using bundled runtime (portable package)"
} else {
# 开发模式:系统 Node.js + 仓库代码
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
if (-not $nodeCmd) {
Write-Err "Node.js not found. Install Node.js 22+ from https://nodejs.org"
exit 1
}
$nodeExe = $nodeCmd.Source
if (-not $ProjectRoot) { $ProjectRoot = Split-Path -Parent $PSScriptRoot }
$agentJs = Join-Path $ProjectRoot "apps\agent\dist\index.js"
if (-not (Test-Path $agentJs)) {
Write-Err "Agent build not found: $agentJs"
Write-Warn "Run 'pnpm install' and 'pnpm build' in the repo root first."
exit 1
}
Write-Ok "Using system Node.js"
}
$ver = (& $nodeExe --version).TrimStart('v')
$major = [int]($ver.Split('.')[0])
if ($major -lt 22) {
Write-Err "Node.js 22+ required, found v$ver"
exit 1
}
Write-Ok "Node.js v$ver"
Write-Ok "Agent entry: $agentJs"
# 4. Config + pre-shared key
# 数据目录必须与 agent 侧一致 —— apps/agent/src/config.ts 的 agentDir() 是
# process.env.NODEAGENT_HOME ?? join(homedir(), '.nodeagent')
# 若计划任务环境里设了 NODEAGENT_HOME 而这里写死 USERPROFILE\.nodeagent,
# 就会「配置写A、读取B」,表现为装完连不上(E_AUTH_FAILED)。
$cfgDir = if ($env:NODEAGENT_HOME) { $env:NODEAGENT_HOME } else { Join-Path $env:USERPROFILE ".nodeagent" }
$cfgPath = Join-Path $cfgDir "agent.json"
New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null
if (Test-Path $cfgPath) {
try { $existing = Get-Content $cfgPath -Raw | ConvertFrom-Json } catch { $existing = $null }
if ($existing -and -not $Key) {
$Key = $existing.key
Write-Ok "Existing config found, reusing its key"
}
}
if (-not $Key) {
$bytes = New-Object byte[] 32
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
$Key = ($bytes | ForEach-Object { $_.ToString('x2') }) -join ''
Write-Ok "Generated a new pre-shared key"
}
$cfgJson = @{
node_id = $NodeId
host = "0.0.0.0"
port = $Port
tls = (-not $NoTls)
key = $Key
log_level = "info"
allow_input = [bool]$AllowInput
auth_mode = "psk"
} | ConvertTo-Json
# 必须写「无 BOM」的 UTF-8:Windows PowerShell 5.1 的 `Set-Content -Encoding UTF8`
# 会写入 BOM(U+FEFF),导致 Node 侧 JSON.parse 报 "Unexpected token ''"
[System.IO.File]::WriteAllText($cfgPath, $cfgJson, (New-Object System.Text.UTF8Encoding($false)))
Write-Ok "Config written: $cfgPath"
# 5. Firewall (inbound, agent listens)
Write-Step "Configuring firewall..."
$ruleName = "nodeagent (TCP $Port)"
Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue |
Remove-NetFirewallRule -ErrorAction SilentlyContinue
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Action Allow `
-Protocol TCP -LocalPort $Port -Profile Any | Out-Null
Write-Ok "Inbound rule allowed on TCP $Port"
# 6. Scheduled task
Write-Step "Registering scheduled task..."
$taskName = "nodeagent"
$action = New-ScheduledTaskAction -Execute $nodeExe -Argument "`"$agentJs`"" -WorkingDirectory $ProjectRoot
# 触发器:登录时(默认)+ 可选开机时;无人值守模式下开机触发器才真正有意义
$triggers = @(New-ScheduledTaskTrigger -AtLogOn)
if ($AtStartup -or $Unattended) {
$triggers += New-ScheduledTaskTrigger -AtStartup
}
# 自愈:异常退出后自动重启(最多 3 次,间隔 1 分钟),且不设执行时长上限
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) `
-StartWhenAvailable
# 登录类型决定「能干什么」:
# Interactive —— 运行在交互会话,**图形能力可用**,但注销后停
# S4U —— 无需密码、注销/重启后仍运行,但**图形能力不可用**(见 -Unattended 说明)
if ($Unattended) {
if ($AllowInput) {
Write-Warn "-Unattended 与 -AllowInput 冲突:Session 0 无法注入输入,已强制关闭输入控制"
$AllowInput = $false
# 回写配置,避免配置与运行模式不一致
$cfg = Get-Content $cfgPath -Raw -Encoding UTF8 | ConvertFrom-Json
$cfg.allow_input = $false
[System.IO.File]::WriteAllText($cfgPath, ($cfg | ConvertTo-Json), (New-Object System.Text.UTF8Encoding($false)))
}
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
-RunLevel Highest -LogonType S4U
Write-Warn "无人值守模式:agent 将在非交互会话运行"
Write-Warn " → 可用:exec / 文件传输 / 进程与服务 / 审计 / 重启 / 异步任务 / 剪贴板"
Write-Warn " → 不可用:截屏、UIA 找元素、鼠标键盘注入(需交互桌面)"
} else {
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
-RunLevel Highest -LogonType Interactive
}
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $triggers `
-Settings $settings -Principal $principal `
-Description "nodeagent - cross-machine AI takeover agent" | Out-Null
$modeLabel = if ($Unattended) { "unattended (S4U, survives logoff)" } else { "interactive (logon)" }
Write-Ok "Scheduled task '$taskName' registered — mode: $modeLabel"
# 7. Start now
Write-Step "Starting agent..."
Start-ScheduledTask -TaskName $taskName
# 等待端口真正进入监听(最多 15s);失败则给出可执行的诊断步骤
$listening = $false
for ($i = 0; $i -lt 15; $i++) {
Start-Sleep -Seconds 1
if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) {
$listening = $true
break
}
}
$state = (Get-ScheduledTask -TaskName $taskName).State
if ($listening) {
Write-Ok "Agent is listening on port $Port (task state: $state)"
} else {
Write-Warn "Task state: $state, but port $Port is NOT listening"
$info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
if ($info) { Write-Warn "Last run result: $($info.LastTaskResult)" }
Write-Warn "Troubleshoot:"
Write-Warn " 1) Start the task manually: Start-ScheduledTask -TaskName nodeagent"
Write-Warn " 2) Check the process: Get-Process node"
Write-Warn " 3) Run in foreground to see the real error:"
Write-Warn " & '$nodeExe' '$agentJs'"
}
# 8. Print connection info
$ips = @()
try {
$ips = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } |
Select-Object -ExpandProperty IPAddress)
} catch { }
$scheme = if ($NoTls) { "ws" } else { "wss" }
Write-Host ""
Write-Host " Installation complete" -ForegroundColor Green
Write-Host " ---------------------" -ForegroundColor DarkGray
Write-Host " Node ID : $NodeId"
Write-Host " Listen : 0.0.0.0:$Port ($scheme)"
Write-Host " Local IP : $($ips -join ', ')"
Write-Host " Node.js : $nodeExe"
Write-Host " Config : $cfgPath"
$ctlLabel = if ($AllowInput) { "ENABLED (mouse/keyboard control)" } else { "disabled (default)" }
Write-Host " Input ctl : $ctlLabel"
Write-Host " Auth mode : psk (pre-shared key)"
Write-Host " Discovery : UDP 8766 heartbeat (LAN auto-discovery; outbound only)"
# v21: TLS certificate fingerprint (sha256 of DER) — the Mac side pins this (TOFU).
# This must match what nodeagent computes, so out-of-band verification is possible:
# the Mac prints the same value at first connect; compare the two.
$certFp = ""
try {
$certPath = Join-Path (Split-Path -Parent $cfgPath) "certs\cert.pem"
if (Test-Path -LiteralPath $certPath) {
$x = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $certPath
$sha = [System.Security.Cryptography.SHA256]::Create().ComputeHash($x.GetRawCertData())
$certFp = ($sha | ForEach-Object { $_.ToString("x2") }) -join ""
}
} catch { }
Write-Host ""
Write-Host " Pre-shared key (copy this to the Mac side):" -ForegroundColor Yellow
Write-Host " $Key" -ForegroundColor White
if ($certFp) {
Write-Host ""
Write-Host " TLS cert fingerprint (verify the Mac side pinned the same one):" -ForegroundColor Yellow
Write-Host " $certFp" -ForegroundColor White
}
Write-Host ""
Write-Host " On the Mac, run:" -ForegroundColor Cyan
$insecureFlag = if ($NoTls) { "" } else { " --insecure" }
$primaryIp = if ($ips.Count -gt 0) { $ips[0] } else { '<被控端IP>' }
Write-Host " nodeagent connect $primaryIp --port $Port --key $Key$insecureFlag"
Write-Host ""
Write-Host " Zero-trust (optional): run 'nodeagent keygen' on the Mac, then add the" -ForegroundColor DarkGray
Write-Host " printed entry into this file's acl.clients and set auth_mode to 'ed25519'." -ForegroundColor DarkGray
Write-Host ""