Windows 11 OpenSSH 启用和 PowerShell 7 安装配置

本文记录如何尽量只使用 PowerShell 命令,在 Windows 11 上完成以下配置:

  1. 安装 MSI 版 PowerShell 7。
  2. 安装并启用 OpenSSH Server。
  3. 将 SSH 默认登录 Shell 设置为 PowerShell 7。
  4. 配置 SSH 端口、公钥登录和防火墙。
  5. 使用命令行创建和维护 PowerShell Profile。

参考文档:

  1. 在 Windows 上安装 PowerShell
  2. 适用于 Windows 的 OpenSSH 入门
  3. OpenSSH Server configuration for Windows
  4. Install Win32 OpenSSH

除远程客户端的连接命令外,本文命令均在 Windows 上执行。涉及安装系统组件、修改注册表、服务、防火墙和 %ProgramData% 文件时,需要使用管理员 PowerShell。

安装 PowerShell 7

Windows 11 自带的是 Windows PowerShell 5.1,命令为 powershell.exe。PowerShell 7 的命令为 pwsh.exe,两者可以共存。

先确认当前终端具有管理员权限:

$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$isAdministrator = $principal.IsInRole(
    [Security.Principal.WindowsBuiltInRole]::Administrator
)

if (-not $isAdministrator) {
    throw '请使用管理员身份运行 PowerShell'
}

推荐使用 winget 安装 MSI 版 PowerShell 7。MSI 版安装在稳定路径,适合 OpenSSH、计划任务和服务等后台进程调用:

C:\Program Files\PowerShell\7\pwsh.exe

执行安装:

winget install `
  --id Microsoft.PowerShell `
  --exact `
  --source winget `
  --installer-type wix `
  --scope machine `
  --architecture x64 `
  --silent `
  --accept-package-agreements `
  --accept-source-agreements `
  --disable-interactivity

验证安装路径和版本:

$pwsh = Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe'

if (-not (Test-Path -LiteralPath $pwsh)) {
    throw "未找到 PowerShell 7:$pwsh"
}

& $pwsh -NoLogo -NoProfile -Command '
    $PSVersionTable.PSVersion.ToString()
    $PSHOME
'

以后可以使用下面的命令升级:

winget upgrade `
  --id Microsoft.PowerShell `
  --exact `
  --source winget `
  --silent `
  --accept-package-agreements `
  --accept-source-agreements `
  --disable-interactivity

不建议将 Windows Store/MSIX 版本的实际安装路径写入 OpenSSH 的 DefaultShell。该路径通常位于 C:\Program Files\WindowsApps\Microsoft.PowerShell_*,包含版本号并可能在升级后变化;用户目录下的 WindowsApps\pwsh.exe 通常只是应用执行别名。

安装并启用 OpenSSH Server

查询 OpenSSH Client 和 Server 的安装状态:

Get-WindowsCapability -Online |
    Where-Object Name -Like 'OpenSSH.*' |
    Select-Object Name, State

安装 OpenSSH Server:

$serverCapability = Get-WindowsCapability `
    -Online `
    -Name 'OpenSSH.Server~~~~0.0.1.0'

if ($serverCapability.State -ne 'Installed') {
    Add-WindowsCapability `
        -Online `
        -Name 'OpenSSH.Server~~~~0.0.1.0'
}

如果本机还需要作为 SSH 客户端,可以用相同方式安装:

$clientCapability = Get-WindowsCapability `
    -Online `
    -Name 'OpenSSH.Client~~~~0.0.1.0'

if ($clientCapability.State -ne 'Installed') {
    Add-WindowsCapability `
        -Online `
        -Name 'OpenSSH.Client~~~~0.0.1.0'
}

启动 sshd,并设置为开机自动启动:

Set-Service -Name sshd -StartupType Automatic
Start-Service -Name sshd
Get-Service -Name sshd |
    Select-Object Name, Status, StartType

OpenSSH Server 通常会自动创建 22 端口的防火墙规则。下面的命令可以补建或启用该规则:

$ruleName = 'OpenSSH-Server-In-TCP'
$rule = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue

if ($null -eq $rule) {
    New-NetFirewallRule `
        -Name $ruleName `
        -DisplayName 'OpenSSH Server (sshd)' `
        -Enabled True `
        -Direction Inbound `
        -Protocol TCP `
        -Action Allow `
        -LocalPort 22
}
else {
    Enable-NetFirewallRule -Name $ruleName
}

