Send-FilesToSftp.ps1 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  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 ArchivePath
  41. Move successfully uploaded files to this local folder instead of leaving them in place.
  42. The folder will be created if it does not exist.
  43. Cannot be combined with -DeleteAfterTransfer.
  44. .PARAMETER LocalRenamePattern
  45. Regex pattern to match in the filename when renaming the local file after a successful upload.
  46. Must be used with -LocalRenameReplacement.
  47. Works standalone (rename in place) or with -ArchivePath (rename while archiving).
  48. .PARAMETER LocalRenameReplacement
  49. Replacement string for -LocalRenamePattern. Supports regex capture groups ($1, ${1}, etc.).
  50. .PARAMETER DeleteAfterTransfer
  51. Delete local files after successful upload (move behavior).
  52. Cannot be combined with -ArchivePath.
  53. .PARAMETER DryRun
  54. Show what would be transferred without actually uploading.
  55. .PARAMETER LogFile
  56. Path to a log file. If omitted, logs to console only.
  57. .PARAMETER WinScpDllPath
  58. Path to WinSCPnet.dll. Default: looks in script directory, then common install paths.
  59. .EXAMPLE
  60. # Interactive - transfer all CSVs
  61. .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
  62. -HostName "sftp.example.com" -UserName "uploader" -FileFilter '\.csv$'
  63. .EXAMPLE
  64. # Rename files by appending today's date before the extension
  65. .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
  66. -HostName "sftp.example.com" -UserName "uploader" `
  67. -RenamePattern '^(.+?)(\.[^.]+)$' -RenameReplacement "`$1_$(Get-Date -Format 'yyyyMMdd')`$2"
  68. .EXAMPLE
  69. # Add a prefix to every uploaded file
  70. .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
  71. -HostName "sftp.example.com" -UserName "uploader" `
  72. -RenamePattern '^' -RenameReplacement 'processed_'
  73. .EXAMPLE
  74. # Archive files to a local folder after upload
  75. .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
  76. -HostName "sftp.example.com" -UserName "uploader" `
  77. -ArchivePath "C:\exports\sent"
  78. .EXAMPLE
  79. # Archive and rename locally (append date) after upload
  80. .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
  81. -HostName "sftp.example.com" -UserName "uploader" `
  82. -ArchivePath "C:\exports\sent" `
  83. -LocalRenamePattern '^(.+?)(\.[^.]+)$' -LocalRenameReplacement "`$1_sent`$2"
  84. .EXAMPLE
  85. # Rename local file in place after upload (no archive)
  86. .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
  87. -HostName "sftp.example.com" -UserName "uploader" `
  88. -LocalRenamePattern '^' -LocalRenameReplacement 'done_'
  89. .EXAMPLE
  90. # Unattended with saved credentials and move behavior
  91. .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
  92. -HostName "sftp.example.com" -UserName "uploader" `
  93. -CredentialFile "C:\secure\sftp_cred.xml" `
  94. -FileFilter '^report_\d{8}' -DeleteAfterTransfer -LogFile "C:\logs\sftp.log"
  95. .EXAMPLE
  96. # Dry run to preview what would transfer
  97. .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
  98. -HostName "sftp.example.com" -UserName "uploader" -DryRun
  99. .EXAMPLE
  100. # Key-based auth, recursive scan
  101. .\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/archive" `
  102. -HostName "sftp.example.com" -UserName "svcaccount" `
  103. -KeyFilePath "C:\keys\id_rsa.ppk" -Recurse
  104. #>
  105. [CmdletBinding(SupportsShouldProcess)]
  106. param(
  107. [Parameter(Mandatory)]
  108. [ValidateScript({ Test-Path $_ -PathType Container })]
  109. [string]$LocalPath,
  110. [Parameter(Mandatory)]
  111. [string]$RemotePath,
  112. [string]$FileFilter = '.*',
  113. [Parameter(Mandatory)]
  114. [string]$HostName,
  115. [int]$Port = 22,
  116. [Parameter(Mandatory)]
  117. [string]$UserName,
  118. [string]$SshHostKeyFingerprint = $null,
  119. [PSCredential]$Credential,
  120. [string]$CredentialFile,
  121. [string]$KeyFilePath,
  122. [string]$RenamePattern,
  123. [string]$RenameReplacement,
  124. [string]$ArchivePath,
  125. [string]$LocalRenamePattern,
  126. [string]$LocalRenameReplacement,
  127. [switch]$Recurse,
  128. [switch]$DeleteAfterTransfer,
  129. [switch]$DryRun,
  130. [string]$LogFile,
  131. [string]$WinScpDllPath
  132. )
  133. # ── Logging ──────────────────────────────────────────────────────────────────
  134. function Write-Log {
  135. param(
  136. [string]$Message,
  137. [ValidateSet('INFO','WARN','ERROR','SUCCESS')]
  138. [string]$Level = 'INFO'
  139. )
  140. $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
  141. $entry = "[$timestamp] [$Level] $Message"
  142. switch ($Level) {
  143. 'ERROR' { Write-Host $entry -ForegroundColor Red }
  144. 'WARN' { Write-Host $entry -ForegroundColor Yellow }
  145. 'SUCCESS' { Write-Host $entry -ForegroundColor Green }
  146. default { Write-Host $entry }
  147. }
  148. if ($LogFile) {
  149. $entry | Out-File -FilePath $LogFile -Append -Encoding utf8
  150. }
  151. }
  152. # ── Locate WinSCP .NET Assembly ──────────────────────────────────────────────
  153. function Find-WinScpDll {
  154. $searchPaths = @(
  155. $WinScpDllPath
  156. (Join-Path $PSScriptRoot 'WinSCPnet.dll')
  157. (Join-Path $PSScriptRoot 'lib\WinSCPnet.dll')
  158. 'C:\Program Files (x86)\WinSCP\WinSCPnet.dll'
  159. 'C:\Program Files\WinSCP\WinSCPnet.dll'
  160. ) | Where-Object { $_ }
  161. foreach ($path in $searchPaths) {
  162. if (Test-Path $path) {
  163. return $path
  164. }
  165. }
  166. # Try NuGet package in common locations
  167. $nugetPath = Get-ChildItem -Path "$env:USERPROFILE\.nuget\packages\winscp" -Filter 'WinSCPnet.dll' -Recurse -ErrorAction SilentlyContinue |
  168. Sort-Object LastWriteTime -Descending | Select-Object -First 1
  169. if ($nugetPath) { return $nugetPath.FullName }
  170. return $null
  171. }
  172. # ── Main ─────────────────────────────────────────────────────────────────────
  173. try {
  174. # ── Validate parameters ──────────────────────────────────────────────
  175. if (($RenamePattern -and -not $RenameReplacement) -or ($RenameReplacement -and -not $RenamePattern)) {
  176. Write-Log "-RenamePattern and -RenameReplacement must be used together." -Level ERROR
  177. exit 1
  178. }
  179. if (($LocalRenamePattern -and -not $LocalRenameReplacement) -or ($LocalRenameReplacement -and -not $LocalRenamePattern)) {
  180. Write-Log "-LocalRenamePattern and -LocalRenameReplacement must be used together." -Level ERROR
  181. exit 1
  182. }
  183. if ($ArchivePath -and $DeleteAfterTransfer) {
  184. Write-Log "-ArchivePath and -DeleteAfterTransfer cannot be used together." -Level ERROR
  185. exit 1
  186. }
  187. # ── Create archive folder if needed ─────────────────────────────────
  188. if ($ArchivePath -and -not (Test-Path $ArchivePath)) {
  189. New-Item -ItemType Directory -Path $ArchivePath -Force | Out-Null
  190. Write-Log "Created archive folder: $ArchivePath"
  191. }
  192. Write-Log "═══ SFTP Transfer Starting ═══"
  193. Write-Log "Local path : $LocalPath"
  194. Write-Log "Remote path : $RemotePath"
  195. Write-Log "File filter : $FileFilter"
  196. Write-Log "Host : ${HostName}:${Port}"
  197. if ($RenamePattern) { Write-Log "Remote rename : '$RenamePattern' → '$RenameReplacement'" }
  198. if ($LocalRenamePattern) { Write-Log "Local rename : '$LocalRenamePattern' → '$LocalRenameReplacement'" }
  199. if ($ArchivePath) { Write-Log "Archive to : $ArchivePath" }
  200. if ($DryRun) { Write-Log "*** DRY RUN MODE - No files will be transferred ***" -Level WARN }
  201. # ── Find and load WinSCP ─────────────────────────────────────────────
  202. $dllPath = Find-WinScpDll
  203. if (-not $dllPath) {
  204. Write-Log "WinSCPnet.dll not found. Install options:" -Level ERROR
  205. Write-Log " 1) Install-Package WinSCP -Source nuget.org" -Level ERROR
  206. Write-Log " 2) Download from https://winscp.net/eng/downloads.php (.NET assembly)" -Level ERROR
  207. Write-Log " 3) Place WinSCPnet.dll in the same folder as this script" -Level ERROR
  208. exit 1
  209. }
  210. Add-Type -Path $dllPath
  211. Write-Log "Loaded WinSCP from: $dllPath"
  212. # ── Resolve credentials ──────────────────────────────────────────────
  213. $password = $null
  214. if ($Credential) {
  215. $password = $Credential.GetNetworkCredential().Password
  216. }
  217. elseif ($CredentialFile) {
  218. if (-not (Test-Path $CredentialFile)) {
  219. Write-Log "Credential file not found: $CredentialFile" -Level ERROR
  220. exit 1
  221. }
  222. $savedCred = Import-Clixml -Path $CredentialFile
  223. $password = $savedCred.GetNetworkCredential().Password
  224. Write-Log "Loaded credentials from file"
  225. }
  226. elseif (-not $KeyFilePath) {
  227. $promptCred = Get-Credential -UserName $UserName -Message "Enter SFTP password for $HostName"
  228. if (-not $promptCred) {
  229. Write-Log "No credentials provided. Aborting." -Level ERROR
  230. exit 1
  231. }
  232. $password = $promptCred.GetNetworkCredential().Password
  233. }
  234. # ── Collect files ────────────────────────────────────────────────────
  235. $gciParams = @{ Path = $LocalPath; File = $true }
  236. if ($Recurse) { $gciParams['Recurse'] = $true }
  237. $allFiles = Get-ChildItem @gciParams | Where-Object { $_.Name -match $FileFilter }
  238. if (-not $allFiles -or $allFiles.Count -eq 0) {
  239. Write-Log "No files matched filter '$FileFilter' in $LocalPath" -Level WARN
  240. exit 0
  241. }
  242. Write-Log "Found $($allFiles.Count) file(s) matching filter"
  243. if ($DryRun) {
  244. Write-Log "Files that would be transferred:" -Level INFO
  245. foreach ($f in $allFiles) {
  246. $destName = if ($RenamePattern) { $f.Name -replace $RenamePattern, $RenameReplacement } else { $f.Name }
  247. $localFinalName = if ($LocalRenamePattern) { $f.Name -replace $LocalRenamePattern, $LocalRenameReplacement } else { $f.Name }
  248. $remoteDir = $RemotePath.TrimEnd('/') + ($f.DirectoryName.Substring($LocalPath.TrimEnd('\').Length) -replace '\\', '/')
  249. $remoteDest = "$remoteDir/$destName"
  250. $sizeKB = [math]::Round($f.Length / 1KB, 1)
  251. $remoteRenameNote = if ($RenamePattern -and $destName -ne $f.Name) { " [remote name: $destName]" } else { '' }
  252. Write-Log " UPLOAD : $($f.FullName) → $remoteDest (${sizeKB} KB)$remoteRenameNote"
  253. if ($DeleteAfterTransfer) {
  254. Write-Log " LOCAL : DELETE $($f.FullName)"
  255. }
  256. elseif ($ArchivePath) {
  257. $archiveDest = Join-Path $ArchivePath $localFinalName
  258. $localNote = if ($LocalRenamePattern -and $localFinalName -ne $f.Name) { " [renamed from $($f.Name)]" } else { '' }
  259. Write-Log " LOCAL : MOVE → $archiveDest$localNote"
  260. }
  261. elseif ($LocalRenamePattern -and $localFinalName -ne $f.Name) {
  262. $renameDest = Join-Path $f.DirectoryName $localFinalName
  263. Write-Log " LOCAL : RENAME → $renameDest"
  264. }
  265. }
  266. Write-Log "DRY RUN complete. $($allFiles.Count) file(s) would be transferred." -Level SUCCESS
  267. exit 0
  268. }
  269. # ── Configure session ────────────────────────────────────────────────
  270. $sessionOptions = New-Object WinSCP.SessionOptions -Property @{
  271. Protocol = [WinSCP.Protocol]::Sftp
  272. HostName = $HostName
  273. PortNumber = $Port
  274. UserName = $UserName
  275. }
  276. if ($password) {
  277. $sessionOptions.Password = $password
  278. }
  279. if ($KeyFilePath) {
  280. if (-not (Test-Path $KeyFilePath)) {
  281. Write-Log "Private key file not found: $KeyFilePath" -Level ERROR
  282. exit 1
  283. }
  284. $sessionOptions.SshPrivateKeyPath = $KeyFilePath
  285. Write-Log "Using key-based auth: $KeyFilePath"
  286. }
  287. if ($SshHostKeyFingerprint) {
  288. if ($SshHostKeyFingerprint -eq '*') {
  289. Write-Log "Accepting any SSH host key — NOT SAFE FOR PRODUCTION" -Level WARN
  290. $sessionOptions.GiveUpSecurityAndAcceptAnySshHostKey = $true
  291. }
  292. else {
  293. $sessionOptions.SshHostKeyFingerprint = $SshHostKeyFingerprint
  294. }
  295. }
  296. else {
  297. Write-Log "No SSH host key fingerprint provided — accepting any key" -Level WARN
  298. Write-Log " Get your fingerprint with: ssh-keyscan $HostName | ssh-keygen -lf -" -Level WARN
  299. $sessionOptions.GiveUpSecurityAndAcceptAnySshHostKey = $true
  300. }
  301. # ── Open session and transfer ────────────────────────────────────────
  302. $session = New-Object WinSCP.Session
  303. $successCount = 0
  304. $failCount = 0
  305. try {
  306. $session.Open($sessionOptions)
  307. Write-Log "Connected to $HostName" -Level SUCCESS
  308. # Ensure remote directory exists
  309. if (-not $session.FileExists($RemotePath)) {
  310. Write-Log "Creating remote directory: $RemotePath"
  311. $session.CreateDirectory($RemotePath)
  312. }
  313. foreach ($file in $allFiles) {
  314. $relativePath = ''
  315. if ($Recurse) {
  316. $relativePath = $file.DirectoryName.Substring($LocalPath.TrimEnd('\').Length) -replace '\\', '/'
  317. }
  318. $destName = if ($RenamePattern) { $file.Name -replace $RenamePattern, $RenameReplacement } else { $file.Name }
  319. $targetDir = $RemotePath.TrimEnd('/') + $relativePath
  320. $targetPath = "$targetDir/$destName"
  321. if ($RenamePattern -and $destName -ne $file.Name) {
  322. Write-Log "Renaming: $($file.Name) → $destName"
  323. }
  324. try {
  325. # Ensure subdirectory exists on remote when recursing
  326. if ($Recurse -and $relativePath -and (-not $session.FileExists($targetDir))) {
  327. $session.CreateDirectory($targetDir)
  328. Write-Log "Created remote dir: $targetDir"
  329. }
  330. $transferOptions = New-Object WinSCP.TransferOptions
  331. $transferOptions.TransferMode = [WinSCP.TransferMode]::Binary
  332. # Always upload without auto-delete; handle local disposition ourselves
  333. $result = $session.PutFiles($file.FullName, $targetPath, $false, $transferOptions)
  334. $result.Check()
  335. $sizeKB = [math]::Round($file.Length / 1KB, 1)
  336. Write-Log "Transferred: $($file.Name) → $targetPath (${sizeKB} KB)" -Level SUCCESS
  337. $successCount++
  338. # ── Local post-transfer disposition ──────────────────────
  339. $localFinalName = if ($LocalRenamePattern) { $file.Name -replace $LocalRenamePattern, $LocalRenameReplacement } else { $file.Name }
  340. if ($DeleteAfterTransfer) {
  341. Remove-Item -LiteralPath $file.FullName -Force
  342. Write-Log "Deleted local: $($file.FullName)"
  343. }
  344. elseif ($ArchivePath) {
  345. # Preserve subdirectory structure when recursing
  346. $archiveDir = if ($Recurse -and $relativePath) {
  347. $sub = $file.DirectoryName.Substring($LocalPath.TrimEnd('\').Length).TrimStart('\')
  348. Join-Path $ArchivePath $sub
  349. } else { $ArchivePath }
  350. if (-not (Test-Path $archiveDir)) {
  351. New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
  352. }
  353. $archiveDest = Join-Path $archiveDir $localFinalName
  354. Move-Item -LiteralPath $file.FullName -Destination $archiveDest -Force
  355. $archiveNote = if ($localFinalName -ne $file.Name) { " (renamed from $($file.Name))" } else { '' }
  356. Write-Log "Archived: $($file.FullName) → $archiveDest$archiveNote"
  357. }
  358. elseif ($LocalRenamePattern -and $localFinalName -ne $file.Name) {
  359. $renameDest = Join-Path $file.DirectoryName $localFinalName
  360. Rename-Item -LiteralPath $file.FullName -NewName $localFinalName -Force
  361. Write-Log "Renamed local: $($file.Name) → $localFinalName"
  362. }
  363. }
  364. catch {
  365. Write-Log "FAILED: $($file.Name) — $($_.Exception.Message)" -Level ERROR
  366. $failCount++
  367. }
  368. }
  369. }
  370. finally {
  371. if ($session) { $session.Dispose() }
  372. }
  373. # ── Summary ──────────────────────────────────────────────────────────
  374. Write-Log "═══ Transfer Complete ═══"
  375. Write-Log " Succeeded : $successCount"
  376. if ($failCount -gt 0) {
  377. Write-Log " Failed : $failCount" -Level ERROR
  378. }
  379. if ($DeleteAfterTransfer) {
  380. Write-Log " Mode : MOVE (source files deleted on success)"
  381. }
  382. elseif ($ArchivePath) {
  383. Write-Log " Mode : ARCHIVE → $ArchivePath"
  384. }
  385. else {
  386. Write-Log " Mode : COPY (source files retained)"
  387. }
  388. if ($failCount -gt 0) { exit 1 }
  389. }
  390. catch {
  391. Write-Log "Fatal error: $($_.Exception.Message)" -Level ERROR
  392. Write-Log $_.ScriptStackTrace -Level ERROR
  393. exit 1
  394. }