Files
nodeagent/scripts/install.ps1
T
jkooe 8b97fc6106 feat(v24): 零信任优先安装 —— 拿到控制端公钥就走 ed25519,否则明确告警
## 为什么不是直接改 auth_mode 默认值
ed25519 的**私钥必须在控制端**,被控端安装时拿不到公钥。硬改默认值会让新装的控制端
连不上(还得手工改配置回退)—— 比「有条件就走零信任 + 无条件时明确告警」更糟。
所以这里的"默认"是**优先顺序**,不是死值。

## 做法
install.ps1 新增 -ClientAclFile(默认探测脚本同目录 client-acl.json,内容即
`nodeagent keygen` 打印的那一条 acl.clients JSON):
- 有且含 client_id+pubkey → auth_mode=ed25519,写 acl{default_effect=deny, clients=[…]},
  绿色确认 + 打印 --auth-mode ed25519 连接示例
- 无 / 缺字段 → 保持 psk,但输出**醒目黄色告警**:说明 client_id 可自报的固有风险
  + 三步切换指引(keygen → 存 client-acl.json → 重跑 install.cmd)
install.cmd 无需改动(install.ps1 自行探测)。

## 验证(真机,不是推理)
- **PowerShell 5.1 语法校验**:PARSE_OK,BOM 保留(EF BB BF)
- **等效逻辑三 CASE**:
  A 有文件 → auth_mode=ed25519 + default_effect=deny + clients 完整(allow/deny/note 保留)
  B 无文件 → auth_mode=psk 且**不写 acl 字段**
  C 缺 pubkey → WARN 并回退 psk(不写半截 ACL)
- 编码守卫:ps1-encoding 24 例全过(BOM + 无 U+FFFD)
- 新增 tests/unit/install-zerotrust.test.mjs(8 例源码断言:参数、探测路径、
  useZeroTrust 条件化、deny 兜底、字段校验、告警文案、连接示例、ConvertTo-Json -Depth 6)

## 文档
docs/SECURITY.md 新增 §4.7 并更新 §5 清单 —— 把 v23 的三项(握手封禁/会话上限/
安装期密钥)与本次 ed25519 默认化一并标为已完成,只留「审计链外部锚定」待做。

## 数据
单测 250 → **258** 全绿;7 包构建通过。
2026-10-09 23:53:28 +08:00

348 lines
16 KiB
PowerShell
Raw 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,
# v23:从**标准输入**读密钥(而非命令行 -Key)。
# 为什么:命令行参数对同机其他用户可见(任务管理器/Get-CimInstance Win32_Process
# 的 CommandLine 字段就是明文)。install.cmd 用 `echo <key>| powershell ...` 喂进来。
[switch]$KeyFromStdin,
# v24 零信任优先:控制端公钥文件路径(内容是 `nodeagent keygen` 打印的那一条 JSON,
# 即 acl.clients 的一项)。给了它 → auth_mode 直接走 ed25519(零信任),
# 不再依赖可被自报的 client_id。留空则自动探测脚本同目录的 client-acl.json。
[string]$ClientAclFile = ""
)
$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
# v23:stdin 读取要在「复用既有配置」之前 —— 调用方显式给了 key 就以它为准。
# 同时规避了一个易错点:[Console]::In.ReadLine() 在没有管道时会阻塞,
# 所以必须先判 [Console]::IsInputRedirected。
if ($KeyFromStdin -and -not $Key) {
if (-not [Console]::IsInputRedirected) {
Write-Err "-KeyFromStdin requires the key on stdin (e.g. from install.cmd's pipe)."
exit 1
}
$line = [Console]::In.ReadLine()
if (-not $line) {
Write-Err "stdin was empty; no key to read."
exit 1
}
# 管道会带入 CRLF/首尾空白;密钥是纯 hex,一律 Trim
$Key = $line.Trim()
if ($Key -notmatch '^[0-9a-fA-F]{16,128}$') {
Write-Err "key from stdin is not valid hex (16-128 chars)."
exit 1
}
Write-Ok "Key read from stdin (not visible in process command line)"
}
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"
}
# --- v24: zero-trust-first -------------------------------------------------
# PSK 有一个固有弱点:client_id 是调用方**自报**的,所以"谁知道共享 key,谁就能
# 冒充任意未登记的 client_id"—— ACL 的身份维度在 psk 模式下形同虚设。
# ed25519 没有这个问题(身份由私钥签名决定)。故:**只要拿到控制端公钥就走 ed25519**。
$aclEntry = $null
$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
$aclPath = if ($ClientAclFile) { $ClientAclFile } else { Join-Path $scriptDir 'client-acl.json' }
if (Test-Path $aclPath) {
try { $aclEntry = Get-Content $aclPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $aclEntry = $null }
if ($aclEntry -and (-not $aclEntry.client_id -or -not $aclEntry.pubkey)) {
Write-Warn "client-acl.json is missing client_id or pubkey - ignored"
$aclEntry = $null
}
}
$useZeroTrust = ($aclEntry -ne $null)
$cfgObj = [ordered]@{
node_id = $NodeId
host = "0.0.0.0"
port = $Port
tls = (-not $NoTls)
key = $Key
log_level = "info"
allow_input = [bool]$AllowInput
auth_mode = if ($useZeroTrust) { "ed25519" } else { "psk" }
}
if ($useZeroTrust) {
# default_effect=deny:未在 clients 中登记的一律拒绝(零信任兜底)
$cfgObj.acl = @{
default_effect = "deny"
clients = @($aclEntry)
}
}
$cfgJson = $cfgObj | ConvertTo-Json -Depth 6
# 必须写「无 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 ""
$primaryIp = if ($ips.Count -gt 0) { $ips[0] } else { '<target-ip>' }
$insecureFlag = if ($NoTls) { "" } else { " --insecure" }
if ($useZeroTrust) {
Write-Host " Zero-trust is ON (auth_mode=ed25519, default_effect=deny)." -ForegroundColor Green
Write-Host " Registered client_id: $($aclEntry.client_id)" -ForegroundColor Green
Write-Host ""
Write-Host " On the Mac, connect with the private key you generated:" -ForegroundColor Cyan
Write-Host " nodeagent connect $primaryIp --port $Port --id $($aclEntry.client_id) --auth-mode ed25519$insecureFlag"
} else {
Write-Host " On the Mac, run:" -ForegroundColor Cyan
Write-Host " nodeagent connect $primaryIp --port $Port --key $Key$insecureFlag"
Write-Host ""
Write-Host " ================ SECURITY WARNING ================" -ForegroundColor Yellow
Write-Host " This agent runs in PSK mode, where the client_id is" -ForegroundColor Yellow
Write-Host " SELF-REPORTED: anyone holding the shared key can" -ForegroundColor Yellow
Write-Host " impersonate any unregistered client_id, so the ACL" -ForegroundColor Yellow
Write-Host " identity dimension is effectively void." -ForegroundColor Yellow
Write-Host ""
Write-Host " To switch to zero-trust (recommended):" -ForegroundColor Cyan
Write-Host " 1) On the Mac: nodeagent keygen --id <name>" -ForegroundColor Cyan
Write-Host " 2) Save the printed JSON as client-acl.json next to this script" -ForegroundColor Cyan
Write-Host " 3) Re-run install.cmd (it auto-detects the file)" -ForegroundColor Cyan
Write-Host " ====================================================" -ForegroundColor Yellow
}
Write-Host ""