Windows下FRP隧道服务的启动脚本与进程守护方案

概述

 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 名、启动脚本名三项,即可守护任意同类常驻控制台服务。对于以脚本方式常驻的自建服务,”被守护”与”守护自身被保活”两层缺一不可;补齐权限配置后,整套机制即可在无人值守条件下长期运行。

0 评论
最新
最旧 得票最多