PowerShell 技能连载 - SSH 远程管理

适用于 PowerShell 7.0 及以上版本

传统的 PowerShell Remoting 基于 WinRM(Windows Remote Management)协议,虽然功能强大,但存在明显的平台限制——它只能在 Windows 环境中工作。在企业混合环境中,运维人员往往需要同时管理 Windows 和 Linux 服务器,WinRM 的局限性就成了一个棘手的问题。此外,WinRM 在防火墙策略严格的网络中配置繁琐,端口和认证方式也不够灵活。

PowerShell 7 引入了基于 SSH 的远程会话支持,彻底改变了这一局面。通过 Enter-PSSessionInvoke-Command-HostName 参数,PowerShell 可以通过 SSH 协议建立远程连接,实现真正的跨平台远程管理。这意味着你可以从 Windows 管理 Linux 服务器、从 Linux 管理 Windows 服务器,甚至可以在云环境和容器中使用统一的远程管理体验。

SSH Remoting 不仅能与现有的 SSH 基础设施无缝集成,还支持密钥认证、跳板机(ProxyJump)、多主机并行执行等高级场景。本文将介绍如何配置和使用 PowerShell SSH Remoting,以及在混合环境中的实战技巧。

SSH 远程会话配置与基础操作

在使用 SSH Remoting 之前,需要确保目标主机已安装并运行 SSH 服务。PowerShell 7 在连接时会使用系统自带的 SSH 客户端,因此无需额外安装 WinRM。

以下示例展示如何通过 SSH 建立交互式远程会话和执行远程命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 查看当前 PowerShell 版本,确认是否支持 SSH Remoting
$PSVersionTable.PSVersion

# 使用 Enter-PSSession 通过 SSH 连接到远程 Linux 主机
Enter-PSSession -HostName admin@192.168.1.100
# 在远程会话中执行命令
hostname
uname -a
whoami
# 退出远程会话
Exit-PSSession

# 使用 Invoke-Command 通过 SSH 在远程主机上执行脚本块
Invoke-Command -HostName admin@192.168.1.100 -ScriptBlock {
# 获取系统信息
$osInfo = Get-Content /etc/os-release | ConvertFrom-StringData
[PSCustomObject]@{
Distribution = $osInfo.NAME
Version = $osInfo.VERSION
Kernel = uname -r
Uptime = (uptime -p)
MemoryTotal = "#{math.Round((Get-Content /proc/meminfo |
Select-String 'MemTotal').ToString().Split()[1] / 1MB, 2)} GB"
}
}

# 指定 SSH 端口(非默认 22 端口的情况)
$session = New-PSSession -HostName admin@192.168.1.100 -Port 2222
Invoke-Command -Session $session -ScriptBlock {
systemctl status nginx --no-pager
}
Remove-PSSession $session

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Major  Minor  Patch  PreReleaseLabel BuildLabel
----- ----- ----- --------------- ----------
7 4 0

[admin@192.168.1.100]: PS /home/admin> hostname
web-server-01
[admin@192.168.1.100]: PS /home/admin> uname -a
Linux web-server-01 5.15.0-91-generic #101-Ubuntu SMP x86_64 GNU/Linux
[admin@192.168.1.100]: PS /home/admin> whoami
admin

Distribution : Ubuntu 22.04.3 LTS
Version : 22.04.3 LTS (Jammy Jellyfish)
Kernel : 5.15.0-91-generic
Uptime : up 42 days, 3 hours
MemoryTotal : 15.62 GB

nginx.service - A high performance web server
Loaded: loaded (/lib/systemd/system/nginx.service; enabled)
Active: active (running) since Mon 2025-12-01 08:00:00 UTC

SSH 密钥认证与多主机管理

密码认证虽然简单,但在自动化场景中存在安全和管理上的问题。SSH 密钥认证不仅更安全,还能让脚本在无人值守的情况下自动运行。PowerShell 7 的 SSH Remoting 原生支持密钥认证,同时可以通过 SSH 配置文件管理多个目标主机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 生成 SSH 密钥对(如果尚未生成)
ssh-keygen -t ed25519 -C "powershell-remoting" -f ~/.ssh/id_ed25519

# 将公钥复制到远程主机(使用 ssh-copy-id)
ssh-copy-id -i ~/.ssh/id_ed25519.pub admin@192.168.1.100
ssh-copy-id -i ~/.ssh/id_ed25519.pub admin@192.168.1.101
ssh-copy-id -i ~/.ssh/id_ed25519.pub admin@192.168.1.102

# 配置 SSH config 文件,简化连接管理
$sshConfig = @"
Host web-server
HostName 192.168.1.100
User admin
Port 22
IdentityFile ~/.ssh/id_ed25519

Host db-server
HostName 192.168.1.101
User admin
Port 22
IdentityFile ~/.ssh/id_ed25519

Host app-server
HostName 192.168.1.102
User admin
Port 2222
IdentityFile ~/.ssh/id_ed25519
"@

# 将配置写入 SSH config 文件
$sshConfigPath = "$HOME/.ssh/config"
$sshConfig | Set-Content -Path $sshConfigPath -Force
(Get-Item $sshConfigPath).Attributes = 'ReadOnly'

# 使用 SSH 别名直接连接(无需输入密码)
Enter-PSSession -HostName web-server

# 通过密钥认证在多台主机上并行执行命令
$servers = @('web-server', 'db-server', 'app-server')
$results = Invoke-Command -HostName $servers -ScriptBlock {
[PSCustomObject]@{
Host = $env:COMPUTERNAME ?? hostname
OS = try { (Get-Content /etc/os_release -ErrorAction Stop |
Select-String 'PRETTY_NAME').ToString().Split('"')[1] }
catch { $PSVersionTable.OS }
PSVersion = $PSVersionTable.PSVersion.ToString()
CPU_Usage = (Get-Process | Measure-Object CPU -Maximum).Maximum
Disk_Free = (Get-PSResourceInfo -Available -ErrorAction SilentlyContinue |
Measure-Object).Count
}
} | Select-Object Host, OS, PSVersion

$results | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
Generating public/private ed25519 key pair.
Your identification has been saved in /home/user/.ssh/id_ed25519
Your public key has been saved in /home/user/.ssh/id_ed25519.pub

Host OS PSVersion
---- -- ---------
web-server Ubuntu 22.04.3 LTS 7.4.0
db-server Ubuntu 22.04.3 LTS 7.4.0
app-server Debian 12.4 7.3.2

混合环境自动化:Windows + Linux 批量操作

在企业环境中,Windows 和 Linux 服务器通常共存。SSH Remoting 让我们可以用统一的 PowerShell 脚本同时管理两种平台,而不需要分别为 WinRM 和 SSH 编写不同的管理逻辑。下面的示例展示了如何在混合环境中进行批量巡检和配置管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# 定义混合主机列表(Windows 用 -ComputerName,Linux 用 -HostName)
$windowsServers = @('WIN-SVR01', 'WIN-SVR02')
$linuxServers = @('web-server', 'db-server', 'app-server')

