Private/Start-OpenSshServer.ps1
|
Set-StrictMode -Version Latest function Get-OpenSshServerStartupVersion { '0.3.1' } function Get-OpenSshServerStartupHelp { @" Start-OpenSshServer.ps1 Usage: .\Start-OpenSshServer.ps1 [options] Options: -AutoFix Enable automatic remediation (default). -NoAutoFix Disable automatic remediation. -Yes Skip confirmation prompts for AutoFix. -DryRun Preview remediation actions without applying changes. -Port <int> TCP port for sshd (default: 22). -FirewallRuleName Firewall rule display name (default: OpenSSH-Server-In-TCP). -Json Emit machine-readable JSON output only. -Quiet Suppress non-error output. -Trace Emit verbose diagnostic output. -Version Print version and exit. -Help Print this help and exit. Notes: - Use -WhatIf to simulate changes (PowerShell common parameter). - Use -Confirm to force confirmation prompts (PowerShell common parameter). "@ } function Test-IsAdmin { $current = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($current) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Confirm-AutoFix { param( [Parameter(Mandatory)] [string]$Message, [Parameter(Mandatory)] [bool]$Yes ) if ($Yes) { return $true } if (-not [Environment]::UserInteractive) { return $false } $answer = Read-Host "$Message (Y/n)" if ([string]::IsNullOrWhiteSpace($answer)) { return $true } return $answer -match '^(y|yes)$' } function Get-InvocationArgumentList { param( [Parameter(Mandatory)] [hashtable]$BoundParameters, [Parameter(Mandatory)] [string[]]$ExcludeKeys ) $argumentList = @() foreach ($key in $BoundParameters.Keys) { if ($ExcludeKeys -contains $key) { continue } $value = $BoundParameters[$key] if ($value -is [switch]) { if ($value.IsPresent) { $argumentList += "-$key" } } elseif ($value -is [bool]) { if ($value) { $argumentList += "-$key" } } else { $argumentList += "-$key" $argumentList += "$value" } } return $argumentList } $script:OpenSshStartupDependencies = @{ TestPath = { param($Path) Test-Path $Path } GetChildItem = { param($Path) Get-ChildItem -Path $Path -ErrorAction SilentlyContinue } GetCommand = { param($Name) Get-Command -Name $Name -ErrorAction SilentlyContinue } GetService = { param($Name) Get-Service -Name $Name -ErrorAction Stop } StartService = { param($Name) Start-Service -Name $Name -ErrorAction Stop } GetFirewallRule = { param($DisplayName) Get-NetFirewallRule -DisplayName $DisplayName -ErrorAction SilentlyContinue } GetFirewallPortFilter = { param($Rule) $Rule | Get-NetFirewallPortFilter } EnableFirewallRule = { param($DisplayName) Enable-NetFirewallRule -DisplayName $DisplayName | Out-Null } SetFirewallRule = { param($DisplayName, $Port) Set-NetFirewallRule -DisplayName $DisplayName -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port -Profile Any | Out-Null } NewFirewallRule = { param($DisplayName, $Port) New-NetFirewallRule -DisplayName $DisplayName -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port -Profile Any | Out-Null } GetNetTcpConnection = { param($Port) Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue } GetProcess = { param($Id) Get-Process -Id $Id -ErrorAction Stop } AddWindowsCapability = { Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -ErrorAction Stop | Out-Null } AddWindowsFeature = { Add-WindowsFeature -Name 'OpenSSH-Server' -IncludeAllSubFeature -ErrorAction Stop | Out-Null } RepairWindowsCapability = { Repair-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -ErrorAction Stop | Out-Null } RunSshKeygen = { param($Path) & $Path -A | Out-Null } IsAdmin = { Test-IsAdmin } Elevate = { param($ExePath, $ArgumentList) Start-Process -FilePath $ExePath -ArgumentList $ArgumentList -Verb RunAs | Out-Null } RunSudo = { param($ExePath, $ArgumentList) & sudo -- $ExePath @ArgumentList } } function Get-StartupResult { [pscustomobject]@{ version = Get-OpenSshServerStartupVersion status = 'success' started = $false checks = @() actions = @() warnings = @() errors = @() } } function Add-ResultItem { param( [Parameter(Mandatory)] [object]$Result, [Parameter(Mandatory)] [string]$Collection, [Parameter(Mandatory)] [object]$Item ) $Result.$Collection += $Item } function Invoke-OpenSshServerStartup { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [switch]$AutoFix, [switch]$NoAutoFix, [Alias('Force')] [switch]$Yes, [switch]$DryRun, [ValidateRange(1, 65535)] [int]$Port = 22, [string]$FirewallRuleName = 'OpenSSH-Server-In-TCP', [switch]$Json, [switch]$Quiet, [switch]$Trace, [switch]$Version, [switch]$Help, [Parameter(DontShow)] [hashtable]$Dependencies ) if ($Version) { Write-Output (Get-OpenSshServerStartupVersion) return } if ($Help) { Write-Output (Get-OpenSshServerStartupHelp) return } if ($Json) { $Quiet = $true } if ($Trace) { $VerbosePreference = 'Continue' } if ($DryRun) { $WhatIfPreference = $true } $result = Get-StartupResult $deps = if ($Dependencies) { $Dependencies } else { $script:OpenSshStartupDependencies } $isWindowsPlatform = [System.Environment]::OSVersion.Platform -eq 'Win32NT' if (-not $PSBoundParameters.ContainsKey('AutoFix') -and -not $PSBoundParameters.ContainsKey('NoAutoFix')) { $AutoFix = $true } if ($NoAutoFix) { $AutoFix = $false } $null = $AutoFix $null = $Yes $invocationBoundParameters = $PSBoundParameters function Write-StartupLog { param( [Parameter(Mandatory)] [string]$Message, [ValidateSet('Info', 'Warning', 'Error', 'Verbose')] [string]$Level = 'Info' ) if ($Quiet -and $Level -ne 'Error') { return } switch ($Level) { 'Info' { Write-Information $Message -InformationAction Continue } 'Warning' { Write-Warning $Message } 'Error' { Write-Error $Message } 'Verbose' { Write-Verbose $Message } } } function Register-Check { param( [string]$Id, [string]$Status, [string]$Message, [string]$Remediation ) $item = [pscustomobject]@{ id = $Id status = $Status message = $Message remediation = $Remediation } Add-ResultItem -Result $result -Collection 'checks' -Item $item } function Register-Action { param( [string]$Action, [string]$Details ) Add-ResultItem -Result $result -Collection 'actions' -Item ([pscustomobject]@{ action = $Action details = $Details }) } function Register-Error { param( [string]$Id, [string]$Message, [string]$Remediation ) $result.status = 'error' Add-ResultItem -Result $result -Collection 'errors' -Item ([pscustomobject]@{ id = $Id message = $Message remediation = $Remediation }) Write-StartupLog -Level 'Error' -Message $Message if ($Remediation) { Write-StartupLog -Level 'Error' -Message "Remediation: $Remediation" } } function Register-Warning { param( [string]$Id, [string]$Message ) Add-ResultItem -Result $result -Collection 'warnings' -Item ([pscustomobject]@{ id = $Id message = $Message }) Write-StartupLog -Level 'Warning' -Message $Message } function Request-Elevation { param( [Parameter(Mandatory)] [string]$Reason ) if ($WhatIfPreference) { Register-Error -Id 'requires_admin' -Message "Administrator privileges required to $Reason." -Remediation 'Run in an elevated PowerShell session and retry.' return $false } $confirmMessage = "Administrator privileges required to $Reason. Relaunch as Administrator now?" if (-not (Confirm-AutoFix -Message $confirmMessage -Yes:$Yes)) { Register-Error -Id 'requires_admin' -Message "Administrator privileges required to $Reason." -Remediation 'Start PowerShell as Administrator and rerun.' return $false } $scriptPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Start-OpenSshServer.ps1' $exePath = if (& $deps.GetCommand 'pwsh') { 'pwsh' } else { 'powershell' } $baseArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath) + (Get-InvocationArgumentList -BoundParameters $invocationBoundParameters -ExcludeKeys @('Dependencies')) $usedSudo = $false if (& $deps.GetCommand 'sudo') { & $deps.RunSudo $exePath $baseArgs if ($LASTEXITCODE -eq 0) { $usedSudo = $true Register-Action -Action 'elevate' -Details 'Relaunched with sudo.' Register-Warning -Id 'relaunching_elevated' -Message 'Elevated command launched via sudo.' } else { Register-Warning -Id 'sudo_failed' -Message 'sudo is unavailable or failed; falling back to a new elevated window.' } } if (-not $usedSudo) { $argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-NoExit', '-File', $scriptPath) + (Get-InvocationArgumentList -BoundParameters $invocationBoundParameters -ExcludeKeys @('Dependencies')) & $deps.Elevate $exePath $argList Register-Action -Action 'elevate' -Details 'Relaunched with elevation.' Register-Warning -Id 'relaunching_elevated' -Message 'Opened a new elevated PowerShell window to continue operation.' } $script:ElevationRequested = $true throw 'ElevationRestarted' return $true } function Invoke-Check { param( [string]$Id, [string]$Description, [scriptblock]$Test, [scriptblock]$Fix, [string]$FailureMessage, [string]$Remediation ) $attempt = 0 while ($true) { $attempt++ try { $testResult = & $Test if ($testResult) { Register-Check -Id $Id -Status 'ok' -Message $Description -Remediation '' return } } catch { Write-StartupLog -Level 'Verbose' -Message "Check '$Id' threw: $($_.Exception.Message)" } if ($AutoFix -and $Fix) { if (-not (& $deps.IsAdmin)) { $adminMessage = if ($Id -eq 'sshd_running') { "Issue detected ($Id): OpenSSH Server is not running. Administrator privileges are required to start it." } else { "Issue detected ($Id): $FailureMessage Administrator privileges are required to apply automatic remediation." } Register-Warning -Id 'autofix_requires_admin' -Message $adminMessage $invocationBoundParameters['Yes'] = $true $null = Request-Elevation -Reason "apply automatic remediation for issue '$Id'" throw 'AutoFixRequiresAdmin' } $confirmMessage = "Issue detected ($Id): $FailureMessage Apply automatic remediation now?" if (-not (Confirm-AutoFix -Message $confirmMessage -Yes:$Yes)) { Register-Error -Id $Id -Message $FailureMessage -Remediation 'Rerun with -AutoFix -Yes to allow automatic remediation.' throw 'AutoFixDeclined' } if ($PSCmdlet.ShouldProcess($Description, 'Apply automatic remediation')) { try { & $Fix Register-Action -Action $Id -Details 'Applied automatic remediation.' } catch { Register-Error -Id $Id -Message "Automatic remediation failed: $($_.Exception.Message)" -Remediation 'Resolve the issue manually and rerun the script.' throw } } if ($attempt -lt 2) { continue } } Register-Check -Id $Id -Status 'error' -Message $FailureMessage -Remediation $Remediation Register-Error -Id $Id -Message $FailureMessage -Remediation $Remediation throw $FailureMessage } } if (-not $isWindowsPlatform) { Register-Error -Id 'unsupported_os' -Message "This script only supports Windows. Detected platform: $([System.Environment]::OSVersion.Platform)." -Remediation 'Run this script on Windows with OpenSSH Server installed.' return $result } $openSshRoot = Join-Path $env:WINDIR 'System32\OpenSSH' $sshdPath = Join-Path $openSshRoot 'sshd.exe' $sshKeygenPath = Join-Path $openSshRoot 'ssh-keygen.exe' $configPath = Join-Path $env:ProgramData 'ssh\sshd_config' $hostKeyPattern = Join-Path $env:ProgramData 'ssh\ssh_host_*_key' $script:ElevationRequested = $false try { Invoke-Check -Id 'openssh_binary' -Description 'OpenSSH server binaries are present.' -Test { & $deps.TestPath $sshdPath } -Fix { if (& $deps.GetCommand 'Add-WindowsCapability') { & $deps.AddWindowsCapability } elseif (& $deps.GetCommand 'Add-WindowsFeature') { & $deps.AddWindowsFeature } else { throw 'OpenSSH installation commands are unavailable.' } } -FailureMessage "OpenSSH Server is not installed. Missing binary: $sshdPath" -Remediation "Install OpenSSH Server (Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0') and rerun." Invoke-Check -Id 'sshd_service' -Description "OpenSSH Server service 'sshd' is registered." -Test { & $deps.GetService 'sshd' | Out-Null return $true } -Fix { if (& $deps.GetCommand 'Add-WindowsCapability') { & $deps.AddWindowsCapability } elseif (& $deps.GetCommand 'Add-WindowsFeature') { & $deps.AddWindowsFeature } else { throw 'OpenSSH installation commands are unavailable.' } } -FailureMessage "OpenSSH Server service 'sshd' is not installed." -Remediation "Install OpenSSH Server and ensure the 'sshd' service exists." Invoke-Check -Id 'sshd_config' -Description 'OpenSSH server configuration file exists.' -Test { & $deps.TestPath $configPath } -Fix { if (& $deps.GetCommand 'Repair-WindowsCapability') { & $deps.RepairWindowsCapability } else { throw 'OpenSSH configuration file missing and repair command unavailable.' } } -FailureMessage "OpenSSH configuration file is missing: $configPath" -Remediation 'Reinstall or repair OpenSSH Server to restore sshd_config.' Invoke-Check -Id 'host_keys' -Description 'OpenSSH host keys are present.' -Test { $keys = & $deps.GetChildItem $hostKeyPattern return $null -ne $keys -and @($keys).Count -gt 0 } -Fix { if (-not (& $deps.TestPath $sshKeygenPath)) { throw "ssh-keygen is missing: $sshKeygenPath" } & $deps.RunSshKeygen $sshKeygenPath } -FailureMessage 'OpenSSH host keys are missing.' -Remediation 'Generate host keys with "ssh-keygen -A" and retry.' Invoke-Check -Id 'firewall_module' -Description 'Windows Firewall cmdlets are available.' -Test { return $null -ne (& $deps.GetCommand 'Get-NetFirewallRule') } -Fix $null -FailureMessage 'Windows Firewall cmdlets are unavailable (NetSecurity module missing).' -Remediation 'Install the NetSecurity module or run on a Windows build that includes it.' Invoke-Check -Id 'firewall_service' -Description 'Windows Firewall service (MpsSvc) is running.' -Test { $fw = & $deps.GetService 'MpsSvc' return $fw.Status -eq 'Running' } -Fix { & $deps.StartService 'MpsSvc' } -FailureMessage 'Windows Firewall service (MpsSvc) is not running.' -Remediation 'Start the Windows Firewall service and rerun.' Invoke-Check -Id 'firewall_rule' -Description "Firewall rule '$FirewallRuleName' should allow inbound TCP $Port." -Test { $rule = & $deps.GetFirewallRule $FirewallRuleName if (-not $rule) { return $false } if ($rule.Enabled -ne 'True') { return $false } $portFilters = & $deps.GetFirewallPortFilter $rule foreach ($filter in $portFilters) { if ($filter.Protocol -eq 'TCP' -and ($filter.LocalPort -eq $Port -or $filter.LocalPort -eq 'Any')) { return $true } } return $false } -Fix { $rule = & $deps.GetFirewallRule $FirewallRuleName if (-not $rule) { & $deps.NewFirewallRule $FirewallRuleName $Port } else { & $deps.EnableFirewallRule $FirewallRuleName & $deps.SetFirewallRule $FirewallRuleName $Port } } -FailureMessage "Firewall rule '$FirewallRuleName' is missing or does not allow TCP $Port." -Remediation 'Create or enable an inbound firewall rule for the OpenSSH Server port.' Invoke-Check -Id 'tcp_cmdlets' -Description 'NetTCPIP cmdlets are available.' -Test { return $null -ne (& $deps.GetCommand 'Get-NetTCPConnection') } -Fix $null -FailureMessage 'NetTCPIP cmdlets are unavailable (Get-NetTCPConnection missing).' -Remediation 'Install the NetTCPIP module or run on a Windows build that includes it.' Invoke-Check -Id 'port_available' -Description "TCP port $Port is available for sshd." -Test { $listeners = & $deps.GetNetTcpConnection $Port if (-not $listeners) { return $true } foreach ($listener in $listeners) { try { $proc = & $deps.GetProcess $listener.OwningProcess if ($proc.ProcessName -ne 'sshd') { return $false } } catch { return $false } } return $true } -Fix $null -FailureMessage "TCP port $Port is already in use by another process." -Remediation 'Stop the conflicting service or change the SSH port and update firewall rules.' Invoke-Check -Id 'sshd_running' -Description "OpenSSH Server service 'sshd' is running." -Test { $service = & $deps.GetService 'sshd' return $service.Status -eq 'Running' } -Fix { & $deps.StartService 'sshd' } -FailureMessage "OpenSSH Server service 'sshd' is not running." -Remediation 'Start the OpenSSH Server service or check the OpenSSH operational log (OpenSSH/Operational) if it fails to start.' Invoke-Check -Id 'sshd_listening' -Description "sshd is listening on TCP port $Port." -Test { $listeners = & $deps.GetNetTcpConnection $Port if (-not $listeners) { return $false } foreach ($listener in $listeners) { try { $proc = & $deps.GetProcess $listener.OwningProcess if ($proc.ProcessName -eq 'sshd') { return $true } } catch { return $false } } return $false } -Fix $null -FailureMessage "sshd is not listening on TCP port $Port after startup." -Remediation 'Check OpenSSH logs and sshd_config for binding errors.' $result.started = $true Write-StartupLog -Message 'OpenSSH Server is running and ready.' -Level 'Info' } catch { if ($script:ElevationRequested) { return $result } if ($result.status -ne 'error') { $result.status = 'error' } } return $result } |