Files
nodeagent/scripts/control.ps1
T
jkooe c8aba83589 fix(control): control.ps1 的 $scheme: 被当 drive 引用 → 整个脚本不可用(真机踩到)
## 现象(Parallels Win11 ARM 真机)
`control.ps1 -Action stop/start/status` 一律报:
  所在位置 control.ps1:100 字符: 36
  +  Write-Host "  协议     : $scheme://0.0.0.0:$Port"
  变量引用无效。':' 后面的变量名称字符无效。

## 根因
PowerShell 把 `$scheme:` 解析为 **drive 引用**(如 `env:`),而 `scheme` 不是 drive → 语法错误。
且这是**解析期**错误 → **整个脚本无法运行**(不是只有那一行),stop/start/status 三个子命令全废。
修法:写成 `${scheme}://`(用花括号界定变量名)。

## 改动
- `scripts/control.ps1:100`:`$scheme://` → `${scheme}://`,并加注释说明为什么必须用花括号
  (注释里刻意**不写**危险字面量 —— 否则会被新守卫命中,这点在验证时真踩到了)
- 新增守卫(`tests/unit/security-hardening.test.mjs`):扫全部 `.ps1/.psm1`,
  禁止 `$ident:` 形式(白名单 `env`/`nuget`/`psitem`/`PSScriptRoot`)

## 验证
- 守卫 **9/9 绿**;全量单测 **274 绿**;ps1-encoding 保持通过(BOM 未被破坏)
- **注入故障实测**:把 `${scheme}` 回退成 `$scheme:` → 守卫**立即红**(失败数 1)→ 恢复后绿
  (按项目纪律:没验证过会红的守卫等于没有)
- 全仓重扫同类模式:**仅此 1 处**
2026-10-11 00:05:42 +08:00