# Windows 主机通过 WinRM 获取信息
$winResults = Invoke-Command -ComputerName $windowsServers -ScriptBlock {
[PSCustomObject]@{
Host = $env:COMPUTERNAME
Platform = 'Windows'
OS_Version = [System.Environment]::OSVersion.VersionString
CPU_Count = $env:NUMBER_OF_PROCESSORS
Memory_GB = [math]::Round(
(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2
)
Disk_Free_GB = [math]::Round(
(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
Measure-Object FreeSpace -Sum).Sum / 1GB, 2
)
Uptime_Days = ((Get-Date) - (Get-CimInstance Win32_OperatingSystem).
LastBootUpTime).Days
}
}

# Linux 主机通过 SSH 获取信息
$linuxResults = Invoke-Command -HostName $linuxServers -ScriptBlock {
$memInfo = Get-Content /proc/meminfo
$memTotal = [math]::Round(
($memInfo | Select-String 'MemTotal').ToString().Split()[1] / 1MB, 2
)
$diskFree = [math]::Round(
(df -BG / | Select-Object -Last 1).ToString().Split()[3] -replace 'G',''
)
$uptimeDays = [math]::Floor(
(Get-Content /proc/uptime).ToString().Split('.')[0] / 86400
)

[PSCustomObject]@{
Host = hostname
Platform = 'Linux'
OS_Version = (Get-Content /etc/os-release |
Select-String 'PRETTY_NAME').ToString().Split('"')[1]
CPU_Count = nproc
Memory_GB = $memTotal
Disk_Free_GB = $diskFree
Uptime_Days = $uptimeDays
}
}

# 合并结果并生成巡检报告
$report = $winResults + $linuxResults |
Sort-Object Platform, Host |
Format-Table Host, Platform, OS_Version, CPU_Count,
Memory_GB, Disk_Free_GB, Uptime_Days -AutoSize

$report | Out-String | Write-Host

# 导出为 CSV 文件
$winResults + $linuxResults |
Sort-Object Platform, Host |
Export-Csv -Path "./server-inventory-$(Get-Date -Format 'yyyyMMdd').csv" `
-NoTypeInformation -Encoding UTF8

Write-Host "巡检报告已导出到 CSV 文件" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
Host        Platform OS_Version                      CPU_Count Memory_GB Disk_Free_GB Uptime_Days
---- -------- ---------- --------- --------- ------------ -----------
WIN-SVR01 Windows Microsoft Windows Server 2022 8 32.00 156.43 45
WIN-SVR02 Windows Microsoft Windows Server 2022 4 16.00 89.21 30
app-server Linux Debian GNU/Linux 12 (bookworm) 2 7.81 34 62
db-server Linux Ubuntu 22.04.3 LTS (Jammy) 4 15.62 120 42
web-server Linux Ubuntu 22.04.3 LTS (Jammy) 2 3.91 18 42

巡检报告已导出到 CSV 文件

注意事项

  1. SSH 服务必须预装:目标 Linux 主机需要安装并启动 sshd 服务,同时确保 PowerShell 7 已安装在远端(否则 Enter-PSSession 只会进入普通的 SSH Shell,而非 PowerShell 远程会话)。

  2. 认证方式选择:生产环境应优先使用 SSH 密钥认证(Ed25519 或 RSA),避免在脚本中硬编码密码。密钥的私钥文件务必设置严格的文件权限(chmod 600)。

  3. SSH 配置文件的作用:通过 ~/.ssh/config 文件管理主机别名、端口和密钥路径,可以大幅简化 Enter-PSSession -HostName 的使用,同时还能配置跳板机(ProxyJump)和连接超时等参数。

  4. 混合环境注意区分参数:Windows 主机使用 -ComputerName(走 WinRM),Linux 主机使用 -HostName(走 SSH)。如果 Windows 主机也配置了 SSH 服务,也可以统一使用 -HostName 参数。

  5. 错误处理与超时:SSH 连接可能因网络问题超时,建议在脚本中设置 $PSSessionOption 的超时参数,并使用 try-catch 包裹远程操作,避免单台主机故障导致整个批量任务中断。

  6. 安全加固建议:建议在 sshd_config 中禁用密码登录(PasswordAuthentication no)、禁用 root 远程登录(PermitRootLogin no),并使用 AllowUsers 限制可远程连接的用户范围,以降低安全风险。

PowerShell 技能连载 - Azure Bastion 远程连接

适用于 PowerShell 5.1 及以上版本

Azure Bastion 是微软在 Azure 平台上提供的全托管 PaaS 服务,它通过 SSL/TLS 隧道为虚拟机提供安全的 RDP 和 SSH 连接,无需在虚拟机上暴露公共 IP 地址或开放入站端口。在企业混合云环境中,Bastion 充当了”跳板机”的安全替代方案,所有远程会话流量都经过加密且不经过公网,从根本上降低了暴力破解和端口扫描的风险。

传统的远程连接方式要求运维人员先通过 VPN 或跳板机接入内网,再使用 RDP/SSH 客户端连接目标虚拟机。这种方式不仅配置复杂,而且跳板机本身也面临安全威胁。Azure Bastion 将这一流程简化为浏览器直连或 CLI 命令行操作,同时与企业目录服务(Entra ID)深度集成,支持条件访问策略和多因素认证(MFA)。

本文将介绍如何通过 PowerShell 和 Azure CLI 管理 Azure Bastion 资源,包括部署 Bastion 主机、建立远程会话、查看连接会话日志以及批量审计 Bastion 配置。每个场景都提供可执行的代码示例和输出演示。

部署 Azure Bastion 主机

Azure Bastion 需要部署在专门的子网(AzureBastionSubnet)中,并且要求关联一个 Standard SKU 的公共 IP 地址。以下代码演示了如何通过 PowerShell 在现有虚拟网络中部署 Bastion 主机,包括前置条件的检查和资源创建的完整流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# 定义变量
$resourceGroup = 'rg-bastion-demo'
$location = 'eastasia'
$vnetName = 'vnet-demo'
$bastionName = 'bastion-demo'
$publicIpName = 'pip-bastion-demo'

# 确保虚拟网络中存在 AzureBastionSubnet 子网
$vnet = Get-AzVirtualNetwork -Name $vnetName -ResourceGroupName $resourceGroup

$bastionSubnet = $vnet.Subnets | Where-Object { $_.Name -eq 'AzureBastionSubnet' }

if (-not $bastionSubnet) {
# Bastion 子网要求至少 /26 的地址空间
$subnetConfig = Add-AzVirtualNetworkSubnetConfig `
-Name 'AzureBastionSubnet' `
-AddressPrefix '10.0.1.0/26' `
-VirtualNetwork $vnet

$vnet | Set-AzVirtualNetwork | Out-Null
Write-Host "已创建 AzureBastionSubnet 子网"
} else {
Write-Host "AzureBastionSubnet 子网已存在,跳过创建"
}

# 创建 Standard SKU 公共 IP
$publicIp = New-AzPublicIpAddress `
-ResourceGroupName $resourceGroup `
-Name $publicIpName `
-Location $location `
-AllocationMethod Static `
-Sku Standard

Write-Host "公共 IP 已创建: $($publicIp.IpAddress)"

# 创建 Bastion 主机
$bastion = New-AzBastion `
-ResourceGroupName $resourceGroup `
-Name $bastionName `
-PublicIpAddressRgName $resourceGroup `
-PublicIpAddressName $publicIpName `
-VirtualNetworkRgName $resourceGroup `
-VirtualNetworkName $vnetName

Write-Host "Bastion 主机部署完成"
Write-Host "名称: $($bastion.Name)"
Write-Host "SKU: $($bastion.Sku.Text)"
Write-Host "状态: $($bastion.ProvisioningState)"

Azure Bastion 的子网名称必须是 AzureBastionSubnet,这是一个硬性要求,不能自定义。子网的最小地址空间为 /26(64 个地址),因为 Azure 会预留部分地址用于内部服务。Bastion 主机的 SKU 分为 Basic、Standard 和 Developer 三种,Standard 和 Developer SKU 支持自定义端口和原生 SSH 客户端连接。

执行结果示例:

1
2
3
4
5
6
AzureBastionSubnet 子网已存在,跳过创建
公共 IP 已创建: 20.205.83.47
Bastion 主机部署完成
名称: bastion-demo
SKU: Standard
状态: Succeeded

通过 Bastion 建立 SSH 远程会话

Standard 和 Developer SKU 的 Azure Bastion 支持原生 SSH 客户端连接,这意味着可以直接在 PowerShell 终端中发起 SSH 会话,无需打开浏览器。Azure CLI 提供了 az network bastion ssh 命令来实现这一功能,以下代码演示了如何封装调用逻辑并管理连接过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
function Connect-BastionSsh {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ResourceGroup,

[Parameter(Mandatory)]
[string]$BastionName,

[Parameter(Mandatory)]
[string]$VmName,

[ValidateSet('aad', 'password', 'ssh-key')]
[string]$AuthType = 'ssh-key',

[string]$Username = 'azureuser',

[string]$SshKeyPath = "$env:USERPROFILE\.ssh\id_rsa"
)

Write-Host "正在通过 Bastion 建立 SSH 连接..."
Write-Host " Bastion: $BastionName"
Write-Host " 目标 VM: $VmName"
Write-Host " 认证方式: $AuthType"

$commonArgs = @(
'network', 'bastion', 'ssh',
'--resource-group', $ResourceGroup,
'--name', $BastionName,
'--target-resource-id',
"/subscriptions/$(Get-AzContext).Subscription.Id/resourceGroups/$ResourceGroup/providers/Microsoft.Compute/virtualMachines/$VmName",
'--auth-type', $AuthType,
'--username', $Username
)

if ($AuthType -eq 'ssh-key') {
$commonArgs += @('--ssh-key', $SshKeyPath)
}

# 启动交互式 SSH 会话
& az @commonArgs
}

# 使用 SSH 密钥认证连接 Linux 虚拟机
Connect-BastionSsh `
-ResourceGroup 'rg-bastion-demo' `
-BastionName 'bastion-demo' `
-VmName 'vm-linux-01' `
-AuthType 'ssh-key' `
-Username 'azureuser'

这段代码封装了 Connect-BastionSsh 函数,支持三种认证方式:Entra ID(AAD)认证、密码认证和 SSH 密钥认证。函数内部通过 Azure CLI 的 az network bastion ssh 命令建立隧道,使用 & 调用运算符将控制权交给 SSH 客户端,用户可以在交互式会话中执行远程命令。对于 Windows 虚拟机,只需将 ssh 替换为 rdp 即可通过 RDP 协议连接。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
正在通过 Bastion 建立 SSH 连接...
Bastion: bastion-demo
目标 VM: vm-linux-01
认证方式: ssh-key
Opening SSH session to vm-linux-01 via Bastion bastion-demo...

Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-1019-azure x86_64)

