diff --git a/AGENTS.md b/AGENTS.md index d797a5c6..d5156c71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,6 @@ - 生产服务器不能直接访问上游时,只允许通过专用低权限 SSH 账户建立受限反向隧道;隧道端口仅绑定 Docker 私有网桥,禁止监听公网地址,隧道账户禁止 Shell、TTY、本地转发和任意端口转发。 - 隧道由 `scripts/keep-upstream-tunnel.ps1` 自动重连,并通过 `scripts/install-upstream-tunnel-task.ps1` 安装为开发机登录任务。数据库凭据不得作为隧道参数;每台候选中继开发机使用独立 SSH 私钥,不得多人共享私钥。 - 正式环境最多安排 1 台主中继和 1 台备用中继;同一时刻只允许一个远端监听成功,备用机低频重试。隧道中断时页面继续读取旧快照,禁止绕过缓存直接重试上游。 -- 上游地址、账号、密码只允许存在于服务器私有环境配置;代码、提交、PR、共享文档和日志中一律不得出现真实值。 +- 上游数据库账号和密码只允许存在于服务器私有环境配置;实际端点除服务器配置外,只允许存在于中继开发机当前用户专属、已收紧 ACL 的隧道配置。代码、提交、PR、共享文档和日志中一律不得出现真实值。 详细 SOP 见 `docs/开发与部署SOP.md`。 diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md index 45bde78b..c0bd4adc 100644 --- a/docs/开发与部署SOP.md +++ b/docs/开发与部署SOP.md @@ -205,7 +205,7 @@ JIANSUN_SLOW_QUERY_COOLDOWN=300 -UpstreamHost '<上游数据库地址>' ``` -安装脚本创建当前用户的登录计划任务,实际守护逻辑位于 `scripts/keep-upstream-tunnel.ps1`。守护进程使用 SSH keepalive 检测断线并带随机延迟重连,避免多台备用机同时高频连接服务器。 +安装脚本把端点和私钥路径写入当前用户专属的本机私有配置文件,并移除继承权限,只允许当前用户和 `SYSTEM` 读取;登录计划任务本身只保存该私有文件路径,不直接暴露端点。实际守护逻辑位于 `scripts/keep-upstream-tunnel.ps1`。守护进程使用 SSH keepalive 检测断线并带随机延迟重连,避免多台备用机同时高频连接服务器。 启用前按以下顺序验收:容器到隧道端口 TCP 可达;只开放一个已探测的小表范围;第一次查询实际访问上游且在超时内完成;第二次相同查询命中本地快照且不再访问上游;最后才把服务器私有 `JIANSUN_ENABLED` 改为 `true`。核心大表不随总开关自动开放。 diff --git a/scripts/install-upstream-tunnel-task.ps1 b/scripts/install-upstream-tunnel-task.ps1 index 68ae8912..c150276f 100644 --- a/scripts/install-upstream-tunnel-task.ps1 +++ b/scripts/install-upstream-tunnel-task.ps1 @@ -34,7 +34,9 @@ param( [int] $RetrySeconds = 30, [ValidatePattern('^[A-Za-z0-9._-]+$')] - [string] $TaskName = 'CaiWuXiTong-UpstreamTunnel' + [string] $TaskName = 'CaiWuXiTong-UpstreamTunnel', + + [string] $ConfigPath ) Set-StrictMode -Version Latest @@ -49,6 +51,12 @@ function ConvertTo-TaskArgument { $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' +$privateConfigPath = if ([string]::IsNullOrWhiteSpace($ConfigPath)) { + Join-Path (Join-Path $env:LOCALAPPDATA 'CaiWuXiTong') 'upstream-tunnel.json' +} +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.' @@ -59,20 +67,35 @@ $argumentList = @( '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', (ConvertTo-TaskArgument $tunnelScript), - '-ServerHost', (ConvertTo-TaskArgument $ServerHost), - '-ServerPort', $ServerPort, - '-RemoteUser', (ConvertTo-TaskArgument $RemoteUser), - '-PrivateKeyPath', (ConvertTo-TaskArgument $resolvedKeyPath), - '-RemoteBindAddress', (ConvertTo-TaskArgument $RemoteBindAddress), - '-RemoteBindPort', $RemoteBindPort, - '-UpstreamHost', (ConvertTo-TaskArgument $UpstreamHost), - '-UpstreamPort', $UpstreamPort, - '-RetrySeconds', $RetrySeconds + '-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] @{ + 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' @@ -108,6 +131,75 @@ if ($PSCmdlet.ShouldProcess($TaskName, 'register and start the read-only upstrea $action.Arguments = $taskArguments $action.WorkingDirectory = $PSScriptRoot + $existingTask = $null + $previousWrapperPid = $null + try { + $existingTask = $rootFolder.GetTask($TaskName) + } + catch [System.Runtime.InteropServices.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, @@ -117,7 +209,54 @@ if ($PSCmdlet.ShouldProcess($TaskName, 'register and start the read-only upstrea 3, # TASK_LOGON_INTERACTIVE_TOKEN $null ) - $null = $registeredTask.Run($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) } diff --git a/scripts/keep-upstream-tunnel.ps1 b/scripts/keep-upstream-tunnel.ps1 index 20efd01c..855d3767 100644 --- a/scripts/keep-upstream-tunnel.ps1 +++ b/scripts/keep-upstream-tunnel.ps1 @@ -1,8 +1,11 @@ #Requires -Version 5.1 -[CmdletBinding()] +[CmdletBinding(DefaultParameterSetName = 'Explicit')] param( - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $true, ParameterSetName = 'Config')] + [string] $ConfigPath, + + [Parameter(Mandatory = $true, ParameterSetName = 'Explicit')] [ValidatePattern('^[A-Za-z0-9._:-]+$')] [string] $ServerHost, @@ -12,18 +15,18 @@ param( [ValidatePattern('^[A-Za-z0-9._-]+$')] [string] $RemoteUser = 'caiwu-tunnel', - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $true, ParameterSetName = 'Explicit')] [string] $PrivateKeyPath, - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $true, ParameterSetName = 'Explicit')] [ValidatePattern('^[A-Za-z0-9._:-]+$')] [string] $RemoteBindAddress, - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $true, ParameterSetName = 'Explicit')] [ValidateRange(1, 65535)] [int] $RemoteBindPort, - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $true, ParameterSetName = 'Explicit')] [ValidatePattern('^[A-Za-z0-9._:-]+$')] [string] $UpstreamHost, @@ -38,6 +41,44 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$statePath = $null + +if ($PSCmdlet.ParameterSetName -eq 'Config') { + $resolvedConfigPath = (Resolve-Path -LiteralPath $ConfigPath -ErrorAction Stop).Path + $statePath = $resolvedConfigPath + '.pid' + $settings = Get-Content -LiteralPath $resolvedConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json + + $ServerHost = [string] $settings.ServerHost + $ServerPort = [int] $settings.ServerPort + $RemoteUser = [string] $settings.RemoteUser + $PrivateKeyPath = [string] $settings.PrivateKeyPath + $RemoteBindAddress = [string] $settings.RemoteBindAddress + $RemoteBindPort = [int] $settings.RemoteBindPort + $UpstreamHost = [string] $settings.UpstreamHost + $UpstreamPort = [int] $settings.UpstreamPort + $RetrySeconds = [int] $settings.RetrySeconds +} + +$endpointPattern = '^[A-Za-z0-9._:-]+$' +foreach ($endpoint in @($ServerHost, $RemoteBindAddress, $UpstreamHost)) { + if ([string]::IsNullOrWhiteSpace($endpoint) -or $endpoint -notmatch $endpointPattern) { + throw 'The tunnel configuration contains an invalid endpoint.' + } +} + +if ($RemoteUser -notmatch '^[A-Za-z0-9._-]+$') { + throw 'The tunnel configuration contains an invalid SSH user.' +} + +foreach ($port in @($ServerPort, $RemoteBindPort, $UpstreamPort)) { + if ($port -lt 1 -or $port -gt 65535) { + throw 'The tunnel configuration contains an invalid port.' + } +} + +if ($RetrySeconds -lt 10 -or $RetrySeconds -gt 300) { + throw 'The tunnel retry interval must be between 10 and 300 seconds.' +} $sshCommand = Get-Command 'ssh.exe' -ErrorAction Stop $resolvedKeyPath = (Resolve-Path -LiteralPath $PrivateKeyPath -ErrorAction Stop).Path @@ -68,6 +109,15 @@ try { exit 0 } + if ($null -ne $statePath) { + [System.IO.File]::WriteAllText($statePath, [string] $PID, [System.Text.Encoding]::ASCII) + $currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + & icacls.exe $statePath '/inheritance:r' '/grant:r' "*$currentSid`:(F)" '*S-1-5-18:(F)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw 'The tunnel PID file permissions could not be restricted.' + } + } + $forward = '{0}:{1}:{2}:{3}' -f $RemoteBindAddress, $RemoteBindPort, $UpstreamHost, $UpstreamPort $destination = '{0}@{1}' -f $RemoteUser, $ServerHost $sshArguments = @( @@ -102,6 +152,13 @@ try { while ($true) } finally { + if ($null -ne $statePath -and (Test-Path -LiteralPath $statePath -PathType Leaf)) { + $recordedPid = Get-Content -LiteralPath $statePath -Raw -ErrorAction SilentlyContinue + if ([string] $recordedPid -eq [string] $PID) { + Remove-Item -LiteralPath $statePath -Force -ErrorAction SilentlyContinue + } + } + if ($ownsMutex) { $mutex.ReleaseMutex() }