Lately, I've been spending time learning more about ssh. Sadly, I've rarely had a need to learn and use ssh. But of course, with PowerShell 7 and ssh-based remoting, it is time to up my game. I've started deploying the ssh server component to my Windows test servers (I'll write about that another day) and exploring how to use ssh in my automation and scripting work.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
During recent testing, I tried to connect to a remote host where I had re-installed ssh. This resulted in a security warning that the thumbprints didn't match the current known host entry. Thus, I finally got around to learning where remote host configuration is stored. (I freely admit there's more that I don't know about ssh than what I do.) The ssh client stores remote host information in a text file in the .ssh directory which is in the root of the user's home folder. You can reference this file as ~\.ssh\known_hosts.
If you open the file in a text editor you'll see something like this:
localhost ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGCbSDjrVNB/oGITy7qKovJam+k2HKYCtJzyiYTjevW/mYIF5umy/1eOG7Nb2AtNgpI0p6ahZtChttdT/hcZfAU=
192.168.3.199 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOZuPhlOpxsbkWNT2YKr4OgjSz05B0CYfHbmPlFeshOVo3r5GaXDZ+N5aRfbbPf6VYqMK/XPgO7VQ2rNcHdAxOw=
192.168.3.200 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOmQ3X0Xa74ntBTAbTwCK64ZkQqkU2q0xBNWAp00xAMbSNwAvWMxysq6zWkHUx0/+yS/Rewxs8vjiHpzhuf+f5A=
This is content from the file on my Windows 10 desktop. On some clients, you might see the content in this form:
|1|p+R0sPINIOBsfxHaXM42p+ndtJY=|6TxeZvVc26Emf4yLsKnLJjdxd2w= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBO5YNHNaeOKpLorx4BqqXRBr/kK2+ZEd1OKHMSmozLLt7yS4hEHVG5Dg71qBVHZiE1dIUxeMmNEgexix4Odj/hM=
|1|0yLDq/BJ0VCDcNlshrO5ngGMLOQ=|hhWDD/C5KvfhpYzA+6q6XgEgMMA= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHswks7/UNgwKica/vF0cswPYoEVuMznbLKXLRXs+WWAULXZeEcUEgHm5NDeHGs+oU+7UeIoQ8xjiwl8T7MF/iw=
In your ssh_config file, there is a setting called HashKnownHosts. On my Windows system, this is set to know so the host information is stored in plain text. Otherwise, you get a hashed entry in known_hosts and there's no way to reverse the process. Keep this in mind as I continue.
After a little research, I learned that there is a structure to the known_hosts file. Unfortunately, it isn't as simple as a space-delimited file. Although you could try this on your file.
$known = "~\.ssh\known_hosts"
Import-CSV $known -Delimiter " " -Header "Hostname","Keytype","Thumbprint"
However, you might get results like this:
In my case, I had entries with ports, hostnames with square brackets, and some entries with an optional IP address. But no matter. If I know the structure I can parse each entry using regular expressions and create a custom object. And that's what I did.
Named Regular Expression Captures
To simplify the process (honestly!), I worked out a regular expression pattern that uses named captures.
[regex]$rx = "^(?<host>^\S+?)((?=:))?((?<=:)(?<port>\d+))?(,(?<address>\S+))?\s(?<type>[\w-]+)\s(?<thumbprint>.*)"
The pattern starts by defining a named capture called host that matches on a group of non-whitespace characters, but using a less greedy match.
^(?<host>^\S+?)
This pattern might be followed by a colon. This is a positive look-ahead.
((?=:)
The second capture is one for the port, which might be optional. I'm looking for a group of numbers that are preceded by a colon. This is a look-behind. And this whole capture is options.
((?<=:)(?<port>\d+))?
Likewise, there might be an IP address preceded by a comma.
(,(?<address>\S+))?
Then I know there should a white space (\s) followed by the thumbprint type which I'm defining as any word character and the dash.
(?<type>[\w-]+)
There's another space (\s) and the rest should be the thumbprint.
(?<thumbprint>.*)
To process the text file, I found it easiest to turn it into an array of strings.
$content = (Get-Content -Path $known) -split "`n"
Now I can try my regex pattern on each entry.
Each named capture is a group.
I can easily get each named capture like this:
$matched.groups["host"].value -replace ":$|\[|\]", ""
For the host, I'm replacing square brackets and any trailing colons, with nothing. There is probably a way to do this with the regular expression capture, but it is complicated enough and a little extra step like this is no big deal. I can use this technique to construct a custom object.
[pscustomobject]@{
PSTypeName = "sshKnownHost"
Hostname = $sshHost
Port = $matched.groups["port"].value
Address = $IP
Keytype = $matched.groups["type"].value
Thumbprint = $matched.groups["thumbprint"].value
}
I've defined a type name for the custom object called sshKnownHost. I did this so that if I wanted to, I could create custom format or type extensions.
Here's the complete function, which you can find as a gist on GitHub.
#requires -version 5.1
Function Get-sshKnownHost {
<#
.Synopsis
Parse ssh known hosts
.Description
This command will parse the ssh known_hosts file and write custom objects to
the pipeline. If HashKnownHosts is set to yes in ssh_config, you may not get
meaningful results.
.Parameter Hostname
Specify a hostname. Wildcards are permitted. Leave blank to see all known
ssh hosts.
.Example
PS C:\> Get-sshKnownHost
Hostname : localhost
Port :
Address :
Keytype : ecdsa-sha2-nistp256
Thumbprint : AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGCbSDjrVN
B/oGITy7qKovJam+k2HKYCtJzyiYTjevW/mYIF5umy/1eOG7Nb2AtNgpI0p6ah
ZtChttdT/hcZfAU=
Hostname : 192.168.3.199
Port :
Address :
Keytype : ecdsa-sha2-nistp256
Thumbprint : AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOZuPhlOpx
sbkWNT2YKr4OgjSz05B0CYfHbmPlFeshOVo3r5GaXDZ+N5aRfbbPf6VYqMK/XP
gO7VQ2rNcHdAxOw=
...
.Example
PS C:\> Get-KnownHost -hostname srv*
Hostname : srv1
Port :
Address : 192.168.3.50
Keytype : ecdsa-sha2-nistp256
Thumbprint : AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBP6unwvoWX
AbQ2ymqO3/TB2zSBayXP1ke2J+YxxOe57WoJ9ZEWdDyNdXwjYPzO139eVa8gFz
gPSV4DgDm/hLfYM=
Hostname : srv2
Port :
Address : 192.168.3.51
Keytype : ecdsa-sha2-nistp256
Thumbprint : AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKcUMuWoDN
oM0RojPT+p/z8F2N7pPychlc49oTvCsGH5urCaTu6R4Fu9tctxLvuPKRIpl08+
DRTnXOGI3m/wDbw=
.Notes
Learn more about PowerShell: https://jdhitsolutions.com/blog/essential-powershell-resources/
.Link
ssh-keygen
.Inputs
none
.Outputs
sshKnownHost
#>
[cmdletbinding()]
[alias("gkh")]
[Outputtype("sshKnownHost")]
Param(
[Parameter(Position = 0, HelpMessage = "Specify a hostname. Wildcards are permitted. Leave blank to see all known hosts.")]
[string]$Hostname
)
Write-Verbose "Starting $($MyInvocation.MyCommand)"
#define a list to hold host data
$data = [System.Collections.Generic.list[object]]::New()
[regex]$rx = "^(?<host>^\S+?)((?=:))?((?<=:)(?<port>\d+))?(,(?<address>\S+))?\s(?<type>[\w-]+)\s(?<thumbprint>.*)"
$known = "~\.ssh\known_hosts"
Write-Verbose "Testing for $known"
if (Test-Path $known) {
$content = (Get-Content -Path $known) -split "`n"
Write-Verbose "Found $($content.count) entries"
#process all entries even if searching for a single hostname because there
#might be multiple entries
foreach ($entry in $content) {
$matched = $rx.Match($entry)
$sshHost = $matched.groups["host"].value -replace ":$|\[|\]", ""
$IP = $matched.groups["address"].value -replace "\[|\]", ""
Write-Verbose "Processing $sshHost"
#regex named captures are case-sensitive
#I haven't perfected the regex capture so I'll manually trim and trailing : in the hostname capture
$obj = [pscustomobject]@{
PSTypeName = "sshKnownHost"
Hostname = $sshHost
Port = $matched.groups["port"].value
Address = $IP
Keytype = $matched.groups["type"].value
Thumbprint = $matched.groups["thumbprint"].value
}
#add each new object to the list
$data.Add($obj)
} #foreach entry
}
else {
Write-Warning "Can't find $known. Is the ssh client installed?"
}
if ($Hostname -AND $data.count -gt 0) {
Write-Verbose "Searching for $hostname"
$data.where({$_.hostname -like $hostname})
}
elseif ($data.count -gt 0) {
$data
}
Write-Verbose "Ending $($MyInvocation.MyCommand)"
} #close function
This still works with hashed entries.
The last entry is in plain text because I changed the config file. But now I have a PowerShell tool that I can easily use from the console.
Summary
I am under no illusions that this is the best way to my goal. I also know that it is likely possible to construct an alternate regex pattern. But I know that working on this built-up my regex muscles, and the end result is a PowerShell tool that fills a need. PowerShell is all about objects in the pipeline. Ideally, you don't want to be parsing text, except when you need to parse text to create objects, as I've done with this function.
If you're using ssh, I hope you'll give the function and try and let me know how it works out for you. Other questions or comments about what I did or why are welcome as usual.