Last login: Wed Dec 3 06:22:15 2025 from 10.0.1.4
azureuser@vm-linux-01:~$

查看 Bastion 会话日志与连接审计

Azure Bastion 的 Standard 和 Developer SKU 支持将远程会话日志导出到存储账户,这在安全合规场景中非常重要。以下代码演示了如何查询 Bastion 的活跃会话、导出会话日志以及生成连接审计报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
function Get-BastionSessionReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ResourceGroup,

[Parameter(Mandatory)]
[string]$BastionName
)

# 获取 Bastion 资源详情
$bastion = Get-AzBastion -ResourceGroupName $ResourceGroup -Name $BastionName

$report = [ordered]@{
BastionName = $bastion.Name
Location = $bastion.Location
Sku = $bastion.Sku.Text
Provisioning = $bastion.ProvisioningState
EnableTunneling = $bastion.EnableTunneling
EnableIpConnect = $bastion.EnableIpConnect
EnableShareableLink = $bastion.EnableShareableLink
DnsName = $bastion.DnsName
}

Write-Host "`n===== Bastion 配置报告 ====="
foreach ($key in $report.Keys) {
Write-Host ("{0,-25} : {1}" -f $key, $report[$key])
}

# 获取 Bastion 关联的虚拟网络信息
$vnetId = $bastion.IpConfigurations.Subnet.Id -replace '/subnets/.*$', ''
$vnetName = ($vnetId -split '/')[-1]
$vnetRg = (($vnetId -split '/')[4])

$vnet = Get-AzVirtualNetwork -Name $vnetName -ResourceGroupName $vnetRg
$bastionSubnet = $vnet.Subnets | Where-Object { $_.Name -eq 'AzureBastionSubnet' }

Write-Host "`n===== 网络配置 ====="
Write-Host ("{0,-25} : {1}" -f '关联虚拟网络', $vnetName)
Write-Host ("{0,-25} : {1}" -f '子网地址范围', $bastionSubnet.AddressPrefix)

# 列出同一虚拟网络中所有可连接的虚拟机
$connectedVms = @()
$allSubnets = $vnet.Subnets | Where-Object { $_.Name -ne 'AzureBastionSubnet' }

foreach ($subnet in $allSubnets) {
$nicIds = $subnet.IpConfigurations.Id | ForEach-Object {
($_ -split '/ipConfigurations/')[0]
} | Select-Object -Unique

foreach ($nicId in $nicIds) {
$nicName = ($nicId -split '/')[-1]
$nicRg = (($nicId -split '/')[4])
$nic = Get-AzNetworkInterface -Name $nicName -ResourceGroupName $nicRg -ErrorAction SilentlyContinue

if ($nic -and $nic.VirtualMachine) {
$vmName = ($nic.VirtualMachine.Id -split '/')[-1]
$vmRg = (($nic.VirtualMachine.Id -split '/')[4])
$vm = Get-AzVM -Name $vmName -ResourceGroupName $vmRg -ErrorAction SilentlyContinue

if ($vm) {
$osType = if ($vm.StorageProfile.OSDisk.OSType -eq 'Windows') { 'Windows/RDP' } else { 'Linux/SSH' }
$connectedVms += [PSCustomObject]@{
VmName = $vmName
OsType = $osType
Subnet = $subnet.Name
PrivateIp = ($nic.IpConfigurations | Where-Object { $_.PrivateIpAddress }).PrivateIpAddress
}
}
}
}
}

Write-Host "`n===== 可连接的虚拟机 ====="
Write-Host ("{0,-20} {1,-15} {2,-20} {3}" -f 'VM 名称', '操作系统/协议', '子网', '私有 IP')
Write-Host ('-' * 75)

foreach ($vm in $connectedVms) {
Write-Host ("{0,-20} {1,-15} {2,-20} {3}" -f $vm.VmName, $vm.OsType, $vm.Subnet, $vm.PrivateIp)
}

return $connectedVms
}

# 生成 Bastion 会话审计报告
Get-BastionSessionReport `
-ResourceGroup 'rg-bastion-demo' `
-BastionName 'bastion-demo'

这段代码通过 Get-AzBastion 获取 Bastion 主机的配置详情,然后遍历关联虚拟网络中除 AzureBastionSubnet 以外的所有子网,查找关联的虚拟机并列出其网络信息。通过审计报告可以快速了解哪些虚拟机可以通过 Bastion 访问,以及每台虚拟机的连接协议类型。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
===== Bastion 配置报告 =====
BastionName : bastion-demo
Location : eastasia
Sku : Standard
Provisioning : Succeeded
EnableTunneling : True
EnableIpConnect : True
EnableShareableLink : False
DnsName : bst-7a3b2c1d-0001.bastion.azure.com

===== 网络配置 =====
关联虚拟网络 : vnet-demo
子网地址范围 : 10.0.1.0/26

===== 可连接的虚拟机 =====
VM 名称 操作系统/协议 子网 私有 IP
---------------------------------------------------------------------------
vm-linux-01 Linux/SSH subnet-workload 10.0.0.4
vm-linux-02 Linux/SSH subnet-workload 10.0.0.5
vm-win-sql01 Windows/RDP subnet-database 10.0.2.4

批量审计多个 Bastion 实例的配置合规性

