支持直接使用密码部署并自动复用本机登录信息 (#137)
This commit is contained in:
parent
914a3fe352
commit
784e82b0a2
@ -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、应用容器健康、受影响的真实页面/接口、迁移是否执行,以及当前和上一版镜像可回滚。
|
||||
|
||||
@ -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
54
scripts/deploy-auth.ps1
Normal 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')
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
2
scripts/deploy-password-prompt.cmd
Normal file
2
scripts/deploy-password-prompt.cmd
Normal file
@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
powershell.exe -NoProfile -STA -ExecutionPolicy Bypass -File "%~dp0deploy-password-prompt.ps1"
|
||||
47
scripts/deploy-password-prompt.ps1
Normal file
47
scripts/deploy-password-prompt.ps1
Normal 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()
|
||||
}
|
||||
86
tests/scripts/deploy-auth-server.py
Normal file
86
tests/scripts/deploy-auth-server.py
Normal 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()
|
||||
73
tests/scripts/deploy-password-auth.ps1
Normal file
73
tests/scripts/deploy-password-auth.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user