Files
nodeagent/scripts/control.ps1
T
jkooe b7f44f7a64 feat(scripts): na-gui 陈旧计划任务白名单清理(ARCH B-P1-6)
历史版本在被控端上注册过 na-gui 等旧名计划任务,漏删会留下「端口被占、
新版本起不来」的幽灵任务。误删用户自己的任务则是比漏删严重得多的
生产事故 —— 故只按白名单删。

- scripts/lib/scheduled-tasks.mjs(新):TASK_WHITELIST(nodeagent /
  na-gui / nodeagent-console,只增不改)+ planTaskCleanup 纯函数
  (查询结果注入,白名单外一律不删、精确匹配大小写敏感、重复名去重)。
- scripts/uninstall.ps1:卸载末尾按白名单逐个注销(-ccontains 存在性
  检查 + 动作进日志)。
- scripts/control.ps1:新增 cleanup 子命令(ValidateSet + Clear-StaleTasks),
  Uninstall-Agent 复用同一白名单;control.cmd 菜单加第 8 项
  (CRLF 安全插入,纯 ASCII 不变)。
- tests/unit/na-gui-cleanup.test.mjs(新,10 例):白名单命中才删 /
  白名单外零动作 / 空列表 / 重复名 / 脏输入 + ps1 源码守卫与跨端
  一致性守卫(白名单 mjs 与 ps1 漂移即红 —— 注入故障实测 2 处变红,
  恢复后全绿)。
2026-10-26 01:01:05 +08:00

278 lines
13 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 | cleanup | uninstall | uninstall-purge
.EXAMPLE
.\control.ps1 -Action status
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('status', 'start', 'stop', 'restart', 'logs', 'cleanup', '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"
# B-P1-6 / T05:计划任务白名单。历史版本注册过 na-gui 等旧名任务,漏删会留下
# 「端口被占、新版本起不来」的幽灵任务。铁律:只删白名单内**且实际存在**的
# 任务,禁通配删除 —— 误删用户自己的任务是比漏删严重得多的生产事故。
# 与 scripts/lib/scheduled-tasks.mjs 的 TASK_WHITELIST 同口径
# (tests/unit/na-gui-cleanup.test.mjs 有跨端一致性守卫,白名单不许两边漂移)。
$taskWhitelist = @("nodeagent", "na-gui", "nodeagent-console")
# 数据目录:必须与 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 ""
}
# ── cleanup(白名单清理陈旧计划任务)────────────────────────────────────────────
function Clear-StaleTasks {
if (-not (Test-Admin)) {
Write-Err "清理计划任务需要管理员权限,请右键 control.cmd → 以管理员身份运行"
return
}
Write-Step "清理 nodeagent 历史计划任务(仅白名单内且存在的)..."
# -ccontains 为大小写敏感包含,与 scheduled-tasks.mjs 的精确匹配同口径
$existing = Get-ScheduledTask | Select-Object -ExpandProperty TaskName
foreach ($name in $taskWhitelist) {
if ($existing -ccontains $name) {
Stop-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
Write-Ok "已删除计划任务: $name"
} else {
Write-Host " - $name (不存在,跳过)"
}
}
Write-Host ""
Write-Host " 提示:白名单外的任务一律不碰(误删他人任务是生产事故)。" -ForegroundColor DarkGray
Write-Host " 若端口仍被占用,用 status 子命令查看占用进程 PID 后手动处理。" -ForegroundColor DarkGray
}
# ── uninstall ─────────────────────────────────────────────────────────────────
function Uninstall-Agent([bool]$Purge) {
if (-not (Test-Admin)) {
Write-Err "卸载需要管理员权限,请右键 control.cmd → 以管理员身份运行"
return
}
Write-Step "停止并注销计划任务(白名单内且存在的)..."
$existing = Get-ScheduledTask | Select-Object -ExpandProperty TaskName
foreach ($name in $taskWhitelist) {
if ($existing -ccontains $name) {
Stop-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
Write-Ok "已删除计划任务: $name"
}
}
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 }
'cleanup' { Clear-StaleTasks }
'uninstall' { Uninstall-Agent -Purge $false }
'uninstall-purge' { Uninstall-Agent -Purge $true }
}