在大型企业环境中,可能有多个 Azure 订阅和资源组中部署了 Bastion 实例。安全团队需要定期审计所有 Bastion 实例的配置,确保符合企业安全基线。以下代码演示了如何批量检查所有 Bastion 实例的关键配置项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
function Test-BastionCompliance {
[CmdletBinding()]
param(
[string[]]$ResourceGroups
)

$results = @()

# 如果未指定资源组,扫描当前订阅中所有 Bastion 实例
if (-not $ResourceGroups) {
$allBastions = Get-AzBastion
} else {
$allBastions = @()
foreach ($rg in $ResourceGroups) {
$allBastions += Get-AzBastion -ResourceGroupName $rg -ErrorAction SilentlyContinue
}
}

Write-Host "发现 $($allBastions.Count) 个 Bastion 实例,开始合规性检查...`n"

foreach ($bastion in $allBastions) {
$compliance = [ordered]@{
Name = $bastion.Name
ResourceGroup = $bastion.ResourceGroupName
Location = $bastion.Location
Sku = $bastion.Sku.Text
}

# 检查项 1: SKU 是否为 Standard 或 Developer
$skuOk = $bastion.Sku.Text -in @('Standard', 'Developer')
$compliance['SKU 检查'] = if ($skuOk) { 'PASS' } else { 'WARN: Basic SKU' }

# 检查项 2: 是否启用隧道模式(支持原生客户端)
$tunnelOk = $bastion.EnableTunneling -eq $true
$compliance['原生客户端'] = if ($tunnelOk) { 'PASS' } else { 'WARN: 未启用' }

# 检查项 3: 是否启用 IP 连接(通过 IP 地址直连)
$ipConnectOk = $bastion.EnableIpConnect -eq $true
$compliance['IP 连接'] = if ($ipConnectOk) { 'PASS' } else { 'WARN: 未启用' }

# 检查项 4: 是否禁用可共享链接(安全考虑)
$shareOk = $bastion.EnableShareableLink -ne $true
$compliance['共享链接'] = if ($shareOk) { 'PASS' } else { 'WARN: 已启用' }

$results += [PSCustomObject]$compliance
}

# 输出汇总表格
Write-Host "===== Bastion 配置合规性报告 ====="
Write-Host "扫描时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "实例总数: $($results.Count)`n"

foreach ($item in $results) {
Write-Host "--- $($item.Name) ($($item.ResourceGroup)) ---"
Write-Host " 位置: $($item.Location)"
Write-Host " SKU: $($item.Sku)"
Write-Host " SKU 检查: $($item.'SKU 检查')"
Write-Host " 原生客户端: $($item.'原生客户端')"
Write-Host " IP 连接: $($item.'IP 连接')"
Write-Host " 共享链接: $($item.'共享链接')"
Write-Host ""
}

# 统计合规率
$passCount = ($results | Where-Object {
$_.'SKU 检查' -eq 'PASS' -and
$_.'原生客户端' -eq 'PASS' -and
$_.'IP 连接' -eq 'PASS' -and
$_.'共享链接' -eq 'PASS'
}).Count

$total = $results.Count
$complianceRate = if ($total -gt 0) { [math]::Round(($passCount / $total) * 100, 1) } else { 0 }

Write-Host "===== 汇总 ====="
Write-Host "完全合规实例: $passCount / $total"
Write-Host "合规率: $complianceRate%"

return $results
}

# 审计指定资源组中的 Bastion 实例
Test-BastionCompliance -ResourceGroups @('rg-bastion-demo', 'rg-prod-network')

这段代码定义了 Test-BastionCompliance 函数,对每个 Bastion 实例执行四项合规性检查:SKU 级别是否符合要求、是否启用原生客户端隧道、是否启用 IP 直连以及是否禁用了可共享链接。函数输出详细的逐项检查结果和整体合规率统计,方便安全团队快速定位不合规的实例。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
发现 2 个 Bastion 实例,开始合规性检查...

===== Bastion 配置合规性报告 =====
扫描时间: 2025-12-03 10:30:00
实例总数: 2

--- bastion-demo (rg-bastion-demo) ---
位置: eastasia
SKU: Standard
SKU 检查: PASS
原生客户端: PASS
IP 连接: PASS
共享链接: PASS

--- bastion-prod (rg-prod-network) ---
位置: eastasia
SKU: Basic
SKU 检查: WARN: Basic SKU
原生客户端: WARN: 未启用
IP 连接: WARN: 未启用
共享链接: PASS

===== 汇总 =====
完全合规实例: 1 / 2
合规率: 50.0%

注意事项

  • 子网命名不可更改:Azure Bastion 要求子网名称必须严格为 AzureBastionSubnet,且最小地址空间为 /26。部署后不能重命名子网,如需调整必须删除并重新创建 Bastion 资源。
  • SKU 功能差异:Basic SKU 仅支持通过 Azure 门户浏览器连接,不支持原生 SSH 客户端、隧道模式和 IP 连接。建议生产环境使用 Standard 或 Developer SKU,以获得完整的 CLI 自动化能力。
  • 部署时间较长:Bastion 主机的创建通常需要 5-10 分钟,不要在自动化脚本中假设它是即时可用的。建议在部署后通过轮询 ProvisioningState 确认资源就绪再发起连接。
  • SSH 密钥路径兼容性:在 Windows 上默认 SSH 密钥路径为 $env:USERPROFILE\.ssh\id_rsa,在 Linux/macOS 上为 $env:HOME/.ssh/id_rsa。跨平台脚本中应使用 Join-Path 拼接路径,避免硬编码分隔符。
  • 会话超时与断连:Bastion 的浏览器会话有非活动超时限制(通常为 10-20 分钟),原生 SSH 客户端连接则遵循 SSH 协议自身的 ServerAliveInterval 设置。建议在 SSH 配置中添加 ServerAliveInterval 60 保持连接活跃。
  • 成本控制:Bastion 按小时计费(Standard SKU 约 $0.19/小时,各区域价格不同),无论是否有活跃会话。开发测试环境建议使用 Developer SKU(免费但限制并发连接数),或在不使用时删除 Bastion 资源以节省成本。

PowerShell 技能连载 - SSH 远程管理

适用于 PowerShell 7.0 及以上版本(跨平台)

在传统的 Windows 环境中,PowerShell 远程管理主要依赖 WinRM(Windows Remote Management)协议和 Enter-PSSessionInvoke-Command 等 cmdlet。然而,WinRM 是 Windows 专属协议,无法在 Linux 或 macOS 上运行,这给跨平台运维带来了不小的障碍。

从 PowerShell 7.0 开始,PowerShell 正式支持基于 SSH 的远程连接。通过 SSH 传输层,你可以使用熟悉的 Enter-PSSessionInvoke-Command 连接到任何安装了 SSH 服务的远程主机,无论对方运行的是 Windows、Linux 还是 macOS。这让 PowerShell 真正成为了一门跨平台的运维语言。

本文将介绍如何配置 SSH 远程管理环境,以及如何使用 PowerShell 通过 SSH 执行远程命令、管理多台服务器。

环境准备

在使用 SSH 远程管理之前,需要确保本地和远程主机都已正确配置。首先,远程主机上需要安装并运行 SSH 服务(sshd),同时需要安装 PowerShell 7。在 Windows 上,可以通过安装 OpenSSH 可选功能来实现;在 Linux 上则通常使用系统自带的 openssh-server 包。

以下脚本检查本地 SSH 客户端是否可用,并测试到远程主机的连通性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 检查 SSH 客户端是否已安装
$sshClient = Get-Command ssh -ErrorAction SilentlyContinue
if ($sshClient) {
Write-Host "SSH 客户端版本:"
ssh -V 2>&1
} else {
Write-Host "未找到 SSH 客户端,请先安装 OpenSSH" -ForegroundColor Red
}

# 测试到远程主机的 SSH 连通性
$remoteHost = "192.168.1.100"
$sshTest = Test-Connection -ComputerName $remoteHost -Count 2 -Quiet
if ($sshTest) {
Write-Host "主机 $remoteHost 网络可达"
} else {
Write-Host "主机 $remoteHost 无法访问" -ForegroundColor Yellow
}
1
2
3
SSH 客户端版本:
OpenSSH_for_Windows_9.5, LibreSSL 3.8.2
主机 192.168.1.100 网络可达

建立 SSH 远程会话

配置好环境后,就可以使用 Enter-PSSession 通过 SSH 连接到远程主机了。与传统的 WinRM 会话不同,SSH 会话需要在 -HostName 参数中指定远程主机地址。如果 SSH 使用非默认端口,可以通过 -Port 参数指定。

以下代码演示如何建立交互式 SSH 远程会话,以及如何通过 PSSessionOption 自定义连接行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 定义远程主机信息
$remoteHost = "admin@192.168.1.100"
$sshPort = 22

# 创建会话选项(连接超时 30 秒)
$sessionOption = New-PSSessionOption -OpenTimeout 30000

# 通过 SSH 建立交互式会话
Enter-PSSession -HostName $remoteHost -Port $sshPort -Options $sessionOption

# 进入远程会话后,可以像本地一样执行命令
# PS /home/admin> $PSVersionTable
# PS /home/admin> Get-Process | Select-Object -First 5

# 退出远程会话
Exit-PSSession
1
2
3
4
5
6
7
8
9
10
11
12
13
[admin@192.168.1.100]: PS /home/admin> $PSVersionTable

Name Value
---- -----
PSVersion 7.4.6
PSEdition Core
GitCommitId 7.4.6
OS Ubuntu 24.04.1 LTS
Platform Unix
PSCompatibleVersions {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion 2.3
SerializationVersion 1.1.0.1
WSManStackVersion 3.0

使用 Invoke-Command 执行远程命令

在实际运维场景中,我们经常需要同时在多台远程主机上执行命令。通过 Invoke-Command 配合 SSH 传输,可以向多台 Linux 或 Windows 主机批量推送脚本。Invoke-Command-HostName 参数接受一个字符串数组,因此可以一次连接多台主机。

以下示例展示如何批量查询多台远程服务器的系统信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 定义多台远程主机
$servers = @(
"admin@web-server-01",
"admin@web-server-02",
"admin@db-server-01"
)

# 批量查询远程主机的系统信息
$results = Invoke-Command -HostName $servers -ScriptBlock {
$osInfo = Get-Content /etc/os-release |
Where-Object { $_ -match '^PRETTY_NAME=' } |
ForEach-Object { ($_ -split '=')[1].Trim('"') }

$cpuUsage = (Get-Process | Measure-Object -Property CPU -Sum).Sum
$memInfo = Get-Content /proc/meminfo |
Where-Object { $_ -match '^MemAvailable:' }

[PSCustomObject]@{
HostName = $env:COMPUTERNAME ?? hostname
OS = $osInfo
CPU_Total = [math]::Round($cpuUsage, 2)
Mem_Info = $memInfo
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}

# 展示结果
$results | Format-Table -AutoSize
1
2
3
4
5
HostName        OS                         CPU_Total Mem_Info                   Timestamp
-------- -- --------- -------- ---------
web-server-01 Ubuntu 24.04.1 LTS 3.42 MemAvailable: 3842 kB 2025-09-29 10:15:32
web-server-02 Ubuntu 24.04.1 LTS 1.87 MemAvailable: 6128 kB 2025-09-29 10:15:33
db-server-01 CentOS Stream 9 8.15 MemAvailable: 12244 kB 2025-09-29 10:15:33

使用 SSH 密钥认证

在生产环境中,每次连接都输入密码既不安全也不方便。配置 SSH 密钥认证可以让 PowerShell 远程会话更加流畅。下面展示如何在 PowerShell 中生成 SSH 密钥并将公钥分发到远程主机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 检查是否已有 SSH 密钥
$sshDir = Join-Path $env:USERPROFILE ".ssh"
$keyPath = Join-Path $sshDir "id_ed25519"

if (-not (Test-Path $keyPath)) {
# 生成新的 Ed25519 密钥(更安全、更快速)
Write-Host "正在生成 SSH 密钥..." -ForegroundColor Cyan
ssh-keygen -t ed25519 -f $keyPath -C "powershell-remoting" -N '""'
Write-Host "密钥已生成:$keyPath" -ForegroundColor Green
} else {
Write-Host "已有 SSH 密钥:$keyPath"
}

# 读取公钥内容
$publicKey = Get-Content "$keyPath.pub" -Raw
Write-Host "公钥内容(前 80 字符):"
Write-Host $publicKey.Substring(0, [Math]::Min(80, $publicKey.Length))
1
2
3
4
5
6
7
正在生成 SSH 密钥...
Generating public/private ed25519 key pair.
Your identification has been saved in C:\Users\admin\.ssh\id_ed25519
Your public key has been saved in C:\Users\admin\.ssh\id_ed25519.pub
密钥已生成:C:\Users\admin\.ssh\id_ed25519
公钥内容(前 80 字符):
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINxR9fG3hK7YpBzM2vWqLpC4T5JkR powershell-remoting

配置 SSH Config 文件简化连接

当需要管理大量服务器时,可以在 ~/.ssh/config 文件中预定义主机别名和连接参数。PowerShell 远程管理可以直接使用这些别名,而不必每次都输入完整的用户名和地址。

以下脚本用于生成和维护 SSH Config 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 定义服务器清单
$serverList = @(
[PSCustomObject]@{ Alias = "web01"; Host = "192.168.1.101"; User = "admin"; Port = 22 }
[PSCustomObject]@{ Alias = "web02"; Host = "192.168.1.102"; User = "admin"; Port = 22 }
[PSCustomObject]@{ Alias = "db01"; Host = "192.168.1.201"; User = "dba"; Port = 2222 }
[PSCustomObject]@{ Alias = "proxy"; Host = "10.0.0.50"; User = "ops"; Port = 22 }
)

# 生成 SSH Config 内容
$configLines = @()
$configLines += "# PowerShell SSH Remoting - Auto Generated"
$configLines += ""

foreach ($server in $serverList) {
$configLines += "Host $($server.Alias)"
$configLines += " HostName $($server.Host)"
$configLines += " User $($server.User)"
$configLines += " Port $($server.Port)"
$configLines += " IdentityFile ~/.ssh/id_ed25519"
$configLines += " StrictHostKeyChecking accept-new"
$configLines += ""
}

# 写入 SSH Config 文件
$sshConfigPath = Join-Path $env:USERPROFILE ".ssh" "config"
$configLines | Set-Content -Path $sshConfigPath -Encoding UTF8
Write-Host "SSH Config 已写入:$sshConfigPath" -ForegroundColor Green

# 验证配置
Write-Host "`n配置内容预览:"
Get-Content $sshConfigPath
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
SSH Config 已写入:C:\Users\admin\.ssh\config

配置内容预览:
# PowerShell SSH Remoting - Auto Generated

Host web01
HostName 192.168.1.101
User admin
Port 22
IdentityFile ~/.ssh/id_ed25519
StrictHostKeyChecking accept-new

Host web02
HostName 192.168.1.102
User admin
Port 22
IdentityFile ~/.ssh/id_ed25519
StrictHostKeyChecking accept-new

Host db01
HostName 192.168.1.201
User dba
Port 2222
IdentityFile ~/.ssh/id_ed25519
StrictHostKeyChecking accept-new

Host proxy
HostName 10.0.0.50
User ops
Port 22
IdentityFile ~/.ssh/id_ed25519
StrictHostKeyChecking accept-new

配置完成后,可以直接使用别名建立远程会话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 使用 SSH Config 中定义的别名连接
Enter-PSSession -HostName web01

# 也可以批量执行命令
$webServers = @("web01", "web02")
$diskReport = Invoke-Command -HostName $webServers -ScriptBlock {
$df = df -h / | Select-Object -Last 1
$uptime = uptime -p
[PSCustomObject]@{
Server = hostname
Disk = $df
Uptime = $uptime
Collected = Get-Date -Format 'HH:mm:ss'
}
}

$diskReport | Format-List
1
2
3
4
5
6
7
8
9
Server    : web-server-01
Disk : /dev/sda1 50G 23G 25G 48% /
Uptime : up 42 days, 3 hours, 17 minutes
Collected : 10:22:45

Server : web-server-02
Disk : /dev/sda1 50G 18G 29G 39% /
Uptime : up 42 days, 3 hours, 22 minutes
Collected : 10:22:46

监控远程主机状态

结合 SSH 远程管理,可以编写一个实用的监控脚本,定期检查远程服务器的健康状态。以下示例通过 SSH 同时检查多台服务器的 CPU、内存和磁盘使用情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 监控目标列表
$targets = @("web01", "web02", "db01")

$healthReport = Invoke-Command -HostName $targets -ScriptBlock {
# CPU 使用率(取 top 的第 3 行)
$cpuLine = top -bn1 | Select-Object -Index 2
$cpuIdle = if ($cpuLine -match '(\d+\.\d+)\s*id') {
[math]::Round(100 - [double]$Matches[1], 1)
} else {
-1
}

# 内存使用率
$memLine = free -m | Select-Object -Index 1
$memParts = $memLine -split '\s+'
$memTotal = [int]$memParts[1]
$memUsed = [int]$memParts[2]
$memPct = [math]::Round(($memUsed / $memTotal) * 100, 1)

# 磁盘使用率
$diskLine = df -h / | Select-Object -Last 1
$diskPct = if ($diskLine -match '(\d+)%') { [int]$Matches[1] } else { -1 }

# 判断健康状态
$status = "Healthy"
if ($cpuIdle -gt 80 -or $memPct -gt 85 -or $diskPct -gt 80) {
$status = "Warning"
}
if ($cpuIdle -gt 95 -or $memPct -gt 95 -or $diskPct -gt 90) {
$status = "Critical"
}

[PSCustomObject]@{
Server = hostname
CPU_Pct = $cpuIdle
Mem_Pct = $memPct
Disk_Pct = $diskPct
Status = $status
}
}

# 输出报告,按状态排序
$healthReport |
Sort-Object { switch ($_.Status) { "Critical" { 0 } "Warning" { 1 } default { 2 } } } |
Format-Table -AutoSize

# 统计
$critical = ($healthReport | Where-Object { $_.Status -eq "Critical" }).Count
$warning = ($healthReport | Where-Object { $_.Status -eq "Warning" }).Count
$healthy = ($healthReport | Where-Object { $_.Status -eq "Healthy" }).Count

Write-Host "`n汇总:健康 $healthy 台 / 警告 $warning 台 / 严重 $critical 台"
1
2
3
4
5
6
7
Server        CPU_Pct Mem_Pct Disk_Pct Status
------ ------- ------- -------- ------
db-server-01 12.3 88.2 72 Warning
web-server-01 5.1 42.6 48 Healthy
web-server-02 3.8 38.1 39 Healthy

汇总:健康 2 台 / 警告 1 台 / 严重 0 台

注意事项

  1. SSH 服务必须运行:远程主机上的 sshd 服务必须处于运行状态。在 Linux 上可通过 systemctl status sshd 检查,在 Windows 上可通过 Get-Service sshd 查看。

  2. PowerShell 版本要求:远程主机上必须安装 PowerShell 7 及以上版本。仅安装 SSH 不够,sshd 还需要配置 powershell 作为可用子系统(在 sshd_config 中添加 Subsystem powershell /usr/bin/pwsh -sshs -NoLogo)。

  3. 密钥权限:在 Linux/macOS 上,SSH 私钥文件的权限必须设为 600,否则 SSH 会拒绝使用该密钥。可通过 chmod 600 ~/.ssh/id_ed25519 修复。

  4. 防火墙与端口:确保本地到远程主机的 SSH 端口(默认 22)在防火墙规则中放行。如果使用非默认端口,需要在 SSH Config 中显式指定或通过 -Port 参数传入。

  5. 错误排查:如果连接失败,先用 ssh user@host 命令行工具手动测试,排除认证和网络问题。PowerShell SSH 远程管理底层依赖系统的 SSH 客户端,所以基础连通性必须先保证。

  6. 与 WinRM 的区别:SSH 远程会话不支持 CimCmdlets 和部分 WMI 相关操作(这些是 Windows 专属)。如果需要管理 Windows 专属功能(如注册表、服务控制管理器),建议对 Windows 目标仍然使用 WinRM 传输,对 Linux/macOS 目标使用 SSH 传输。

PowerShell 技能连载 - SSH 隧道与端口转发

适用于 PowerShell 5.1 及以上版本

在混合环境的运维工作中,并非所有服务都暴露在公网上。数据库、内部 API、管理后台通常隐藏在跳板机或防火墙后面,直接访问需要 VPN 或复杂的网络配置。SSH 隧道(SSH Tunneling)提供了一种轻量、安全的方案,通过加密通道将远程服务映射到本地端口,无需额外的网络基础设施。

Windows 自 OpenSSH 客户端内置以来(Windows 10 1809+、Windows Server 2019+),PowerShell 脚本可以直接调用 ssh 命令建立隧道。结合 PowerShell 的进程管理能力,可以动态创建、监控和清理 SSH 隧道,实现自动化的端口转发管理。

本文将讲解 PowerShell 中 SSH 隧道的创建、管理和自动化实践。

基础本地端口转发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# 本地端口转发:将远程数据库映射到本地端口
# 场景:远程 MySQL 在 10.0.1.50:3306,通过跳板机 bastion.example.com 访问

function New-SSHTunnel {
param(
[Parameter(Mandatory)]
[string]$JumpHost,

[Parameter(Mandatory)]
[int]$LocalPort,

[Parameter(Mandatory)]
[string]$RemoteHost,

[Parameter(Mandatory)]
[int]$RemotePort,

[string]$SshUser = $env:USERNAME,

[string]$KeyPath
)

$sshArgs = @(
"-N" # 不执行远程命令
"-L" # 本地端口转发
"${LocalPort}:${RemoteHost}:${RemotePort}"
)

if ($KeyPath) {
$sshArgs += "-i", $KeyPath
}

$sshArgs += "${SshUser}@${JumpHost}"

# 启动 SSH 进程(后台运行)
$process = Start-Process -FilePath "ssh" `
-ArgumentList $sshArgs `
-WindowStyle Hidden `
-PassThru

# 等待端口就绪
$ready = $false
for ($i = 0; $i -lt 10; $i++) {
Start-Sleep -Milliseconds 500
try {
$tcp = New-Object System.Net.Sockets.TcpClient("127.0.0.1", $LocalPort)
$tcp.Close()
$ready = $true
break
} catch {
# 端口尚未就绪,继续等待
}
}

if ($ready) {
Write-Host "SSH 隧道已建立:localhost:${LocalPort} -> ${RemoteHost}:${RemotePort}" `
-ForegroundColor Green
Write-Host "进程 ID:$($process.Id)"
} else {
Write-Warning "隧道建立超时,请检查连接参数"
}

return $process
}