设置 SSH 默认 Shell

Windows OpenSSH 的默认 Shell 由注册表项 HKLM:\SOFTWARE\OpenSSH\DefaultShell 控制。将其设置为 MSI 版 PowerShell 7:

$pwsh = Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe'

if (-not (Test-Path -LiteralPath $pwsh)) {
    throw "未找到 PowerShell 7:$pwsh"
}

New-Item -Path 'HKLM:\SOFTWARE\OpenSSH' -Force | Out-Null
New-ItemProperty `
    -Path 'HKLM:\SOFTWARE\OpenSSH' `
    -Name DefaultShell `
    -Value $pwsh `
    -PropertyType String `
    -Force

Restart-Service -Name sshd

查看当前配置:

Get-ItemPropertyValue `
    -LiteralPath 'HKLM:\SOFTWARE\OpenSSH' `
    -Name DefaultShell

如果只想使用 Windows PowerShell 5.1,可以改为:

C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe

配置公钥

先在发起连接的客户端生成密钥。已有密钥时不需要重复生成:

ssh-keygen -t ed25519

把 .pub 文件中的公钥复制到 Windows,切勿复制私钥。为了避免在关闭密码登录后无法连接,应先写入公钥并测试成功,再设置 PasswordAuthentication no。

管理员账户

Windows OpenSSH 的默认配置会让 Administrators 组中的用户共用:

C:\ProgramData\ssh\administrators_authorized_keys

在管理员 PowerShell 中执行下面的命令,把示例公钥替换为客户端的真实公钥:

$publicKey = 'ssh-ed25519 AAAA... user@client'
$authorizedKeys = Join-Path `
    $env:ProgramData `
    'ssh\administrators_authorized_keys'

if (-not (Test-Path -LiteralPath $authorizedKeys)) {
    New-Item -ItemType File -Path $authorizedKeys -Force |
        Out-Null
}

$keyExists = Get-Content -LiteralPath $authorizedKeys |
    Where-Object { $_.Trim() -eq $publicKey }

if (-not $keyExists) {
    Add-Content `
        -LiteralPath $authorizedKeys `
        -Value $publicKey `
        -Encoding ascii
}

icacls.exe $authorizedKeys /inheritance:r
icacls.exe $authorizedKeys `
    /grant:r `
    '*S-1-5-32-544:F' `
    '*S-1-5-18:F'

这里使用 SID 指定本机 Administrators 组和 SYSTEM,避免系统显示语言不同导致组名无法识别。

普通账户

普通用户的公钥文件是:

C:\Users\<用户名>\.ssh\authorized_keys

在该用户自己的 PowerShell 会话中执行:

$publicKey = 'ssh-ed25519 AAAA... user@client'
$sshDirectory = Join-Path $HOME '.ssh'
$authorizedKeys = Join-Path $sshDirectory 'authorized_keys'

New-Item `
    -ItemType Directory `
    -Path $sshDirectory `
    -Force |
    Out-Null

if (-not (Test-Path -LiteralPath $authorizedKeys)) {
    New-Item -ItemType File -Path $authorizedKeys -Force |
        Out-Null
}

$keyExists = Get-Content -LiteralPath $authorizedKeys |
    Where-Object { $_.Trim() -eq $publicKey }

