本文记录如何尽量只使用 PowerShell 命令,在 Windows 11 上完成以下配置:
- 安装 MSI 版 PowerShell 7。
- 安装并启用 OpenSSH Server。
- 将 SSH 默认登录 Shell 设置为 PowerShell 7。
- 配置 SSH 端口、公钥登录和防火墙。
- 使用命令行创建和维护 PowerShell Profile。
参考文档:
- 在 Windows 上安装 PowerShell
- 适用于 Windows 的 OpenSSH 入门
- OpenSSH Server configuration for Windows
- 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
下面的脚本完成这些操作:
- 备份现有配置。
- 把 SSH 端口设置为
2222。 - 启用公钥登录并关闭密码登录。
- 在重启服务前执行
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