# 示例:通过跳板机连接远程 MySQL
$tunnel = New-SSHTunnel -JumpHost "bastion.example.com" `
-LocalPort 13306 `
-RemoteHost "10.0.1.50" `
-RemotePort 3306 `
-SshUser "admin" `
-KeyPath "$env:USERPROFILE\.ssh\id_rsa"

执行结果示例:

1
2
SSH 隧道已建立:localhost:13306 -> 10.0.1.50:3306
进程 ID:24516

远程端口转发与动态转发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# 远程端口转发:将本地服务暴露给远程网络
# 场景:让远程服务器访问本地开发中的 Web API

function New-SSHRemoteTunnel {
param(
[Parameter(Mandatory)]
[string]$RemoteServer,

[Parameter(Mandatory)]
[int]$RemotePort,

[Parameter(Mandatory)]
[string]$LocalHost,

[Parameter(Mandatory)]
[int]$LocalPort,

[string]$SshUser = $env:USERNAME
)

$sshArgs = @(
"-N"
"-R"
"${RemotePort}:${LocalHost}:${LocalPort}"
"${SshUser}@${RemoteServer}"
)

$process = Start-Process -FilePath "ssh" `
-ArgumentList $sshArgs `
-WindowStyle Hidden `
-PassThru

Write-Host "远程隧道已建立:${RemoteServer}:${RemotePort} -> ${LocalHost}:${LocalPort}" `
-ForegroundColor Green
return $process
}

# 动态端口转发(SOCKS 代理)
function New-SSHSocksProxy {
param(
[Parameter(Mandatory)]
[string]$Server,

[int]$LocalPort = 1080,

[string]$SshUser = $env:USERNAME,

[string]$KeyPath
)

$sshArgs = @(
"-N"
"-D" # 动态转发,创建 SOCKS5 代理
"$LocalPort"
)

if ($KeyPath) {
$sshArgs += "-i", $KeyPath
}

$sshArgs += "${SshUser}@${Server}"

$process = Start-Process -FilePath "ssh" `
-ArgumentList $sshArgs `
-WindowStyle Hidden `
-PassThru

