304 lines
12 KiB
PowerShell
304 lines
12 KiB
PowerShell
#Requires -Version 5.1
|
|
|
|
[CmdletBinding(SupportsShouldProcess = $true)]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Za-z0-9._:-]+$')]
|
|
[string] $ServerHost,
|
|
|
|
[ValidateRange(1, 65535)]
|
|
[int] $ServerPort = 22,
|
|
|
|
[ValidatePattern('^[A-Za-z0-9._-]+$')]
|
|
[string] $RemoteUser = 'caiwu-tunnel',
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[string] $PrivateKeyPath,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Za-z0-9._:-]+$')]
|
|
[string] $RemoteBindAddress,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateRange(1, 65535)]
|
|
[int] $RemoteBindPort,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Za-z0-9._:-]+$')]
|
|
[string] $UpstreamHost,
|
|
|
|
[ValidateRange(1, 65535)]
|
|
[int] $UpstreamPort = 1433,
|
|
|
|
[ValidateRange(10, 300)]
|
|
[int] $RetrySeconds = 30,
|
|
|
|
[ValidatePattern('^[a-z0-9_-]+$')]
|
|
[string] $SourceKey = 'default',
|
|
|
|
[ValidatePattern('^[A-Za-z0-9._-]+$')]
|
|
[string] $TaskName = 'CaiWuXiTong-UpstreamTunnel',
|
|
|
|
[string] $ConfigPath
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
function ConvertTo-TaskArgument {
|
|
param([Parameter(Mandatory = $true)][string] $Value)
|
|
|
|
return '"' + $Value.Replace('"', '\"') + '"'
|
|
}
|
|
|
|
$tunnelScript = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot 'keep-upstream-tunnel.ps1') -ErrorAction Stop).Path
|
|
$resolvedKeyPath = (Resolve-Path -LiteralPath $PrivateKeyPath -ErrorAction Stop).Path
|
|
$windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
|
|
if ($SourceKey -ne 'default' -and -not $PSBoundParameters.ContainsKey('TaskName')) {
|
|
$TaskName = 'CaiWuXiTong-UpstreamTunnel-{0}' -f $SourceKey
|
|
}
|
|
|
|
$privateConfigPath = if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
|
|
$configFileName = if ($SourceKey -eq 'default') {
|
|
'upstream-tunnel.json'
|
|
}
|
|
else {
|
|
'upstream-tunnel.{0}.json' -f $SourceKey
|
|
}
|
|
|
|
Join-Path (Join-Path $env:LOCALAPPDATA 'CaiWuXiTong') $configFileName
|
|
}
|
|
else {
|
|
[System.IO.Path]::GetFullPath($ConfigPath)
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $windowsPowerShell -PathType Leaf)) {
|
|
throw 'Windows PowerShell was not found; the tunnel task cannot be installed.'
|
|
}
|
|
|
|
$argumentList = @(
|
|
'-NoProfile',
|
|
'-ExecutionPolicy', 'Bypass',
|
|
'-WindowStyle', 'Hidden',
|
|
'-File', (ConvertTo-TaskArgument $tunnelScript),
|
|
'-ConfigPath', (ConvertTo-TaskArgument $privateConfigPath)
|
|
)
|
|
$taskArguments = $argumentList -join ' '
|
|
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
|
|
if ($PSCmdlet.ShouldProcess($TaskName, 'register and start the read-only upstream tunnel task')) {
|
|
$privateConfigDirectory = Split-Path -Parent $privateConfigPath
|
|
$null = New-Item -ItemType Directory -Path $privateConfigDirectory -Force
|
|
|
|
$privateSettings = [ordered] @{
|
|
SourceKey = $SourceKey
|
|
ServerHost = $ServerHost
|
|
ServerPort = $ServerPort
|
|
RemoteUser = $RemoteUser
|
|
PrivateKeyPath = $resolvedKeyPath
|
|
RemoteBindAddress = $RemoteBindAddress
|
|
RemoteBindPort = $RemoteBindPort
|
|
UpstreamHost = $UpstreamHost
|
|
UpstreamPort = $UpstreamPort
|
|
RetrySeconds = $RetrySeconds
|
|
}
|
|
$json = $privateSettings | ConvertTo-Json
|
|
[System.IO.File]::WriteAllText($privateConfigPath, $json, (New-Object System.Text.UTF8Encoding($false)))
|
|
|
|
$currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
& icacls.exe $privateConfigPath '/inheritance:r' '/grant:r' "*$currentSid`:(F)" '*S-1-5-18:(F)' | Out-Null
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw 'The private tunnel configuration permissions could not be restricted.'
|
|
}
|
|
|
|
# The ScheduledTasks module can report false RPC errors on trimmed Windows
|
|
# installations. The built-in COM API works in Windows PowerShell and pwsh.
|
|
$taskService = New-Object -ComObject 'Schedule.Service'
|
|
$rootFolder = $null
|
|
$definition = $null
|
|
$registeredTask = $null
|
|
|
|
try {
|
|
$taskService.Connect()
|
|
$rootFolder = $taskService.GetFolder('\')
|
|
$definition = $taskService.NewTask(0)
|
|
|
|
$definition.RegistrationInfo.Description = 'CaiWuXiTong restricted read-only upstream tunnel with automatic reconnect.'
|
|
$definition.Principal.UserId = $currentUser
|
|
$definition.Principal.LogonType = 3 # TASK_LOGON_INTERACTIVE_TOKEN
|
|
$definition.Principal.RunLevel = 0 # TASK_RUNLEVEL_LUA
|
|
|
|
$definition.Settings.Enabled = $true
|
|
$definition.Settings.StartWhenAvailable = $true
|
|
$definition.Settings.DisallowStartIfOnBatteries = $false
|
|
$definition.Settings.StopIfGoingOnBatteries = $false
|
|
$definition.Settings.ExecutionTimeLimit = 'PT0S'
|
|
$definition.Settings.MultipleInstances = 2 # TASK_INSTANCES_IGNORE_NEW
|
|
$definition.Settings.RestartCount = 999
|
|
$definition.Settings.RestartInterval = 'PT1M'
|
|
|
|
$trigger = $definition.Triggers.Create(9) # TASK_TRIGGER_LOGON
|
|
$trigger.UserId = $currentUser
|
|
$trigger.Enabled = $true
|
|
|
|
# Restart-on-failure does not cover tasks that Windows reports as
|
|
# externally terminated (for example 0xC000013A). Re-run the task
|
|
# every five minutes as a watchdog; IgnoreNew keeps the healthy
|
|
# long-running wrapper as the only instance.
|
|
$watchdogTrigger = $definition.Triggers.Create(2) # TASK_TRIGGER_DAILY
|
|
$watchdogTrigger.StartBoundary = (Get-Date).AddMinutes(1).ToString("yyyy-MM-dd'T'HH:mm:ss")
|
|
$watchdogTrigger.DaysInterval = 1
|
|
$watchdogTrigger.Repetition.Interval = 'PT5M'
|
|
$watchdogTrigger.Repetition.Duration = 'P1D'
|
|
$watchdogTrigger.Repetition.StopAtDurationEnd = $false
|
|
$watchdogTrigger.Enabled = $true
|
|
|
|
$action = $definition.Actions.Create(0) # TASK_ACTION_EXEC
|
|
$action.Path = $windowsPowerShell
|
|
$action.Arguments = $taskArguments
|
|
$action.WorkingDirectory = $PSScriptRoot
|
|
|
|
$existingTask = $null
|
|
$previousWrapperPid = $null
|
|
try {
|
|
$existingTask = $rootFolder.GetTask($TaskName)
|
|
}
|
|
catch [System.Runtime.InteropServices.COMException] {
|
|
$existingTask = $null
|
|
}
|
|
catch [System.IO.FileNotFoundException] {
|
|
# PowerShell 7 can translate Task Scheduler's missing-task HRESULT
|
|
# to FileNotFoundException instead of COMException.
|
|
$existingTask = $null
|
|
}
|
|
|
|
if ($null -ne $existingTask -and $existingTask.State -eq 4) {
|
|
$statePath = $privateConfigPath + '.pid'
|
|
if (-not (Test-Path -LiteralPath $statePath -PathType Leaf)) {
|
|
throw 'The existing tunnel task is running without a managed PID file; refusing an unsafe restart.'
|
|
}
|
|
|
|
$wrapperPid = [int] (Get-Content -LiteralPath $statePath -Raw)
|
|
$previousWrapperPid = $wrapperPid
|
|
$wrapperProcess = Get-Process -Id $wrapperPid -ErrorAction Stop
|
|
if ($wrapperProcess.ProcessName -notin @('powershell', 'pwsh')) {
|
|
throw 'The managed tunnel PID does not belong to PowerShell; refusing an unsafe restart.'
|
|
}
|
|
|
|
$sshChildren = @(
|
|
Get-CimInstance -ClassName Win32_Process -Filter "ParentProcessId = $wrapperPid AND Name = 'ssh.exe'" -ErrorAction Stop
|
|
)
|
|
$existingTask.Stop(0)
|
|
|
|
foreach ($sshChild in $sshChildren) {
|
|
$sshProcess = Get-Process -Id ([int] $sshChild.ProcessId) -ErrorAction SilentlyContinue
|
|
if ($null -ne $sshProcess -and $sshProcess.ProcessName -eq 'ssh') {
|
|
Stop-Process -Id $sshProcess.Id -Force
|
|
}
|
|
}
|
|
|
|
for ($attempt = 0; $attempt -lt 20 -and $existingTask.State -eq 4; $attempt++) {
|
|
Start-Sleep -Milliseconds 100
|
|
}
|
|
|
|
if ($existingTask.State -eq 4) {
|
|
throw 'The existing tunnel task did not stop cleanly.'
|
|
}
|
|
|
|
$remainingWrapper = Get-Process -Id $wrapperPid -ErrorAction SilentlyContinue
|
|
if ($null -ne $remainingWrapper) {
|
|
if ($remainingWrapper.ProcessName -notin @('powershell', 'pwsh')) {
|
|
throw 'The stopped tunnel PID was reused by another process; refusing an unsafe cleanup.'
|
|
}
|
|
Stop-Process -Id $wrapperPid -Force
|
|
}
|
|
|
|
[System.IO.File]::Delete($statePath)
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
else {
|
|
$inactiveStatePath = $privateConfigPath + '.pid'
|
|
if (Test-Path -LiteralPath $inactiveStatePath -PathType Leaf) {
|
|
$inactivePid = [int] (Get-Content -LiteralPath $inactiveStatePath -Raw)
|
|
$inactiveProcess = Get-Process -Id $inactivePid -ErrorAction SilentlyContinue
|
|
if ($null -ne $inactiveProcess -and $inactiveProcess.ProcessName -in @('powershell', 'pwsh')) {
|
|
throw 'A managed tunnel wrapper is running outside the scheduled task; refusing an unsafe replacement.'
|
|
}
|
|
[System.IO.File]::Delete($inactiveStatePath)
|
|
}
|
|
}
|
|
|
|
if ($null -ne $existingTask) {
|
|
[void] [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($existingTask)
|
|
}
|
|
|
|
$registeredTask = $rootFolder.RegisterTaskDefinition(
|
|
$TaskName,
|
|
$definition,
|
|
6, # TASK_CREATE_OR_UPDATE
|
|
$null,
|
|
$null,
|
|
3, # TASK_LOGON_INTERACTIVE_TOKEN
|
|
$null
|
|
)
|
|
|
|
$taskStarted = $false
|
|
for ($startAttempt = 0; $startAttempt -lt 3 -and -not $taskStarted; $startAttempt++) {
|
|
$runningInstance = $registeredTask.Run($null)
|
|
if ($null -ne $runningInstance -and [System.Runtime.InteropServices.Marshal]::IsComObject($runningInstance)) {
|
|
[void] [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($runningInstance)
|
|
}
|
|
|
|
for ($stateAttempt = 0; $stateAttempt -lt 20; $stateAttempt++) {
|
|
Start-Sleep -Milliseconds 100
|
|
$taskProbe = $rootFolder.GetTask($TaskName)
|
|
$taskState = [int] $taskProbe.State
|
|
[void] [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($taskProbe)
|
|
|
|
if ($taskState -eq 4) {
|
|
$taskStarted = $true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (-not $taskStarted) {
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
}
|
|
|
|
if (-not $taskStarted) {
|
|
throw 'The tunnel task was registered but did not enter the running state.'
|
|
}
|
|
|
|
$statePath = $privateConfigPath + '.pid'
|
|
$managedPid = $null
|
|
for ($stateAttempt = 0; $stateAttempt -lt 30; $stateAttempt++) {
|
|
Start-Sleep -Milliseconds 100
|
|
if (Test-Path -LiteralPath $statePath -PathType Leaf) {
|
|
$candidatePid = [int] (Get-Content -LiteralPath $statePath -Raw)
|
|
$candidateProcess = Get-Process -Id $candidatePid -ErrorAction SilentlyContinue
|
|
if ($null -ne $candidateProcess -and $candidateProcess.ProcessName -in @('powershell', 'pwsh')) {
|
|
if ($null -eq $previousWrapperPid -or $candidatePid -ne $previousWrapperPid) {
|
|
$managedPid = $candidatePid
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($null -eq $managedPid) {
|
|
throw 'The tunnel task started without creating a valid managed PID file.'
|
|
}
|
|
|
|
Write-Output ('Scheduled task {0} was registered and started.' -f $TaskName)
|
|
}
|
|
finally {
|
|
foreach ($comObject in @($registeredTask, $definition, $rootFolder, $taskService)) {
|
|
if ($null -ne $comObject -and [System.Runtime.InteropServices.Marshal]::IsComObject($comObject)) {
|
|
[void] [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($comObject)
|
|
}
|
|
}
|
|
}
|
|
}
|