支持直接使用密码部署并自动复用本机登录信息 (#137)

This commit is contained in:
WuYongJie 2026-09-09 11:54:21 +08:00
parent 914a3fe352
commit 784e82b0a2
8 changed files with 330 additions and 60 deletions

View File

@ -66,6 +66,7 @@
- 普通代码发布由任务 Codex 在完成验证与合并后直接执行,不要求用户再次说“部署、上线”;用户明确要求仅本地开发、仅审查或暂不上线时遵循其范围。正式发布只使用已合并的最新 `main`。
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
- 新人只需表达部署需求;Codex 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。
- 当前生产入口为 `https://caiwu.jiansuntuzhi.cn:8442`;公网仍只使用 `8442`,日常发布不得改成无端口地址。
- 发布脚本检测到新增数据库迁移时必须停止,取得负责人明确授权并完成备份后才能使用迁移开关。
- 容器内 Laravel 命令使用 `www-data`。发布后只需确认部署 SHA、应用容器健康、受影响的真实页面/接口、迁移是否执行,以及当前和上一版镜像可回滚。

View File

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

54
scripts/deploy-auth.ps1 Normal file
View File

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

View File

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

View File

@ -0,0 +1,2 @@
@echo off
powershell.exe -NoProfile -STA -ExecutionPolicy Bypass -File "%~dp0deploy-password-prompt.ps1"

View File

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

View File

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

View File

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