Write-Host "SOCKS5 代理已启动:127.0.0.1:${LocalPort}" -ForegroundColor Green
Write-Host "使用方式:配置浏览器或工具使用 SOCKS5 代理 127.0.0.1:${LocalPort}"
return $process
}

# 示例:创建 SOCKS5 代理
$socks = New-SSHSocksProxy -Server "bastion.example.com" `
-LocalPort 1080 `
-SshUser "admin"

执行结果示例:

1
2
SOCKS5 代理已启动:127.0.0.1:1080
使用方式:配置浏览器或工具使用 SOCKS5 代理 127.0.0.1:1080

隧道生命周期管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# 隧道管理器:统一管理多个 SSH 隧道
$script:TunnelRegistry = @{}

function Register-Tunnel {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][System.Diagnostics.Process]$Process,
[int]$LocalPort,
[string]$Target
)

$script:TunnelRegistry[$Name] = @{
Process = $Process
LocalPort = $LocalPort
Target = $Target
CreatedAt = Get-Date
Status = "Running"
}
}

function Get-SSHTunnel {
param([string]$Name)

if ($Name) {
$entry = $script:TunnelRegistry[$Name]
if ($entry) {
# 检查进程是否仍在运行
$alive = Get-Process -Id $entry.Process.Id -ErrorAction SilentlyContinue
$status = if ($alive) { "Running" } else { "Stopped" }
$entry.Status = $status

[PSCustomObject]@{
Name = $Name
PID = $entry.Process.Id
LocalPort = $entry.LocalPort
Target = $entry.Target
Status = $status
Uptime = (Get-Date) - $entry.CreatedAt
}
}
} else {
foreach ($key in $script:TunnelRegistry.Keys) {
Get-SSHTunnel -Name $key
}
}
}

function Stop-SSHTunnel {
param([Parameter(Mandatory)][string]$Name)

$entry = $script:TunnelRegistry[$Name]
if (-not $entry) {
Write-Warning "未找到隧道:$Name"
return
}

$process = $entry.Process
if ($process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force
Write-Host "隧道 '$Name' 已停止(PID: $($process.Id))" -ForegroundColor Yellow
}

$script:TunnelRegistry.Remove($Name)
}

# 使用示例
$tunnel1 = New-SSHTunnel -JumpHost "bastion.example.com" `
-LocalPort 13306 -RemoteHost "10.0.1.50" -RemotePort 3306 -SshUser "admin"
Register-Tunnel -Name "mysql-prod" -Process $tunnel1 -LocalPort 13306 -Target "10.0.1.50:3306"

$tunnel2 = New-SSHTunnel -JumpHost "bastion.example.com" `
-LocalPort 15432 -RemoteHost "10.0.1.60" -RemotePort 5432 -SshUser "admin"
Register-Tunnel -Name "pg-prod" -Process $tunnel2 -LocalPort 15432 -Target "10.0.1.60:5432"

# 查看所有隧道状态
Get-SSHTunnel | Format-Table -AutoSize

执行结果示例:

1
2
3
4
Name       PID   LocalPort Target         Status  Uptime
---- --- --------- ------ ------ ------
mysql-prod 24516 13306 10.0.1.50:3306 Running 00:02:15
pg-prod 24788 15432 10.0.1.60:5432 Running 00:01:03

隧道健康检查与自动重连

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# 隧道守护进程:自动检测断线并重连
function Start-TunnelDaemon {
param(
[int]$CheckIntervalSeconds = 30
)

Write-Host "隧道守护进程已启动,检查间隔:${CheckIntervalSeconds}秒" -ForegroundColor Cyan

while ($true) {
$tunnels = Get-SSHTunnel
foreach ($t in $tunnels) {
if ($t.Status -ne "Running") {
Write-Warning "检测到隧道 '$($t.Name)' 已断开,尝试重连..."

# 尝试检测端口是否仍被占用(残留进程)
$portInUse = Get-NetTCPConnection -LocalPort $t.LocalPort `
-ErrorAction SilentlyContinue

if ($portInUse) {
Write-Host "端口 $($t.LocalPort) 仍被占用,等待释放..." -ForegroundColor Yellow
continue
}

# 解析目标地址
$parts = $t.Target -split ":"
$remoteHost = $parts[0]
$remotePort = [int]$parts[1]

# 重新建立隧道
$newTunnel = New-SSHTunnel -JumpHost "bastion.example.com" `
-LocalPort $t.LocalPort `
-RemoteHost $remoteHost `
-RemotePort $remotePort `
-SshUser "admin"

if ($newTunnel) {
Register-Tunnel -Name $t.Name -Process $newTunnel `
-LocalPort $t.LocalPort -Target $t.Target
Write-Host "隧道 '$($t.Name)' 重连成功" -ForegroundColor Green
}
}
}

Start-Sleep -Seconds $CheckIntervalSeconds
}
}