241 lines
10 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 - control operations for an installed Windows agent.
.DESCRIPTION
Backs control.cmd. Everything Chinese-facing lives here (rather than in
the .cmd) because CMD's code page cannot round-trip UTF-8, while this
file is saved as UTF-8 **with BOM** so Windows PowerShell 5.1 reads it
correctly. PowerShell 5.1 parses BOM-less .ps1 as ANSI/GBK and the
Chinese comments then break string boundaries -> bogus syntax errors.
.PARAMETER Action
status | start | stop | restart | logs | uninstall | uninstall-purge
.EXAMPLE
.\control.ps1 -Action status
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('status', 'start', 'stop', 'restart', 'logs', 'uninstall', 'uninstall-purge')]
[string]$Action,
[int]$Port = 8765
)
$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 }
$taskName = "nodeagent"
# 数据目录:必须与 agent 侧一致 —— apps/agent/src/config.ts 的 agentDir() 是
# process.env.NODEAGENT_HOME ?? join(homedir(), '.nodeagent')
# 计划任务里若设了 NODEAGENT_HOME,只查 %USERPROFILE%\.nodeagent 会读错配置。
$cfgDir = if ($env:NODEAGENT_HOME) { $env:NODEAGENT_HOME } else { Join-Path $env:USERPROFILE ".nodeagent" }
$cfgPath = Join-Path $cfgDir "agent.json"
$auditLog = Join-Path $cfgDir "audit.log"
function Test-Admin {
([Security.Principal.WindowsPrincipal] `
[Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Get-TaskState {
try { (Get-ScheduledTask -TaskName $taskName -ErrorAction Stop).State.ToString() }
catch { "NotInstalled" }
}
# ── status ────────────────────────────────────────────────────────────────────
function Show-Status {
Write-Host ""
Write-Host " nodeagent 状态" -ForegroundColor White
Write-Host " --------------" -ForegroundColor DarkGray
# 计划任务
$state = Get-TaskState
if ($state -eq "NotInstalled") {
Write-Warn "计划任务 '$taskName' 未安装(请先双击 install.cmd)"
} else {
$color = if ($state -eq "Running") { "Green" } else { "Yellow" }
Write-Host " 计划任务 : " -NoNewline; Write-Host $state -ForegroundColor $color
$info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
if ($info) {
Write-Host " 上次运行 : $($info.LastRunTime) 结果码 $($info.LastTaskResult)"
}
}
# 进程
$procs = @(Get-Process node -ErrorAction SilentlyContinue)
if ($procs.Count -gt 0) {
Write-Host " 进程 : " -NoNewline
Write-Host "$($procs.Count) 个 node 进程 (PID $($procs.Id -join ', '))" -ForegroundColor Green
} else {
Write-Host " 进程 : " -NoNewline; Write-Host "无" -ForegroundColor DarkGray
}
# 端口
$listen = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
if ($listen.Count -gt 0) {
Write-Host " 端口 : " -NoNewline
Write-Host "$Port 正在监听 (PID $($listen[0].OwningProcess))" -ForegroundColor Green
} else {
Write-Host " 端口 : " -NoNewline
Write-Host "$Port 未监听" -ForegroundColor Yellow
}
# 配置
if (Test-Path $cfgPath) {
try {
$cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json
Write-Host " 节点 ID : $($cfg.node_id)"
Write-Host " 鉴权模式 : $($cfg.auth_mode)"
$inp = if ($cfg.allow_input) { "已启用" } else { "已禁用" }
Write-Host " 输入控制 : $inp"
$scheme = if ($cfg.tls) { "wss" } else { "ws" }
Write-Host " 协议 : ${scheme}://0.0.0.0:$Port"
# ⚠️ 变量名必须用 ${ } 包起来:裸写「变量名后紧跟冒号」会被 PowerShell 当成
# drive 引用(如 env:),属**解析期**错误 → 整个脚本都跑不起来,
# stop/start/status 三个子命令会全部失效(2026-10-11 真机踩到)
} catch {
Write-Warn "配置文件解析失败: $cfgPath"
}
} else {
Write-Warn "未找到配置文件: $cfgPath"
}
# 本机 IP
$ips = @()
try {
$ips = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } |
Select-Object -ExpandProperty IPAddress)
} catch { }
Write-Host " 本机 IP : $($ips -join ', ')"
Write-Host ""
if ($state -eq "NotInstalled") {
Write-Warn "尚未安装 —— 请双击 install.cmd 一键部署"
} elseif ($listen.Count -eq 0) {
Write-Warn "端口未监听 —— 试试菜单里的「重启」,或双击 install.cmd 重新部署"
}
}
# ── start / stop / restart ────────────────────────────────────────────────────
function Start-Agent {
if ((Get-TaskState) -eq "NotInstalled") {
Write-Err "计划任务未安装,请先双击 install.cmd"
return
}
Write-Step "启动计划任务..."
Start-ScheduledTask -TaskName $taskName
# 轮询端口真正起来(最多 15s),而不是盲等
for ($i = 0; $i -lt 15; $i++) {
Start-Sleep -Seconds 1
if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) {
Write-Ok "agent 已启动并在 $Port 监听"
return
}
}
Write-Warn "任务已触发,但 $Port 仍未监听"
$info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
if ($info) { Write-Warn "上次运行结果码: $($info.LastTaskResult)(0 = 成功,267009 = 任务已在运行)" }
}
function Stop-Agent {
if ((Get-TaskState) -eq "NotInstalled") {
Write-Err "计划任务未安装"
return
}
Write-Step "停止计划任务..."
Stop-ScheduledTask -TaskName $taskName
Start-Sleep -Seconds 1
# 兜底:计划任务偶尔留孤儿子进程
$left = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
if ($left.Count -gt 0) {
Write-Warn "端口 $Port 仍被占用 (PID $($left[0].OwningProcess)),可能有残留进程"
Write-Warn "可手动结束: Stop-Process -Id $($left[0].OwningProcess) -Force"
} else {
Write-Ok "agent 已停止"
}
}
# ── logs ──────────────────────────────────────────────────────────────────────
function Show-Logs {
if (-not (Test-Path $auditLog)) {
Write-Warn "未找到审计日志: $auditLog"
Write-Warn "(审计日志需在 agent 配置中开启 audit.enabled)"
return
}
Write-Step "审计日志末尾 40 条: $auditLog"
Write-Host ""
# 只取尾部若干行,不把整个日志(默认上限 10MB x 5 份)读进内存。
# -Encoding UTF8 在 5.1 下能正确识别 UTF-8;解析前仍剥一次 BOM。
$lines = @(Get-Content -LiteralPath $auditLog -Tail 40 -Encoding UTF8 -ErrorAction SilentlyContinue |
Where-Object { $_.Trim() })
if ($lines.Count -eq 0) { Write-Warn "日志为空"; return }
foreach ($line in $lines) {
if ($line[0] -eq [char]0xFEFF) { $line = $line.Substring(1) }
try {
$e = $line | ConvertFrom-Json
# ts 是 Unix 毫秒(见 apps/agent/src/audit.ts 的 AuditEntry)
$ts = "?"
if ($e.ts) {
try { $ts = ([DateTimeOffset]::FromUnixTimeMilliseconds([int64]$e.ts)).LocalDateTime.ToString("MM-dd HH:mm:ss") }
catch { $ts = "$($e.ts)" }
}
$ty = if ($e.type) { $e.type } else { "?" }
$ci = if ($e.client_id) { $e.client_id } else { "-" }
$extra = ""
if ($e.capability) { $extra += " $($e.capability)" }
if ($e.status) { $extra += " [$($e.status)]" }
if ($e.duration_ms) { $extra += " $($e.duration_ms)ms" }
if ($e.error) { $extra += " ERROR=$($e.error)" }
if ($e.reason) { $extra += " reason=$($e.reason)" }
Write-Host (" {0,-15} {1,-20} {2,-14}{3}" -f $ts, $ty, $ci, $extra) -ForegroundColor DarkGray
} catch {
Write-Host " $line" -ForegroundColor DarkGray
}
}
Write-Host ""
}
# ── uninstall ─────────────────────────────────────────────────────────────────
function Uninstall-Agent([bool]$Purge) {
if (-not (Test-Admin)) {
Write-Err "卸载需要管理员权限,请右键 control.cmd → 以管理员身份运行"
return
}
Write-Step "停止并注销计划任务..."
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Write-Step "移除防火墙规则..."
Get-NetFirewallRule -DisplayName "nodeagent (TCP $Port)" -ErrorAction SilentlyContinue |
Remove-NetFirewallRule -ErrorAction SilentlyContinue
if ($Purge) {
Write-Step "清除配置目录..."
Remove-Item -Recurse -Force $cfgDir -ErrorAction SilentlyContinue
Write-Ok "已彻底卸载(含配置与审计日志)"
} else {
Write-Ok "已卸载(配置保留在 $cfgDir)"
}
}
# ── dispatch ──────────────────────────────────────────────────────────────────
switch ($Action) {
'status' { Show-Status }
'start' { Start-Agent }
'stop' { Stop-Agent }
'restart' { Stop-Agent; Start-Sleep -Seconds 1; Start-Agent }
'logs' { Show-Logs }
'uninstall' { Uninstall-Agent -Purge $false }
'uninstall-purge' { Uninstall-Agent -Purge $true }
}