この記事は「リモート FTP サーバがパスワード認証である」「バッチでディレクトリ同期したいが同期前に変更をプレビューしたい」「パスワード平文保持は回避したい」を前提にしています。回避したほうが安全という保証はありません & 方針は組織に依ります。お気付きの点がありましたらご指摘いただけますと幸いです。
概要
Windows マシンとリモート FTP サーバをディレクトリ同期するには、WinSCP で FTP サーバに接続し、メニューの「コマンド」から「同期」を選択すれば可能です。ただし、WinSCP にはスクリプティングインタフェースもあり [文献1]、これを使ったバッチファイルを書けば叩くだけでファイル同期できて便利です [文献2]。
しかしこのバッチファイル方式では、1 回の接続で「1. どのファイルが同期されるかプレビューする」「2. それを確認した上で実際に同期する」を同時にできないです (※1)。そのため、リモート FTP サーバがパスワード認証だと 2 回パスワードを入力することになり、GUI よりかえって不便です (※2)。
「スクリプトを叩くだけ」かつ「1 回以下のパスワード入力」で「プレビュー&同期」するには、以下のような方法があると思います。
- 【ア】 (非推奨) WinSCP にパスワードを保存しておいてスクリプトから利用する [文献3]: これならパスワード入力は 0 回ですが、パスワード保存は非推奨であり、保存されたパスワードは (その Windows マシンにそのユーザとしてログインしていれば) 簡単に表示できます [文献4]。なのでマスターパスワード設定が必須とされますが [文献5]、それならマスターパスワードが訊かれるのではと思います (やっていないのでわかりません)。
-
【イ】 プレビューだけ先に別の手段でやっておく: 例えば同期の目的が Web サイトのデプロイなら、ローカルファイルに対応する Web ページを curl して差分を取れば実質プレビューできると思います (アクセス制限がある場所は curl できないかもしれませんが)。
- これを GitHub Actions に組み込めば便利そうです (GitHub Actions で続けて
lftp
でデプロイすればよいですが、今回はデプロイ前に変更をプレビューしたいとします)。
- これを GitHub Actions に組み込めば便利そうです (GitHub Actions で続けて
-
【ウ】 PowerShell で受け取ったパスワードを暗号化して保持し再利用する: PowerShell にはユーザ入力を暗号化して保持する仕組みがあるので (
-AsSecureString
[文献6])、これでパスワードを保持しておけばプレビューの接続にも同期の接続にも利用できます。 - 【エ】 PowerShell から WinSCP .NET アセンブリをつかう: PowerShell をつかうなら、WinSCP .NET アセンブリ [文献7] の API を直接利用すれば 1 回の接続でプレビューと同期ができます (ただこの場合も API に渡すためにパスワードを暗号化して保持させます)。
- 【オ】 暗号化したパスワードを設定ファイル保存しておいて PowerShell から利用する: 暗号化してあれば保存してもよいなら、パスワード入力を 0 回にできます [文献8]。
この記事では WinSCP スクリプティングインターフェースを利用したバッチファイル例 (パスワード入力が 2 回必要) と、上記の【エ】の PowerShell スクリプトの例を示します。
※1. これは [文献1] のコマンドにユーザ確認を待機できるものがないためです (待機せずプレビュー → 同期はできますが、これでは何の意味もない)。なお、接続先 FTP サーバに pause や sleep のようなコマンドがあれば call PAUSE
などで待機する (またはユーザに中断を促す) ことができるかもしれないですが、今回はないものとします。
※2. 今回は安全上、バッチファイル (または別の設定ファイル) へのパスワードべた書きや、ユーザ入力から受け取ったパスワードの平文保持もしないものとします。また、外部パスワード管理アプリも使用しないものとします (結局アプリへのログインが生じそうなため)。
参考文献
- Scripting and Task Automation :: WinSCP
- WinSCPのコマンドでフォルダを同期するようにした | nameless name
- WinSCPスクリプト 及び実行 #winscp - Qiita
- 【Tips】【WinSCP】サーバーのパスワードを忘れた時は・・・ #SSH - Qiita
- Can I recover password stored in WinSCP session? :: WinSCP
- ConvertFrom-SecureString (Microsoft.PowerShell.Security) - PowerShell | Microsoft Learn
- WinSCP .NET Assembly and COM Library :: WinSCP
- Protecting credentials used for automation :: WinSCP
-
以降のスクリプトは以下を前提としています。必要に応じて修正してください。
- リモート FTP サーバに FTPS (Explicit) で接続します。通信プロトコルが異なる場合は修正してください ([文献1] や [文献7] を参照)。
- スクリプトがあるディレクトリ以下の
.\site\
をリモートの/home/{FTPアカウント}/www
に送る同期をします。適宜修正してください。 - お手元の WinSCP のパスが異なる場合はそれも修正してください。
WinSCP スクリプティングインターフェースのコマンドを利用したバッチファイル
以下の synchronize0.bat
synchronize1.bat
synchronize2.bat
は WinSCP スクリプティングインターフェースを利用したバッチファイルの例です。FTP サーバに接続する度にパスワードを訊かれます (そのため、プレビュー時にも同期時にもパスワード入力が必要です)。
バッチファイルの ftps://{FTPアカウント}@{FTPサーバ名}
の箇所を ftps://{FTPアカウント}:{パスワード}@{FTPサーバ名}
にすればパスワードを訊かれなくなります。が、セキュリティ上、バッチファイルにパスワードを書き込むことは推奨されません。
@echo off
"C:\Program Files (x86)\WinSCP\WinSCP.com" /log=%~dp0"log.txt" /ini=nul /command ^
"open ftps://{FTPアカウント}@{FTPサーバ名} -explicit -rawsettings Utf=1" ^
"synchronize remote "%~dp0"site /home/{FTPアカウント}/www -preview" ^
"exit"
pause
@echo off
setlocal
set "ARG_PREVIEW=-preview"
set /p USER_INPUT="Sync right away? (y/n): "
if /i "%USER_INPUT%"=="y" set "ARG_PREVIEW="
echo ARG_PREVIEW is [%ARG_PREVIEW%]
"C:\Program Files (x86)\WinSCP\WinSCP.com" /log=%~dp0"log.txt" /ini=nul ^
/command ^
"open ftps://{FTPアカウント}@{FTPサーバ名} -explicit -rawsettings Utf=1" ^
"synchronize remote "%~dp0"site /home/{FTPアカウント}/www "%ARG_PREVIEW% ^
"exit"
pause
endlocal
@echo off
:LOOP
call :SYNC
goto :LOOP
:SYNC
setlocal
set "ARG_PREVIEW=-preview"
set /p USER_INPUT="Sync right away? (y/n): "
if /i "%USER_INPUT%"=="y" set "ARG_PREVIEW="
echo ARG_PREVIEW is [%ARG_PREVIEW%]
"C:\Program Files (x86)\WinSCP\WinSCP.com" /log=%~dp0"log.txt" /ini=nul ^
/command ^
"open ftps://{FTPアカウント}@{FTPサーバ名} -explicit -rawsettings Utf=1" ^
"synchronize remote "%~dp0"site /home/{FTPアカウント}/www "%ARG_PREVIEW% ^
"exit"
endlocal
WinSCP .NET アセンブリを利用した PowerShell スクリプト
以下のスクリプトはユーザにパスワード入力を促した後、1 回の接続内でプレビューも同期もします (なお、PowerShell スクリプトはファイルを右クリックして「PowerShell で実行」をクリックするなどで実行できます)。
<#
PowerShell: WinSCP を使って FTPS (explicit) で同期するスクリプト
#>
param(
[string] $accountName = "{FTPアカウント}",
[string] $HostName = "{FTPサーバ名}",
[string] $LocalPath = (Join-Path $PSScriptRoot "site"),
[string] $RemotePath = "/home/{FTPアカウント}/www",
[string] $LogFile = (Join-Path $PSScriptRoot "log.txt")
)
# ----- WinSCP .NET アセンブリ読み込み -----
$assemblyPath = "C:\Program Files (x86)\WinSCP\WinSCPnet.dll"
if (-not (Test-Path $AssemblyPath)) { throw "WinSCPnet.dll not found: $AssemblyPath" }
Add-Type -Path $AssemblyPath
# ----- Credential 入力 -----
$pass = Read-Host "Enter password for $accountName" -AsSecureString # パスワード入力を促す
$cred = New-Object System.Management.Automation.PSCredential($accountName, $pass)
# ----- セッションオプション設定 -----
$sessionOptions = New-Object WinSCP.SessionOptions -Property @{
Protocol = [WinSCP.Protocol]::Ftp
HostName = $HostName
UserName = $cred.UserName
FtpSecure = [WinSCP.FtpSecure]::Explicit
}
# ----- 同期の設定 -----
$direction = [WinSCP.SynchronizationMode]::Remote # 同期の向き: ローカルからリモートに送る
$remove = $false # 送り側にないファイルを削除するか: しない
$mirror = $false # 送り側のタイムスタンプのほうが古いとき上書きするか: しない
$to = New-Object WinSCP.TransferOptions
$criteria = [WinSCP.SynchronizationCriteria]::Time
$session = New-Object WinSCP.Session
$session.SessionLogPath = $LogFile
try {
# パスワードを取り出して接続
$sessionOptions.Password = $cred.GetNetworkCredential().Password
$session.Open($sessionOptions)
$sessionOptions.Password = $null # 接続したら削除
$cred = $null # 接続したら削除
# --- 1) プレビュー ---
$diffs = $session.CompareDirectories(
$direction, $LocalPath, $RemotePath, $remove, $mirror, $criteria, $to
)
if ($diffs.Count -eq 0) { Write-Host "No changes."; return }
Write-Host "Planned changes:"
foreach ($d in $diffs) {
$path = if ($d.Local) { $d.Local.FileName } else { $d.Remote.FileName }
Write-Host (" [{0}] {1}" -f $d.Action, $path)
}
# --- 2) 確認 ---
$ans = Read-Host "Proceed with actual sync? [y/N]"
if ($ans -notmatch '^(y|yes)$') { Write-Host "Aborted."; return }
# --- 3) 同期 ---
$result = $session.SynchronizeDirectories(
$direction, $LocalPath, $RemotePath, $remove, $mirror, $criteria, $to
)
$result.Check()
Write-Host "Synchronized:"
foreach ($u in $result.Uploads) { Write-Host (" [Upload] {0} -> {1}" -f $u.FileName, $u.Destination) }
foreach ($d in $result.Downloads) { Write-Host (" [Download] {0} -> {1}" -f $d.FileName, $d.Destination) }
foreach ($r in $result.Removals) { Write-Host (" [Remove] {0}" -f $r.FileName) }
} catch {
Write-Error $_.Exception.Message
} finally {
if ($session) { $session.Dispose() }
Read-Host "Press Enter to exit"
}