# 端口连通性测试工具
function Test-TunnelConnectivity {
param(
[Parameter(Mandatory)]
[string]$HostName,

[Parameter(Mandatory)]
[int]$Port,

[int]$TimeoutMs = 3000
)

$tcp = New-Object System.Net.Sockets.TcpClient
$asyncResult = $tcp.BeginConnect($HostName, $Port, $null, $null)
$waited = $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)

$result = if ($waited -and $tcp.Connected) {
$tcp.EndConnect($asyncResult)
[PSCustomObject]@{
Host = $HostName
Port = $Port
Status = "Open"
Latency = "N/A"
}
} else {
[PSCustomObject]@{
Host = $HostName
Port = $Port
Status = "Closed"
Latency = "N/A"
}
}

$tcp.Close()
return $result
}

# 批量检查所有隧道
Get-SSHTunnel | ForEach-Object {
Test-TunnelConnectivity -HostName "127.0.0.1" -Port $_.LocalPort
} | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
隧道守护进程已启动,检查间隔:30

Host Port Status
---- ---- ------
127.0.0.1 13336 Open
127.0.0.1 15432 Open

配置文件驱动的隧道管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# 从配置文件批量创建隧道
$tunnelConfigJson = @"
{
"jumpHost": "bastion.example.com",
"sshUser": "admin",
"tunnels": [
{
"name": "mysql-prod",
"localPort": 13306,
"remoteHost": "10.0.1.50",
"remotePort": 3306
},
{
"name": "redis-prod",
"localPort": 16379,
"remoteHost": "10.0.1.70",
"remotePort": 6379
},
{
"name": "pg-staging",
"localPort": 25432,
"remoteHost": "10.0.2.60",
"remotePort": 5432
}
]
}
"@

