概述
Windows 上长期运行自建的命令行网络服务(如内网穿透客户端)时,常见的部署方式是编写一个启动脚本放入开机启动项。这种方式存在一个普遍问题:服务进程寄生在终端窗口的进程树下,窗口被意外关闭或服务异常退出后,没有任何机制使其恢复。
本文以 FRP 内网穿透客户端为例——用于为远程桌面建立安全隧道——依次说明其功能定位、启动脚本的实现方式、该部署方式的风险点,以及如何设计一个可靠的守护进程,包括守护进程自身的保活与权限配置。
一、文件清单
全部脚本放置于 FRP 程序目录(示例中为 I:\FRP\):
| 文件 | 来源 | 作用 |
|---|---|---|
frpc.exe / frpc.ini | 官方发行包自带 | FRP 客户端程序与配置 |
FRP.vbs | 手工放置 | 开机启动脚本:提权并在 Windows Terminal 中运行 frpc |
guard.vbs | 手工放置 | 守护启动器:以隐藏窗口方式启动 guard.ps1 |
guard.ps1 | 手工放置 | 守护主程序:轮询检测 frpc 进程,消失时自动恢复 |
guard.log | 守护首次启动后自动生成 | 运行日志,超 512KB 自动轮转为 guard.log.1 |
一、服务定位:FRP 为远程桌面建立安全隧道
I:\FRP\ 目录下运行 frpc.exe -c frpc.ini,即 FRP 的客户端。它主动连接部署在公网服务器上的 frps 服务端,将本机的 3389 端口(Windows 远程桌面)映射出去,从而实现在外部通过公网地址访问远程桌面。
之所以通过 FRP 中转而不直接将 3389 端口暴露于公网,原因在于:远程桌面端口是自动化扫描与爆破攻击的主要目标,直接暴露等同于持续遭受攻击。FRP 的工作方向是反向的——由内网机器主动向外连接公网服务器建立隧道,内网机器自身无需对公网开放任何端口,外部流量只能到达可控的 frps 入口。攻击面因此从本机缩小至隧道入口,这是”更安全”的实质含义。
frpc.ini 为 FRP 官方格式的客户端配置,最小可用示例(RDP 隧道场景):
[common] server_addr = <公网服务器IP> server_port = 17000 token = <与frps一致的鉴权口令> [rdp] type = tcp local_ip = 127.0.0.1 local_port = 3389 remote_port = 16000
建立隧道后,访问”公网服务器IP:16000″即等同于访问本机远程桌面。
该服务具有命令行常驻程序的两个典型特征:要求持续运行,且依赖控制台窗口查看实时日志。这两个特征决定了后文脚本设计与守护方案的形态。
二、启动脚本:FRP.vbs 中的两个关键设计
启动脚本 FRP.vbs 完整内容:
Set WshShell = CreateObject("WScript.Shell")
cmd = "if(Get-Command pwsh -ErrorAction SilentlyContinue){" & _
"wt.exe -w 0 --title FRP -d ""I:\FRP"" pwsh -NoExit -Command "".\frpc.exe -c frpc.ini""}" & _
"else{" & _
"wt.exe -w 0 --title FRP -d ""I:\FRP"" cmd /k frpc.exe -c frpc.ini}"
WshShell.Run "powershell -Command ""Start-Process powershell -ArgumentList '-Command','" & cmd & "' -Verb RunAs""", 1, False
Set WshShell = Nothing
包含两个核心设计:
其一,权限提升。 Start-Process powershell ... -Verb RunAs 表示以管理员身份启动。开机自启时由用户确认一次 UAC;此后进程携带管理员令牌运行,具备提升自身权限的资格。
其二,以 Windows Terminal 承载。 wt.exe 是 Windows Terminal 的命令行入口,参数 -w 0 表示”附加到最近活动的终端窗口并新建标签页”;--title 指定标签页标题,-d 指定工作目录。服务运行在该终端窗口内,日志实时可见,且多个同类脚本可汇聚于同一窗口的不同标签页集中查看。
此外有一处辅助逻辑:if(Get-Command pwsh) 判断已安装 PowerShell 7 则使用 pwsh,否则回退 cmd /k,保证兼容性。
将该脚本的快捷方式放入启动文件夹(Win+R 执行 shell:startup),即完成开机自启。
三、风险:服务进程与终端窗口绑定
上述部署方式在运行阶段没有问题,风险隐藏在进程结构中。
实际查看该 Windows 11 环境的进程树:frpc.exe ← pwsh.exe ← WindowsTerminal.exe
服务进程的祖先为 WindowsTerminal.exe。由此产生的直接后果是:终止该终端窗口——无论是手动关闭、还是终端自身崩溃——都会沿进程树终止其下所有子进程,服务随之中断。
在无人在场的时间段(如夜间、外出),这种中断不会有任何提示,服务将持续处于停止状态直至用户重新发现。因此需要引入守护机制:持续监测服务进程状态,在其消失时自动恢复。
四、守护进程的设计
实现之前需要先明确四个设计约束,它们决定了守护方案是否可靠。
约束 1:守护进程不得从终端窗口派生
若守护进程位于 WindowsTerminal 的进程树下,它将与服务进程同时被终止,守护失去意义。
这里可以利用 Windows 进程树的一个特性:进程终止只向下传播至子进程,不向上影响父进程。 终止父进程可能连带子进程,但子进程无论以何种方式死亡都不会波及父进程。因此守护只需保证自身不是终端的后代即可。
实现通过启动器 guard.vbs 完成:
Set WshShell = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")
strDir = fso.GetParentFolderName(WScript.ScriptFullName)
WshShell.Run "powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & strDir & "\guard.ps1""", 0, False
Set WshShell = Nothing
wscript.exe 本身无窗口,其启动的 powershell 进程在创建时即为隐藏状态(Run 的第二个参数 0),全程不经过任何终端。由此形成的进程链为 powershell(守护)← wscript ← 启动入口,与终端无关。在该环境的实测中,守护进程的父链中不存在 WindowsTerminal 节点——终端以任何方式终止都不会影响守护进程,服务进程死亡同样不会反向终止作为旁观者的守护。
约束 2:存活检测
采用轮询方式,周期检测目标进程是否存在,这是最朴素也最不依赖额外组件的做法。核心检测函数:
function Test-TargetRunning {
$list = @(Get-Process -Name $ProcName -ErrorAction SilentlyContinue)
if ($list.Count -eq 0) { return $false }
foreach ($p in $list) {
$path = $null
try { $path = $p.Path } catch { $path = $null }
# 提权进程的路径信息对非提权查询不可读,此时按进程名认定其存活
if ([string]::IsNullOrEmpty($path)) { return $true }
if ($path.StartsWith($Dir, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
}
return $false
}
其中”路径读取为空即视为存活”是必要处理:被守护的 frpc 以管理员身份运行,非提权身份的 Get-Process 无法读取其 Path 属性。若仅以路径匹配判断,守护会将正常运行的服务误判为已停止,进而反复开窗。
约束 3:恢复方式为复用原启动脚本
守护检测到进程消失后,不重新构造启动命令,而是再次执行原有的 FRP.vbs:
function Start-ByLauncher {
if (-not (Test-Path -LiteralPath $LaunchPath)) {
Write-Log ('launcher missing: ' + $LaunchPath)
return $false
}
Start-Process -FilePath 'wscript.exe' -ArgumentList ('"' + $LaunchPath + '"') -WindowStyle Hidden
return $true
}
这样恢复出的服务与开机自启或手动启动的行为完全一致:相同的终端窗口与实时输出、相同的标题、相同的 pwsh/cmd 分支,避免了”手动启动与自动恢复表现不一致”这一常见维护问题。
约束 4:防止恢复机制失控
异常场景是服务因配置错误无法启动:守护每 15 秒检测到进程消失并尝试恢复一次,配合每次恢复触发的 UAC 提示,一小时内可产生上百次弹窗。需通过三层限制消除该风险:
# 限制一:互斥锁,保证目录下仅有一个守护实例
$Mutex = New-Object System.Threading.Mutex($false, $MtxName)
try { $acquired = $Mutex.WaitOne(0) } catch { $acquired = $true }
if (-not $acquired) { exit 0 }
# 上一实例被强制终止所遗留的废弃锁不应阻止新实例,
# 因此捕获异常并视为获取成功
# 限制二:恢复动作后设置冷却时间再进入检测
$CooldownSec = 25
# 限制三:连续 3 次恢复失败后进入 10 分钟退避
$MaxFail = 3
$BackoffSec = 600
配套运维手段:所有动作写入同目录 guard.log(超过 512KB 自动轮转为 guard.log.1),用于事后排查。
守护主程序 guard.ps1 完整代码
将上述约束组装为完整脚本:
# ============================================================ # FRP guard - restarts the service back into a visible window. # Watchdog for frpc.exe. # When the process disappears, the guard re-runs FRP.vbs, so the Windows Terminal window comes back with live output, identical to a manual start. # The guard itself is launched by guard.vbs as a standalone hidden powershell process, outside the terminal's process tree, so closing or crashing the terminal cannot kill it. # UAC note: if the guard runs elevated (task scheduler with highest privileges), restarts raise no prompt. # If the guard is NOT elevated, every restart requires clicking one UAC box. # Log: guard.log in this folder (auto-rotates at 512KB). # ============================================================ # ---------- tunables (adapt these to your own service) ---------- $ProcName = 'frpc' # target process name, without .exe $ExeName = 'frpc.exe' # executable file, existence check $LauncherRel = 'FRP.vbs' # re-run this: brings the window back $IntervalSec = 15 # polling interval when all is well $CooldownSec = 25 # wait after a restart attempt $MaxFail = 3 # give up after this many in a row $BackoffSec = 600 # long sleep after giving up # ----------------------------------------------------------------- $ErrorActionPreference = 'SilentlyContinue' $Dir = $PSScriptRoot if (-not $Dir) { $Dir = Split-Path -Parent $MyInvocation.MyCommand.Definition } $ExePath = Join-Path $Dir $ExeName $LaunchPath = Join-Path $Dir $LauncherRel $LogPath = Join-Path $Dir 'guard.log' # one guard instance per folder; a duplicate launch exits silently $MtxName = 'Local\QwenGuard-' + (($Dir + '|' + $ProcName) -replace '[^A-Za-z0-9]', '') $Mutex = New-Object System.Threading.Mutex($false, $MtxName) # if a previous guard instance was force-killed, its abandoned mutex must # NOT block this instance: treat any acquire exception as ownership gained try { $acquired = $Mutex.WaitOne(0) } catch { $acquired = $true } if (-not $acquired) { exit 0 } function Write-Log([string]$Text) { $line = '{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Text try { if ((Test-Path -LiteralPath $LogPath) -and ((Get-Item -LiteralPath $LogPath).Length -gt 524288)) { Move-Item -LiteralPath $LogPath ($LogPath + '.1') -Force } Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } catch { } } $IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) function Test-TargetRunning { $list = @(Get-Process -Name $ProcName -ErrorAction SilentlyContinue) if ($list.Count -eq 0) { return $false } foreach ($p in $list) { $path = $null try { $path = $p.Path } catch { $path = $null } # an elevated process hides its path from a non-elevated query; # there the process name alone proves it is up if ([string]::IsNullOrEmpty($path)) { return $true } if ($path.StartsWith($Dir, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } } return $false } # Reuse the original launcher, so the window, the title and the # pwsh / cmd choice inside it stay exactly as configured. function Start-ByLauncher { if (-not (Test-Path -LiteralPath $LaunchPath)) { Write-Log ('launcher missing: ' + $LaunchPath) return $false } Start-Process -FilePath 'wscript.exe' -ArgumentList ('"' + $LaunchPath + '"') -WindowStyle Hidden return $true } Write-Log ('guard started pid=' + $PID + ' target=' + $ProcName + ' elevated=' + $IsAdmin + ' interval=' + $IntervalSec + 's' + ' mode=visible-window') if (-not $IsAdmin) { Write-Log ('guard is NOT elevated - each restart will raise a UAC prompt.' + ' Run the guard with highest privileges to keep restarts silent.') } $Fail = 0 while ($true) { if (-not (Test-Path -LiteralPath $ExePath)) { Write-Log ('executable missing: ' + $ExePath + ' - backing off') Start-Sleep -Seconds $BackoffSec continue } if (Test-TargetRunning) { if ($Fail -ne 0) { Write-Log ($ProcName + ' is back up') } $Fail = 0 Start-Sleep -Seconds $IntervalSec continue } Write-Log ($ProcName + ' is not running - reopening it with its window') $null = Start-ByLauncher $Fail = $Fail + 1 if ($Fail -ge $MaxFail) { Write-Log ('still down after ' + $Fail + ' attempts - backing off ' + $BackoffSec + 's.' + ' Check for a pending UAC prompt nobody clicked.') $Fail = 0 Start-Sleep -Seconds $BackoffSec } else { Start-Sleep -Seconds $CooldownSec } }
五、守护进程自身的保活与权限
守护进程仍需应对自身被终止的情况(例如在任务管理器中被误结束,或被清理类软件关闭)。
这一层交由 Windows 任务计划程序完成:1schtasks /Create /TN "Guard-FRP" /TR "wscript.exe \"I:\FRP\guard.vbs\"" /SC MINUTE /MO 5 /RL HIGHEST /F
该配置同时解决两个问题:
- 保活:
/SC MINUTE /MO 5使任务每 5 分钟触发一次。守护在运行时,新实例因互斥锁已被占用而立即静默退出,因此任何时刻仅存在一个守护;守护已终止时,最迟 5 分钟内被重新拉起。开机后的首次启动亦由该周期任务覆盖。 - 权限:
/RL HIGHEST使守护以管理员身份运行。这是整个方案中使自动恢复真正无人值守的关键——FRP.vbs 中的-Verb RunAs提权请求,对已持有管理员权限的调用者会自动满足,恢复过程不再产生 UAC 弹窗,无需人工确认。
需要注意的语法差异:上述命令为 cmd 格式。在 PowerShell 中执行时,\" 转义会被参数绑定拆散,导致 schtasks 报参数错误。
PowerShell 下应改用单引号包裹整个 /TR 值:1schtasks /Create /TN "Guard-FRP" /TR 'wscript.exe "I:\FRP\guard.vbs"' /SC MINUTE /MO 5 /RL HIGHEST /F
删除该任务:schtasks /Delete /TN "Guard-FRP" /F。
以上命令为便捷创建,当然也可以使用计划任务图形界面直观的建立。
小结
整套方案由三类常规组件构成:VBS 负责以 Windows Terminal 承载服务并保持日志可见,PowerShell 负责进程轮询与恢复,任务计划程序负责守护自身的保活与权限。它们分别覆盖自建服务运维的四个环节:
- 故障发现:以 15 秒周期检测进程存在性,不依赖人工查看日志;
- 过程可追溯:所有检测与恢复动作记录于带轮转的日志文件;
- 服务恢复:复用原始启动脚本,恢复行为与手动启动一致;
- 守护可靠性:通过进程树隔离避免与服务共同终止,以互斥锁避免实例堆积,以冷却与退避避免异常放大,以周期任务覆盖守护自身的失效。
方案不依赖 FRP 本身:替换 guard.ps1 顶部参数区的进程名、exe 名、启动脚本名三项,即可守护任意同类常驻控制台服务。对于以脚本方式常驻的自建服务,”被守护”与”守护自身被保活”两层缺一不可;补齐权限配置后,整套机制即可在无人值守条件下长期运行。