Send-FilesToSftp.ps1 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. <#
  2. .SYNOPSIS
  3. Transfers files to an SFTP server with filtering and logging.
  4. .DESCRIPTION
  5. Moves or copies files from a local folder to a remote SFTP destination.
  6. Supports regex/wildcard file filtering, recursive scanning, and secure
  7. credential storage. Uses the WinSCP .NET assembly for SFTP transport.
  8. .PARAMETER LocalPath
  9. Local source folder to scan for files.
  10. .PARAMETER RemotePath
  11. Remote SFTP destination folder (e.g. /uploads/incoming).
  12. .PARAMETER FileFilter
  13. Regex pattern to match filenames (e.g. '\.csv$' or '^report_\d{8}').
  14. Default: '.*' (all files).
  15. .PARAMETER HostName
  16. SFTP server hostname or IP.
  17. .PARAMETER Port
  18. SFTP port. Default: 22.
  19. .PARAMETER UserName
  20. SFTP username.
  21. .PARAMETER SshHostKeyFingerprint
  22. SSH host key fingerprint for server verification.
  23. Use "ssh-rsa 2048 xx:xx:xx..." format, or pass "*" to accept any (NOT recommended for production).
  24. .PARAMETER Credential
  25. PSCredential object. If omitted, you'll be prompted interactively.
  26. .PARAMETER CredentialFile
  27. Path to a saved credential file (Export-Clixml). For scheduled/unattended runs.
  28. Create one with: Get-Credential | Export-Clixml -Path "C:\secure\sftp_cred.xml"
  29. .PARAMETER KeyFilePath
  30. Path to a private key file for key-based auth (optional).
  31. .PARAMETER Recurse
  32. Scan subdirectories in LocalPath.
  33. .PARAMETER DeleteAfterTransfer
  34. Delete local files after successful upload (move behavior).
  35. .PARAMETER DryRun
  36. Show what would be transferred without actually uploading.
  37. .PARAMETER LogFile
  38. Path to a log file. If omitted, logs to console only.
  39. .PARAMETER WinScpDllPath
  40. Path to WinSCPnet.dll. Default: looks in script directory, then common install paths.
  41. .EXAMPLE
  42. # Interactive - transfer all CSVs
  43. .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
  44. -HostName "sftp.example.com" -UserName "uploader" -FileFilter '\.csv$'
  45. .EXAMPLE
  46. # Unattended with saved credentials and move behavior
  47. .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
  48. -HostName "sftp.example.com" -UserName "uploader" `
  49. -CredentialFile "C:\secure\sftp_cred.xml" `
  50. -FileFilter '^report_\d{8}' -DeleteAfterTransfer -LogFile "C:\logs\sftp.log"
  51. .EXAMPLE
  52. # Dry run to preview what would transfer
  53. .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
  54. -HostName "sftp.example.com" -UserName "uploader" -DryRun
  55. .EXAMPLE
  56. # Key-based auth, recursive scan
  57. .\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/archive" `
  58. -HostName "sftp.example.com" -UserName "svcaccount" `
  59. -KeyFilePath "C:\keys\id_rsa.ppk" -Recurse
  60. #>
  61. [CmdletBinding(SupportsShouldProcess)]
  62. param(
  63. [Parameter(Mandatory)]
  64. [ValidateScript({ Test-Path $_ -PathType Container })]
  65. [string]$LocalPath,
  66. [Parameter(Mandatory)]
  67. [string]$RemotePath,
  68. [string]$FileFilter = '.*',
  69. [Parameter(Mandatory)]
  70. [string]$HostName,
  71. [int]$Port = 22,
  72. [Parameter(Mandatory)]
  73. [string]$UserName,
  74. [string]$SshHostKeyFingerprint = $null,
  75. [PSCredential]$Credential,
  76. [string]$CredentialFile,
  77. [string]$KeyFilePath,
  78. [switch]$Recurse,
  79. [switch]$DeleteAfterTransfer,
  80. [switch]$DryRun,
  81. [string]$LogFile,
  82. [string]$WinScpDllPath
  83. )
  84. # ── Logging ──────────────────────────────────────────────────────────────────
  85. function Write-Log {
  86. param(
  87. [string]$Message,
  88. [ValidateSet('INFO','WARN','ERROR','SUCCESS')]
  89. [string]$Level = 'INFO'
  90. )
  91. $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
  92. $entry = "[$timestamp] [$Level] $Message"
  93. switch ($Level) {
  94. 'ERROR' { Write-Host $entry -ForegroundColor Red }
  95. 'WARN' { Write-Host $entry -ForegroundColor Yellow }
  96. 'SUCCESS' { Write-Host $entry -ForegroundColor Green }
  97. default { Write-Host $entry }
  98. }
  99. if ($LogFile) {
  100. $entry | Out-File -FilePath $LogFile -Append -Encoding utf8
  101. }
  102. }
  103. # ── Locate WinSCP .NET Assembly ──────────────────────────────────────────────
  104. function Find-WinScpDll {
  105. $searchPaths = @(
  106. $WinScpDllPath
  107. (Join-Path $PSScriptRoot 'WinSCPnet.dll')
  108. (Join-Path $PSScriptRoot 'lib\WinSCPnet.dll')
  109. 'C:\Program Files (x86)\WinSCP\WinSCPnet.dll'
  110. 'C:\Program Files\WinSCP\WinSCPnet.dll'
  111. ) | Where-Object { $_ }
  112. foreach ($path in $searchPaths) {
  113. if (Test-Path $path) {
  114. return $path
  115. }
  116. }
  117. # Try NuGet package in common locations
  118. $nugetPath = Get-ChildItem -Path "$env:USERPROFILE\.nuget\packages\winscp" -Filter 'WinSCPnet.dll' -Recurse -ErrorAction SilentlyContinue |
  119. Sort-Object LastWriteTime -Descending | Select-Object -First 1
  120. if ($nugetPath) { return $nugetPath.FullName }
  121. return $null
  122. }
  123. # ── Main ─────────────────────────────────────────────────────────────────────
  124. try {
  125. Write-Log "═══ SFTP Transfer Starting ═══"
  126. Write-Log "Local path : $LocalPath"
  127. Write-Log "Remote path : $RemotePath"
  128. Write-Log "File filter : $FileFilter"
  129. Write-Log "Host : ${HostName}:${Port}"
  130. if ($DryRun) { Write-Log "*** DRY RUN MODE - No files will be transferred ***" -Level WARN }
  131. # ── Find and load WinSCP ─────────────────────────────────────────────
  132. $dllPath = Find-WinScpDll
  133. if (-not $dllPath) {
  134. Write-Log "WinSCPnet.dll not found. Install options:" -Level ERROR
  135. Write-Log " 1) Install-Package WinSCP -Source nuget.org" -Level ERROR
  136. Write-Log " 2) Download from https://winscp.net/eng/downloads.php (.NET assembly)" -Level ERROR
  137. Write-Log " 3) Place WinSCPnet.dll in the same folder as this script" -Level ERROR
  138. exit 1
  139. }
  140. Add-Type -Path $dllPath
  141. Write-Log "Loaded WinSCP from: $dllPath"
  142. # ── Resolve credentials ──────────────────────────────────────────────
  143. $password = $null
  144. if ($Credential) {
  145. $password = $Credential.GetNetworkCredential().Password
  146. }
  147. elseif ($CredentialFile) {
  148. if (-not (Test-Path $CredentialFile)) {
  149. Write-Log "Credential file not found: $CredentialFile" -Level ERROR
  150. exit 1
  151. }
  152. $savedCred = Import-Clixml -Path $CredentialFile
  153. $password = $savedCred.GetNetworkCredential().Password
  154. Write-Log "Loaded credentials from file"
  155. }
  156. elseif (-not $KeyFilePath) {
  157. $promptCred = Get-Credential -UserName $UserName -Message "Enter SFTP password for $HostName"
  158. if (-not $promptCred) {
  159. Write-Log "No credentials provided. Aborting." -Level ERROR
  160. exit 1
  161. }
  162. $password = $promptCred.GetNetworkCredential().Password
  163. }
  164. # ── Collect files ────────────────────────────────────────────────────
  165. $gciParams = @{ Path = $LocalPath; File = $true }
  166. if ($Recurse) { $gciParams['Recurse'] = $true }
  167. $allFiles = Get-ChildItem @gciParams | Where-Object { $_.Name -match $FileFilter }
  168. if (-not $allFiles -or $allFiles.Count -eq 0) {
  169. Write-Log "No files matched filter '$FileFilter' in $LocalPath" -Level WARN
  170. exit 0
  171. }
  172. Write-Log "Found $($allFiles.Count) file(s) matching filter"
  173. if ($DryRun) {
  174. Write-Log "Files that would be transferred:" -Level INFO
  175. foreach ($f in $allFiles) {
  176. $relativePath = $f.FullName.Substring($LocalPath.TrimEnd('\').Length)
  177. $remoteDest = ($RemotePath.TrimEnd('/') + $relativePath) -replace '\\', '/'
  178. $sizeKB = [math]::Round($f.Length / 1KB, 1)
  179. Write-Log " $($f.FullName) → $remoteDest (${sizeKB} KB)"
  180. }
  181. Write-Log "DRY RUN complete. $($allFiles.Count) file(s) would be transferred." -Level SUCCESS
  182. exit 0
  183. }
  184. # ── Configure session ────────────────────────────────────────────────
  185. $sessionOptions = New-Object WinSCP.SessionOptions -Property @{
  186. Protocol = [WinSCP.Protocol]::Sftp
  187. HostName = $HostName
  188. PortNumber = $Port
  189. UserName = $UserName
  190. }
  191. if ($password) {
  192. $sessionOptions.Password = $password
  193. }
  194. if ($KeyFilePath) {
  195. if (-not (Test-Path $KeyFilePath)) {
  196. Write-Log "Private key file not found: $KeyFilePath" -Level ERROR
  197. exit 1
  198. }
  199. $sessionOptions.SshPrivateKeyPath = $KeyFilePath
  200. Write-Log "Using key-based auth: $KeyFilePath"
  201. }
  202. if ($SshHostKeyFingerprint) {
  203. if ($SshHostKeyFingerprint -eq '*') {
  204. Write-Log "Accepting any SSH host key — NOT SAFE FOR PRODUCTION" -Level WARN
  205. $sessionOptions.GiveUpSecurityAndAcceptAnySshHostKey = $true
  206. }
  207. else {
  208. $sessionOptions.SshHostKeyFingerprint = $SshHostKeyFingerprint
  209. }
  210. }
  211. else {
  212. Write-Log "No SSH host key fingerprint provided — accepting any key" -Level WARN
  213. Write-Log " Get your fingerprint with: ssh-keyscan $HostName | ssh-keygen -lf -" -Level WARN
  214. $sessionOptions.GiveUpSecurityAndAcceptAnySshHostKey = $true
  215. }
  216. # ── Open session and transfer ────────────────────────────────────────
  217. $session = New-Object WinSCP.Session
  218. $successCount = 0
  219. $failCount = 0
  220. try {
  221. $session.Open($sessionOptions)
  222. Write-Log "Connected to $HostName" -Level SUCCESS
  223. # Ensure remote directory exists
  224. if (-not $session.FileExists($RemotePath)) {
  225. Write-Log "Creating remote directory: $RemotePath"
  226. $session.CreateDirectory($RemotePath)
  227. }
  228. foreach ($file in $allFiles) {
  229. $relativePath = ''
  230. if ($Recurse) {
  231. $relativePath = $file.DirectoryName.Substring($LocalPath.TrimEnd('\').Length) -replace '\\', '/'
  232. }
  233. $targetDir = $RemotePath.TrimEnd('/') + $relativePath
  234. $targetPath = "$targetDir/$($file.Name)"
  235. try {
  236. # Ensure subdirectory exists on remote when recursing
  237. if ($Recurse -and $relativePath -and (-not $session.FileExists($targetDir))) {
  238. $session.CreateDirectory($targetDir)
  239. Write-Log "Created remote dir: $targetDir"
  240. }
  241. $transferOptions = New-Object WinSCP.TransferOptions
  242. $transferOptions.TransferMode = [WinSCP.TransferMode]::Binary
  243. $result = $session.PutFiles($file.FullName, $targetPath, $DeleteAfterTransfer, $transferOptions)
  244. $result.Check()
  245. $sizeKB = [math]::Round($file.Length / 1KB, 1)
  246. Write-Log "Transferred: $($file.Name) → $targetPath (${sizeKB} KB)" -Level SUCCESS
  247. $successCount++
  248. }
  249. catch {
  250. Write-Log "FAILED: $($file.Name) — $($_.Exception.Message)" -Level ERROR
  251. $failCount++
  252. }
  253. }
  254. }
  255. finally {
  256. if ($session) { $session.Dispose() }
  257. }
  258. # ── Summary ──────────────────────────────────────────────────────────
  259. Write-Log "═══ Transfer Complete ═══"
  260. Write-Log " Succeeded : $successCount"
  261. if ($failCount -gt 0) {
  262. Write-Log " Failed : $failCount" -Level ERROR
  263. }
  264. if ($DeleteAfterTransfer) {
  265. Write-Log " Mode : MOVE (source files deleted on success)"
  266. }
  267. else {
  268. Write-Log " Mode : COPY (source files retained)"
  269. }
  270. if ($failCount -gt 0) { exit 1 }
  271. }
  272. catch {
  273. Write-Log "Fatal error: $($_.Exception.Message)" -Level ERROR
  274. Write-Log $_.ScriptStackTrace -Level ERROR
  275. exit 1
  276. }