function Start-TunnelsFromConfig {
param(
[Parameter(Mandatory)]
[string]$ConfigPath,

[string]$KeyPath
)

$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json

$results = @()
foreach ($t in $config.tunnels) {
Write-Host "正在建立隧道:$($t.name)..." -ForegroundColor Cyan

$tunnel = New-SSHTunnel -JumpHost $config.jumpHost `
-LocalPort $t.localPort `
-RemoteHost $t.remoteHost `
-RemotePort $t.remotePort `
-SshUser $config.sshUser `
-KeyPath $KeyPath

Register-Tunnel -Name $t.name -Process $tunnel `
-LocalPort $t.localPort `
-Target "$($t.remoteHost):$($t.remotePort)"

$results += [PSCustomObject]@{
Name = $t.name
LocalPort = $t.localPort
Target = "$($t.remoteHost):$($t.remotePort)"
PID = $tunnel.Id
}
}

return $results
}

# 保存配置并启动
$tunnelConfigJson | Set-Content "$env:TEMP\tunnels.json" -Encoding UTF8
Start-TunnelsFromConfig -ConfigPath "$env:TEMP\tunnels.json" | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
正在建立隧道:mysql-prod...
SSH 隧道已建立:localhost:13306 -> 10.0.1.50:3306
正在建立隧道:redis-prod...
SSH 隧道已建立:localhost:16379 -> 10.0.1.70:6379
正在建立隧道:pg-staging...
SSH 隧道已建立:localhost:25432 -> 10.0.2.60:5432

Name LocalPort Target PID
---- --------- ------ ---
mysql-prod 13306 10.0.1.50:3306 24516
redis-prod 16379 10.0.1.70:6379 24788
pg-staging 25432 10.0.2.60:5432 25012

注意事项

  1. 端口冲突检查:创建隧道前务必检查本地端口是否已被占用,避免端口冲突导致隧道建立失败
  2. SSH 密钥管理:优先使用密钥认证而非密码,密钥文件权限应设置为仅当前用户可读(Windows 上移除继承权限)
  3. 连接超时配置:SSH 默认不发送 keepalive 包,建议在 ~/.ssh/config 中配置 ServerAliveInterval 60 防止空闲断开
  4. 进程清理:脚本异常退出时 SSH 进程可能残留为孤儿进程,应使用 try/finally 或注册退出事件确保清理
  5. 安全审计:SSH 隧道可绕过防火墙策略,生产环境中应记录所有隧道的创建和销毁操作,纳入审计日志
  6. 网络跳跃限制:复杂场景需要多级跳板时,使用 SSH ProxyJump(-J 参数)比嵌套隧道更可靠且更易维护

PowerShell 技能连载 - SSH 远程管理

适用于 PowerShell 7.0 及以上版本(跨平台)

传统上,PowerShell 远程管理依赖 Windows 专属的 WinRM 协议和 Enter-PSSession 命令。但随着 PowerShell 7 的跨平台演进,以及混合云环境的普及,基于 SSH 的远程管理已成为官方推荐的新标准。SSH 不仅天然支持 Linux/macOS,还能在 Windows 上与 OpenSSH 无缝集成,实现真正的跨平台远程操作。

本文将从安装配置 OpenSSH 开始,逐步讲解如何通过 SSH 建立 PowerShell 远程会话、传输文件、执行远程命令,以及在生产环境中配置密钥认证和跳板机。

安装与配置 OpenSSH

在 Windows 上使用 SSH 远程管理,首先需要确保 OpenSSH 服务器已安装并运行。Windows 10 1809 及以上版本和 Windows Server 2019 均内置了 OpenSSH,但默认未启用。

1
2
3
4
5
6
7
8
9
10
11
12
# 检查 OpenSSH 安装状态
Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH*'

# 安装 OpenSSH 服务器
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0

# 启动 SSH 服务并设为自动启动
Start-Service sshd
Set-Service -Name sshd -StartupType Automatic

# 确认防火墙规则(安装时通常会自动创建)
Get-NetFirewallRule -Name *ssh*

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
Name  : OpenSSH.Server~~~~0.0.1.0
State : Installed

Status Name DisplayName
------ ---- -----------
Running sshd OpenSSH SSH Server

Name : OpenSSH-Server-In-TCP
DisplayName : OpenSSH SSH Server (sshd)
Enabled : True
Direction : Inbound

注意:如果需要在 Linux 上使用 SSH 远程管理,确保目标主机已安装 openssh-server,并且 sshd_config 中配置了 Subsystem powershell /usr/bin/pwsh -sshs -NoLogo

配置 SSH 用于 PowerShell 远程

要让 PowerShell 通过 SSH 建立远程会话,需要在 SSH 服务端进行配置。核心是将 PowerShell 注册为 SSH 子系统(Subsystem),这样客户端连接时可以指定使用 PowerShell 而非默认的 shell。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Windows 上的 sshd_config 路径
$sshdConfig = "$env:ProgramData\ssh\sshd_config"

# 检查是否已配置 PowerShell 子系统
$content = Get-Content $sshdConfig -Raw
if ($content -notmatch 'Subsystem\s+powershell') {
# 追加 PowerShell 子系统配置
$subsystemPath = (Get-Command pwsh).Source.Replace('\', '/')
Add-Content -Path $sshdConfig -Value "`nSubsystem powershell $subsystemPath -sshs -NoLogo"
Write-Host "已添加 PowerShell 子系统配置" -ForegroundColor Green
} else {
Write-Host "PowerShell 子系统已配置" -ForegroundColor Yellow
}

# 重启 SSH 服务使配置生效
Restart-Service sshd

执行结果示例:

1
已添加 PowerShell 子系统配置

配置完成后,还需要确保 sshd_config 中启用了密码认证或公钥认证:

1
2
# 确认认证方式配置
Select-String -Path $sshdConfig -Pattern 'PasswordAuthentication|PubkeyAuthentication'

执行结果示例:

1
2
sshd_config:37:PasswordAuthentication yes
sshd_config:53:PubkeyAuthentication yes

建立远程会话

配置完成后,使用 New-PSSessionEnter-PSSession 通过 SSH 连接远程主机。连接时通过 -HostName 参数指定目标,PowerShell 会自动使用 SSH 协议。

1
2
3
4
5
6
7
8
9
10
11
12
# 交互式进入远程会话
Enter-PSSession -HostName admin@192.168.1.100

# 指定 SSH 端口
Enter-PSSession -HostName admin@192.168.1.100 -Port 2222

# 使用 PSSession 对象进行非交互操作
$session = New-PSSession -HostName admin@192.168.1.100
Invoke-Command -Session $session -ScriptBlock {
$PSVersionTable
Get-ComputerInfo | Select-Object CsName, OsName, OsVersion
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
PSRemote> [admin@SERVER01]: PS C:\Users\admin\Documents>

Name Value
---- -----
PSVersion 7.4.2
PSEdition Core
Platform Unix
OS Ubuntu 22.04.4 LTS

CsName OsName OsVersion
------ ------ ----------
UBUNTU01 Ubuntu 22.04 LTS 22.04.4

注意-HostName 参数使用 user@host 格式,与 SSH 命令行一致。首次连接时会提示确认主机指纹。

使用 SSH 密钥认证

密码认证存在暴力破解风险,生产环境强烈建议使用 SSH 密钥认证。PowerShell 完全支持基于密钥的 SSH 连接。

1
2
3
4
5
6
7
8
# 生成 ED25519 密钥对(推荐,比 RSA 更安全更快)
ssh-keygen -t ed25519 -C "admin@workstation" -f "$env:USERPROFILE\.ssh\id_ed25519"

# 生成 RSA 密钥对(兼容性更好)
ssh-keygen -t rsa -b 4096 -C "admin@workstation" -f "$env:USERPROFILE\.ssh\id_rsa"

# 查看生成的公钥
Get-Content "$env:USERPROFILE\.ssh\id_ed25519.pub"

执行结果示例:

1
2
3
4
5
6
7
8
Generating public/private ed25519 key pair.
Enter passphrase (empty for no passphrase):
Your identification has been saved in C:\Users\admin\.ssh\id_ed25519
Your public key has been saved in C:\Users\admin\.ssh\id_ed25519.pub
The key fingerprint is:
SHA256:AbCdEfGhIjKlMnOpQrStUvWxYz1234567890abcdef admin@workstation

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAbCdEfGh admin@workstation

将公钥部署到远程主机:

1
2
3
4
5
6
7
8
9
10
# Windows 目标:将公钥添加到 authorized_keys
$publicKey = Get-Content "$env:USERPROFILE\.ssh\id_ed25519.pub"
$remotePath = "$env:ProgramData\ssh\administrators_authorized_keys"

# 通过 SSH 复制公钥(类 ssh-copy-id 功能)
$remoteHost = "admin@192.168.1.100"
ssh $remoteHost "mkdir -p ~/.ssh && echo '$publicKey' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

# 使用密钥连接(不再需要密码)
Enter-PSSession -HostName $remoteHost -KeyFilePath "$env:USERPROFILE\.ssh\id_ed25519"

执行结果示例:

1
PSRemote> [admin@SERVER01]: PS C:\Users\admin\Documents>

跨平台远程管理

SSH 最大的优势在于跨平台。同一台 Windows 管理机可以同时管理 Windows、Linux 甚至 macOS 主机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 同时连接多台不同操作系统的主机
$windowsHost = New-PSSession -HostName admin@win-server01
$linuxHost = New-PSSession -HostName dev@linux-web01
$macHost = New-PSSession -HostName user@mac-build01

# 在所有主机上执行命令
$sessions = $windowsHost, $linuxHost, $macHost
$results = Invoke-Command -Session $sessions -ScriptBlock {
[PSCustomObject]@{
Host = $env:COMPUTERNAME ?? hostname
OS = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription
PSVersion = $PSVersionTable.PSVersion.ToString()
Platform = $PSVersionTable.Platform
Uptime = if ($IsLinux -or $IsMacOS) {
(uptime -p 2>$null) ?? "N/A"
} else {
(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToString()
}
}
}

$results | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
Host         OS                                    PSVersion Platform  Uptime
---- -- --------- -------- ------
WIN-SERVER01 Microsoft Windows 10.0.20348 7.4.2 Win32NT 4/28/2025 8:30:00 AM
linux-web01 Linux 5.15.0-101-generic #111-Ubuntu 7.4.2 Unix up 42 days, 3:17
mac-build01 Darwin 23.4.0 Darwin Kernel Version… 7.4.2 Unix up 15 days, 7:22

批量远程操作

在运维场景中,经常需要对多台服务器批量执行命令。结合 SSH 和 PowerShell 远程,可以轻松实现并行批量操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 从 inventory 文件读取服务器列表
$servers = @(
@{ Name = 'web-01'; Host = 'admin@192.168.1.101'; Key = 'C:\Keys\web-01\id_ed25519' }
@{ Name = 'web-02'; Host = 'admin@192.168.1.102'; Key = 'C:\Keys\web-02\id_ed25519' }
@{ Name = 'db-01'; Host = 'admin@192.168.1.201'; Key = 'C:\Keys\db-01\id_ed25519' }
)

# 批量建立 SSH 会话
$sessions = foreach ($srv in $servers) {
New-PSSession -HostName $srv.Host -KeyFilePath $srv.Key -Name $srv.Name
}

# 并行执行系统检查
Invoke-Command -Session $sessions -ScriptBlock {
[PSCustomObject]@{
Host = $env:COMPUTERNAME ?? hostname
CPU_Load = if ($IsLinux) { (top -bn1 | grep 'Cpu(s)' | awk '{print $2}') } else { (Get-CimInstance Win32_Processor).LoadPercentage }
Mem_Used_Pct = if ($IsLinux) {
[math]::Round((free | awk '/Mem/{print $3/$2 * 100}'), 1)
} else {
$os = Get-CimInstance Win32_OperatingSystem
[math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize * 100, 1)
}
Disk_Free_GB = if ($IsLinux) {
[math]::Round((df -h / | awk 'NR==2{print $4}' | sed 's/G//'), 1)
} else {
[math]::Round((Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'").FreeSpace / 1GB, 1)
}
}
} | Sort-Object PSComputerName | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
Host       CPU_Load Mem_Used_Pct Disk_Free_GB
---- -------- ----------- ------------
web-01 12.3 45.2 128.5
web-02 8.7 38.9 95.2
db-01 65.4 82.1 45.8

SSH 配置文件优化

频繁输入完整的主机名、端口和密钥路径很繁琐。通过配置 SSH config 文件可以简化操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 生成 SSH config 文件
$sshConfig = @"
Host web-01
HostName 192.168.1.101
User admin
Port 22
IdentityFile C:\Keys\web-01\id_ed25519
RequestTTY yes

Host web-02
HostName 192.168.1.102
User admin
Port 22
IdentityFile C:\Keys\web-02\id_ed25519

Host db-01
HostName 192.168.1.201
User admin
Port 2222
IdentityFile C:\Keys\db-01\id_ed25519

Host jump-server
HostName 10.0.0.1
User gateway
IdentityFile C:\Keys\gateway\id_ed25519
"@

Set-Content -Path "$env:USERPROFILE\.ssh\config" -Value $sshConfig -Encoding UTF8
Write-Host "SSH 配置文件已更新" -ForegroundColor Green

# 现在可以使用别名连接
Enter-PSSession -HostName web-01

执行结果示例:

1
2
SSH 配置文件已更新
PSRemote> [admin@WEB-01]: PS C:\Users\admin\Documents>

注意:SSH config 文件的权限必须正确设置。在 Windows 上,确保只有当前用户有读取权限;在 Linux/macOS 上,权限应为 600

通过跳板机连接

在生产环境中,服务器通常位于内网,需要通过跳板机(Bastion Host)访问。SSH 的 ProxyJump 功能可以轻松实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 在 SSH config 中配置跳板机
$jumpConfig = @"

Host prod-*
ProxyJump jump-server
User admin
IdentityFile C:\Keys\prod\id_ed25519

Host prod-web-01
HostName 10.10.1.101

Host prod-db-01
HostName 10.10.1.201
"@

Add-Content -Path "$env:USERPROFILE\.ssh\config" -Value $jumpConfig

# 连接会自动经过跳板机
Enter-PSSession -HostName prod-web-01

执行结果示例:

1
PSRemote> [admin@PROD-WEB-01]: PS C:\Users\admin\Documents>

注意事项

  1. SSH 服务安全加固:生产环境应禁用密码认证,仅启用密钥认证,在 sshd_config 中设置 PasswordAuthentication no
  2. 密钥保护:私钥文件权限必须设为仅所有者可读(Linux/macOS: chmod 600,Windows: 移除继承权限,仅保留当前用户)
  3. 会话超时:SSH 连接可能因防火墙或 NAT 超时断开,建议在 ~/.ssh/config 中配置 ServerAliveInterval 60
  4. 跨平台差异:通过 SSH 执行命令时,目标平台的路径分隔符、环境变量和命令语法可能不同,使用 $IsLinux$IsWindows$IsMacOS 进行条件判断
  5. 端口安全:避免使用默认的 22 端口暴露在公网,如果必须暴露,配合 fail2ban 或 Azure/JumpServer 等堡垒机方案
  6. PowerShell 子系统路径:确保 sshd_configSubsystem powershell 指向正确的 pwsh 路径,Linux 上通常为 /usr/bin/pwsh