Files
filestosftp/Send-FilesToSftp.ps1
T

488 lines
20 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 ArchivePath
Move successfully uploaded files to this local folder instead of leaving them in place.
The folder will be created if it does not exist.
Cannot be combined with -DeleteAfterTransfer.
.PARAMETER LocalRenamePattern
Regex pattern to match in the filename when renaming the local file after a successful upload.
Must be used with -LocalRenameReplacement.
Works standalone (rename in place) or with -ArchivePath (rename while archiving).
.PARAMETER LocalRenameReplacement
Replacement string for -LocalRenamePattern. Supports regex capture groups ($1, ${1}, etc.).
.PARAMETER DeleteAfterTransfer
Delete local files after successful upload (move behavior).
Cannot be combined with -ArchivePath.
.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
# Archive files to a local folder after upload
.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
-HostName "sftp.example.com" -UserName "uploader" `
-ArchivePath "C:\exports\sent"
.EXAMPLE
# Archive and rename locally (append date) after upload
.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
-HostName "sftp.example.com" -UserName "uploader" `
-ArchivePath "C:\exports\sent" `
-LocalRenamePattern '^(.+?)(\.[^.]+)$' -LocalRenameReplacement "`$1_sent`$2"
.EXAMPLE
# Rename local file in place after upload (no archive)
.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
-HostName "sftp.example.com" -UserName "uploader" `
-LocalRenamePattern '^' -LocalRenameReplacement 'done_'
.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,
[string]$ArchivePath,
[string]$LocalRenamePattern,
[string]$LocalRenameReplacement,
[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 parameters ──────────────────────────────────────────────
if (($RenamePattern -and -not $RenameReplacement) -or ($RenameReplacement -and -not $RenamePattern)) {
Write-Log "-RenamePattern and -RenameReplacement must be used together." -Level ERROR
exit 1
}
if (($LocalRenamePattern -and -not $LocalRenameReplacement) -or ($LocalRenameReplacement -and -not $LocalRenamePattern)) {
Write-Log "-LocalRenamePattern and -LocalRenameReplacement must be used together." -Level ERROR
exit 1
}
if ($ArchivePath -and $DeleteAfterTransfer) {
Write-Log "-ArchivePath and -DeleteAfterTransfer cannot be used together." -Level ERROR
exit 1
}
# ── Create archive folder if needed ─────────────────────────────────
if ($ArchivePath -and -not (Test-Path $ArchivePath)) {
New-Item -ItemType Directory -Path $ArchivePath -Force | Out-Null
Write-Log "Created archive folder: $ArchivePath"
}
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 "Remote rename : '$RenamePattern' → '$RenameReplacement'" }
if ($LocalRenamePattern) { Write-Log "Local rename : '$LocalRenamePattern' → '$LocalRenameReplacement'" }
if ($ArchivePath) { Write-Log "Archive to : $ArchivePath" }
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 }
$localFinalName = if ($LocalRenamePattern) { $f.Name -replace $LocalRenamePattern, $LocalRenameReplacement } else { $f.Name }
$remoteDir = $RemotePath.TrimEnd('/') + ($f.DirectoryName.Substring($LocalPath.TrimEnd('\').Length) -replace '\\', '/')
$remoteDest = "$remoteDir/$destName"
$sizeKB = [math]::Round($f.Length / 1KB, 1)
$remoteRenameNote = if ($RenamePattern -and $destName -ne $f.Name) { " [remote name: $destName]" } else { '' }
Write-Log " UPLOAD : $($f.FullName)$remoteDest (${sizeKB} KB)$remoteRenameNote"
if ($DeleteAfterTransfer) {
Write-Log " LOCAL : DELETE $($f.FullName)"
}
elseif ($ArchivePath) {
$archiveDest = Join-Path $ArchivePath $localFinalName
$localNote = if ($LocalRenamePattern -and $localFinalName -ne $f.Name) { " [renamed from $($f.Name)]" } else { '' }
Write-Log " LOCAL : MOVE → $archiveDest$localNote"
}
elseif ($LocalRenamePattern -and $localFinalName -ne $f.Name) {
$renameDest = Join-Path $f.DirectoryName $localFinalName
Write-Log " LOCAL : RENAME → $renameDest"
}
}
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
# Always upload without auto-delete; handle local disposition ourselves
$result = $session.PutFiles($file.FullName, $targetPath, $false, $transferOptions)
$result.Check()
$sizeKB = [math]::Round($file.Length / 1KB, 1)
Write-Log "Transferred: $($file.Name)$targetPath (${sizeKB} KB)" -Level SUCCESS
$successCount++
# ── Local post-transfer disposition ──────────────────────
$localFinalName = if ($LocalRenamePattern) { $file.Name -replace $LocalRenamePattern, $LocalRenameReplacement } else { $file.Name }
if ($DeleteAfterTransfer) {
Remove-Item -LiteralPath $file.FullName -Force
Write-Log "Deleted local: $($file.FullName)"
}
elseif ($ArchivePath) {
# Preserve subdirectory structure when recursing
$archiveDir = if ($Recurse -and $relativePath) {
$sub = $file.DirectoryName.Substring($LocalPath.TrimEnd('\').Length).TrimStart('\')
Join-Path $ArchivePath $sub
} else { $ArchivePath }
if (-not (Test-Path $archiveDir)) {
New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
}
$archiveDest = Join-Path $archiveDir $localFinalName
Move-Item -LiteralPath $file.FullName -Destination $archiveDest -Force
$archiveNote = if ($localFinalName -ne $file.Name) { " (renamed from $($file.Name))" } else { '' }
Write-Log "Archived: $($file.FullName)$archiveDest$archiveNote"
}
elseif ($LocalRenamePattern -and $localFinalName -ne $file.Name) {
$renameDest = Join-Path $file.DirectoryName $localFinalName
Rename-Item -LiteralPath $file.FullName -NewName $localFinalName -Force
Write-Log "Renamed local: $($file.Name)$localFinalName"
}
}
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)"
}
elseif ($ArchivePath) {
Write-Log " Mode : ARCHIVE → $ArchivePath"
}
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
}