387 lines
14 KiB
PowerShell
387 lines
14 KiB
PowerShell
<#
|
|
.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
|
|
}
|