Send-FilesToSftp.ps1 14 KB

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