安全:隐藏并管控上游隧道配置

This commit is contained in:
WuYongJie 2026-07-22 11:08:11 +08:00
parent a0ee9d7dd4
commit f652bf68eb
4 changed files with 215 additions and 19 deletions

View File

@ -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`。

View File

@ -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`。核心大表不随总开关自动开放。

View File

@ -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)
}

View File

@ -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()
}