From 3b5da1c2c2eba285788840e5ee2721d381ca5c13 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 2 Jun 2026 11:48:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=A3=9E=E7=89=9B=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E5=8D=87=E7=BA=A7=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy-registry.ps1 | 111 ++++++++++++++++-- .../__tests__/deploy-registry-script.test.ts | 23 ++++ 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/deploy-registry.ps1 b/deploy-registry.ps1 index c68439c..4d58438 100644 --- a/deploy-registry.ps1 +++ b/deploy-registry.ps1 @@ -12,6 +12,8 @@ param( [int]$SshPort = 22, [string]$Username = "root", [SecureString]$Password = $null, + [switch]$RemoteSudo, + [SecureString]$RemoteSudoPassword = $null, [string]$KeyPath = "", [string]$PlinkHostKey = "", @@ -119,6 +121,7 @@ function Show-Help { Write-Host "主要参数:" -ForegroundColor White Write-Host " -Server 远程服务器IP或域名;远程部署时必须显式传入" Write-Host " -SshPort SSH端口,默认 22;内部测试机或客户自定义端口必须显式传入" + Write-Host " -RemoteSudo 远端 Docker 需要 sudo 时启用;常见于飞牛 / NAS 普通管理员账号" Write-Host " -RegistryHost Registry地址;发布和部署时必须显式传入" Write-Host " -RegistryUsername Registry账号,默认 flnxi" Write-Host " -RegistryPassword Registry密码,建议用 Read-Host -AsSecureString 输入" @@ -160,6 +163,14 @@ function ConvertTo-ShellSingleQuoted { return "'" + ($Value -replace "'", "'\''") + "'" } +function Normalize-RemoteShellScript { + param([string]$Script) + if ($null -eq $Script) { + return "" + } + return (($Script -replace "`r`n", "`n") -replace "`r", "") +} + function Assert-Tool { param([string]$Name) if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { @@ -361,6 +372,20 @@ function Ensure-SshPasswordIfNeeded { } } +function Ensure-RemoteSudoPasswordIfNeeded { + if ($SkipRemote -or -not $RemoteSudo) { + return + } + if ($RemoteSudoPassword) { + return + } + if ($Password) { + $script:RemoteSudoPassword = $Password + return + } + $script:RemoteSudoPassword = Read-Host "请输入远程 sudo 密码" -AsSecureString +} + function Test-RegistryEndpoint { Assert-RegistryHostConfigured Write-RegistryHttpNotice @@ -434,13 +459,26 @@ function Invoke-RemoteCommand { Assert-RemoteServerConfigured $target = "${Username}@${Server}" - $remoteCommand = "bash -lc " + (ConvertTo-ShellSingleQuoted -Value $Command) - $stdinPassword = "" + $normalizedCommand = Normalize-RemoteShellScript -Script $Command + $remoteCommand = "bash -lc " + (ConvertTo-ShellSingleQuoted -Value $normalizedCommand) + $stdinLines = New-Object System.Collections.Generic.List[string] + if ($RemoteSudo) { + $sudoPasswordPlain = ConvertTo-PlainText -SecureValue $RemoteSudoPassword + if ([string]::IsNullOrWhiteSpace($sudoPasswordPlain)) { + throw "远程 sudo 密码为空,无法执行需要 sudo 的 Docker 命令" + } + $stdinLines.Add($sudoPasswordPlain) + } if ((-not $SkipRegistryLogin) -and (-not $NoRegistryPasswordInput)) { - $stdinPassword = ConvertTo-PlainText -SecureValue $RegistryPassword - if ([string]::IsNullOrWhiteSpace($stdinPassword)) { + $stdinRegistryPassword = ConvertTo-PlainText -SecureValue $RegistryPassword + if ([string]::IsNullOrWhiteSpace($stdinRegistryPassword)) { throw "Registry 密码为空,无法远程登录 Registry" } + $stdinLines.Add($stdinRegistryPassword) + } + $stdinPayload = "" + if ($stdinLines.Count -gt 0) { + $stdinPayload = ($stdinLines -join "`n") + "`n" } if ($KeyPath -and (Test-Path $KeyPath)) { @@ -461,10 +499,10 @@ function Invoke-RemoteCommand { Write-Host "[DRYRUN] ssh -p $SshPort $target bash -lc '<远程部署脚本,敏感信息已隐藏>'" -ForegroundColor DarkGray return } - if ($SkipRegistryLogin -or $NoRegistryPasswordInput) { + if ($stdinLines.Count -eq 0) { & ssh @args } else { - $stdinPassword | ssh @args + $stdinPayload | ssh @args } if ($LASTEXITCODE -ne 0) { throw "远程部署命令失败,退出码:$LASTEXITCODE" @@ -488,10 +526,10 @@ function Invoke-RemoteCommand { Write-Host "[DRYRUN] plink -P $SshPort $target bash -lc '<远程部署脚本,敏感信息已隐藏>'" -ForegroundColor DarkGray return } - if ($SkipRegistryLogin -or $NoRegistryPasswordInput) { + if ($stdinLines.Count -eq 0) { & plink.exe @args } else { - $stdinPassword | plink.exe @args + $stdinPayload | plink.exe @args } if ($LASTEXITCODE -ne 0) { throw "远程部署命令失败,退出码:$LASTEXITCODE" @@ -522,9 +560,13 @@ function New-RemoteDeployScript { $remoteObjectBackupEnabled = if ($NoRemoteObjectStorageBackup) { "0" } else { "1" } $externalDataBackupConfirmed = if ($ExternalDataBackupConfirmed) { "1" } else { "0" } $rollbackEnabled = if ($NoRollback) { "0" } else { "1" } + $remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" } $lines = New-Object System.Collections.Generic.List[string] $lines.Add("set -Eeuo pipefail") + if ($RemoteSudo) { + $lines.Add("IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }") + } if (-not $SkipRegistryLogin) { $lines.Add("IFS= read -r REGISTRY_PASSWORD || { echo '未收到 Registry 密码'; exit 22; }") } @@ -541,6 +583,7 @@ function New-RemoteDeployScript { $lines.Add("external_data_backup_confirmed=$externalDataBackupConfirmed") $lines.Add("remote_backup_helper_image=$quotedRemoteBackupHelperImage") $lines.Add("rollback_enabled=$rollbackEnabled") + $lines.Add("remote_sudo_enabled=$remoteSudoEnabled") $lines.Add("export APP_IMAGE=$quotedRegistryImage") $lines.Add("export NEXTAUTH_URL=$quotedNextAuthUrl") $lines.Add("export APP_PUBLIC_URL=$quotedAppPublicUrl") @@ -558,6 +601,21 @@ db_backup="" object_backup="" minio_volume_name="" +sudo_auth() { + if [ "${remote_sudo_enabled:-0}" = "1" ]; then + printf '%s\n' "$SUDO_PASSWORD" | sudo -S -p '' true + fi +} + +docker() { + if [ "${remote_sudo_enabled:-0}" = "1" ]; then + sudo_auth + sudo -n docker "$@" + else + command docker "$@" + fi +} + upsert_env() { key="$1" value="$2" @@ -576,6 +634,7 @@ read_env_value() { awk -F= -v k="$key" ' index($0, k "=") == 1 { value=substr($0, length(k) + 2) + gsub(/\r$/, "", value) gsub(/^"|"$/, "", value) gsub(/^'\''|'\''$/, "", value) print value @@ -908,9 +967,26 @@ function New-RemoteRollbackScript { $quotedRemotePath = ConvertTo-ShellSingleQuoted -Value $RemotePath $quotedReason = ConvertTo-ShellSingleQuoted -Value $Reason + $remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" } + $sudoReadLine = if ($RemoteSudo) { "IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }" } else { "" } return @" set -Eeuo pipefail +$sudoReadLine +remote_sudo_enabled=$remoteSudoEnabled +sudo_auth() { + if [ "`${remote_sudo_enabled:-0}" = "1" ]; then + printf '%s\n' "`$SUDO_PASSWORD" | sudo -S -p '' true + fi +} +docker() { + if [ "`${remote_sudo_enabled:-0}" = "1" ]; then + sudo_auth + sudo -n docker "`$@" + else + command docker "`$@" + fi +} cd $quotedRemotePath reason=$quotedReason context_file="./backups/LAST_DEPLOY_CONTEXT.env" @@ -1072,6 +1148,7 @@ function Invoke-DeployApp { Ensure-RegistryPassword Ensure-SshPasswordIfNeeded + Ensure-RemoteSudoPasswordIfNeeded Invoke-ProjectCheck Test-RegistryEndpoint $registryImage = Invoke-PublishAppImage -VersionTag $deployTag -Repository $deployRepository -PushLatest:$false @@ -1304,8 +1381,26 @@ function Invoke-ShowStatus { if (-not $SkipRemote) { Ensure-SshPasswordIfNeeded + Ensure-RemoteSudoPasswordIfNeeded + $remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" } + $sudoReadLine = if ($RemoteSudo) { "IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }" } else { "" } $remoteStatus = @" set -e +$sudoReadLine +remote_sudo_enabled=$remoteSudoEnabled +sudo_auth() { + if [ "`${remote_sudo_enabled:-0}" = "1" ]; then + printf '%s\n' "`$SUDO_PASSWORD" | sudo -S -p '' true + fi +} +docker() { + if [ "`${remote_sudo_enabled:-0}" = "1" ]; then + sudo_auth + sudo -n docker "`$@" + else + command docker "`$@" + fi +} echo '--- docker ps ---' docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' | grep -E 'flnxi|postgres|minio|license' || true echo '--- compose ---' diff --git a/scripts/__tests__/deploy-registry-script.test.ts b/scripts/__tests__/deploy-registry-script.test.ts index daf9e48..6ff0e5c 100644 --- a/scripts/__tests__/deploy-registry-script.test.ts +++ b/scripts/__tests__/deploy-registry-script.test.ts @@ -81,4 +81,27 @@ describe('deploy-registry.ps1', () => { expect(script).toContain('$lines.Add(\'docker compose -f "$compose_file" ps\')') expect(script).not.toContain('$lines.Add("docker compose -f `"$compose_file`"') }) + + it('支持飞牛/NAS 普通管理员通过 sudo 执行远程 Docker 升级', () => { + expect(script).toContain('[switch]$RemoteSudo') + expect(script).toContain('[SecureString]$RemoteSudoPassword = $null') + expect(script).toContain('function Ensure-RemoteSudoPasswordIfNeeded') + expect(script).toContain('$script:RemoteSudoPassword = $Password') + expect(script).toContain('remote_sudo_enabled=$remoteSudoEnabled') + expect(script).toContain('IFS= read -r SUDO_PASSWORD') + expect(script).toContain('sudo -n docker "$@"') + expect(script).toContain('sudo -n docker "`$@"') + expect(script).toContain('Invoke-RemoteCommand -Command $rollbackScript -NoRegistryPasswordInput') + }) + + it('发送到 Linux 的远程脚本会统一去除 Windows 回车符', () => { + expect(script).toContain('function Normalize-RemoteShellScript') + expect(script).toContain('($Script -replace "`r`n", "`n") -replace "`r", ""') + expect(script).toContain('$normalizedCommand = Normalize-RemoteShellScript -Script $Command') + expect(script).toContain('ConvertTo-ShellSingleQuoted -Value $normalizedCommand') + }) + + it('读取远端 .env 时会去除 Windows 行尾回车', () => { + expect(script).toContain('gsub(/\\r$/, "", value)') + }) })