Public Access
Compare commits
10
Commits
cfa83d91d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
698c102c1b | ||
|
|
c3ad14cb88 | ||
|
|
1965e07c7c | ||
|
|
b34360c917 | ||
|
|
1edd6520a3 | ||
|
|
1fa89da183 | ||
|
|
585befe922 | ||
|
|
0747612810 | ||
|
|
d499721132 | ||
|
|
e384719ecb |
+121
-3
@@ -323,6 +323,121 @@ function Add-Practices($entityName) {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PracticeNames($entityName) {
|
||||
$export1 = Join-Path (Join-Path $entitiesDir $entityName) "Export1"
|
||||
if (-not (Test-Path $export1)) { return @() }
|
||||
return @(Get-ChildItem -Path $export1 -Directory | ForEach-Object { $_.Name })
|
||||
}
|
||||
|
||||
# Same rules engine.ps1 uses: practice.json replaces or adds to entity.json for
|
||||
# that one practice, so what gets tested here is what will actually be sent.
|
||||
function Get-MergedPracticeConfig($entityName, $practiceName, $entityConfig) {
|
||||
$file = Join-Path (Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $practiceName) "practice.json"
|
||||
$merged = $entityConfig | ConvertTo-Json -Depth 10 | ConvertFrom-Json
|
||||
if (-not (Test-Path $file)) { return $merged }
|
||||
|
||||
$override = Get-Content $file -Raw | ConvertFrom-Json
|
||||
foreach ($section in $override.PSObject.Properties) {
|
||||
if ($section.Name -like "_*") { continue }
|
||||
if ($null -eq $merged.PSObject.Properties[$section.Name]) {
|
||||
$merged | Add-Member -NotePropertyName $section.Name -NotePropertyValue $section.Value
|
||||
} elseif ($section.Value -is [System.Management.Automation.PSCustomObject]) {
|
||||
foreach ($prop in $section.Value.PSObject.Properties) {
|
||||
$merged.$($section.Name) | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force
|
||||
}
|
||||
} else {
|
||||
Set-Field $merged $section.Name $section.Value
|
||||
}
|
||||
}
|
||||
return $merged
|
||||
}
|
||||
|
||||
function Set-PracticeLogin($entityName, $entityConfig) {
|
||||
$practices = Get-PracticeNames $entityName
|
||||
if ($practices.Count -eq 0) {
|
||||
Write-Host ""
|
||||
Write-Host "No practice folders yet - add some first." -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
$choice = Read-Choice "Which practice?" $practices 1
|
||||
$practiceName = $practices[$choice - 1]
|
||||
$dir = Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $practiceName
|
||||
$file = Join-Path $dir "practice.json"
|
||||
|
||||
$existing = $null
|
||||
if (Test-Path $file) { $existing = Get-Content $file -Raw | ConvertFrom-Json }
|
||||
|
||||
Write-Section "$practiceName"
|
||||
Write-Host "Anything you set here is used for $practiceName only." -ForegroundColor DarkGray
|
||||
Write-Host "Anything you leave alone still comes from $entityName." -ForegroundColor DarkGray
|
||||
|
||||
$ftps = [ordered]@{}
|
||||
if ($existing -and $existing.ftps) {
|
||||
foreach ($p in $existing.ftps.PSObject.Properties) { $ftps[$p.Name] = $p.Value }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
if (Read-YesNo "Does $practiceName have its own username and password?" "y") {
|
||||
Write-Host ""
|
||||
$ftps["username"] = Read-Answer "Username for $practiceName" $ftps["username"]
|
||||
$keep = $false
|
||||
if ($ftps["password"]) {
|
||||
Write-Host ""
|
||||
Write-Host "A password is already saved for $practiceName." -ForegroundColor DarkGray
|
||||
$keep = Read-YesNo "Keep it?" "y"
|
||||
}
|
||||
if (-not $keep) { $ftps["password"] = Read-Answer "Password for $practiceName" $null }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
if (Read-YesNo "Does $practiceName use a different server than the rest of ${entityName}?" "n") {
|
||||
$srv = Read-ServerDetails "$practiceName" $existing.ftps
|
||||
$ftps["host"] = $srv.host
|
||||
$ftps["protocol"] = $srv.protocol
|
||||
$ftps["username"] = $srv.username
|
||||
$ftps["password"] = $srv.password
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
if (Read-YesNo "Does $practiceName use a different folder on the server?" "n") {
|
||||
$ftps["tiff_path"] = Read-RemotePath "$practiceName's daily image files" $ftps["tiff_path"]
|
||||
}
|
||||
|
||||
if ($ftps.Count -eq 0) {
|
||||
if (Test-Path $file) {
|
||||
Remove-Item $file -Force
|
||||
Write-Host ""
|
||||
Write-Host "Nothing set - removed $practiceName's practice.json." -ForegroundColor Gray
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "Nothing set for $practiceName." -ForegroundColor Gray
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
$out = [ordered]@{
|
||||
"_note" = "Settings for $practiceName only. Anything not listed comes from ..\..\entity.json."
|
||||
ftps = [PSCustomObject]$ftps
|
||||
}
|
||||
([PSCustomObject]$out) | ConvertTo-Json -Depth 10 | Set-Content -Path $file -Encoding UTF8
|
||||
Write-Host ""
|
||||
Write-Host "Saved $file" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Show-PracticeLogins($entityName, $entityConfig) {
|
||||
$practices = Get-PracticeNames $entityName
|
||||
if ($practices.Count -eq 0) { return }
|
||||
Write-Host ""
|
||||
Write-Host " Practices" -ForegroundColor White
|
||||
foreach ($p in $practices) {
|
||||
$merged = Get-MergedPracticeConfig $entityName $p $entityConfig
|
||||
$own = Test-Path (Join-Path (Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $p) "practice.json")
|
||||
$tag = if ($own) { "own settings" } else { "uses $entityName's" }
|
||||
Write-Host (" {0,-12} {1,-18} login: {2}" -f $p, $tag, (Show-Value $merged.ftps.username))
|
||||
}
|
||||
}
|
||||
|
||||
function Edit-Entity($entityName) {
|
||||
$config = Get-EntityConfig $entityName
|
||||
if (-not $config) {
|
||||
@@ -340,15 +455,17 @@ function Edit-Entity($entityName) {
|
||||
while ($true) {
|
||||
Write-Header "$entityName - current settings"
|
||||
Show-Settings $config
|
||||
Show-PracticeLogins $entityName $config
|
||||
|
||||
$choice = Read-Choice "What do you want to change?" @(
|
||||
"The daily-upload server (address, protocol, login)",
|
||||
"Where the daily image files go on the server",
|
||||
"Where PT STMT files from the dated folder go",
|
||||
"Patient files (server and folder)",
|
||||
"Give one practice its own login or folder",
|
||||
"Add practice folders",
|
||||
"Nothing - save and go back"
|
||||
) 6
|
||||
) 7
|
||||
|
||||
switch ($choice) {
|
||||
1 {
|
||||
@@ -396,8 +513,9 @@ function Edit-Entity($entityName) {
|
||||
Write-Host " Patient files turned off for $entityName." -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
5 { Add-Practices $entityName }
|
||||
6 {
|
||||
5 { Set-PracticeLogin $entityName $config }
|
||||
6 { Add-Practices $entityName }
|
||||
7 {
|
||||
$file = Save-EntityConfig $entityName $config
|
||||
Copy-RunBat $entityName
|
||||
Write-Host ""
|
||||
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
# Standalone SFTP/FTP login test. Nothing is read from a config file - you type
|
||||
# the details in - so it isolates the account from whatever a script is doing
|
||||
# with it. Called by test-login.bat.
|
||||
|
||||
$scriptRoot = Split-Path -Parent $PSCommandPath
|
||||
|
||||
function Read-Answer($question, $default) {
|
||||
while ($true) {
|
||||
if ($default) {
|
||||
Write-Host "$question " -NoNewline -ForegroundColor White
|
||||
Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray
|
||||
Write-Host ": " -NoNewline -ForegroundColor White
|
||||
} else {
|
||||
Write-Host "${question}: " -NoNewline -ForegroundColor White
|
||||
}
|
||||
$answer = Read-Host
|
||||
if ([string]::IsNullOrWhiteSpace($answer)) {
|
||||
if ($default) { return $default }
|
||||
Write-Host " (required)" -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
return $answer
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||
Write-Host " Login test" -ForegroundColor Cyan
|
||||
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Type the details exactly as they work in WinSCP or FileZilla." -ForegroundColor Gray
|
||||
|
||||
# Find WinSCPnet.dll wherever it happens to live - this script is meant to be
|
||||
# dropped next to whatever uploader is in use, not just this one.
|
||||
function Find-WinSCPDll {
|
||||
$parent = Split-Path -Parent $scriptRoot
|
||||
$candidates = @(
|
||||
(Join-Path $scriptRoot "WinSCP\WinSCPnet.dll"),
|
||||
(Join-Path $scriptRoot "WinSCPnet.dll"),
|
||||
(Join-Path $parent "WinSCP\WinSCPnet.dll"),
|
||||
(Join-Path $parent "WinSCPnet.dll"),
|
||||
"C:\Program Files (x86)\WinSCP\WinSCPnet.dll",
|
||||
"C:\Program Files\WinSCP\WinSCPnet.dll"
|
||||
)
|
||||
foreach ($c in $candidates) {
|
||||
if (Test-Path $c) { return $c }
|
||||
}
|
||||
# Anywhere under the folder this script sits in.
|
||||
$found = Get-ChildItem -Path $scriptRoot -Filter "WinSCPnet.dll" -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($found) { return $found.FullName }
|
||||
return $null
|
||||
}
|
||||
|
||||
$winscpDll = Find-WinSCPDll
|
||||
if (-not $winscpDll) {
|
||||
Write-Host ""
|
||||
Write-Host "Could not find WinSCPnet.dll automatically." -ForegroundColor Yellow
|
||||
Write-Host "It sits either in a WinSCP folder next to your upload script, or" -ForegroundColor Gray
|
||||
Write-Host "in C:\Program Files (x86)\WinSCP\ if WinSCP is installed normally." -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
$winscpDll = Read-Answer "Full path to WinSCPnet.dll" $null
|
||||
if (-not (Test-Path $winscpDll)) {
|
||||
Write-Host "Nothing at that path." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Using $winscpDll" -ForegroundColor DarkGray
|
||||
|
||||
# Windows tags files copied from elsewhere as untrusted, which stops Add-Type
|
||||
# loading them. Harmless everywhere else, so failure here is not worth a word.
|
||||
try {
|
||||
Get-ChildItem -Path (Split-Path -Parent $winscpDll) -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Unblock-File -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
try {
|
||||
Add-Type -Path $winscpDll
|
||||
} catch {
|
||||
Write-Host "Could not load WinSCPnet.dll - $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host "If it says the operation is not supported, Windows has the file" -ForegroundColor Yellow
|
||||
Write-Host "blocked: right-click it, Properties, tick Unblock, OK." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Read-Number($question, $min, $max, $default) {
|
||||
while ($true) {
|
||||
Write-Host "$question " -NoNewline -ForegroundColor White
|
||||
Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray
|
||||
Write-Host ": " -NoNewline -ForegroundColor White
|
||||
$a = Read-Host
|
||||
if ([string]::IsNullOrWhiteSpace($a)) { $a = [string]$default }
|
||||
$n = 0
|
||||
if ([int]::TryParse(([string]$a).Trim(), [ref]$n) -and $n -ge $min -and $n -le $max) { return $n }
|
||||
Write-Host " (a number between $min and $max)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# FileZilla keeps site passwords Base64-encoded, so the value sitting in the
|
||||
# exported XML is not the password - it has to be decoded first. Copying it
|
||||
# out verbatim fails against the server every time, which is exactly what a
|
||||
# working FileZilla login plus a failing script looks like.
|
||||
function Import-FileZillaSite {
|
||||
Write-Host ""
|
||||
Write-Host "In FileZilla: File > Export, tick 'Site Manager entries', save the .xml." -ForegroundColor Gray
|
||||
$path = Read-Answer "Path to the exported .xml" $null
|
||||
$path = $path.Trim('"').Trim()
|
||||
if (-not (Test-Path $path)) {
|
||||
Write-Host " Nothing at that path." -ForegroundColor Red
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$xml = [xml](Get-Content $path -Raw)
|
||||
} catch {
|
||||
Write-Host " That file is not readable XML - $($_.Exception.Message)" -ForegroundColor Red
|
||||
return $null
|
||||
}
|
||||
|
||||
$servers = @($xml.SelectNodes("//Server"))
|
||||
if ($servers.Count -eq 0) {
|
||||
Write-Host " No sites found in that file." -ForegroundColor Red
|
||||
return $null
|
||||
}
|
||||
|
||||
$node = $servers[0]
|
||||
if ($servers.Count -gt 1) {
|
||||
Write-Host ""
|
||||
Write-Host "Sites in that file:" -ForegroundColor White
|
||||
for ($i = 0; $i -lt $servers.Count; $i++) {
|
||||
$n = $servers[$i]
|
||||
Write-Host (" {0}. {1} {2}@{3}:{4}" -f ($i + 1), $n.Name, $n.User, $n.Host, $n.Port)
|
||||
}
|
||||
$node = $servers[(Read-Number "Which one" 1 $servers.Count 1) - 1]
|
||||
}
|
||||
|
||||
$pass = $null
|
||||
$passNode = $node.SelectSingleNode("Pass")
|
||||
if ($passNode) {
|
||||
$encoding = $passNode.GetAttribute("encoding")
|
||||
if ($encoding -eq "base64") {
|
||||
try {
|
||||
$pass = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($passNode.InnerText))
|
||||
Write-Host ""
|
||||
Write-Host " Decoded the password from Base64." -ForegroundColor Green
|
||||
Write-Host " The XML holds $($passNode.InnerText.Length) characters; the real" -ForegroundColor Gray
|
||||
Write-Host " password is $($pass.Length). Using the decoded one." -ForegroundColor Gray
|
||||
} catch {
|
||||
Write-Host " The password in the XML is not valid Base64." -ForegroundColor Yellow
|
||||
}
|
||||
} elseif ($encoding -eq "crypt") {
|
||||
Write-Host ""
|
||||
Write-Host " This export is protected by a FileZilla master password," -ForegroundColor Yellow
|
||||
Write-Host " so the password cannot be read out of it. Type it instead." -ForegroundColor Yellow
|
||||
} else {
|
||||
$pass = $passNode.InnerText
|
||||
}
|
||||
}
|
||||
|
||||
# FileZilla's protocol numbering: 1 is SFTP, 0 is plain FTP, and the
|
||||
# TLS variants use higher values. Only used as the menu default.
|
||||
$proto = 1
|
||||
switch ([string]$node.Protocol) {
|
||||
"0" { $proto = 3 }
|
||||
"1" { $proto = 1 }
|
||||
"3" { $proto = 2 }
|
||||
"4" { $proto = 2 }
|
||||
}
|
||||
|
||||
return @{
|
||||
Host = [string]$node.Host
|
||||
Port = [string]$node.Port
|
||||
User = [string]$node.User
|
||||
Password = $pass
|
||||
Proto = $proto
|
||||
}
|
||||
}
|
||||
|
||||
$fromXml = $null
|
||||
Write-Host ""
|
||||
Write-Host "Where are the details coming from?" -ForegroundColor White
|
||||
Write-Host " 1. I will type them in"
|
||||
Write-Host " 2. Read them from a FileZilla site export (.xml)"
|
||||
if ((Read-Number "Choose 1-2" 1 2 1) -eq 2) {
|
||||
$fromXml = Import-FileZillaSite
|
||||
if (-not $fromXml) {
|
||||
Write-Host ""
|
||||
Write-Host "Falling back to typing them in." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
$serverHost = Read-Answer "Server" $(if ($fromXml) { $fromXml.Host } else { $null })
|
||||
$serverHost = ($serverHost -replace '^\w+://', '').Split('/')[0].Trim()
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Protocol:" -ForegroundColor White
|
||||
Write-Host " 1. SFTP"
|
||||
Write-Host " 2. FTPS"
|
||||
Write-Host " 3. FTP"
|
||||
$protoDefault = if ($fromXml) { $fromXml.Proto } else { 1 }
|
||||
$protoChoice = Read-Number "Choose 1-3" 1 3 $protoDefault
|
||||
$protocol = @("sftp", "ftps", "ftp")[$protoChoice - 1]
|
||||
|
||||
$defaultPort = if ($fromXml -and $fromXml.Port) { $fromXml.Port }
|
||||
elseif ($protocol -eq "sftp") { 22 } else { 21 }
|
||||
$port = Read-Number "Port" 1 65535 $defaultPort
|
||||
$username = Read-Answer "Username" $(if ($fromXml) { $fromXml.User } else { $null })
|
||||
|
||||
if ($fromXml -and $fromXml.Password) {
|
||||
$password = $fromXml.Password
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "Paste the password. It is not echoed and not saved anywhere." -ForegroundColor DarkGray
|
||||
Write-Host "Password: " -NoNewline -ForegroundColor White
|
||||
$secure = Read-Host -AsSecureString
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
$password = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||
|
||||
# Nothing echoes while typing, so a slipped paste looks identical to a good
|
||||
# one. Typing it twice is the only way to be sure of what actually went in.
|
||||
Write-Host "Type it once more to check: " -NoNewline -ForegroundColor White
|
||||
$secure2 = Read-Host -AsSecureString
|
||||
$bstr2 = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure2)
|
||||
$password2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr2)
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr2)
|
||||
|
||||
if ($password -ne $password2) {
|
||||
Write-Host ""
|
||||
Write-Host " !! The two did not match." -ForegroundColor Red
|
||||
Write-Host " First entry: $($password.Length) characters. Second: $($password2.Length)." -ForegroundColor Red
|
||||
Write-Host " One of them is wrong, so this test would prove nothing." -ForegroundColor Red
|
||||
Write-Host " Start over and paste carefully, or type it by hand." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# -- What the credentials actually contain ------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host ("-" * 64)
|
||||
Write-Host "What was entered" -ForegroundColor Cyan
|
||||
Write-Host ("-" * 64)
|
||||
Write-Host " Server : [$serverHost] port $port over $($protocol.ToUpper())"
|
||||
Write-Host " Username : [$username] ($($username.Length) characters)"
|
||||
Write-Host " Password : $($password.Length) characters"
|
||||
|
||||
$warned = $false
|
||||
if ($username -ne $username.Trim()) {
|
||||
Write-Host " !! The username has a leading or trailing space." -ForegroundColor Yellow
|
||||
$warned = $true
|
||||
}
|
||||
if ($password -ne $password.Trim()) {
|
||||
Write-Host " !! The password has a leading or trailing space." -ForegroundColor Yellow
|
||||
$warned = $true
|
||||
}
|
||||
if ($password -match '[\$`]') {
|
||||
Write-Host " !! The password contains a `$ or a backtick." -ForegroundColor Yellow
|
||||
Write-Host " In a PowerShell script these MUST be in single quotes:" -ForegroundColor Yellow
|
||||
Write-Host " `$Password = 'the`$password' <- correct" -ForegroundColor Yellow
|
||||
Write-Host " `$Password = `"the`$password`" <- WRONG, PowerShell eats `$password" -ForegroundColor Yellow
|
||||
Write-Host " This is the most common reason a login works in the WinSCP" -ForegroundColor Yellow
|
||||
Write-Host " window but fails from a script with the same credentials." -ForegroundColor Yellow
|
||||
$warned = $true
|
||||
}
|
||||
if ($password -match '[^\x20-\x7E]') {
|
||||
Write-Host " !! The password contains a non-standard character." -ForegroundColor Yellow
|
||||
$warned = $true
|
||||
}
|
||||
# A password copied straight out of a FileZilla export is still Base64.
|
||||
if (-not $fromXml -and $password.Length -ge 8 -and $password -cmatch '^[A-Za-z0-9+/]+={0,2}$' -and ($password.Length % 4) -eq 0) {
|
||||
$decoded = $null
|
||||
try { $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($password)) } catch { }
|
||||
if ($decoded -and $decoded -match '^[\x20-\x7E]+$') {
|
||||
Write-Host " !! This looks like Base64, not a password." -ForegroundColor Yellow
|
||||
Write-Host " FileZilla stores passwords Base64-encoded in its exported" -ForegroundColor Yellow
|
||||
Write-Host " XML. Decoded, this is $($decoded.Length) characters - that is" -ForegroundColor Yellow
|
||||
Write-Host " probably the real password. Re-run and choose option 2 to" -ForegroundColor Yellow
|
||||
Write-Host " read the export directly, or paste the decoded value." -ForegroundColor Yellow
|
||||
$warned = $true
|
||||
}
|
||||
}
|
||||
if (-not $warned) {
|
||||
Write-Host " Nothing unusual about either string." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# -- Try it -------------------------------------------------------------------
|
||||
$logFile = Join-Path $scriptRoot "login-test.log"
|
||||
if (Test-Path $logFile) { Remove-Item $logFile -Force }
|
||||
|
||||
$opts = New-Object WinSCP.SessionOptions
|
||||
$opts.HostName = $serverHost
|
||||
$opts.PortNumber = $port
|
||||
$opts.UserName = $username
|
||||
$opts.Password = $password
|
||||
if ($protocol -eq "sftp") {
|
||||
$opts.Protocol = [WinSCP.Protocol]::Sftp
|
||||
$opts.GiveUpSecurityAndAcceptAnySshHostKey = $true
|
||||
} else {
|
||||
$opts.Protocol = [WinSCP.Protocol]::Ftp
|
||||
if ($protocol -eq "ftps") {
|
||||
$opts.FtpSecure = [WinSCP.FtpSecure]::Explicit
|
||||
$opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true
|
||||
} else {
|
||||
$opts.FtpSecure = [WinSCP.FtpSecure]::None
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ("-" * 64)
|
||||
Write-Host "Connecting" -ForegroundColor Cyan
|
||||
Write-Host ("-" * 64)
|
||||
|
||||
$session = New-Object WinSCP.Session
|
||||
$session.SessionLogPath = $logFile
|
||||
$connected = $false
|
||||
try {
|
||||
$session.Open($opts)
|
||||
$connected = $true
|
||||
Write-Host " CONNECTED" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " FAILED" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if ($connected) {
|
||||
try {
|
||||
Write-Host ""
|
||||
Write-Host " Home directory: $($session.HomePath)" -ForegroundColor Gray
|
||||
Write-Host " Folders visible at the top level:" -ForegroundColor Gray
|
||||
$listing = $session.ListDirectory($session.HomePath)
|
||||
$shown = 0
|
||||
foreach ($item in $listing.Files) {
|
||||
if ($item.Name -eq "." -or $item.Name -eq "..") { continue }
|
||||
$kind = if ($item.IsDirectory) { "[dir] " } else { " " }
|
||||
Write-Host " $kind$($item.Name)"
|
||||
$shown++
|
||||
if ($shown -ge 25) { Write-Host " ... (more)"; break }
|
||||
}
|
||||
if ($shown -eq 0) { Write-Host " (nothing - the account may be locked to an empty folder)" }
|
||||
} catch {
|
||||
Write-Host " Connected, but could not list the folder: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
$checkPath = Read-Answer "Remote folder to check (or . to skip)" "."
|
||||
if ($checkPath -ne ".") {
|
||||
try {
|
||||
if ($session.FileExists($checkPath)) {
|
||||
Write-Host " $checkPath ... found" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " $checkPath ... NOT THERE" -ForegroundColor Red
|
||||
}
|
||||
} catch {
|
||||
Write-Host " Could not check: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
$session.Dispose()
|
||||
|
||||
# -- The authentication conversation ------------------------------------------
|
||||
if (-not $connected -and (Test-Path $logFile)) {
|
||||
Write-Host ""
|
||||
Write-Host ("-" * 64)
|
||||
Write-Host "What the server actually said" -ForegroundColor Cyan
|
||||
Write-Host ("-" * 64)
|
||||
|
||||
$log = Get-Content $logFile -ErrorAction SilentlyContinue
|
||||
|
||||
$server = $log | Select-String -Pattern "Remote version:" | Select-Object -First 1
|
||||
if ($server) { Write-Host " $(($server.Line -split 'Remote version:')[1].Trim())" -ForegroundColor Gray }
|
||||
|
||||
$methodLine = $log | Select-String -Pattern "Server offered these authentication methods:" | Select-Object -First 1
|
||||
$methods = ""
|
||||
if ($methodLine) {
|
||||
$methods = (($methodLine.Line -split "authentication methods:")[1]).Trim()
|
||||
Write-Host " Methods the server accepts: $methods" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
$sentPassword = ($log | Select-String -Pattern "Sent password").Count -gt 0
|
||||
$pwRejected = ($log | Select-String -Pattern "Password authentication failed").Count -gt 0
|
||||
|
||||
Write-Host ""
|
||||
if ($pwRejected -or ($sentPassword -and -not $connected)) {
|
||||
Write-Host " The password reached the server and the server rejected it." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host " That rules out the script, the protocol, the port and the host" -ForegroundColor Yellow
|
||||
Write-Host " key - all of those worked. Only three things are left:" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host " 1. The password is not the one this account expects." -ForegroundColor White
|
||||
Write-Host " Check it is this practice's own password, not another's." -ForegroundColor Gray
|
||||
Write-Host " 2. The account is locked out from repeated failed tries," -ForegroundColor White
|
||||
Write-Host " or expired. Log in with the WinSCP window right now - if" -ForegroundColor Gray
|
||||
Write-Host " that fails too, it is the account, not anything here." -ForegroundColor Gray
|
||||
Write-Host " 3. The account is set to require a key rather than a" -ForegroundColor White
|
||||
Write-Host " password, even though the server allows passwords" -ForegroundColor Gray
|
||||
Write-Host " generally. Only the partner can tell you that." -ForegroundColor Gray
|
||||
} elseif ($methods -and $methods -notmatch "password") {
|
||||
Write-Host " The server never offered password authentication." -ForegroundColor Yellow
|
||||
Write-Host " It accepts: $methods" -ForegroundColor Yellow
|
||||
Write-Host " A password will never work here - the partner has to enable" -ForegroundColor Yellow
|
||||
Write-Host " password auth or issue an SSH key for this account." -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host " It failed before any password was sent - so this is about" -ForegroundColor Yellow
|
||||
Write-Host " reaching the server, not about the credentials." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " Full log: $logFile" -ForegroundColor Cyan
|
||||
Write-Host " It contains no password. Send it to Brad." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||
if ($connected) {
|
||||
Write-Host " Login works. These details are correct." -ForegroundColor Green
|
||||
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||
|
||||
# Getting a working password into a script is its own trap, so hand back
|
||||
# the literal to paste rather than leaving the quoting to chance.
|
||||
$needsCare = $password -match '[\$`]'
|
||||
if ($needsCare -or $password -match "'" -or $password -match '%') {
|
||||
Write-Host ""
|
||||
Write-Host " Putting this password into a PowerShell script" -ForegroundColor Cyan
|
||||
Write-Host (" " + ("-" * 60)) -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host " Use exactly this line, single quotes included:" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host " `$Password = $("'" + ($password -replace "'", "''") + "'")" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
if ($needsCare) {
|
||||
Write-Host " This password contains a `$ or a backtick. Inside DOUBLE" -ForegroundColor Yellow
|
||||
Write-Host " quotes PowerShell treats that as a variable and silently" -ForegroundColor Yellow
|
||||
Write-Host " sends something shorter - the login then fails even though" -ForegroundColor Yellow
|
||||
Write-Host " the password is right. Single quotes send it untouched." -ForegroundColor Yellow
|
||||
}
|
||||
if ($password -match "'") {
|
||||
Write-Host " The password contains an apostrophe, so it is doubled in" -ForegroundColor Yellow
|
||||
Write-Host " the line above. That is correct - it stays one character." -ForegroundColor Yellow
|
||||
}
|
||||
if ($password -match '%') {
|
||||
Write-Host " The password contains a %. That is fine in PowerShell, but" -ForegroundColor Yellow
|
||||
Write-Host " in a .bat file it must be written as %% instead." -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host " Safer still: keep it out of the script. Put the password on" -ForegroundColor Gray
|
||||
Write-Host " the first line of its own file and read it back - then no" -ForegroundColor Gray
|
||||
Write-Host " quoting rules apply to it at all:" -ForegroundColor Gray
|
||||
Write-Host ' $Password = Get-Content pw.txt -TotalCount 1' -ForegroundColor Gray
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host " Nothing in this password needs special quoting in a script." -ForegroundColor Green
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||
Write-Host " If a script using these same details still fails, the script is" -ForegroundColor Green
|
||||
Write-Host " changing one of them on the way through." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " Login failed. Compare against the WinSCP window: File > Session" -ForegroundColor Yellow
|
||||
Write-Host " > Generate Session URL/Code > .NET assembly code. That shows the" -ForegroundColor Yellow
|
||||
Write-Host " exact settings the working connection uses." -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ("=" * 64) -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
+1
-1
@@ -1 +1 @@
|
||||
490c308 2026-08-26 setup wizard + any-file-type upload
|
||||
c3ad14c 2026-08-30 password quoting guidance
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit checks for the PowerShell scripts. Run from the repo root.
|
||||
# Needs pwsh on PATH (see the project notes for how it was installed).
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
fail=0
|
||||
|
||||
echo "== ASCII only =="
|
||||
# Windows PowerShell 5.1 reads a BOM-less .ps1 in the system codepage, which
|
||||
# turns any multi-byte character into mojibake - including quote characters
|
||||
# that break string literals mid-script.
|
||||
for f in *.ps1; do
|
||||
if LC_ALL=C grep -nP '[^\x00-\x7F]' "$f" >/dev/null; then
|
||||
echo " FAIL $f contains non-ASCII:"
|
||||
LC_ALL=C grep -nP '[^\x00-\x7F]' "$f" | head -5
|
||||
fail=1
|
||||
else
|
||||
echo " ok $f"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "== \$var? in a string =="
|
||||
# ? is a legal character in an unbraced PowerShell variable name, so "$name?"
|
||||
# looks up a variable called name? and silently renders empty. Use ${name}?.
|
||||
if grep -nP '\$\w+\?' ./*.ps1; then
|
||||
echo " FAIL - wrap those as \${name}? or the text disappears at runtime"
|
||||
fail=1
|
||||
else
|
||||
echo " ok none found"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== parse + Windows PowerShell 5.1 compatibility =="
|
||||
pwsh -NoProfile -Command '
|
||||
$bad = 0
|
||||
foreach ($f in Get-ChildItem -Filter *.ps1) {
|
||||
$e = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$e) | Out-Null
|
||||
if ($e.Count) {
|
||||
$bad++
|
||||
Write-Host " FAIL $($f.Name)"
|
||||
$e | ForEach-Object { " line $($_.Extent.StartLineNumber): $($_.Message)" }
|
||||
} else { Write-Host " ok $($f.Name)" }
|
||||
}
|
||||
if (Get-Module -ListAvailable PSScriptAnalyzer) {
|
||||
Import-Module PSScriptAnalyzer
|
||||
$s = @{ Rules = @{ PSUseCompatibleSyntax = @{ Enable = $true; TargetVersions = @("5.1","7.0") } } }
|
||||
$c = Invoke-ScriptAnalyzer -Path . -Recurse -Settings $s -IncludeRule PSUseCompatibleSyntax
|
||||
if ($c) { $bad++; $c | ForEach-Object { " FAIL 5.1: $($_.ScriptName):$($_.Line) $($_.Message)" } }
|
||||
else { Write-Host " ok 5.1 syntax clean" }
|
||||
} else {
|
||||
Write-Host " skip PSScriptAnalyzer not installed"
|
||||
}
|
||||
exit $bad
|
||||
' || fail=1
|
||||
|
||||
echo
|
||||
echo "== JSON templates =="
|
||||
python3 - <<'PY' || fail=1
|
||||
import glob, json, sys
|
||||
bad = 0
|
||||
for f in glob.glob("Entities/**/*.json", recursive=True) + ["settings.json"]:
|
||||
try:
|
||||
json.load(open(f))
|
||||
print(" ok %s" % f)
|
||||
except Exception as e:
|
||||
print(" FAIL %s: %s" % (f, e)); bad = 1
|
||||
sys.exit(bad)
|
||||
PY
|
||||
|
||||
echo
|
||||
if [ "$fail" -eq 0 ]; then echo "ALL CHECKS PASSED"; else echo "CHECKS FAILED"; fi
|
||||
exit $fail
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
REM Tests one SFTP/FTP login on its own. Nothing is read from a config file.
|
||||
powershell.exe -ExecutionPolicy Bypass -File "%~dp0Test-Login.ps1"
|
||||
pause
|
||||
Reference in New Issue
Block a user