Initial commit: Send-FilesToSftp.ps1 and README

This commit is contained in:
2026-04-16 21:25:49 -05:00
commit b2fed6c373
2 gewijzigde bestanden met toevoegingen van 537 en 0 verwijderingen
+191
Bestand weergeven
@@ -0,0 +1,191 @@
# Send-FilesToSftp.ps1
PowerShell script to transfer files to an SFTP server with regex filtering, secure credential handling, and logging.
## Prerequisites
**WinSCP .NET Assembly** (`WinSCPnet.dll`) is required. Install using one of these methods:
| Method | Steps |
|--------|-------|
| **Drop-in** | Download the [.NET assembly package](https://winscp.net/eng/downloads.php), extract `WinSCPnet.dll` next to the script |
| **NuGet** | `Install-Package WinSCP -Source nuget.org` |
| **WinSCP Installer** | Install WinSCP and check the ".NET assembly" option during setup |
The script auto-searches these locations in order:
1. `-WinScpDllPath` parameter (if provided)
2. Same directory as the script
3. `lib\` subdirectory of the script folder
4. `C:\Program Files (x86)\WinSCP\`
5. `C:\Program Files\WinSCP\`
6. NuGet package cache (`~\.nuget\packages\winscp`)
## Quick Start
```powershell
# Basic usage — prompted for password interactively
.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
-HostName "sftp.example.com" -UserName "uploader"
```
## Parameters
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `-LocalPath` | Yes | — | Local source folder to scan |
| `-RemotePath` | Yes | — | Remote SFTP destination folder |
| `-HostName` | Yes | — | SFTP server hostname or IP |
| `-UserName` | Yes | — | SFTP username |
| `-FileFilter` | No | `.*` | Regex pattern to match filenames |
| `-Port` | No | `22` | SFTP port |
| `-Credential` | No | — | `PSCredential` object |
| `-CredentialFile` | No | — | Path to saved credential XML (see below) |
| `-KeyFilePath` | No | — | Path to SSH private key (`.ppk`) |
| `-SshHostKeyFingerprint` | No | — | SSH host key fingerprint for verification |
| `-Recurse` | No | `false` | Scan subdirectories |
| `-DeleteAfterTransfer` | No | `false` | Delete local files after successful upload |
| `-DryRun` | No | `false` | Preview transfers without uploading |
| `-LogFile` | No | — | Path to log file (logs to console if omitted) |
| `-WinScpDllPath` | No | — | Explicit path to `WinSCPnet.dll` |
## Authentication
The script supports three auth methods, checked in this order:
### 1. Credential Object (interactive or pipeline)
```powershell
$cred = Get-Credential
.\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/uploads" `
-HostName "sftp.example.com" -UserName "brad" -Credential $cred
```
### 2. Saved Credential File (unattended / scheduled tasks)
```powershell
# One-time setup — run as the same user that will run the scheduled task
Get-Credential | Export-Clixml -Path "C:\secure\sftp_cred.xml"
# Use in script
.\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/uploads" `
-HostName "sftp.example.com" -UserName "svc_upload" `
-CredentialFile "C:\secure\sftp_cred.xml"
```
> **Note:** `Export-Clixml` encrypts credentials using Windows DPAPI, tied to the user account and machine that created the file. Only that same user on that same machine can decrypt it.
### 3. SSH Private Key
```powershell
.\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/uploads" `
-HostName "sftp.example.com" -UserName "svc_upload" `
-KeyFilePath "C:\keys\id_rsa.ppk"
```
### 4. Interactive Prompt
If none of the above are provided and no key file is specified, you'll be prompted for a password at runtime.
## File Filtering (Regex)
The `-FileFilter` parameter uses PowerShell regex (case-insensitive by default).
| Filter | Matches |
|--------|---------|
| `'\.csv$'` | All CSV files |
| `'\.xlsx?$'` | `.xls` and `.xlsx` files |
| `'^report_\d{8}'` | Files starting with `report_` + 8 digits (e.g. `report_20260415.csv`) |
| `'(?-i)^Data'` | Files starting with `Data` (case-sensitive) |
| `'badge.*\.xlsx$'` | Excel files with `badge` anywhere in the name |
| `'\.(csv\|txt)$'` | CSV or TXT files |
| `'.*'` | Everything (default) |
## Usage Examples
### Transfer all CSVs
```powershell
.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
-HostName "sftp.example.com" -UserName "uploader" -FileFilter '\.csv$'
```
### Move files (delete after upload) with logging
```powershell
.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
-HostName "sftp.example.com" -UserName "svc_upload" `
-CredentialFile "C:\secure\cred.xml" `
-DeleteAfterTransfer -LogFile "C:\logs\sftp_transfer.log"
```
### Dry run — preview without transferring
```powershell
.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" `
-HostName "sftp.example.com" -UserName "uploader" -DryRun
```
### Recursive with subdirectories
```powershell
.\Send-FilesToSftp.ps1 -LocalPath "C:\data\projects" -RemotePath "/archive" `
-HostName "sftp.example.com" -UserName "uploader" `
-Recurse -FileFilter '\.pdf$'
```
## SSH Host Key Fingerprint
For production use, always provide the host key fingerprint to prevent MITM attacks:
```powershell
# Get the fingerprint from the server
ssh-keyscan sftp.example.com | ssh-keygen -lf -
# Use it in the script
.\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/uploads" `
-HostName "sftp.example.com" -UserName "uploader" `
-SshHostKeyFingerprint "ssh-rsa 2048 aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99"
```
Pass `"*"` to accept any key (development/testing only — **not recommended for production**).
## Scheduled Task Setup
To run unattended via Windows Task Scheduler:
1. **Save credentials** as the service account user:
```powershell
Get-Credential | Export-Clixml -Path "C:\secure\sftp_cred.xml"
```
2. **Create the scheduled task:**
```powershell
$action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\scripts\Send-FilesToSftp.ps1" -LocalPath "C:\exports" -RemotePath "/incoming" -HostName "sftp.example.com" -UserName "svc_upload" -CredentialFile "C:\secure\sftp_cred.xml" -FileFilter "\.csv$" -DeleteAfterTransfer -LogFile "C:\logs\sftp.log"'
$trigger = New-ScheduledTaskTrigger -Daily -At "2:00AM"
Register-ScheduledTask -TaskName "SFTP Upload" `
-Action $action -Trigger $trigger `
-User "DOMAIN\svc_upload" -Password "****" `
-RunLevel Highest
```
## Exit Codes
| Code | Meaning |
|------|---------|
| `0` | All files transferred (or no files matched filter) |
| `1` | One or more failures occurred |
## Log Output
Logs include timestamps and severity levels. Sample output:
```
[2026-04-16 14:30:01] [INFO] ═══ SFTP Transfer Starting ═══
[2026-04-16 14:30:01] [INFO] Local path : C:\exports
[2026-04-16 14:30:01] [INFO] Remote path : /incoming
[2026-04-16 14:30:01] [INFO] File filter : \.csv$
[2026-04-16 14:30:01] [INFO] Found 3 file(s) matching filter
[2026-04-16 14:30:02] [SUCCESS] Connected to sftp.example.com
[2026-04-16 14:30:03] [SUCCESS] Transferred: report_20260415.csv → /incoming/report_20260415.csv (42.3 KB)
[2026-04-16 14:30:03] [SUCCESS] Transferred: badges_export.csv → /incoming/badges_export.csv (18.1 KB)
[2026-04-16 14:30:04] [SUCCESS] Transferred: access_log.csv → /incoming/access_log.csv (7.8 KB)
[2026-04-16 14:30:04] [INFO] ═══ Transfer Complete ═══
[2026-04-16 14:30:04] [INFO] Succeeded : 3
[2026-04-16 14:30:04] [INFO] Mode : COPY (source files retained)
```
+346
Bestand weergeven
@@ -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
}