|
@@ -0,0 +1,346 @@
|
|
|
|
|
+<#
|
|
|
|
|
+.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
|
|
|
|
|
+}
|