Files
filestosftp/Send-FilesToSftp.ps1
T

347 lines
12 KiB
PowerShell
Raw Normal View History

<#
.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
}