| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386 |
- <#
- .SYNOPSIS
- Transfers files to an SFTP server with filtering and logging.
- .DESCRIPTION
- Moves or copies files from a local folder to a remote SFTP destination.
- Supports regex/wildcard file filtering, recursive scanning, and secure
- credential storage. Uses the WinSCP .NET assembly for SFTP transport.
- .PARAMETER LocalPath
- Local source folder to scan for files.
- .PARAMETER RemotePath
- Remote SFTP destination folder (e.g. /uploads/incoming).
- .PARAMETER FileFilter
- Regex pattern to match filenames (e.g. '\.csv$' or '^report_\d{8}').
- Default: '.*' (all files).
- .PARAMETER HostName
- SFTP server hostname or IP.
- .PARAMETER Port
- SFTP port. Default: 22.
- .PARAMETER UserName
- SFTP username.
- .PARAMETER SshHostKeyFingerprint
- SSH host key fingerprint for server verification.
- Use "ssh-rsa 2048 xx:xx:xx..." format, or pass "*" to accept any (NOT recommended for production).
- .PARAMETER Credential
- PSCredential object. If omitted, you'll be prompted interactively.
- .PARAMETER CredentialFile
- Path to a saved credential file (Export-Clixml). For scheduled/unattended runs.
- Create one with: Get-Credential | Export-Clixml -Path "C:\secure\sftp_cred.xml"
- .PARAMETER KeyFilePath
- Path to a private key file for key-based auth (optional).
- .PARAMETER Recurse
- Scan subdirectories in LocalPath.
- .PARAMETER RenamePattern
- Regex pattern to match in the filename for renaming before upload.
- Must be used together with -RenameReplacement.
- Supports capture groups (e.g. '(.+)\.csv$' with replacement '$1_processed.csv').
- .PARAMETER RenameReplacement
- Replacement string for the -RenamePattern match. Supports regex capture groups ($1, ${1}, etc.).
- Use '$0' to reference the full match.
- .PARAMETER DeleteAfterTransfer
- Delete local files after successful upload (move behavior).
- .PARAMETER DryRun
- Show what would be transferred without actually uploading.
- .PARAMETER LogFile
- Path to a log file. If omitted, logs to console only.
- .PARAMETER WinScpDllPath
- Path to WinSCPnet.dll. Default: looks in script directory, then common install paths.
- .EXAMPLE
- # Interactive - transfer all CSVs
- .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
- -HostName "sftp.example.com" -UserName "uploader" -FileFilter '\.csv$'
- .EXAMPLE
- # Rename files by appending today's date before the extension
- .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
- -HostName "sftp.example.com" -UserName "uploader" `
- -RenamePattern '^(.+?)(\.[^.]+)$' -RenameReplacement "`$1_$(Get-Date -Format 'yyyyMMdd')`$2"
- .EXAMPLE
- # Add a prefix to every uploaded file
- .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
- -HostName "sftp.example.com" -UserName "uploader" `
- -RenamePattern '^' -RenameReplacement 'processed_'
- .EXAMPLE
- # Unattended with saved credentials and move behavior
- .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
- -HostName "sftp.example.com" -UserName "uploader" `
- -CredentialFile "C:\secure\sftp_cred.xml" `
- -FileFilter '^report_\d{8}' -DeleteAfterTransfer -LogFile "C:\logs\sftp.log"
- .EXAMPLE
- # Dry run to preview what would transfer
- .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
- -HostName "sftp.example.com" -UserName "uploader" -DryRun
- .EXAMPLE
- # Key-based auth, recursive scan
- .\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/archive" `
- -HostName "sftp.example.com" -UserName "svcaccount" `
- -KeyFilePath "C:\keys\id_rsa.ppk" -Recurse
- #>
- [CmdletBinding(SupportsShouldProcess)]
- param(
- [Parameter(Mandatory)]
- [ValidateScript({ Test-Path $_ -PathType Container })]
- [string]$LocalPath,
- [Parameter(Mandatory)]
- [string]$RemotePath,
- [string]$FileFilter = '.*',
- [Parameter(Mandatory)]
- [string]$HostName,
- [int]$Port = 22,
- [Parameter(Mandatory)]
- [string]$UserName,
- [string]$SshHostKeyFingerprint = $null,
- [PSCredential]$Credential,
- [string]$CredentialFile,
- [string]$KeyFilePath,
- [string]$RenamePattern,
- [string]$RenameReplacement,
- [switch]$Recurse,
- [switch]$DeleteAfterTransfer,
- [switch]$DryRun,
- [string]$LogFile,
- [string]$WinScpDllPath
- )
- # ── Logging ──────────────────────────────────────────────────────────────────
- function Write-Log {
- param(
- [string]$Message,
- [ValidateSet('INFO','WARN','ERROR','SUCCESS')]
- [string]$Level = 'INFO'
- )
- $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
- $entry = "[$timestamp] [$Level] $Message"
- switch ($Level) {
- 'ERROR' { Write-Host $entry -ForegroundColor Red }
- 'WARN' { Write-Host $entry -ForegroundColor Yellow }
- 'SUCCESS' { Write-Host $entry -ForegroundColor Green }
- default { Write-Host $entry }
- }
- if ($LogFile) {
- $entry | Out-File -FilePath $LogFile -Append -Encoding utf8
- }
- }
- # ── Locate WinSCP .NET Assembly ──────────────────────────────────────────────
- function Find-WinScpDll {
- $searchPaths = @(
- $WinScpDllPath
- (Join-Path $PSScriptRoot 'WinSCPnet.dll')
- (Join-Path $PSScriptRoot 'lib\WinSCPnet.dll')
- 'C:\Program Files (x86)\WinSCP\WinSCPnet.dll'
- 'C:\Program Files\WinSCP\WinSCPnet.dll'
- ) | Where-Object { $_ }
- foreach ($path in $searchPaths) {
- if (Test-Path $path) {
- return $path
- }
- }
- # Try NuGet package in common locations
- $nugetPath = Get-ChildItem -Path "$env:USERPROFILE\.nuget\packages\winscp" -Filter 'WinSCPnet.dll' -Recurse -ErrorAction SilentlyContinue |
- Sort-Object LastWriteTime -Descending | Select-Object -First 1
- if ($nugetPath) { return $nugetPath.FullName }
- return $null
- }
- # ── Main ─────────────────────────────────────────────────────────────────────
- try {
- # ── Validate rename parameters ───────────────────────────────────────
- if (($RenamePattern -and -not $RenameReplacement) -or ($RenameReplacement -and -not $RenamePattern)) {
- Write-Log "-RenamePattern and -RenameReplacement must be used together." -Level ERROR
- exit 1
- }
- Write-Log "═══ SFTP Transfer Starting ═══"
- Write-Log "Local path : $LocalPath"
- Write-Log "Remote path : $RemotePath"
- Write-Log "File filter : $FileFilter"
- Write-Log "Host : ${HostName}:${Port}"
- if ($RenamePattern) { Write-Log "Rename : '$RenamePattern' → '$RenameReplacement'" }
- if ($DryRun) { Write-Log "*** DRY RUN MODE - No files will be transferred ***" -Level WARN }
- # ── Find and load WinSCP ─────────────────────────────────────────────
- $dllPath = Find-WinScpDll
- if (-not $dllPath) {
- Write-Log "WinSCPnet.dll not found. Install options:" -Level ERROR
- Write-Log " 1) Install-Package WinSCP -Source nuget.org" -Level ERROR
- Write-Log " 2) Download from https://winscp.net/eng/downloads.php (.NET assembly)" -Level ERROR
- Write-Log " 3) Place WinSCPnet.dll in the same folder as this script" -Level ERROR
- exit 1
- }
- Add-Type -Path $dllPath
- Write-Log "Loaded WinSCP from: $dllPath"
- # ── Resolve credentials ──────────────────────────────────────────────
- $password = $null
- if ($Credential) {
- $password = $Credential.GetNetworkCredential().Password
- }
- elseif ($CredentialFile) {
- if (-not (Test-Path $CredentialFile)) {
- Write-Log "Credential file not found: $CredentialFile" -Level ERROR
- exit 1
- }
- $savedCred = Import-Clixml -Path $CredentialFile
- $password = $savedCred.GetNetworkCredential().Password
- Write-Log "Loaded credentials from file"
- }
- elseif (-not $KeyFilePath) {
- $promptCred = Get-Credential -UserName $UserName -Message "Enter SFTP password for $HostName"
- if (-not $promptCred) {
- Write-Log "No credentials provided. Aborting." -Level ERROR
- exit 1
- }
- $password = $promptCred.GetNetworkCredential().Password
- }
- # ── Collect files ────────────────────────────────────────────────────
- $gciParams = @{ Path = $LocalPath; File = $true }
- if ($Recurse) { $gciParams['Recurse'] = $true }
- $allFiles = Get-ChildItem @gciParams | Where-Object { $_.Name -match $FileFilter }
- if (-not $allFiles -or $allFiles.Count -eq 0) {
- Write-Log "No files matched filter '$FileFilter' in $LocalPath" -Level WARN
- exit 0
- }
- Write-Log "Found $($allFiles.Count) file(s) matching filter"
- if ($DryRun) {
- Write-Log "Files that would be transferred:" -Level INFO
- foreach ($f in $allFiles) {
- $destName = if ($RenamePattern) { $f.Name -replace $RenamePattern, $RenameReplacement } else { $f.Name }
- $relativePath = $f.FullName.Substring($LocalPath.TrimEnd('\').Length)
- $remoteDir = ($RemotePath.TrimEnd('/') + ($f.DirectoryName.Substring($LocalPath.TrimEnd('\').Length) -replace '\\', '/'))
- $remoteDest = "$remoteDir/$destName"
- $sizeKB = [math]::Round($f.Length / 1KB, 1)
- $renameNote = if ($RenamePattern -and $destName -ne $f.Name) { " [renamed from $($f.Name)]" } else { '' }
- Write-Log " $($f.FullName) → $remoteDest (${sizeKB} KB)$renameNote"
- }
- Write-Log "DRY RUN complete. $($allFiles.Count) file(s) would be transferred." -Level SUCCESS
- exit 0
- }
- # ── Configure session ────────────────────────────────────────────────
- $sessionOptions = New-Object WinSCP.SessionOptions -Property @{
- Protocol = [WinSCP.Protocol]::Sftp
- HostName = $HostName
- PortNumber = $Port
- UserName = $UserName
- }
- if ($password) {
- $sessionOptions.Password = $password
- }
- if ($KeyFilePath) {
- if (-not (Test-Path $KeyFilePath)) {
- Write-Log "Private key file not found: $KeyFilePath" -Level ERROR
- exit 1
- }
- $sessionOptions.SshPrivateKeyPath = $KeyFilePath
- Write-Log "Using key-based auth: $KeyFilePath"
- }
- if ($SshHostKeyFingerprint) {
- if ($SshHostKeyFingerprint -eq '*') {
- Write-Log "Accepting any SSH host key — NOT SAFE FOR PRODUCTION" -Level WARN
- $sessionOptions.GiveUpSecurityAndAcceptAnySshHostKey = $true
- }
- else {
- $sessionOptions.SshHostKeyFingerprint = $SshHostKeyFingerprint
- }
- }
- else {
- Write-Log "No SSH host key fingerprint provided — accepting any key" -Level WARN
- Write-Log " Get your fingerprint with: ssh-keyscan $HostName | ssh-keygen -lf -" -Level WARN
- $sessionOptions.GiveUpSecurityAndAcceptAnySshHostKey = $true
- }
- # ── Open session and transfer ────────────────────────────────────────
- $session = New-Object WinSCP.Session
- $successCount = 0
- $failCount = 0
- try {
- $session.Open($sessionOptions)
- Write-Log "Connected to $HostName" -Level SUCCESS
- # Ensure remote directory exists
- if (-not $session.FileExists($RemotePath)) {
- Write-Log "Creating remote directory: $RemotePath"
- $session.CreateDirectory($RemotePath)
- }
- foreach ($file in $allFiles) {
- $relativePath = ''
- if ($Recurse) {
- $relativePath = $file.DirectoryName.Substring($LocalPath.TrimEnd('\').Length) -replace '\\', '/'
- }
- $destName = if ($RenamePattern) { $file.Name -replace $RenamePattern, $RenameReplacement } else { $file.Name }
- $targetDir = $RemotePath.TrimEnd('/') + $relativePath
- $targetPath = "$targetDir/$destName"
- if ($RenamePattern -and $destName -ne $file.Name) {
- Write-Log "Renaming: $($file.Name) → $destName"
- }
- try {
- # Ensure subdirectory exists on remote when recursing
- if ($Recurse -and $relativePath -and (-not $session.FileExists($targetDir))) {
- $session.CreateDirectory($targetDir)
- Write-Log "Created remote dir: $targetDir"
- }
- $transferOptions = New-Object WinSCP.TransferOptions
- $transferOptions.TransferMode = [WinSCP.TransferMode]::Binary
- $result = $session.PutFiles($file.FullName, $targetPath, $DeleteAfterTransfer, $transferOptions)
- $result.Check()
- $sizeKB = [math]::Round($file.Length / 1KB, 1)
- Write-Log "Transferred: $($file.Name) → $targetPath (${sizeKB} KB)" -Level SUCCESS
- $successCount++
- }
- catch {
- Write-Log "FAILED: $($file.Name) — $($_.Exception.Message)" -Level ERROR
- $failCount++
- }
- }
- }
- finally {
- if ($session) { $session.Dispose() }
- }
- # ── Summary ──────────────────────────────────────────────────────────
- Write-Log "═══ Transfer Complete ═══"
- Write-Log " Succeeded : $successCount"
- if ($failCount -gt 0) {
- Write-Log " Failed : $failCount" -Level ERROR
- }
- if ($DeleteAfterTransfer) {
- Write-Log " Mode : MOVE (source files deleted on success)"
- }
- else {
- Write-Log " Mode : COPY (source files retained)"
- }
- if ($failCount -gt 0) { exit 1 }
- }
- catch {
- Write-Log "Fatal error: $($_.Exception.Message)" -Level ERROR
- Write-Log $_.ScriptStackTrace -Level ERROR
- exit 1
- }
|