if (-not $keyExists) {
    Add-Content `
        -LiteralPath $authorizedKeys `
        -Value $publicKey `
        -Encoding ascii
}

$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
icacls.exe $authorizedKeys /inheritance:r
icacls.exe $authorizedKeys `
    /grant:r `
    "*$($userSid):F" `
    '*S-1-5-18:F'

先从客户端强制只使用公钥连接默认的 22 端口,确认不会回退到密码登录:

ssh \
  -p 22 \
  -o BatchMode=yes \
  -o PreferredAuthentications=publickey \
  arloor@windows-ip

公钥验证成功后,再继续关闭密码登录。

配置 sshd_config

配置文件位于:

C:\ProgramData\ssh\sshd_config

下面的脚本完成这些操作:

  1. 备份现有配置。
  2. 把 SSH 端口设置为 2222。
  3. 启用公钥登录并关闭密码登录。
  4. 在重启服务前执行 sshd.exe -t 检查配置。

如需保留默认端口,把 $sshPort 改为 22。

$sshPort = 2222
$configPath = Join-Path $env:ProgramData 'ssh\sshd_config'
$sshd = Join-Path $env:WINDIR 'System32\OpenSSH\sshd.exe'
$backupPath = '{0}.{1}.bak' -f (
    $configPath,
    (Get-Date -Format 'yyyyMMdd-HHmmss')
)

Copy-Item `
    -LiteralPath $configPath `
    -Destination $backupPath

$lines = [System.Collections.Generic.List[string]]::new()
Get-Content -LiteralPath $configPath |
    ForEach-Object { [void]$lines.Add($_) }

$globalEnd = $lines.Count
for ($i = 0; $i -lt $lines.Count; $i++) {
    if ($lines[$i] -match '^\s*Match\s+') {
        $globalEnd = $i
        break
    }
}

$settings = [ordered]@{
    Port                   = $sshPort
    PubkeyAuthentication   = 'yes'
    PasswordAuthentication = 'no'
}

foreach ($name in $settings.Keys) {
    $settingIndex = -1
    $escapedName = [regex]::Escape($name)

    for ($i = 0; $i -lt $globalEnd; $i++) {
        if ($lines[$i] -match "^\s*#?\s*$escapedName(?:\s+.*)?$") {
            $settingIndex = $i
            break
        }
    }

    $newLine = '{0} {1}' -f $name, $settings[$name]
    if ($settingIndex -ge 0) {
        $lines[$settingIndex] = $newLine
    }
    else {
        $lines.Insert($globalEnd, $newLine)
        $globalEnd++
    }
}

[IO.File]::WriteAllLines(
    $configPath,
    $lines,
    [Text.UTF8Encoding]::new($false)
)

& $sshd -t -f $configPath
if ($LASTEXITCODE -ne 0) {
    throw "sshd_config 检查失败,请用备份恢复:$backupPath"
}

为自定义端口创建防火墙规则并重启服务:

$sshPort = 2222
$ruleName = "OpenSSH-Server-In-TCP-$sshPort"
$rule = Get-NetFirewallRule `
    -Name $ruleName `
    -ErrorAction SilentlyContinue

if ($null -eq $rule) {
    New-NetFirewallRule `
        -Name $ruleName `
        -DisplayName "OpenSSH Server (sshd) $sshPort" `
        -Enabled True `
        -Direction Inbound `
        -Protocol TCP `
        -Action Allow `
        -LocalPort $sshPort
}
else {
    Enable-NetFirewallRule -Name $ruleName
}

Restart-Service -Name sshd
Get-NetTCPConnection `
    -State Listen `
    -LocalPort $sshPort

如果配置检查失败,可以使用脚本输出的备份路径恢复:

Copy-Item `
    -LiteralPath 'C:\ProgramData\ssh\sshd_config.时间戳.bak' `
    -Destination 'C:\ProgramData\ssh\sshd_config' `
    -Force

Restart-Service -Name sshd

验证 SSH 登录

从其他设备连接:

ssh -p 2222 arloor@windows-ip

验证远程 Shell 确实是 PowerShell 7:

ssh -p 2222 arloor@windows-ip '$PSVersionTable.PSVersion.ToString(); $PSHOME; whoami'

如果 Windows 本机也保存了对应私钥,可以执行回环测试:

ssh -p 2222 `
    -o BatchMode=yes `
    -o StrictHostKeyChecking=no `
    arloor@127.0.0.1 `
    '$PSVersionTable.PSVersion.ToString(); $PSHOME; whoami'

排查 Permission denied

远程登录出现下面的错误时:

Permission denied (publickey,keyboard-interactive).

先查看 OpenSSH 事件日志:

Get-WinEvent `
    -LogName OpenSSH/Operational `
    -MaxEvents 30 |
    Select-Object TimeCreated, Message |
    Format-List

如果日志包含:

User arloor not allowed because shell c:\program files\powershell\7\pwsh.exe does not exist

说明问题不是公钥,而是 DefaultShell 指向的文件不存在。重新检查并修复:

$pwsh = Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe'
Test-Path -LiteralPath $pwsh

Set-ItemProperty `
    -LiteralPath 'HKLM:\SOFTWARE\OpenSSH' `
    -Name DefaultShell `
    -Value $pwsh

Restart-Service -Name sshd

继续排查时,可以同时检查服务、监听端口、配置语法和公钥文件权限:

$sshPort = 2222
$configPath = Join-Path $env:ProgramData 'ssh\sshd_config'
$sshd = Join-Path $env:WINDIR 'System32\OpenSSH\sshd.exe'

Get-Service -Name sshd
Get-NetTCPConnection `
    -State Listen `
    -LocalPort $sshPort `
    -ErrorAction SilentlyContinue
& $sshd -t -f $configPath
icacls.exe (
    Join-Path $env:ProgramData `
        'ssh\administrators_authorized_keys'
)

使用命令行配置 PowerShell Profile

PowerShell 7 和 Windows PowerShell 5.1 使用不同的 Profile 目录:

PowerShell 7:            ~/Documents/PowerShell/
Windows PowerShell 5.1: ~/Documents/WindowsPowerShell/

以下 Profile 命令需要在 PowerShell 7 中执行。可以从当前 Windows PowerShell 会话直接进入:

& "$env:ProgramFiles\PowerShell\7\pwsh.exe"

先允许当前用户执行本地 Profile 脚本:

Set-ExecutionPolicy `
    -ExecutionPolicy RemoteSigned `
    -Scope CurrentUser

查看 PowerShell 7 当前会话会加载的 Profile 路径:

$PROFILE | Format-List -Force

这里使用 $PROFILE.CurrentUserAllHosts,让当前用户的本地终端和 SSH PowerShell 会话共用配置:

$profilePath = $PROFILE.CurrentUserAllHosts
$profileDirectory = Split-Path -Parent $profilePath

New-Item `
    -ItemType Directory `
    -Path $profileDirectory `
    -Force |
    Out-Null

if (-not (Test-Path -LiteralPath $profilePath)) {
    New-Item `
        -ItemType File `
        -Path $profilePath `
        -Force |
        Out-Null
}

$profilePath

下面的脚本使用标记区块更新 Profile,不会覆盖区块之外已有的个人配置。重复执行时,会替换旧区块而不是重复追加:

$profilePath = $PROFILE.CurrentUserAllHosts
$blockStart = '# >>> common-profile >>>'
$blockEnd = '# <<< common-profile <<<'

$profileBlock = @'
# 按实际情况修改本机代理地址。
$proxy = 'http://127.0.0.1:7890'
$env:HTTP_PROXY = $proxy
$env:HTTPS_PROXY = $proxy

if (Get-Module -ListAvailable -Name PSReadLine) {
    Import-Module PSReadLine

    $isSshSession = -not [string]::IsNullOrWhiteSpace(
        $env:SSH_CONNECTION
    )

    if ($isSshSession) {
        $psReadLineColors = @{
            InlinePrediction = '#666666'
        }
    }
    else {
        $psReadLineColors = @{
            Default                = '#303030'
            Command                = '#8A6500'
            Parameter              = '#4A4A4A'
            Operator               = '#555555'
            Variable               = '#267326'
            String                 = '#287F79'
            Number                 = '#9A4E00'
            Type                   = '#6B3FA0'
            Member                 = '#303030'
            Keyword                = '#006B3C'
            Comment                = '#4F7942'
            Error                  = '#C62828'
            Emphasis               = '#005FAF'
            ContinuationPrompt     = '#555555'
            InlinePrediction       = '#666666'
            ListPrediction         = '#7A5C00'
            Selection              = "`e[30;48;2;190;215;255m"
            ListPredictionSelected = "`e[30;48;2;190;215;255m"
        }
    }

    Set-PSReadLineOption -Colors $psReadLineColors
}

$simpleAliases = @{
    s = 'Select-Object'
    g = 'Get-Content'
}

foreach ($name in $simpleAliases.Keys) {
    Set-Alias -Name $name -Value $simpleAliases[$name]
}

function down {
    shutdown.exe /s /f /t 0
}

function bios {
    shutdown.exe /r /fw /t 0
}

function keep_wsl {
    Start-ScheduledTask -TaskName keep_wsl
}

function Watch-ProxyLog {
    Get-Content `
        -LiteralPath 'C:\tmp\proxy.log_rCURRENT' `
        -Tail 50 `
        -Wait
}
Set-Alias -Name loo -Value Watch-ProxyLog

function Get-FolderSize {
    Write-Host `
        '正在计算当前目录的空间占用,请稍候。' `
        -ForegroundColor Yellow

    $currentSize = (
        Get-ChildItem `
            -File `
            -Force `
            -ErrorAction SilentlyContinue |
        Measure-Object -Property Length -Sum
    ).Sum

    if ($null -eq $currentSize) {
        $currentSize = 0
    }

    $folders = Get-ChildItem -Directory -Force |
        ForEach-Object {
            $size = (
                Get-ChildItem `
                    -LiteralPath $_.FullName `
                    -Recurse `
                    -Force `
                    -File `
                    -ErrorAction SilentlyContinue |
                Measure-Object -Property Length -Sum
            ).Sum

            if ($null -eq $size) {
                $size = 0
            }

            [PSCustomObject]@{
                Name   = $_.Name
                SizeGB = [math]::Round($size / 1GB, 2)
                SizeMB = [math]::Round($size / 1MB, 2)
            }
        }

    $entries = @($folders)
    if ($currentSize -gt 0) {
        $entries = @(
            [PSCustomObject]@{
                Name   = '.'
                SizeGB = [math]::Round($currentSize / 1GB, 2)
                SizeMB = [math]::Round($currentSize / 1MB, 2)
            }
        ) + $entries
    }

    $entries |
        Sort-Object SizeGB -Descending |
        Format-Table -AutoSize
}
Set-Alias -Name ds -Value Get-FolderSize

function hosts {
    Get-Content `
        -LiteralPath `
        'C:\Windows\System32\drivers\etc\hosts'
}

function downloads {
    Set-Location (Join-Path $HOME 'Downloads')
}

function desktop {
    Set-Location (Join-Path $HOME 'Desktop')
}

function projects {
    Set-Location 'D:\Projects'
}

function touch {
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    if (Test-Path -LiteralPath $Path) {
        (Get-Item -LiteralPath $Path).LastWriteTime = Get-Date
    }
    else {
        New-Item -ItemType File -Path $Path
    }
}

function New-RandomPassword {
    param([int]$Length = 16)

    $chars = (
        'abcdefghijklmnopqrstuvwxyz' +
        'ABCDEFGHIJKLMNOPQRSTUVWXYZ' +
        '0123456789!@#$%^&*'
    )

    -join (
        1..$Length |
        ForEach-Object {
            $chars[(Get-Random -Maximum $chars.Length)]
        }
    )
}

function ConvertTo-Base64 {
    param([string]$Text)

    [Convert]::ToBase64String(
        [Text.Encoding]::UTF8.GetBytes($Text)
    )
}

function ConvertFrom-Base64 {
    param([string]$Base64)

    [Text.Encoding]::UTF8.GetString(
        [Convert]::FromBase64String($Base64)
    )
}

if (Get-Command starship -ErrorAction SilentlyContinue) {
    Invoke-Expression (&starship init powershell)
}
'@

$currentProfile = Get-Content `
    -LiteralPath $profilePath `
    -Raw `
    -ErrorAction SilentlyContinue

$pattern = '(?ms)^{0}.*?^{1}\r?\n?' -f (
    [regex]::Escape($blockStart),
    [regex]::Escape($blockEnd)
)
$currentProfile = [regex]::Replace(
    [string]$currentProfile,
    $pattern,
    ''
).TrimEnd()

$sections = @()
if (-not [string]::IsNullOrWhiteSpace($currentProfile)) {
    $sections += $currentProfile
}
$sections += $blockStart
$sections += $profileBlock.Trim()
$sections += $blockEnd

($sections -join [Environment]::NewLine) +
    [Environment]::NewLine |
    Set-Content `
        -LiteralPath $profilePath `
        -Encoding utf8

写入后先检查语法,再加载配置:

$tokens = $null
$parseErrors = $null

[Management.Automation.Language.Parser]::ParseFile(
    $PROFILE.CurrentUserAllHosts,
    [ref]$tokens,
    [ref]$parseErrors
) | Out-Null

if ($parseErrors.Count -gt 0) {
    $parseErrors
}
else {
    . $PROFILE.CurrentUserAllHosts
}

远程关闭或重启 Windows

登录 SSH 后可以直接执行:

# 关机
shutdown.exe /s /f /t 0

# 重启
shutdown.exe /r /f /t 0

# 重启并进入 UEFI 固件设置
shutdown.exe /r /fw /t 0