From 784e82b0a22090295dfee75d8dc665b0c62d2498 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 9 Sep 2026 11:54:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=9B=B4=E6=8E=A5=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E5=AF=86=E7=A0=81=E9=83=A8=E7=BD=B2=E5=B9=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=A4=8D=E7=94=A8=E6=9C=AC=E6=9C=BA=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + docs/开发与部署SOP.md | 10 +-- scripts/deploy-auth.ps1 | 54 ++++++++++++ scripts/deploy-from-workstation.ps1 | 117 +++++++++++++------------ scripts/deploy-password-prompt.cmd | 2 + scripts/deploy-password-prompt.ps1 | 47 ++++++++++ tests/scripts/deploy-auth-server.py | 86 ++++++++++++++++++ tests/scripts/deploy-password-auth.ps1 | 73 +++++++++++++++ 8 files changed, 330 insertions(+), 60 deletions(-) create mode 100644 scripts/deploy-auth.ps1 create mode 100644 scripts/deploy-password-prompt.cmd create mode 100644 scripts/deploy-password-prompt.ps1 create mode 100644 tests/scripts/deploy-auth-server.py create mode 100644 tests/scripts/deploy-password-auth.ps1 diff --git a/AGENTS.md b/AGENTS.md index 44f5a02a..710b99dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,7 @@ - 普通代码发布由任务 Codex 在完成验证与合并后直接执行,不要求用户再次说“部署、上线”;用户明确要求仅本地开发、仅审查或暂不上线时遵循其范围。正式发布只使用已合并的最新 `main`。 - Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`。服务器只运行开发机已构建的 `caiwu_akaunting:` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。 +- 新人只需表达部署需求;Codex 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。 - 当前生产入口为 `https://caiwu.jiansuntuzhi.cn:8442`;公网仍只使用 `8442`,日常发布不得改成无端口地址。 - 发布脚本检测到新增数据库迁移时必须停止,取得负责人明确授权并完成备份后才能使用迁移开关。 - 容器内 Laravel 命令使用 `www-data`。发布后只需确认部署 SHA、应用容器健康、受影响的真实页面/接口、迁移是否执行,以及当前和上一版镜像可回滚。 diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md index c9b25ee4..7ad7db0c 100644 --- a/docs/开发与部署SOP.md +++ b/docs/开发与部署SOP.md @@ -39,9 +39,11 @@ 2. 在 Docker Desktop 的 `Settings -> Resources -> Advanced` 中: - Docker 虚拟磁盘上限建议 60GB; - Windows 磁盘至少保留 15GB 可用空间。 -3. SSH 私钥放到个人目录,例如 `C:\Users\你的用户名\.ssh\caiwu.pem`,不要放进 Git。 +3. 首次部署仅需在本机密码窗口输入一次服务器密码,之后 Codex 自动复用本机加密保存的登录信息;不要求新人生成、配置或管理私钥。密码使用 Windows 当前用户绑定的 DPAPI 加密,保存在 `%LOCALAPPDATA%\CaiWuXiTong\deploy-credentials`,不进入仓库或日志。已有私钥的机器仍可选传 `-KeyPath`。 4. 只使用仓库里的发布脚本。不要自行执行 `docker system prune -a`,也不要重置 Docker Desktop。 +Codex 已有用户提供的密码时可通过 `-Password` 的 `SecureString` 参数直接使用并保存,无需用户再输入;不要把明文写进命令参数或日志。密码变更时由 Codex 使用 `-UpdatePassword` 更新本机登录信息。部署结束保留加密凭据,下次自动使用。 + 说明:Docker 虚拟磁盘清理后,Windows 上看到的 `.vhdx` 文件可能不会立刻变小,但内部可用空间已经释放。不要为了让文件数字变小而删除虚拟磁盘。 ## 3. 为什么后续不会反复全量下载和上传 @@ -71,7 +73,6 @@ ```powershell .\scripts\deploy-from-workstation.ps1 ` -Server 218.93.190.75 ` - -KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" ` -BootstrapServer ``` @@ -98,9 +99,7 @@ git switch main git pull --ff-only -.\scripts\deploy-from-workstation.ps1 ` - -Server 218.93.190.75 ` - -KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" +.\scripts\deploy-from-workstation.ps1 ``` 脚本自动完成: @@ -138,7 +137,6 @@ git pull --ff-only ```powershell .\scripts\deploy-from-workstation.ps1 ` -Server 218.93.190.75 ` - -KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" ` -AllowMigrations ``` diff --git a/scripts/deploy-auth.ps1 b/scripts/deploy-auth.ps1 new file mode 100644 index 00000000..cac90c74 --- /dev/null +++ b/scripts/deploy-auth.ps1 @@ -0,0 +1,54 @@ +function Start-CaiwuDeploymentAuth { + param([string]$Server, [string]$SshUser, [string]$KeyPath, [Security.SecureString]$Password, [switch]$UpdatePassword) + if ($Server -notmatch '^[A-Za-z0-9][A-Za-z0-9.-]*$' -or $SshUser -notmatch '^[A-Za-z_][A-Za-z0-9_-]*$') { + throw '服务器地址或登录用户名格式无效。' + } + $options = @('-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10') + if (-not [string]::IsNullOrWhiteSpace($KeyPath)) { + return @{ Options = $options + @('-i', (Resolve-Path -LiteralPath $KeyPath).Path, '-o', 'BatchMode=yes'); Directory = $null; Saved = @{} } + } + $directory = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) "CaiWuXiTong/deploy-credentials/${SshUser}@${Server}" + [IO.Directory]::CreateDirectory($directory) | Out-Null + $session = @{ Directory = $directory; Saved = @{}; Options = $options + @('-o', 'BatchMode=no', '-o', 'PreferredAuthentications=password', '-o', 'PubkeyAuthentication=no', '-o', 'NumberOfPasswordPrompts=1') } + try { + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $owner = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl.SetOwner($owner) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($owner, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')) + [IO.FileSystemAclExtensions]::SetAccessControl([IO.DirectoryInfo]::new($directory), $acl) + $values = @{ + SSH_ASKPASS = (Join-Path $PSScriptRoot 'deploy-password-prompt.cmd') + SSH_ASKPASS_REQUIRE = 'force' + DISPLAY = 'caiwu-deploy' + CAIWU_DEPLOY_LOGIN = "${SshUser}@${Server}" + CAIWU_DEPLOY_PASSWORD_CACHE = (Join-Path $directory 'password.dpapi') + CAIWU_DEPLOY_PASSWORD_UPDATE = $(if ($UpdatePassword) { '1' } else { $null }) + } + foreach ($name in $values.Keys) { + $session.Saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + [Environment]::SetEnvironmentVariable($name, $values[$name], 'Process') + } + if ($Password) { + $Password | ConvertFrom-SecureString | Set-Content -LiteralPath $env:CAIWU_DEPLOY_PASSWORD_CACHE -Encoding ASCII + $env:CAIWU_DEPLOY_PASSWORD_UPDATE = $null + } + if (Test-Path -LiteralPath $env:CAIWU_DEPLOY_PASSWORD_CACHE) { + Write-Host '正在使用本机已保存的部署登录信息。' + } else { + Write-Host '首次部署:请在本机窗口输入服务器密码。此后自动使用已保存的登录信息。' + } + return $session + } catch { + Stop-CaiwuDeploymentAuth $session + throw + } +} + +function Stop-CaiwuDeploymentAuth { + param($Session) + if (-not $Session) { return } + foreach ($name in $Session.Saved.Keys) { + [Environment]::SetEnvironmentVariable($name, $Session.Saved[$name], 'Process') + } +} diff --git a/scripts/deploy-from-workstation.ps1 b/scripts/deploy-from-workstation.ps1 index fcf9f2eb..d1ef5467 100644 --- a/scripts/deploy-from-workstation.ps1 +++ b/scripts/deploy-from-workstation.ps1 @@ -1,10 +1,10 @@ [CmdletBinding()] param( - [Parameter(Mandatory = $true)] - [string]$Server, + [string]$Server = '218.93.190.75', - [Parameter(Mandatory = $true)] [string]$KeyPath, + [Security.SecureString]$Password, + [switch]$UpdatePassword, [string]$SshUser = 'root', [string]$RemotePath = '/opt/caiwuxitong', @@ -26,7 +26,9 @@ param( $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$keyFullPath = (Resolve-Path $KeyPath).Path +. (Join-Path $PSScriptRoot 'deploy-auth.ps1') +$authSession = $null +$sshOptions = @() $sshTarget = "${SshUser}@${Server}" $imageName = 'caiwu_akaunting' $tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("caiwu-deploy-" + [Guid]::NewGuid().ToString('N')) @@ -114,7 +116,7 @@ function Ensure-QiniuBusinessStorageRuntime { "grep -q '^QINIU_SECRET_KEY=.' '$remoteEnvironment'" ) -join ' && ' - & ssh -i $keyFullPath -o BatchMode=yes $sshTarget $remoteReadyCommand + & ssh @sshOptions $sshTarget $remoteReadyCommand $remoteReady = $LASTEXITCODE -eq 0 if ($remoteReady) { @@ -136,8 +138,7 @@ function Ensure-QiniuBusinessStorageRuntime { $pendingEnvironment = "$remoteEnvironment.pending" $environmentContent | & ssh ` - -i $keyFullPath ` - -o BatchMode=yes ` + @sshOptions ` $sshTarget ` "umask 077; cat > '$pendingEnvironment'; chmod 600 '$pendingEnvironment'; mv '$pendingEnvironment' '$remoteEnvironment'" if ($LASTEXITCODE -ne 0) { @@ -249,17 +250,15 @@ function Start-RegistryTunnel { $startInfo.FileName = $sshPath $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true - foreach ($argument in @( + foreach ($argument in ($sshOptions + @( '-N', - '-i', $keyFullPath, - '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=20', '-L', "127.0.0.1:${LocalPort}:127.0.0.1:5000", $sshTarget - )) { + ))) { $startInfo.ArgumentList.Add($argument) } @@ -303,7 +302,6 @@ try { $deploySha = (git rev-parse HEAD).Trim() $imageTag = $deploySha.Substring(0, 12) $fullImage = "${imageName}:$imageTag" - $remoteArchive = "$RemotePath/image-$imageTag.tar.gz" $buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') if (-not $AppUrl) { @@ -317,6 +315,13 @@ try { Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。' + $authSession = Start-CaiwuDeploymentAuth -Server $Server -SshUser $SshUser -KeyPath $KeyPath -Password $Password -UpdatePassword:$UpdatePassword + $sshOptions = $authSession.Options + & ssh @sshOptions $sshTarget true + if ($LASTEXITCODE -ne 0) { throw '服务器登录失败或已取消,已停止部署。若密码已变更,由 Codex 使用 -UpdatePassword 重新打开本机密码窗口。' } + if ($authSession.Directory) { $env:CAIWU_DEPLOY_PASSWORD_UPDATE = $null } + $remoteArchive = "$RemotePath/image-$imageTag.tar.gz" + & (Join-Path $PSScriptRoot 'build-frontend.ps1') docker buildx inspect $BuilderName 2>$null | Out-Null @@ -351,7 +356,7 @@ try { docker run --rm --network none --user www-data --entrypoint php $fullImage artisan --version } '发布镜像无法引导 Laravel。服务器未发生任何变更。' - $previousShaOutput = @(& ssh -i $keyFullPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true") + $previousShaOutput = @(& ssh @sshOptions -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true") $previousSha = ($previousShaOutput -join "`n").Trim() if ($LASTEXITCODE -ne 0) { throw '无法连接服务器。' @@ -381,22 +386,22 @@ try { if ($BootstrapServer) { $bootstrapRemote = "/tmp/caiwu-bootstrap-$imageTag.sh" Invoke-Native { - scp -i $keyFullPath -o BatchMode=yes deploy/bootstrap-server.sh "${sshTarget}:$bootstrapRemote" + scp @sshOptions deploy/bootstrap-server.sh "${sshTarget}:$bootstrapRemote" } '上传服务器初始化脚本失败。' Invoke-Native { - ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$bootstrapRemote' && '$bootstrapRemote' '$AppUrl' '$RemotePath' && rm -f '$bootstrapRemote'" + ssh @sshOptions $sshTarget "chmod 700 '$bootstrapRemote' && '$bootstrapRemote' '$AppUrl' '$RemotePath' && rm -f '$bootstrapRemote'" } '服务器初始化失败。' } Invoke-Native { - ssh -i $keyFullPath -o BatchMode=yes $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'" + ssh @sshOptions $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'" } '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。' Ensure-QiniuBusinessStorageRuntime $migrationFlag = if ($AllowMigrations) { '1' } else { '0' } Invoke-Native { - scp -i $keyFullPath -o BatchMode=yes ` + scp @sshOptions ` compose.jiangling.yml ` deploy/activate-release.sh ` deploy/activate-incremental-release.sh ` @@ -410,7 +415,7 @@ try { } '上传运行配置失败。' Invoke-Native { - ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/update-runtime-url.sh' && '$RemotePath/update-runtime-url.sh' '$AppUrl' '$RemotePath'" + ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/update-runtime-url.sh' && '$RemotePath/update-runtime-url.sh' '$AppUrl' '$RemotePath'" } '更新服务器访问地址失败。' if ($BootstrapServer) { @@ -430,14 +435,14 @@ try { Remove-Item -LiteralPath $localTar -Force $archiveSizeMB = [math]::Round((Get-Item -LiteralPath $localArchive).Length / 1MB, 1) Write-Host "首次初始化全量包:$archiveSizeMB MiB" - Invoke-Native { scp -i $keyFullPath -o BatchMode=yes $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。' + Invoke-Native { scp @sshOptions $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。' Invoke-Native { - ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'" + ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'" } '服务器激活、健康检查或增量仓库初始化失败。' } else { Invoke-NativeWithRetry { - ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')" + ssh @sshOptions -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')" } '服务器增量镜像仓库初始化失败。' # docker save 导出未压缩层,每次导入重新压缩会改变摘要,导致 Registry 无法复用。 @@ -460,7 +465,7 @@ try { $registryImage = "127.0.0.1:${localRegistryPort}/caiwuxitong/app:$imageTag" $serverRegistryImage = "127.0.0.1:5000/caiwuxitong/app:$imageTag" - $beforeBytesOutput = @(& ssh -i $keyFullPath -o BatchMode=yes $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1") + $beforeBytesOutput = @(& ssh @sshOptions $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1") $beforeBytes = [long](($beforeBytesOutput -join '').Trim()) $uploadTimer = [Diagnostics.Stopwatch]::StartNew() $uploadSucceeded = $false @@ -503,19 +508,19 @@ try { throw '增量镜像层上传失败。' } $uploadTimer.Stop() - $afterBytesOutput = @(& ssh -i $keyFullPath -o BatchMode=yes $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1") + $afterBytesOutput = @(& ssh @sshOptions $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1") $afterBytes = [long](($afterBytesOutput -join '').Trim()) $incrementalMiB = [math]::Round([math]::Max(0, $afterBytes - $beforeBytes) / 1MB, 2) Write-Host "增量新增镜像层:$incrementalMiB MiB;传输耗时:$([math]::Round($uploadTimer.Elapsed.TotalSeconds, 1)) 秒" Invoke-Native { - ssh -i $keyFullPath -o BatchMode=yes $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'" + ssh @sshOptions $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'" } '服务器增量激活或健康检查失败。' } if ($ConfigureDingTalkCallback) { Invoke-Native { - ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/configure-dingtalk-callback.sh' && '$RemotePath/configure-dingtalk-callback.sh' '$Server' '$RemotePath'" + ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/configure-dingtalk-callback.sh' && '$RemotePath/configure-dingtalk-callback.sh' '$Server' '$RemotePath'" } '钉钉 HTTP 回调 Nginx 配置失败。' } @@ -523,39 +528,43 @@ try { Write-Host "发布完成:$fullImage -> $AppUrl" } finally { - Pop-Location + try { + Pop-Location - if (Test-Path -LiteralPath $tempRoot) { - Remove-Item -LiteralPath $tempRoot -Recurse -Force - } - - if ($sshTunnel) { - if (-not $sshTunnel.HasExited) { - $sshTunnel.Kill() + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force } - $sshTunnel.Dispose() - } - if ($remoteArchive) { - ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null - } + if ($sshTunnel) { + if (-not $sshTunnel.HasExited) { + $sshTunnel.Kill() + } + $sshTunnel.Dispose() + } - if ($bootstrapRemote) { - ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$bootstrapRemote'" 2>$null | Out-Null - } + if ($remoteArchive) { + ssh @sshOptions -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null + } - # Keep the compiled PHP base and Composer vendor layers between releases. - # The production build cache is normally about 20 GB; pruning it to 3 GB made - # every deployment rebuild PHP extensions and download all dependencies again. - $pruneHelp = docker buildx prune --help 2>$null | Out-String - $buildCacheMaxAge = "${BuildCacheMaxAgeHours}h" - docker buildx prune --builder $BuilderName --filter "until=$buildCacheMaxAge" --force 2>$null | Out-Null - if ($pruneHelp -match '--max-used-space') { - docker buildx prune --builder $BuilderName --max-used-space $BuildCacheMaxUsedSpace --force 2>$null | Out-Null - } - elseif ($pruneHelp -match '--keep-storage') { - docker buildx prune --builder $BuilderName --keep-storage $BuildCacheMaxUsedSpace --force 2>$null | Out-Null - } + if ($bootstrapRemote) { + ssh @sshOptions -o ConnectTimeout=5 $sshTarget "rm -f '$bootstrapRemote'" 2>$null | Out-Null + } - docker system df + # Keep the compiled PHP base and Composer vendor layers between releases. + # The production build cache is normally about 20 GB; pruning it to 3 GB made + # every deployment rebuild PHP extensions and download all dependencies again. + $pruneHelp = docker buildx prune --help 2>$null | Out-String + $buildCacheMaxAge = "${BuildCacheMaxAgeHours}h" + docker buildx prune --builder $BuilderName --filter "until=$buildCacheMaxAge" --force 2>$null | Out-Null + if ($pruneHelp -match '--max-used-space') { + docker buildx prune --builder $BuilderName --max-used-space $BuildCacheMaxUsedSpace --force 2>$null | Out-Null + } + elseif ($pruneHelp -match '--keep-storage') { + docker buildx prune --builder $BuilderName --keep-storage $BuildCacheMaxUsedSpace --force 2>$null | Out-Null + } + + docker system df + } finally { + Stop-CaiwuDeploymentAuth $authSession + } } diff --git a/scripts/deploy-password-prompt.cmd b/scripts/deploy-password-prompt.cmd new file mode 100644 index 00000000..7c17250c --- /dev/null +++ b/scripts/deploy-password-prompt.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -STA -ExecutionPolicy Bypass -File "%~dp0deploy-password-prompt.ps1" diff --git a/scripts/deploy-password-prompt.ps1 b/scripts/deploy-password-prompt.ps1 new file mode 100644 index 00000000..1d5e7911 --- /dev/null +++ b/scripts/deploy-password-prompt.ps1 @@ -0,0 +1,47 @@ +# OpenSSH invokes this through SSH_ASKPASS. stdout goes only to OpenSSH. +# Never log or pass plaintext as a process argument. Persist only user-bound DPAPI ciphertext. +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false) +# A Codex PowerShell 7 parent may supply a different PSModulePath. +Import-Module (Join-Path $PSHOME 'Modules/Microsoft.PowerShell.Security/Microsoft.PowerShell.Security.psd1') +Import-Module (Join-Path $PSHOME 'Modules/Microsoft.PowerShell.Management/Microsoft.PowerShell.Management.psd1') +if ($env:CAIWU_DEPLOY_PASSWORD_UPDATE -ne '1' -and (Test-Path -LiteralPath $env:CAIWU_DEPLOY_PASSWORD_CACHE)) { + $secure = (Get-Content -LiteralPath $env:CAIWU_DEPLOY_PASSWORD_CACHE -Raw).Trim() | ConvertTo-SecureString + $credential = [Management.Automation.PSCredential]::new('deploy', $secure) + [Console]::Out.WriteLine($credential.GetNetworkCredential().Password) + exit 0 +} +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$form = New-Object Windows.Forms.Form +$form.Text = '财务系统:部署登录' +$form.ClientSize = New-Object Drawing.Size(430, 155) +$form.StartPosition = 'CenterScreen' +$form.TopMost = $true +$label = New-Object Windows.Forms.Label +$label.Text = "请输入服务器 $env:CAIWU_DEPLOY_LOGIN 的密码:" +$label.SetBounds(15, 15, 400, 40) +$inputBox = New-Object Windows.Forms.TextBox +$inputBox.UseSystemPasswordChar = $true +$inputBox.SetBounds(15, 60, 400, 25) +$ok = New-Object Windows.Forms.Button +$ok.Text = '登录' +$ok.SetBounds(250, 105, 75, 30) +$ok.DialogResult = [Windows.Forms.DialogResult]::OK +$cancel = New-Object Windows.Forms.Button +$cancel.Text = '取消' +$cancel.SetBounds(335, 105, 75, 30) +$cancel.DialogResult = [Windows.Forms.DialogResult]::Cancel +$form.Controls.AddRange(@($label, $inputBox, $ok, $cancel)) +$form.AcceptButton = $ok +$form.CancelButton = $cancel +try { + if ($form.ShowDialog() -ne [Windows.Forms.DialogResult]::OK) { exit 1 } + # DPAPI ciphertext, restricted to this Windows user, reused by later deployments. + $secure = ConvertTo-SecureString -String $inputBox.Text -AsPlainText -Force + $secure | ConvertFrom-SecureString | Set-Content -LiteralPath $env:CAIWU_DEPLOY_PASSWORD_CACHE -Encoding ASCII + [Console]::Out.WriteLine($inputBox.Text) +} finally { + $inputBox.Clear() + $form.Dispose() +} diff --git a/tests/scripts/deploy-auth-server.py b/tests/scripts/deploy-auth-server.py new file mode 100644 index 00000000..5dde98b3 --- /dev/null +++ b/tests/scripts/deploy-auth-server.py @@ -0,0 +1,86 @@ +"""Loopback-only SSH/SFTP fixture; password arrives through stdin, never logs.""" +import io +import socket +import sys +import threading +import time +import paramiko + +password = sys.stdin.readline().rstrip('\n') +key = paramiko.RSAKey.generate(2048) + +class Server(paramiko.ServerInterface): + def __init__(self): + self.direct = set() + + def check_auth_password(self, username, supplied): + return paramiko.AUTH_SUCCESSFUL if supplied == password else paramiko.AUTH_FAILED + + def get_allowed_auths(self, username): + return 'password' + + def check_channel_request(self, kind, chanid): + return paramiko.OPEN_SUCCEEDED if kind == 'session' else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED + + def check_channel_direct_tcpip_request(self, chanid, origin, destination): + self.direct.add(chanid) + return paramiko.OPEN_SUCCEEDED + + def check_channel_exec_request(self, channel, command): + def finish(): + time.sleep(0.1) + channel.send_exit_status(0) + channel.close() + threading.Thread(target=finish, daemon=True).start() + return True + +class SFTP(paramiko.SFTPServerInterface): + def stat(self, path): + return paramiko.SFTP_NO_SUCH_FILE + + def open(self, path, flags, attr): + class Handle(paramiko.SFTPHandle): + def chattr(self, attr): + return paramiko.SFTP_OK + handle = Handle(flags) + handle.writefile = io.BytesIO() + return handle + +def connection(client): + transport = paramiko.Transport(client) + transport.add_server_key(key) + class SFTPSubsystem(paramiko.SFTPServer): + def start_subsystem(self, name, transport, channel): + super().start_subsystem(name, transport, channel) + channel.send_exit_status(0) + transport.set_subsystem_handler('sftp', SFTPSubsystem, SFTP) + try: + server = Server() + channels = [] + transport.start_server(server=server) + while transport.is_active(): + channel = transport.accept(0.5) + if channel: + channels.append(channel) + if channel.chanid not in server.direct: + continue + def echo(c): + while not c.closed: + if c.recv_ready(): + data = c.recv(1024) + if data: + c.sendall(data) + time.sleep(0.01) + threading.Thread(target=echo, args=(channel,), daemon=True).start() + except (EOFError, OSError, paramiko.SSHException): + pass + finally: + transport.close() + +listener = socket.socket() +listener.bind(('127.0.0.1', 0)) +listener.listen(10) +print(listener.getsockname()[1], flush=True) +while True: + client, _ = listener.accept() + threading.Thread(target=connection, args=(client,), daemon=True).start() diff --git a/tests/scripts/deploy-password-auth.ps1 b/tests/scripts/deploy-password-auth.ps1 new file mode 100644 index 00000000..69bccd35 --- /dev/null +++ b/tests/scripts/deploy-password-auth.ps1 @@ -0,0 +1,73 @@ +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +. (Join-Path $root 'scripts/deploy-auth.ps1') +$session = $null +$server = $null +$tunnel = $null +$username = 'test_' + [Guid]::NewGuid().ToString('N') +try { + $password = [Guid]::NewGuid().ToString('N') + '测试 !$`"' + $session = Start-CaiwuDeploymentAuth -Server '127.0.0.1' -SshUser $username -Password (ConvertTo-SecureString $password -AsPlainText -Force) + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = (Get-Command python).Source + $info.ArgumentList.Add((Join-Path $PSScriptRoot 'deploy-auth-server.py')) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardInput = $true + $info.StandardInputEncoding = [Text.UTF8Encoding]::new($false) + $info.Environment['PYTHONIOENCODING'] = 'utf-8' + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $server = [Diagnostics.Process]::Start($info) + $server.StandardInput.WriteLine($password) + $server.StandardInput.Flush() + $port = $server.StandardOutput.ReadLine() + if ($port -notmatch '^\d+$') { throw 'SSH fixture did not start' } + $options = $session.Options + @('-o', "UserKnownHostsFile=$($session.Directory)/known_hosts") + $target = "${username}@127.0.0.1" + & ssh @options -p $port $target true + if ($LASTEXITCODE -ne 0) { throw 'Password SSH failed' } + Stop-CaiwuDeploymentAuth $session + $session = Start-CaiwuDeploymentAuth -Server '127.0.0.1' -SshUser $username + & ssh @options -p $port $target true + if ($LASTEXITCODE -ne 0) { throw 'Saved password reuse failed' } + $upload = Join-Path $session.Directory 'fixture.txt' + 'fixture' | Set-Content -LiteralPath $upload + & scp @options -P $port $upload "${target}:/fixture.txt" + if ($LASTEXITCODE -ne 0) { throw 'Password SFTP upload failed' } + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + $listener.Start(); $localPort = ([Net.IPEndPoint]$listener.LocalEndpoint).Port; $listener.Stop() + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = (Get-Command ssh).Source + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + foreach ($argument in ($options + @('-p', $port, '-N', '-o', 'ExitOnForwardFailure=yes', '-L', "127.0.0.1:${localPort}:127.0.0.1:1234", $target))) { $info.ArgumentList.Add($argument) } + $tunnel = [Diagnostics.Process]::Start($info) + Start-Sleep -Seconds 2 + $client = [Net.Sockets.TcpClient]::new('127.0.0.1', $localPort) + $stream = $client.GetStream(); $stream.ReadTimeout = 5000 + $bytes = [Text.Encoding]::UTF8.GetBytes('probe'); $stream.Write($bytes, 0, $bytes.Length) + $received = New-Object byte[] 5 + $count = $stream.Read($received, 0, 5) + $client.Dispose() + if ([Text.Encoding]::UTF8.GetString($received, 0, $count) -ne 'probe') { throw 'Password tunnel failed' } + if ((Get-Content -LiteralPath $env:CAIWU_DEPLOY_PASSWORD_CACHE -Raw).Contains($password)) { throw 'Plaintext persisted' } + 'wrong-fixture-password' | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -LiteralPath $env:CAIWU_DEPLOY_PASSWORD_CACHE + & ssh @options -p $port $target true 2>$null + if ($LASTEXITCODE -eq 0) { throw 'Invalid password accepted' } + $existingKey = Join-Path $session.Directory 'fake-key' + 'fixture' | Set-Content -LiteralPath $existingKey + $keySession = Start-CaiwuDeploymentAuth -Server '127.0.0.1' -SshUser $username -KeyPath $existingKey + if ($keySession.Options -notcontains 'BatchMode=yes' -or $keySession.Directory) { throw 'Key compatibility failed' } + Write-Host 'PASS: supplied password SSH, saved-login reuse, SFTP upload, background tunnel, encrypted storage, rejected wrong password, optional key compatibility' +} finally { + if ($tunnel) { if (-not $tunnel.HasExited) { $tunnel.Kill() }; $tunnel.Dispose() } + if ($server) { if (-not $server.HasExited) { $server.Kill() }; $server.Dispose() } + Stop-CaiwuDeploymentAuth $session + # Only this test-created identity directory may be removed. + if ($session -and (Split-Path -Leaf $session.Directory) -eq "${username}@127.0.0.1") { + $expected = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) "CaiWuXiTong/deploy-credentials/${username}@127.0.0.1" + if ([IO.Path]::GetFullPath($session.Directory) -ne [IO.Path]::GetFullPath($expected)) { throw 'Unexpected test cleanup path' } + Remove-Item -LiteralPath $session.Directory -Recurse -Force + } +}