<# .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 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 # 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, [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 { 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 ($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) { $relativePath = $f.FullName.Substring($LocalPath.TrimEnd('\').Length) $remoteDest = ($RemotePath.TrimEnd('/') + $relativePath) -replace '\\', '/' $sizeKB = [math]::Round($f.Length / 1KB, 1) Write-Log " $($f.FullName) → $remoteDest (${sizeKB} KB)" } 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 '\\', '/' } $targetDir = $RemotePath.TrimEnd('/') + $relativePath $targetPath = "$targetDir/$($file.Name)" 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 }